From c40519354ea2ab13c2c96b9d1b5690b721fd9b01 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 15 Oct 2026 08:05:45 +0000 Subject: [PATCH 01/31] =?UTF-8?q?Data:=20From=20Web=20=E2=80=94=20import?= =?UTF-8?q?=20a=20table=20from=20a=20web=20page,=20with=20one-click=20refr?= =?UTF-8?q?esh=20(#1356)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit data.fromWeb fetches an https page through the host's web source (new gridcraft-webquery crate, ureq, native only), extracts the page's tables (engine-side HTML parsing, wasm-clean) and loads the selected one into a new table sheet, saving the connection; data.webPreview lists the page's tables with a preview (the Navigator dialog), and data.webRefresh re-fetches and rewrites a saved table in place. Co-authored-by: setzor --- Cargo.lock | 161 ++++++++ Cargo.toml | 1 + apps/gridcraft-cli/Cargo.toml | 1 + apps/gridcraft-cli/src/main.rs | 2 + apps/gridcraft/Cargo.toml | 1 + apps/gridcraft/src/main.rs | 1 + crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/web.rs | 376 ++++++++++++++++++ crates/engine/src/lib.rs | 8 +- crates/engine/src/webquery.rs | 364 +++++++++++++++++ crates/model/src/features.rs | 17 + crates/model/src/workbook.rs | 4 + crates/ui-egui/src/dialogs.rs | 86 ++++ .../src/keytips/keytip_table/generated.rs | 2 +- crates/ui-egui/src/keytips/keytips.json | 3 +- crates/ui-egui/src/ribbon.rs | 5 + crates/ui-egui/tests/keytips_generated.rs | 5 +- crates/webquery/Cargo.toml | 18 + crates/webquery/src/lib.rs | 66 +++ docs/file-format-parity.md | 8 +- docs/gaps.md | 10 +- docs/parity-checklist.md | 8 +- docs/target-app-parity.md | 5 +- xtask/src/layers.rs | 1 + 24 files changed, 1135 insertions(+), 20 deletions(-) create mode 100644 crates/engine/src/cmd/web.rs create mode 100644 crates/engine/src/webquery.rs create mode 100644 crates/webquery/Cargo.toml create mode 100644 crates/webquery/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 6d618a02a3..86e8186286 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -431,6 +431,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bit-set" version = "0.10.0" @@ -1352,6 +1358,17 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1505,6 +1522,7 @@ dependencies = [ "fmv-macos-events", "gridcraft-engine", "gridcraft-ui-egui", + "gridcraft-webquery", "image", "log", "muda", @@ -1545,6 +1563,7 @@ version = "0.7.0" dependencies = [ "gridcraft-engine", "gridcraft-mcp", + "gridcraft-webquery", "log", "quick-xml", "serde_json", @@ -1670,6 +1689,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gridcraft-webquery" +version = "0.1.0" +dependencies = [ + "gridcraft-engine", + "log", + "ureq", +] + [[package]] name = "gridcraft-xlsx" version = "0.7.0" @@ -1759,6 +1787,22 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "hybrid-array" version = "0.4.15" @@ -3027,6 +3071,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -3074,6 +3132,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -3360,6 +3453,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -3620,6 +3719,47 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "uuid" version = "1.27.0" @@ -3674,6 +3814,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -3894,6 +4040,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -4658,6 +4813,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + [[package]] name = "zip" version = "3.0.0" diff --git a/Cargo.toml b/Cargo.toml index 98dd572e07..a5e1d9c310 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ gridcraft-chart = { path = "crates/chart" } gridcraft-engine = { path = "crates/engine" } gridcraft-ui-egui = { path = "crates/ui-egui" } gridcraft-mcp = { path = "crates/mcp" } +gridcraft-webquery = { path = "crates/webquery" } gridcraft-pdf = { path = "crates/pdf" } # foundation diff --git a/apps/gridcraft-cli/Cargo.toml b/apps/gridcraft-cli/Cargo.toml index 21e9580055..3f7c6fd6e3 100644 --- a/apps/gridcraft-cli/Cargo.toml +++ b/apps/gridcraft-cli/Cargo.toml @@ -15,6 +15,7 @@ path = "src/main.rs" [dependencies] gridcraft-engine = { workspace = true } +gridcraft-webquery = { workspace = true } gridcraft-mcp = { workspace = true } serde_json = { workspace = true } log = { workspace = true } diff --git a/apps/gridcraft-cli/src/main.rs b/apps/gridcraft-cli/src/main.rs index 152ddbcbea..b3f626f413 100644 --- a/apps/gridcraft-cli/src/main.rs +++ b/apps/gridcraft-cli/src/main.rs @@ -165,12 +165,14 @@ fn exec(s: &mut Session, id: &str, params: Value) -> Result { fn open(path: &str) -> Result { let mut s = Session::new(); + s.web_source = Some(gridcraft_webquery::provider()); exec(&mut s, "file.open", json!({"path": path}))?; Ok(s) } fn blank() -> Session { let mut s = Session::new(); + s.web_source = Some(gridcraft_webquery::provider()); s.new_workbook(); s } diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index cd8311b345..dcc4a0b1c5 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -12,6 +12,7 @@ workspace = true [dependencies] gridcraft-engine = { workspace = true } +gridcraft-webquery = { workspace = true } gridcraft-ui-egui = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] } egui = { workspace = true } diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 7c0eb04e6c..a50cd581e7 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -289,6 +289,7 @@ fn main() -> eframe::Result<()> { } } let mut session = Session::new(); + session.web_source = Some(gridcraft_webquery::provider()); if let Some(s) = &sample && let Err(e) = session.execute("file.new", json!({"sample": s})) { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 50865a2b7d..d32906feb3 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -20,6 +20,7 @@ pub mod slicer; pub mod solver; pub mod spelling; pub mod view; +pub mod web; pub mod whatif; use gridcraft_calc::Key; @@ -130,6 +131,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(sheet::specs()); v.extend(insert::specs()); v.extend(data::specs()); + v.extend(web::specs()); v.extend(formulas::specs()); v.extend(review::specs()); v.extend(view::specs()); diff --git a/crates/engine/src/cmd/web.rs b/crates/engine/src/cmd/web.rs new file mode 100644 index 0000000000..d324afd718 --- /dev/null +++ b/crates/engine/src/cmd/web.rs @@ -0,0 +1,376 @@ +//! Data ▸ Get Data ▸ From Web: fetch a page through the host's [`crate::webquery::WebFetch`], +//! preview its tables and load one into a new sheet, with a saved connection `data.webRefresh` +//! re-fetches (Excel's Navigator, Load and Refresh). + +use serde_json::{Value as Json, json}; + +use super::*; +use crate::webquery::{DEFAULT_MAX_BYTES, DEFAULT_MAX_ROWS, MAX_COLS, WebRequest, WebTable}; + +/// Preview rows and columns per table (the Navigator's preview pane). +const PREVIEW_ROWS: usize = 10; +const PREVIEW_COLS: usize = 8; + +pub fn specs() -> Vec { + vec![ + cmd!( + "data.fromWeb", + "From Web", + ["Data", "Get & Transform Data"], + None, + "{url: https page, table?: page table name (default: the first), sheet?: new sheet name, maxRows?: 1..1048575} → imports the table into a new sheet as a table and saves the connection", + has_doc, + from_web + ), + cmd!( + query "data.webPreview", + "Web Tables Preview", + [], + None, + "{url} → {tables: [{name, rows, cols, preview: first rows}]} (the Navigator's table detection)", + has_doc, + web_preview + ), + cmd!( + "data.webRefresh", + "Refresh Web Table", + ["Data", "Queries & Connections"], + None, + "{url? | name?: the saved connection (default: all)} → re-fetches and rewrites the imported table(s) in place", + has_doc, + web_refresh + ), + ] +} + +/// Wall-clock ms since the epoch; `0` on wasm, where reading the clock traps. +#[allow(clippy::disallowed_methods)] +fn now_ms() -> u64 { + #[cfg(not(target_arch = "wasm32"))] + { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0) + } + #[cfg(target_arch = "wasm32")] + { + 0 + } +} + +/// A page request from the command params, with the caps clamped. +fn request(p: &Json) -> Result { + const ID: &str = "data.fromWeb"; + let url = str_param(p, "url").map(str::trim).filter(|u| u.starts_with("http")).ok_or_else(|| bad(ID, "missing `url`"))?; + let max_rows = p + .get("maxRows") + .and_then(Json::as_u64) + .map_or(DEFAULT_MAX_ROWS, |n| usize::try_from(n).unwrap_or(DEFAULT_MAX_ROWS).clamp(1, DEFAULT_MAX_ROWS)); + Ok(WebRequest { url: url.to_string(), max_rows, max_cols: MAX_COLS, max_bytes: DEFAULT_MAX_BYTES }) +} + +fn from_web(s: &mut Session, p: &Json) -> Result { + if str_param(p, "url").map(|u| u.trim().is_empty()).unwrap_or(true) { + s.ui_requests.push(crate::UiRequest::Dialog("fromWeb".into(), json!({}))); + return Ok(Json::Null); + } + let req = request(p)?; + let Some(source) = s.web_source.clone() else { + return Err(EngineError::Other("Web page import is not available in this build.".into())); + }; + let tables = source(&req).map_err(EngineError::Other)?; + let want = str_param(p, "table").map(str::trim).filter(|t| !t.is_empty()); + let table = pick_table(&tables, want).ok_or_else(|| EngineError::Other(no_table_message(&tables)))?; + let name = str_param(p, "sheet").map(|n| n.chars().take(31).collect::()).unwrap_or_else(|| { + let host = req.url.trim_start_matches("https://").split('/').next().unwrap_or("Web").to_string(); + let base = host.rsplit('.').find(|p| !p.is_empty() && *p != "www").unwrap_or("Web").to_string(); + if base.is_empty() { "Web".into() } else { base.chars().take(31).collect() } + }); + load_table(s, &req, table, &name, true) +} + +/// The table `want` names (page name or 1-based index), else the first one. +fn pick_table<'a>(tables: &'a [WebTable], want: Option<&str>) -> Option<&'a WebTable> { + match want { + Some(w) => { + tables.iter().find(|t| t.name.eq_ignore_ascii_case(w)).or_else(|| w.parse::().ok().and_then(|n| tables.get(n.saturating_sub(1)))) + } + None => tables.first(), + } +} + +fn no_table_message(tables: &[WebTable]) -> String { + if tables.is_empty() { + "No tables were found on the page.".into() + } else { + format!("The page has no table by that name. It holds: {}.", tables.iter().map(|t| t.name.as_str()).collect::>().join(", ")) + } +} + +/// Writes `table` to a new sheet as a table and (when `save`) records the connection. +pub(crate) fn load_table(s: &mut Session, req: &WebRequest, table: &WebTable, sheet_name: &str, save: bool) -> Result { + let rows = json_rows(table); + if rows.is_empty() { + return Err(EngineError::Other("The table is empty.".into())); + } + s.execute("home.insertSheet", json!({"name": sheet_name})).or_else(|_| s.execute("home.insertSheet", json!({})))?; + let r = s.doc()?; + let sheet = r.wb.active_sheet; + let sheet_name = r.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default(); + s.execute("range.setValues", json!({"range": "A1", "sheet": sheet, "values": rows}))?; + let w = table.rows.iter().map(Vec::len).max().unwrap_or(1); + let range = RangeRef::new(CellRef::new(0, 0), CellRef::new(table.rows.len() as u32 - 1, w as u32 - 1)); + // The table takes the page table's name when it's a valid, free one. + let tname = table.name.chars().take(255).collect::(); + let created = s.execute("insert.table", json!({"range": range.a1(), "header": true, "name": tname})).is_ok(); + let _ = s.execute("home.autofitColumnWidth", json!({"cols": RangeRef::cols(0, w as u32 - 1).a1()})); + if save { + let synced = now_ms(); + let name = { + let d = s.doc()?; + d.wb.sheet(sheet).and_then(|sh| sh.tables.iter().find(|t| t.range == range).map(|t| t.name.clone())).unwrap_or_else(|| tname.clone()) + }; + let q = + gridcraft_model::WebQuery { name, url: req.url.clone(), table: table.name.clone(), sheet: sheet_name.clone(), last_synced: Some(synced) }; + edit(s, |cx| { + cx.wb.web_queries.retain(|x| !(x.url.eq_ignore_ascii_case(&q.url) && x.table.eq_ignore_ascii_case(&q.table))); + cx.wb.web_queries.push(q.clone()); + cx.structural = true; + Ok(()) + })?; + } + let _ = created; + Ok(json!({"sheet": sheet_name, "table": tname, "rows": table.rows.len(), "cols": w, "tables_found": table_count(s)})) +} + +fn table_count(s: &Session) -> usize { + s.doc().map(|d| d.wb.web_queries.len()).unwrap_or(0) +} + +/// The table as `range.setValues` rows: cell text parsed like typed input, so numbers, dates and +/// booleans keep their types (Excel's column detection). +fn json_rows(table: &WebTable) -> Vec> { + table.rows.iter().map(|row| row.iter().map(|c| if c.is_empty() { Json::Null } else { json!(c) }).collect()).collect() +} + +fn web_preview(s: &mut Session, p: &Json) -> Result { + let req = request(p)?; + let Some(source) = s.web_source.clone() else { + return Err(EngineError::Other("Web page import is not available in this build.".into())); + }; + let tables = source(&req).map_err(EngineError::Other)?; + let preview = |t: &WebTable| { + json!({ + "name": t.name, + "rows": t.rows.len(), + "cols": t.rows.iter().map(Vec::len).max().unwrap_or(0), + "preview": t.rows.iter().take(PREVIEW_ROWS).map(|r| r.iter().take(PREVIEW_COLS).map(|c| json!(c)).collect::>()).collect::>(), + }) + }; + Ok(json!({"url": req.url, "tables": tables.iter().map(preview).collect::>()})) +} + +fn web_refresh(s: &mut Session, p: &Json) -> Result { + const ID: &str = "data.webRefresh"; + let url = str_param(p, "url").map(str::trim).filter(|u| !u.is_empty()); + let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()); + let Some(source) = s.web_source.clone() else { + return Err(EngineError::Other("Web page import is not available in this build.".into())); + }; + let queries: Vec = { + let d = s.doc()?; + d.wb.web_queries + .iter() + .filter(|q| url.is_none_or(|u| q.url.eq_ignore_ascii_case(u)) && name.is_none_or(|n| q.name.eq_ignore_ascii_case(n))) + .cloned() + .collect() + }; + if queries.is_empty() { + return Err(EngineError::Other("No saved web table matches.".into())); + } + let mut refreshed = 0; + let mut errors: Vec = Vec::new(); + for q in queries { + let req = WebRequest { url: q.url.clone(), max_rows: DEFAULT_MAX_ROWS, max_cols: MAX_COLS, max_bytes: DEFAULT_MAX_BYTES }; + match source(&req).map_err(EngineError::Other).and_then(|tables| { + let table = pick_table(&tables, Some(&q.table)).ok_or_else(|| EngineError::Other(no_table_message(&tables)))?; + rewrite_table(s, &q, table) + }) { + Ok((rows, cols)) => { + refreshed += 1; + log::debug!("webRefresh {}: {} rows", q.name, rows); + let _ = cols; + } + Err(e) => errors.push(json!({"table": q.name, "error": e.to_string()})), + } + } + let _ = ID; + Ok(json!({"refreshed": refreshed, "errors": errors})) +} + +/// Re-fetch result over the sheet range the connection's table holds: clear the old range, +/// write the new rows, and resize the table. Formulas and formatting outside the range are kept. +fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTable) -> Result<(usize, usize)> { + let d = s.doc()?; + let Some((sheet, tname, old)) = + d.wb.sheets + .iter() + .enumerate() + .find_map(|(si, sh)| sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&q.name)).map(|t| (si, t.name.clone(), t.range))) + .or_else(|| { + d.wb.sheets.iter().enumerate().find_map(|(si, sh)| { + sh.name.eq_ignore_ascii_case(&q.sheet).then(|| sh.tables.first().map(|t| (si, t.name.clone(), t.range))).flatten() + }) + }) + else { + return Err(EngineError::Other(format!("The sheet or table of the connection `{}` is gone.", q.name))); + }; + let rows = json_rows(table); + if rows.is_empty() { + return Err(EngineError::Other("The table is empty.".into())); + } + let w = table.rows.iter().map(Vec::len).max().unwrap_or(1); + // Clear the old range, then drop the table so a new one can take the new shape. + s.execute("edit.clearContents", json!({"range": old.a1(), "sheet": sheet}))?; + let remove = |s: &mut Session| { + edit(s, |cx| { + if let Some(sh) = cx.wb.sheet_mut(sheet) { + sh.tables.retain(|t| !t.name.eq_ignore_ascii_case(&tname)); + } + cx.structural = true; + Ok(()) + }) + }; + remove(s)?; + s.execute("range.setValues", json!({"range": "A1", "sheet": sheet, "values": rows}))?; + let range = RangeRef::new(CellRef::new(0, 0), CellRef::new(table.rows.len() as u32 - 1, w as u32 - 1)); + let _ = s.execute("insert.table", json!({"range": range.a1(), "header": true, "name": tname})); + let synced = now_ms(); + edit(s, |cx| { + if let Some(qq) = cx.wb.web_queries.iter_mut().find(|x| x.url.eq_ignore_ascii_case(&q.url) && x.table.eq_ignore_ascii_case(&q.table)) { + qq.last_synced = Some(synced); + qq.name = tname.clone(); + qq.sheet = q.sheet.clone(); + } + cx.structural = true; + Ok(()) + })?; + Ok((table.rows.len(), w)) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use gridcraft_core::Value; + + fn page() -> &'static str { + "
SymPrice
ABCD12.50
\ +
x1
" + } + + fn session_with_source() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({})).unwrap(); + s.web_source = Some(Arc::new(|req: &WebRequest| { + assert!(req.url.starts_with("https://")); + Ok(crate::webquery::extract_tables(page(), req.max_rows, req.max_cols)) + })); + s + } + + fn a1(s: &Session, at: &str) -> String { + let d = s.active().unwrap(); + let sh = d.wb.active().unwrap(); + crate::display::cell_text(&d.wb, sh, CellRef::parse(at).unwrap()) + } + + #[test] + fn imports_the_first_table_into_a_new_sheet_and_saves_the_connection() { + let mut s = session_with_source(); + let r = s.execute("data.fromWeb", json!({"url": "https://example.com/markets", "sheet": "Markets"})).unwrap(); + assert_eq!(r["sheet"], "Markets"); + assert_eq!(r["rows"], 2); + assert_eq!(a1(&s, "A1"), "Sym"); + assert_eq!(a1(&s, "B2"), "12.5"); + let d = s.active().unwrap(); + assert_eq!(d.wb.web_queries.len(), 1); + assert_eq!(d.wb.web_queries[0].url, "https://example.com/markets"); + assert!(d.wb.web_queries[0].last_synced.is_some()); + assert!(d.wb.active().unwrap().tables.iter().any(|t| t.name == "prices")); + } + + #[test] + fn imports_a_named_table_and_keeps_text_and_numbers() { + let mut s = session_with_source(); + s.execute("data.fromWeb", json!({"url": "https://example.com", "table": "Table 2"})).unwrap(); + assert_eq!(a1(&s, "A1"), "x"); + let d = s.active().unwrap(); + let sh = d.wb.active().unwrap(); + assert_eq!(sh.value(CellRef::parse("B1").unwrap()), Value::number(1.0)); + } + + #[test] + fn preview_lists_the_page_tables() { + let mut s = session_with_source(); + let r = s.execute("data.webPreview", json!({"url": "https://example.com"})).unwrap(); + let tables = r["tables"].as_array().unwrap(); + assert_eq!(tables.len(), 2); + assert_eq!(tables[0]["name"], "prices"); + assert_eq!(tables[0]["rows"], 2); + assert_eq!(tables[1]["name"], "Table 2"); + } + + #[test] + fn refresh_rewrites_the_table_in_place() { + let mut s = session_with_source(); + s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "Web"})).unwrap(); + // The source now serves a bigger table. + s.web_source = Some(Arc::new(|req: &WebRequest| { + Ok(crate::webquery::extract_tables( + "
SymPrice
A1
B2
", + req.max_rows, + req.max_cols, + )) + })); + let r = s.execute("data.webRefresh", json!({})).unwrap(); + assert_eq!(r["refreshed"], 1); + assert!(r["errors"].as_array().unwrap().is_empty()); + assert_eq!(a1(&s, "A3"), "B"); + assert_eq!(a1(&s, "B3"), "2"); + let d = s.active().unwrap(); + let sh = d.wb.active().unwrap(); + assert_eq!(sh.tables.len(), 1); + assert_eq!(sh.tables[0].range.height(), 3); + // The old range's leftovers are gone: nothing beyond the new table. + assert!(sh.value(CellRef::parse("C1").unwrap()).is_empty()); + } + + #[test] + fn errors_without_source_bad_url_or_missing_table() { + let mut s = Session::new(); + s.execute("file.new", json!({})).unwrap(); + let err = s.execute("data.fromWeb", json!({"url": "https://x.com"})).unwrap_err(); + assert!(err.to_string().contains("not available")); + assert!(s.execute("data.fromWeb", json!({"url": "ftp://x"})).is_err()); + assert!(s.execute("data.fromWeb", json!({})).is_ok()); // opens the dialog + let mut s = session_with_source(); + let err = s.execute("data.fromWeb", json!({"url": "https://example.com", "table": "nope"})).unwrap_err(); + assert!(err.to_string().contains("no table by that name") || err.to_string().to_lowercase().contains("no table")); + assert!(s.execute("data.webRefresh", json!({"name": "nothing"})).is_err()); + } + + #[test] + fn headless_empty_params_fail_instead_of_opening_a_dialog() { + let mut s = session_with_source(); + assert!(s.execute_headless("data.fromWeb", json!({})).is_err()); + } + + #[test] + fn every_command_survives_empty_params() { + let mut s = session_with_source(); + for id in ["data.fromWeb", "data.webPreview", "data.webRefresh"] { + let _ = s.execute(id, json!({})); + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 258a1cb729..6bcbe2885f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -21,6 +21,7 @@ pub mod pivot_calc; pub mod sample; pub mod selection; pub mod tables; +pub mod webquery; use std::sync::Arc; @@ -232,6 +233,8 @@ pub struct Session { docs: Vec, active: usize, pub clipboard: Option, + /// The host's web table fetcher (Data ▸ From Web); `None` in wasm and tests that don't set it. + pub web_source: Option, pub prefs: Prefs, pub mode: Mode, /// Commands run so far, without their passwords ([`cmd::journal_params`]). @@ -374,11 +377,14 @@ impl Session { pub fn execute_headless(&mut self, id: &str, params: Json) -> Result { let r = self.execute(id, params); let requests = self.take_ui_requests(); - let asked_for_file = requests.iter().any(|q| matches!(q, UiRequest::Dialog(name, _) if name == "open" || name == "saveAs")); + let asked_for_file = requests + .iter() + .any(|q| matches!(q, UiRequest::Dialog(name, _) if name == "open" || name == "saveAs" || name == "fromWeb" || name == "fromDatabase")); if r.is_ok() && asked_for_file { let what = match id { "file.open" => "`path` or `base64`", "data.getData" | "data.fromTextCsv" => "`path` or `text`", + "data.fromWeb" => "`url`", _ => "`path`", }; let msg = format!("missing {what}: programmatic calls never open dialogs"); diff --git a/crates/engine/src/webquery.rs b/crates/engine/src/webquery.rs new file mode 100644 index 0000000000..fbfa2c34ed --- /dev/null +++ b/crates/engine/src/webquery.rs @@ -0,0 +1,364 @@ +//! Web table import (Data ▸ Get Data ▸ From Web). +//! +//! The engine stays network-free and wasm-clean: the host (desktop app, CLI) injects a +//! [`WebFetch`] into [`crate::Session::web_source`] that downloads a page; the engine +//! extracts the tables (pure HTML parsing, like the clipboard flavour in +//! [`crate::clip_html`]) and writes them to a sheet. Without a source, `data.fromWeb` +//! returns an error. + +/// Rows imported by default (the sheet's row count minus the header row). +pub const DEFAULT_MAX_ROWS: usize = 1_048_575; +/// Columns imported at most (the sheet's column count). +pub const MAX_COLS: usize = 16_384; +/// Page size and download timeout, in bytes and seconds, when the caller gives none. +pub const DEFAULT_MAX_BYTES: usize = 16 * 1024 * 1024; +pub const DEFAULT_TIMEOUT_SECS: u64 = 30; +/// Longest text a cell holds (Excel's limit). +pub const MAX_TEXT: usize = 32_767; + +/// One page download: the URL and the caps the fetch must honour. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WebRequest { + pub url: String, + pub max_rows: usize, + pub max_cols: usize, + pub max_bytes: usize, +} + +/// One table found on a page: its name, cell text row by row (padded to the widest row). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct WebTable { + pub name: String, + pub rows: Vec>, +} + +/// Fetches a page and returns its tables. Implementations must honour the caps and never panic. +pub type WebFetch = std::sync::Arc Result, String> + Send + Sync>; + +/// The tables of `html`, in document order. Nested tables are flattened into the cell text of +/// the enclosing table (like Excel's detection, a table inside a cell stays part of the cell). +/// `max_rows`/`max_cols` cap each table's size; longer tables are reported truncated. +pub fn extract_tables(html: &str, max_rows: usize, max_cols: usize) -> Vec { + let mut out: Vec = Vec::new(); + if html.len() > DEFAULT_MAX_BYTES { + return out; + } + let mut rest = html; + let mut current: Option = None; + let mut depth = 0usize; + while !rest.is_empty() { + let Some(lt) = rest.find('<') else { break }; + let text = rest.get(..lt).unwrap_or_default(); + if let Some(t) = current.as_mut() { + t.push_text(text); + } + rest = rest.get(lt..).unwrap_or_default(); + if let Some(after) = rest.strip_prefix("").and_then(|e| after.get(e + 3..)).unwrap_or_default(); + continue; + } + let Some(gt) = tag_end(rest) else { break }; + let tag = rest.get(1..gt).unwrap_or_default(); + rest = rest.get(gt + 1..).unwrap_or_default(); + let closing = tag.starts_with('/'); + let body = tag.trim_start_matches('/'); + let name_end = body.find(|c: char| c.is_whitespace() || c == '/').unwrap_or(body.len()); + let name = body.get(..name_end).unwrap_or_default().to_ascii_lowercase(); + match (name.as_str(), closing) { + ("style" | "script" | "noscript", false) => { + let end = format!(" { + match current.as_mut() { + // A nested table: its markup stays part of the enclosing cell's text. + Some(t) => { + depth += 1; + t.push_text(&format!("<{tag}>")); + } + None => { + let id = attr(body, "id"); + let name = id.filter(|s| !s.trim().is_empty()).map(String::from).unwrap_or_else(|| format!("Table {}", out.len() + 1)); + current = Some(TableState::new(name, max_rows, max_cols)); + depth = 0; + } + } + } + ("table", true) => { + if depth > 0 { + depth -= 1; + if let Some(t) = current.as_mut() { + t.push_text(&format!("<{tag}>")); + } + } else if let Some(mut t) = current.take() { + t.close(); + if !t.rows.is_empty() { + out.push(WebTable { name: t.name, rows: t.rows }); + } + } + } + ("tr" | "td" | "th", false) => { + match current.as_mut() { + // Inside a nested table the markup stays part of the enclosing cell's text. + Some(t) if depth > 0 => t.push_text(&format!("<{tag}>")), + Some(t) if name == "tr" => t.start_row(), + Some(t) => t.start_cell(), + None => {} + } + } + _ => {} + } + } + out +} + +/// One `` being read: cells accumulate text until the next ``. +struct TableState { + name: String, + max_rows: usize, + max_cols: usize, + rows: Vec>, + row: Vec, + cell: String, + cell_open: bool, +} + +impl TableState { + fn new(name: String, max_rows: usize, max_cols: usize) -> Self { + Self { name, max_rows, max_cols, rows: Vec::new(), row: Vec::new(), cell: String::new(), cell_open: false } + } + + fn push_text(&mut self, text: &str) { + if self.cell_open { + self.cell.push_str(text); + } + } + + fn start_cell(&mut self) { + self.close_cell(); + self.cell_open = true; + } + + fn start_row(&mut self) { + self.close_cell(); + self.flush_row(); + } + + /// Close the current cell (collapsing whitespace) into the current row. + fn close_cell(&mut self) { + if !self.cell_open { + return; + } + self.cell_open = false; + let text = collapse_ws(&std::mem::take(&mut self.cell)); + if self.row.len() < self.max_cols { + self.row.push(text.chars().take(MAX_TEXT).collect()); + } + } + + fn flush_row(&mut self) { + if self.row.is_empty() || self.rows.len() >= self.max_rows { + self.row.clear(); + return; + } + self.rows.push(std::mem::take(&mut self.row)); + } + + /// `
`/``/`
`: close the open cell and row, pad every row to the table's width. + fn close(&mut self) { + self.close_cell(); + self.flush_row(); + let w = self.rows.iter().map(Vec::len).max().unwrap_or(0).min(self.max_cols); + for r in &mut self.rows { + r.resize(w, String::new()); + } + } +} + +/// Cell text as one line: runs of whitespace collapse to single spaces, entities decode. +fn collapse_ws(s: &str) -> String { + let decoded = decode_entities(s).replace('\u{a0}', " "); + let mut out = String::with_capacity(decoded.len()); + let mut in_ws = false; + for ch in decoded.trim().chars() { + if ch.is_whitespace() { + in_ws = true; + } else { + if in_ws && !out.is_empty() { + out.push(' '); + } + in_ws = false; + out.push(ch); + } + } + out +} + +/// The common named and numeric HTML entities; unknown ones stay as written. +fn decode_entities(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find('&') { + out.push_str(rest.get(..i).unwrap_or_default()); + rest = rest.get(i..).unwrap_or_default(); + let end = rest.find(';').unwrap_or(rest.len()); + let ent = rest.get(..end.min(rest.len())).unwrap_or_default(); + let value = match ent.strip_prefix("&#") { + Some(num) => num + .strip_prefix('x') + .map(|h| u32::from_str_radix(h.trim(), 16).ok()) + .unwrap_or_else(|| num.trim().parse::().ok()) + .and_then(char::from_u32), + None => named_entity(ent.strip_prefix('&').unwrap_or(ent)), + }; + match value { + Some(ch) => { + out.push(ch); + rest = rest.get(end + 1..).unwrap_or_default(); + } + None => { + out.push('&'); + rest = rest.get(1..).unwrap_or_default(); + } + } + } + out.push_str(rest); + out +} + +fn named_entity(name: &str) -> Option { + Some(match name { + "amp" => '&', + "lt" => '<', + "gt" => '>', + "quot" => '"', + "apos" => '\'', + "nbsp" => '\u{a0}', + "copy" => '©', + "reg" => '®', + "trade" => '™', + "hellip" => '…', + "mdash" => '—', + "ndash" => '–', + "lsquo" => '‘', + "rsquo" => '’', + "ldquo" => '“', + "rdquo" => '”', + "bull" => '•', + "middot" => '·', + "deg" => '°', + "euro" => '€', + "pound" => '£', + "yen" => '¥', + "cent" => '¢', + "sect" => '§', + "para" => '¶', + "times" => '×', + "divide" => '÷', + "plusmn" => '±', + "frac12" => '½', + "frac14" => '¼', + "frac34" => '¾', + "sup2" => '²', + "sup3" => '³', + _ => return None, + }) +} + +/// The end of the tag starting at `rest` (`` → index of `>`), tolerating quotes. +fn tag_end(rest: &str) -> Option { + let bytes = rest.as_bytes(); + let mut quote = 0u8; + for (i, &b) in bytes.iter().enumerate().skip(1) { + match (quote, b) { + (0, b'"') | (0, b'\'') => quote = b, + (q, b) if q == b => quote = 0, + (0, b'>') => return Some(i), + _ => {} + } + } + None +} + +/// The value of `attr` in the tag body `body` ("id" of ``). +fn attr<'a>(body: &'a str, attr: &str) -> Option<&'a str> { + let lower = body.to_ascii_lowercase(); + let needle = format!("{attr}="); + let i = lower.find(&needle)?; + let rest = body.get(i + needle.len()..)?; + let rest = rest.trim_start(); + let q = rest.as_bytes().first()?; + if *q == b'"' || *q == b'\'' { + let rest = rest.get(1..)?; + let end = rest.find(*q as char)?; + Some(rest.get(..end).unwrap_or_default()) + } else { + Some(rest.split(|c: char| c.is_whitespace() || c == '>').next().unwrap_or_default()) + } +} + +/// Case-insensitive `find`. +fn find_ci(haystack: &str, needle: &str) -> Option { + let h = haystack.as_bytes(); + let n = needle.as_bytes(); + if n.is_empty() || h.len() < n.len() { + return None; + } + (0..=h.len() - n.len()).find(|&i| h[i..i + n.len()].eq_ignore_ascii_case(n)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_named_and_unnamed_tables() { + let html = "

intro

\ +
SymPrice
ABCD12.50
\ +
xy & z
"; + let t = extract_tables(html, 100, 100); + assert_eq!(t.len(), 2); + assert_eq!(t[0].name, "prices"); + assert_eq!(t[0].rows, vec![vec!["Sym", "Price"], vec!["ABCD", "12.50"]]); + assert_eq!(t[1].name, "Table 2"); + assert_eq!(t[1].rows, vec![vec!["x", "y & z"]]); + } + + #[test] + fn ragged_rows_are_padded_and_spans_ignored() { + let html = "
abc
d
"; + let t = extract_tables(html, 100, 100); + assert_eq!(t[0].rows, vec![vec!["a", "b", "c"], vec!["d", "", ""]]); + } + + #[test] + fn collapses_whitespace_decodes_entities_and_skips_scripts() { + let html = "\ +
Red & Blue\n Widget AB
"; + let t = extract_tables(html, 100, 100); + assert_eq!(t[0].rows[0], vec!["Red & Blue Widget", "AB"]); + } + + #[test] + fn honours_row_and_col_caps() { + let html = "
12
34
"; + assert_eq!(extract_tables(html, 1, 100)[0].rows, vec![vec!["1", "2"]]); + assert_eq!(extract_tables(html, 100, 1)[0].rows, vec![vec!["1"], vec!["3"]]); + } + + #[test] + fn empty_or_broken_markup_yields_nothing_without_panicking() { + assert!(extract_tables("", 10, 10).is_empty()); + assert!(extract_tables("`/``: a `rowspan="0"` cell covers no further rows. + fn end_row_group(&mut self) { + // The row being built belongs to the group too (its last cell may still be open). + let until = self.rows.len() + usize::from(!self.row.is_empty() || self.cell_open); + for u in &mut self.covered_until { + if *u == usize::MAX { + *u = until; + } + } + } + + /// Close the current cell into the current row: past the columns a `rowspan` above covers, + /// then the cell, then an empty placeholder per extra `colspan` column. fn close_cell(&mut self) { if !self.cell_open { return; } self.cell_open = false; - let text = collapse_ws(&std::mem::take(&mut self.cell)); + let (colspan, rowspan) = self.cell_span; + let text = collapse_ws(&std::mem::take(&mut self.cell)).chars().take(MAX_TEXT).collect::(); + // The row being built is the next one to flush: index rows.len(). + let r = self.rows.len(); + while self.covered_until.get(self.row.len()).is_some_and(|&until| until > r) { + if self.row.len() >= self.max_cols { + return; + } + self.row.push(String::new()); + } + let start = self.row.len(); if self.row.len() < self.max_cols { - self.row.push(text.chars().take(MAX_TEXT).collect()); + self.row.push(text); + } + for _ in 1..colspan { + if self.row.len() >= self.max_cols { + break; + } + self.row.push(String::new()); + } + if let Some(span) = rowspan.filter(|s| *s > 1) { + let until = r.saturating_add(span).min(self.max_rows); + if self.covered_until.len() < self.row.len() { + self.covered_until.resize(self.row.len(), 0); + } + for u in self.covered_until.get_mut(start..self.row.len()).into_iter().flatten() { + *u = (*u).max(until); + } + } else if rowspan.is_none() { + // rowspan="0": covers the rest of its row group (thead/tbody/tfoot), capped by the table. + if self.covered_until.len() < self.row.len() { + self.covered_until.resize(self.row.len(), 0); + } + for u in self.covered_until.get_mut(start..self.row.len()).into_iter().flatten() { + *u = usize::MAX; + } } } @@ -284,7 +355,7 @@ fn tag_end(rest: &str) -> Option { fn attr<'a>(body: &'a str, attr: &str) -> Option<&'a str> { let lower = body.to_ascii_lowercase(); let needle = format!("{attr}="); - let i = lower.find(&needle)?; + let i = lower.match_indices(&needle).map(|(i, _)| i).find(|&i| i == 0 || lower.as_bytes()[i - 1].is_ascii_whitespace())?; let rest = body.get(i + needle.len()..)?; let rest = rest.trim_start(); let q = rest.as_bytes().first()?; @@ -354,6 +425,31 @@ mod tests { assert!(extract_tables("
no end", 10, 10).is_empty()); + assert!(extract_tables("
c\">x
", 10, 10)[0].rows[0][0].contains("x")); + assert!(extract_tables("
1
", 10, 10)[0].rows == vec![vec!["1"]]); + } + + #[test] + fn nested_table_text_stays_in_the_cell() { + let html = "
outer
inner
end
"; + let t = extract_tables(html, 100, 100); + assert_eq!(t.len(), 1); + assert_eq!(t[0].rows[0][0], "outer
inner
end"); + } +} diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index b51f7bc98a..1497228a01 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -57,6 +57,23 @@ pub struct TableColumn { pub xml: Option, } +/// A saved From Web connection (Data ▸ Get Data ▸ From Web): one table imported from one page, +/// which `data.webRefresh` re-fetches into the same sheet. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct WebQuery { + /// The connection's name: the worksheet table it fills. + pub name: String, + /// The page the table came from. + pub url: String, + /// The page table's name, as the import saw it. + pub table: String, + /// The sheet the import wrote. + pub sheet: String, + /// When the connection last synced, in ms since the epoch; `None` until the first sync. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_synced: Option, +} + /// A table column's binding to an XML map (``). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct XmlColumnPr { diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 828ca692e7..f0b55e5078 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -113,6 +113,9 @@ pub struct Workbook { /// separator back (a `;` file stays a `;` file). `None` = not read from CSV: CSV saves use `,`. #[serde(default, skip_serializing_if = "Option::is_none")] pub csv_delimiter: Option, + /// Saved From Web connections (Data ▸ Get Data ▸ From Web), one per imported table. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub web_queries: Vec, /// Unmodelled file content kept for saving (package root and workbook level). Not in JSON. #[serde(skip)] pub preserved: Option>, @@ -146,6 +149,7 @@ impl Workbook { cell_styles: vec![], vba_project: None, csv_delimiter: None, + web_queries: Vec::new(), preserved: None, source_dir: None, } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fb899c7026..3706e8a525 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -437,6 +437,7 @@ impl Dialog { vec![Check { key: "header", label: "My data has headers" }, Text { key: "columnsText", label: "Columns (blank = all, e.g. A,C):" }], json!({"header": true}), ), + "fromWeb" => Dialog::custom("fromWeb", "From Web", json!({"url": "", "pick": 0})), "goalSeek" => Dialog::custom("goalSeek", "Goal Seek", json!({"set": active, "to": "0", "changing": ""})), "forecastSheet" => Dialog::form( "forecastSheet", @@ -1057,6 +1058,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "recommendedCharts" => recommended_charts(app, ui, &mut d, &mut confirm, &mut open), "selectData" => select_data(app, ui, &mut d, &mut confirm, &mut open), "commandSearch" => command_search(app, ui, &mut d, &mut confirm), + "fromWeb" => from_web(app, ui, &mut d, &mut confirm, &mut open), "goalSeek" => goal_seek(app, ui, &mut d, &mut confirm), "analysisToolPak" => analysis_toolpak(app, ui, &mut d, &mut confirm, &mut open), "solver" => solver(ui, &mut d, &mut confirm, &mut open), @@ -2277,6 +2279,90 @@ fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm } } +/// Data ▸ From Web: Excel's Navigator. Enter a URL, the page's detected tables are previewed +/// on the right, and Load imports the selected one into a new sheet. +fn from_web(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let get = |k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); + let mut url = get("url"); + egui::Grid::new("fw").show(ui, |ui| { + ui.label("URL:"); + ui.add(egui::TextEdit::singleline(&mut url).desired_width(360.0).hint_text("https://example.com/table.html")); + if ui.button("Go").clicked() && !url.trim().is_empty() { + d.values.insert("url".into(), json!(url.trim())); + d.values.insert("tables".into(), Json::Null); + let r = app.run("data.webPreview", json!({"url": url.trim()})); + match r { + Ok(res) => d.values.insert("tables".into(), res["tables"].clone()), + Err(e) => { + app.message = Some(("From Web".into(), crate::clean_error(&e))); + d.values.insert("tables".into(), Json::Null) + } + }; + } + ui.end_row(); + }); + let tables: Vec = d.values.get("tables").and_then(Json::as_array).cloned().unwrap_or_default(); + if !tables.is_empty() { + ui.separator(); + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.strong("Suggested Tables"); + let mut pick = d.values.get("pick").and_then(Json::as_u64).unwrap_or(0) as usize; + for (i, t) in tables.iter().enumerate() { + let name = t["name"].as_str().unwrap_or("Table"); + let rows = t["rows"].as_u64().unwrap_or(0); + if ui.selectable_label(i == pick, format!("{name} ({rows} rows)")).clicked() { + pick = i; + d.values.insert("pick".into(), json!(i)); + } + } + }); + ui.separator(); + let pick = d.values.get("pick").and_then(Json::as_u64).unwrap_or(0) as usize; + if let Some(t) = tables.get(pick) { + ui.vertical(|ui| { + ui.strong("Preview"); + egui::ScrollArea::vertical().max_height(200.0).id_salt("fw_preview").show(ui, |ui| { + egui::Grid::new("fw_preview_grid").striped(true).show(ui, |ui| { + if let Some(rows) = t["preview"].as_array() { + for row in rows { + for cell in row.as_array().into_iter().flatten() { + ui.label(cell.as_str().unwrap_or_default()); + } + ui.end_row(); + } + } + }); + }); + }); + } + }); + } else if d.values.get("tables").is_some_and(|t| t.is_null()) { + ui.label("No tables were found on this page."); + } else { + ui.label("Enter an address and choose Go to detect the page's tables."); + } + d.values.insert("url".into(), json!(url.clone())); + let mut load = true; + ok_cancel(ui, confirm, &mut load); + if *confirm && !url.trim().is_empty() { + let pick = d.values.get("pick").and_then(Json::as_u64).unwrap_or(0); + let table = tables.get(pick as usize).and_then(|t| t["name"].as_str()).map(String::from); + let params = match table { + Some(t) => json!({"url": url.trim(), "table": t}), + None => json!({"url": url.trim()}), + }; + match app.run("data.fromWeb", params) { + Ok(r) => app.toast = Some((format!("Imported {} rows into {}", r["rows"], r["sheet"]), crate::now_ms())), + Err(e) => app.message = Some(("From Web".into(), crate::clean_error(&e))), + } + } + if !load { + *confirm = true; + *open = false; + } +} + fn goal_seek(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { let get = |k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); let (mut set, mut to, mut changing) = (get("set"), get("to"), get("changing")); diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index 79920b5b8c..a1bce61fab 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -270,7 +270,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("AFF", Entry::Finish(Action::Command("edit.flashFill"))), // Excel: Flash Fill ("AFO", Entry::Finish(Action::Pending("data.fromOtherSources"))), // pending; Excel: Get External Data: From Other Sources; gap #12; reserved until the command lands ("AFT", Entry::Finish(Action::Pending("data.fromText"))), // pending; Excel: Get External Data: From Text; gap #12; reserved until the command lands - ("AFW", Entry::Finish(Action::Pending("data.fromWeb"))), // pending; Excel: Get External Data: From Web; reserved until the command lands + ("AFW", Entry::Finish(Action::Command("data.fromWeb"))), // Excel: Get External Data: From Web; From Web Navigator dialog ("AG", Entry::Continue), // Excel: Group Menu; group level: containment(Group) ("AGA", Entry::Finish(Action::Pending("data.autoOutline"))), // pending; Excel: Auto Outline; Auto Outline; reserved until the command lands ("AGG", Entry::Finish(Action::Command("data.group"))), // Excel: Group diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 59aeadcfb8..be260344b0 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -1812,9 +1812,8 @@ "seq": "AFW", "kind": "command", "target": "data.fromWeb", - "pending": true, "excel": "Get External Data: From Web", - "note": "reserved until the command lands" + "note": "From Web Navigator dialog" }, { "seq": "AG", diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 9c6d89eeb2..79f4b9ae7b 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1891,6 +1891,11 @@ fn data(app: &mut SheetApp, ui: &mut Ui) { if gd.clicked() { app.open_dialog("open", json!({})); } + let web = big_button(ui, Icon::Table, "From\nWeb", "Import a table from a web page (From Web)", false); + control_keytip(app, ui, &web, "data.fromWeb"); + if web.clicked() { + app.open_dialog("fromWeb", json!({})); + } sep(ui); ui.vertical(|ui| { let __k = small_button(ui, Icon::SortAsc, "", "Sort A to Z", false); diff --git a/crates/ui-egui/tests/keytips_generated.rs b/crates/ui-egui/tests/keytips_generated.rs index 653554857e..25a6fce219 100644 --- a/crates/ui-egui/tests/keytips_generated.rs +++ b/crates/ui-egui/tests/keytips_generated.rs @@ -1972,10 +1972,7 @@ fn keytip_aft_() { fn keytip_afw_() { let mut h = harness(blank()); sequence(&mut h, "AFW", false); - assert!(h.state().dialog.is_none(), "AFW must not open a dialog"); - assert!(h.state().message.is_none(), "AFW must not open a message box"); - assert!(h.state().toast.is_none(), "AFW must not raise a toast"); - assert!(h.state().session.journal.is_empty(), "AFW must not journal"); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "data.fromWeb"), "AFW must journal data.fromWeb"); } #[test] diff --git a/crates/webquery/Cargo.toml b/crates/webquery/Cargo.toml new file mode 100644 index 0000000000..6826b9536d --- /dev/null +++ b/crates/webquery/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "gridcraft-webquery" +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +version = "0.1.0" +description = "Web table import (Data > Get Data > From Web): fetch a page and extract its tables. Native only." + +[dependencies] +gridcraft-engine = { workspace = true } +log = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +ureq = { version = "3", default-features = false, features = ["rustls", "gzip"] } + +[lints] +workspace = true diff --git a/crates/webquery/src/lib.rs b/crates/webquery/src/lib.rs new file mode 100644 index 0000000000..3253a2fcca --- /dev/null +++ b/crates/webquery/src/lib.rs @@ -0,0 +1,66 @@ +//! The web table source for Data ▸ Get Data ▸ From Web. +//! +//! [`provider`] returns the [`WebSource`] a native host installs in +//! `Session::web_source`. It fetches one page, extracts its HTML tables and +//! reduces each to a [`WebTable`]. Native only: the engine and the web build +//! never link it. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +use std::sync::Arc; + +use gridcraft_engine::webquery::{WebFetch, WebTable}; + +/// The web source with this build's fetcher. +pub fn provider() -> WebFetch { + Arc::new(fetch) +} + +/// Runs `req`: fetch the URL, extract the tables the page holds. +pub fn fetch(req: &gridcraft_engine::webquery::WebRequest) -> Result, String> { + log::info!("data.fromWeb: {} (max {} bytes)", redact(&req.url), req.max_bytes); + #[cfg(target_arch = "wasm32")] + { + let _ = req; + Err("Web page import is not available in this build.".into()) + } + #[cfg(not(target_arch = "wasm32"))] + { + let html = http_get(req)?; + Ok(gridcraft_engine::webquery::extract_tables(&html, req.max_rows, req.max_cols)) + } +} + +/// The URL without its query string, for logs and messages. +pub fn redact(url: &str) -> String { + url.split_once('?').map_or_else(|| url.to_string(), |(base, _)| format!("{base}?…")) +} + +/// One bounded GET: https only, no redirects, no cookies; size- and time-capped. +#[cfg(not(target_arch = "wasm32"))] +fn http_get(req: &gridcraft_engine::webquery::WebRequest) -> Result { + use std::io::Read; + + if !req.url.trim_start().starts_with("https://") { + return Err("Only https:// addresses can be imported.".into()); + } + let agent = ureq::Agent::new_with_config( + ureq::config::Config::builder().max_redirects(0).timeout_global(Some(std::time::Duration::from_secs(30))).build(), + ); + let resp = agent + .get(&req.url) + .header("user-agent", concat!("GridCraft/", env!("CARGO_PKG_VERSION"))) + .header("accept", "text/html,application/xhtml+xml") + .call() + .map_err(|e| match e { + ureq::Error::StatusCode(code) => format!("The page answered {code} and could not be imported."), + other => other.to_string(), + })?; + let content_type = resp.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or_default().to_string(); + if !content_type.is_empty() && !content_type.contains("html") && !content_type.contains("xml") && !content_type.contains("text") { + return Err(format!("The address serves {content_type}, not a web page.")); + } + let mut bytes: Vec = Vec::new(); + let _ = resp.into_body().into_reader().take(req.max_bytes as u64).read_to_end(&mut bytes).map_err(|e| e.to_string())?; + Ok(String::from_utf8_lossy(&bytes).into_owned()) +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9884724146..e9c54a37a3 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (XLSX chart sheets are kept on save (#727); previously minor: CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** trivial (Import sources: From Web) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -65,8 +65,9 @@ and repair-free opening remain unverified. Excel's Get Data reaches files (Excel, CSV, XML, JSON, PDF, Parquet, folders), databases (SQL Server, Access, ODBC, …), web pages, SharePoint and online services, through Power Query. -GridCraft imports CSV/TSV, JSON and HTML tables only, with no query editor, no connections and no -refresh. Counted under features (Get & Transform, ~10% ready), not here. +GridCraft imports CSV/TSV, JSON and HTML tables, and one detected table from an https web page +(Data ▸ From Web, desktop and CLI), with no query editor and no From Database/folder sources. +Counted under features (Get & Transform, ~15% ready), not here. ## Work (76–114 h) @@ -85,6 +86,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | trivial | Import sources: From Web (one page table, with refresh) | | 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) | | 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) | | 2026-10-12 | minor | `.xls` import reads the calculation settings (CalcMode, CalcIter, CalcCount, CalcDelta, CalcPrecision), so circular models calculate instead of showing `#CIRC!` (#724) | diff --git a/docs/gaps.md b/docs/gaps.md index 03e8672c52..2e8c088dbb 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (§12: From Web imports a page table, with a saved connection `data.webRefresh` re-syncs) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -102,8 +102,11 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- 100–165 h including #5. [localization-parity.md](localization-parity.md) ### 12. Get & Transform, external data, data model (F) -- No Power Query editor, connections, refresh, From Web/database/folder sources, relationships or - data model (`data.fromWeb`, `data.relationships` missing). **Estimate:** 60–100 h. +- No Power Query editor, connections, From Database/folder sources, relationships or data model + (`data.fromDatabase`, `data.relationships` missing). From Web (`data.fromWeb`) fetches an https + page, lists its detected tables in a Navigator dialog and loads the selected one into a new + table sheet, saving the connection; `data.webRefresh` re-fetches and rewrites the table in + place (desktop and CLI). **Estimate:** 50–90 h. ### 13. Analysis tools: Solver, Analysis ToolPak, Forecast Sheet (F) - `data.solver`, `data.analysisToolPak` missing. Forecast Sheet (`data.forecastSheet`) and the @@ -168,6 +171,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | §12: From Web (`data.fromWeb`): web table import with Navigator preview and one-click refresh (#1356) | | 2026-10-12 | minor | rows auto-fit oversized fonts on open and whole-row/column formatting (#833) | | 2026-10-12 | minor | Page Layout view: dragging margins on the rulers (#792) removed from the §14 remainder | | 2026-10-12 | minor | §13: Forecast Sheet Forecast Start (hindcast) landed (#785) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index d218c8169d..9f68e8af9a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,7 +4,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md). -**Overall: 274 / 290 commands implemented (94.5%).** +**Overall: 275 / 290 commands implemented (94.8%).** | Tab | Implemented | Total | Parity | |---|---|---|---| @@ -14,7 +14,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as | Draw | 4 | 4 | 100% | | Page Layout | 23 | 23 | 100% | | Formulas | 23 | 23 | 100% | -| Data | 23 | 28 | 82% | +| Data | 24 | 28 | 86% | | Review | 15 | 18 | 83% | | View | 20 | 20 | 100% | | Automate | 3 | 3 | 100% | @@ -237,12 +237,13 @@ Implemented: - `formulas.calculateNow` Calculate Now (Calculation) - `formulas.calculateSheet` Calculate Sheet (Calculation) -## Data (23 / 28, 82%) +## Data (24 / 28, 86%) Implemented: - `data.getData` Get Data (Get & Transform Data) - `data.fromTextCsv` From Text/CSV (Get & Transform Data) +- `data.fromWeb` From Web (Get & Transform Data) - `data.fromTableRange` From Table/Range (Get & Transform Data) - `data.refreshAll` Refresh All (Queries & Connections) - `data.queriesConnections` Queries & Connections (Queries & Connections) @@ -267,7 +268,6 @@ Implemented: Missing: -- `data.fromWeb` From Web (Get & Transform Data) - `data.stocks` Stocks (Data Types) - `data.geography` Geography (Data Types) - `data.relationships` Relationships (Data Tools) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b623124d9f..52fa61458d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and row formatting) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (§ Get & Transform: From Web imports a web page table with refresh) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -88,7 +88,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 | Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, trendlines, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Several kinds don't survive XLSX round trip (open PRs #158, #161). [chart-parity.md](chart-parity.md) | | Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) | | What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (13 of 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; Answer and Limits reports; no Sensitivity report or options dialog). Missing: Forecast Sheet, Analyze Data | -| Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) | +| Get & Transform, external data, data model | 5 | ~20% | ~10% | 50–90 | Import of CSV/TSV, JSON, HTML, and From Web (page table import with Navigator preview and one-click refresh). No Power Query editor, no From Database or folder sources, no relationships or data model, no external workbook links (dropped with a warning) | | Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | | Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection, Allow Edit Ranges with Excel-compatible password hashes (#307). Missing: comment navigation, Show Changes, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) | | Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). VBA is out of scope by owner decision and is **dropped** when an `.xlsm` is opened (warning shown). No Office Scripts compatibility | @@ -332,6 +332,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | From Web (`data.fromWeb`): web table import with Navigator preview and refresh (#1356) | | 2026-10-12 | minor | Essentials AutoFit evidence: rows auto-fit oversized fonts on open and row formatting (#833) | | 2026-10-12 | minor | Sheet tabs: reveal the selected tab on navigation and resize while preserving manual scrolling (#681); no score change | | 2026-10-12 | minor | PivotTables: values on rows (Σ Values in Rows at any position, all three layouts; `pivot.moveField` and the field pane move it; `dataOnRows` round-trips) (no score change) | diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 0d1990730d..5d28d8b266 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -38,6 +38,7 @@ pub const TABLE: &[(&str, Class)] = &[ ("engine", Class::Layer(5)), ("ui-egui", Class::Layer(6)), ("mcp", Class::Layer(6)), + ("webquery", Class::Layer(6)), // L7 apps (exempt) and tooling (unchecked) ("gridcraft", Class::Layer(7)), ("cli", Class::Layer(7)), From 9a5bb511596a4a6cf06aae128ea52ba28f7ccdd7 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sun, 25 Oct 2026 15:45:47 +0000 Subject: [PATCH 02/31] From Web: address the review findings - Default sheet name is the site's own name, not the TLD (www.example.com -> example), with second-level country suffixes (.co.uk) skipped - A connection is identified by its sheet and table name, so importing the same page into another sheet no longer deletes the first sheet's connection - The page table's name is sanitized into a valid table name; a taken name gets a generated one, and the connection saves the name actually created - Import and refresh are one undo step (s.grouped): a failure rolls the workbook back instead of leaving an empty sheet or a half-cleared table, and a failed insert.table is no longer silently dropped - Refresh writes at the table's own origin, not A1, so a moved table refreshes in place and A1 is not overwritten - extract_tables honours colspan/rowspan, including rowspan="0" within its row group, and attr() matches whole attribute names (no data-id= hit) - redact() logs only the URL origin: userinfo and path never reach the log - From Web dialog: Go resets the table pick, and a failed import keeps the dialog open Co-authored-by: setzor --- crates/engine/src/cmd/web.rs | 151 +++++++++++++++++++++++++++------- crates/engine/src/webquery.rs | 110 +++++++++++++++++++++++-- crates/ui-egui/src/dialogs.rs | 6 +- crates/webquery/src/lib.rs | 11 ++- 4 files changed, 238 insertions(+), 40 deletions(-) diff --git a/crates/engine/src/cmd/web.rs b/crates/engine/src/cmd/web.rs index d324afd718..3a1bd5f09f 100644 --- a/crates/engine/src/cmd/web.rs +++ b/crates/engine/src/cmd/web.rs @@ -80,14 +80,31 @@ fn from_web(s: &mut Session, p: &Json) -> Result { let tables = source(&req).map_err(EngineError::Other)?; let want = str_param(p, "table").map(str::trim).filter(|t| !t.is_empty()); let table = pick_table(&tables, want).ok_or_else(|| EngineError::Other(no_table_message(&tables)))?; - let name = str_param(p, "sheet").map(|n| n.chars().take(31).collect::()).unwrap_or_else(|| { - let host = req.url.trim_start_matches("https://").split('/').next().unwrap_or("Web").to_string(); - let base = host.rsplit('.').find(|p| !p.is_empty() && *p != "www").unwrap_or("Web").to_string(); - if base.is_empty() { "Web".into() } else { base.chars().take(31).collect() } - }); + let name = str_param(p, "sheet").map(|n| n.chars().take(31).collect::()).unwrap_or_else(|| default_sheet_name(&req.url)); load_table(s, &req, table, &name, true) } +/// The site's own name (`https://www.example.com/x` → `example`), capped at 31 characters. +/// Country suffixes that register under a second level (`.co.uk`, `.com.au`) are skipped, so +/// `https://sub.example.co.uk` is `example` too. +fn default_sheet_name(url: &str) -> String { + const SECOND_LEVEL_CCTLDS: [&str; 12] = ["uk", "au", "jp", "br", "cn", "in", "kr", "nz", "za", "sg", "hk", "tw"]; + const SECOND_LEVEL_PREFIXES: [&str; 9] = ["co", "com", "org", "net", "gov", "ac", "edu", "or", "ne"]; + let authority = url.trim_start_matches("https://").split(['/', '?', '#']).next().unwrap_or_default(); + let host = authority.rsplit('@').next().unwrap_or_default().split(':').next().unwrap_or_default(); + let labels: Vec<&str> = host.split('.').filter(|p| !p.is_empty() && *p != "www").collect(); + let base = match labels.len() { + 0 => "Web", + 1 => labels[0], + n => { + let last = labels[n - 1]; + let second = labels[n - 2]; + if SECOND_LEVEL_CCTLDS.contains(&last) && SECOND_LEVEL_PREFIXES.contains(&second) && n > 2 { labels[n - 3] } else { second } + } + }; + base.chars().take(31).collect() +} + /// The table `want` names (page name or 1-based index), else the first one. fn pick_table<'a>(tables: &'a [WebTable], want: Option<&str>) -> Option<&'a WebTable> { match want { @@ -108,6 +125,12 @@ fn no_table_message(tables: &[WebTable]) -> String { /// Writes `table` to a new sheet as a table and (when `save`) records the connection. pub(crate) fn load_table(s: &mut Session, req: &WebRequest, table: &WebTable, sheet_name: &str, save: bool) -> Result { + s.grouped("From Web", |s| load_table_steps(s, req, table, sheet_name, save)) +} + +/// The import as one undo step: a failure after the sheet was inserted rolls the whole +/// workbook back, so no empty sheet or half-written table is left behind. +fn load_table_steps(s: &mut Session, req: &WebRequest, table: &WebTable, sheet_name: &str, save: bool) -> Result { let rows = json_rows(table); if rows.is_empty() { return Err(EngineError::Other("The table is empty.".into())); @@ -119,9 +142,26 @@ pub(crate) fn load_table(s: &mut Session, req: &WebRequest, table: &WebTable, sh s.execute("range.setValues", json!({"range": "A1", "sheet": sheet, "values": rows}))?; let w = table.rows.iter().map(Vec::len).max().unwrap_or(1); let range = RangeRef::new(CellRef::new(0, 0), CellRef::new(table.rows.len() as u32 - 1, w as u32 - 1)); - // The table takes the page table's name when it's a valid, free one. - let tname = table.name.chars().take(255).collect::(); - let created = s.execute("insert.table", json!({"range": range.a1(), "header": true, "name": tname})).is_ok(); + // The table takes the page table's name (spaces and other characters a table name cannot + // hold become `_`); a taken name gets a generated one, and the connection saves the name + // that was actually created. + let sanitize = |n: &str| n.chars().take(255).map(|c| if c.is_alphanumeric() || c == '_' || c == '.' { c } else { '_' }).collect::(); + let mut tname = sanitize(&table.name); + if !tname.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_' || c == '\\') { + tname = format!("Web_{tname}"); + } + s.execute("insert.table", json!({"range": range.a1(), "header": true, "name": tname})).or_else(|e| { + // The name is taken or still not valid: a generated one keeps the import working. + let taken = e.to_string().contains("already exists"); + let generated = s + .doc() + .map(|d| (1..).map(|n| format!("Web_Table{n}")).find(|n| d.wb.table(n).is_none()).unwrap_or_else(|| "Web_Table".into())) + .unwrap_or_else(|_| "Web_Table".into()); + if !taken && !e.to_string().contains("not valid") { + return Err(e); + } + s.execute("insert.table", json!({"range": range.a1(), "header": true, "name": generated})) + })?; let _ = s.execute("home.autofitColumnWidth", json!({"cols": RangeRef::cols(0, w as u32 - 1).a1()})); if save { let synced = now_ms(); @@ -132,13 +172,12 @@ pub(crate) fn load_table(s: &mut Session, req: &WebRequest, table: &WebTable, sh let q = gridcraft_model::WebQuery { name, url: req.url.clone(), table: table.name.clone(), sheet: sheet_name.clone(), last_synced: Some(synced) }; edit(s, |cx| { - cx.wb.web_queries.retain(|x| !(x.url.eq_ignore_ascii_case(&q.url) && x.table.eq_ignore_ascii_case(&q.table))); + cx.wb.web_queries.retain(|x| !(x.sheet.eq_ignore_ascii_case(&q.sheet) && x.name.eq_ignore_ascii_case(&q.name))); cx.wb.web_queries.push(q.clone()); cx.structural = true; Ok(()) })?; } - let _ = created; Ok(json!({"sheet": sheet_name, "table": tname, "rows": table.rows.len(), "cols": w, "tables_found": table_count(s)})) } @@ -229,29 +268,33 @@ fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTabl return Err(EngineError::Other("The table is empty.".into())); } let w = table.rows.iter().map(Vec::len).max().unwrap_or(1); - // Clear the old range, then drop the table so a new one can take the new shape. - s.execute("edit.clearContents", json!({"range": old.a1(), "sheet": sheet}))?; - let remove = |s: &mut Session| { + // The rewrite is one undo step over the table's own place: a failure mid-way rolls the + // old table and its data back instead of leaving the sheet empty. + s.grouped("Refresh Web Table", |s| { + // Clear the old range, then drop the table so a new one can take the new shape. + s.execute("edit.clearContents", json!({"range": old.a1(), "sheet": sheet}))?; + let remove = |s: &mut Session| { + edit(s, |cx| { + if let Some(sh) = cx.wb.sheet_mut(sheet) { + sh.tables.retain(|t| !t.name.eq_ignore_ascii_case(&tname)); + } + cx.structural = true; + Ok(()) + }) + }; + remove(s)?; + // Write at the table's own origin, not A1: a moved table refreshes where it is. + s.execute("range.setValues", json!({"range": old.start.a1(), "sheet": sheet, "values": rows}))?; + let range = RangeRef::new(old.start, CellRef::new(old.start.row + table.rows.len() as u32 - 1, old.start.col + w as u32 - 1)); + s.execute("insert.table", json!({"range": range.a1(), "header": true, "name": tname}))?; + let synced = now_ms(); edit(s, |cx| { - if let Some(sh) = cx.wb.sheet_mut(sheet) { - sh.tables.retain(|t| !t.name.eq_ignore_ascii_case(&tname)); + if let Some(qq) = cx.wb.web_queries.iter_mut().find(|x| x.sheet.eq_ignore_ascii_case(&q.sheet) && x.name.eq_ignore_ascii_case(&q.name)) { + qq.last_synced = Some(synced); } cx.structural = true; Ok(()) - }) - }; - remove(s)?; - s.execute("range.setValues", json!({"range": "A1", "sheet": sheet, "values": rows}))?; - let range = RangeRef::new(CellRef::new(0, 0), CellRef::new(table.rows.len() as u32 - 1, w as u32 - 1)); - let _ = s.execute("insert.table", json!({"range": range.a1(), "header": true, "name": tname})); - let synced = now_ms(); - edit(s, |cx| { - if let Some(qq) = cx.wb.web_queries.iter_mut().find(|x| x.url.eq_ignore_ascii_case(&q.url) && x.table.eq_ignore_ascii_case(&q.table)) { - qq.last_synced = Some(synced); - qq.name = tname.clone(); - qq.sheet = q.sheet.clone(); - } - cx.structural = true; + })?; Ok(()) })?; Ok((table.rows.len(), w)) @@ -307,7 +350,11 @@ mod tests { assert_eq!(a1(&s, "A1"), "x"); let d = s.active().unwrap(); let sh = d.wb.active().unwrap(); - assert_eq!(sh.value(CellRef::parse("B1").unwrap()), Value::number(1.0)); + // The page name "Table 2" cannot be a table name (the space); the import still makes + // the table, with a name the page table's was sanitized into. + assert!(sh.tables.iter().any(|t| t.name == "Table_2")); + // A one-row import is the table's header row, which is text. + assert_eq!(sh.value(CellRef::parse("B1").unwrap()), Value::text("1")); } #[test] @@ -346,6 +393,50 @@ mod tests { assert!(sh.value(CellRef::parse("C1").unwrap()).is_empty()); } + #[test] + fn default_sheet_name_is_the_site_not_the_tld() { + let mut s = session_with_source(); + s.execute("data.fromWeb", json!({"url": "https://www.example.com/markets"})).unwrap(); + let d = s.active().unwrap(); + assert_eq!(d.wb.active().unwrap().name, "example"); + let mut s = session_with_source(); + s.execute("data.fromWeb", json!({"url": "https://sub.example.co.uk/x?a=1"})).unwrap(); + let d = s.active().unwrap(); + assert_eq!(d.wb.active().unwrap().name, "example"); + } + + #[test] + fn a_second_import_keeps_the_first_connection() { + let mut s = session_with_source(); + s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "One"})).unwrap(); + s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "Two"})).unwrap(); + let d = s.active().unwrap(); + assert_eq!(d.wb.web_queries.len(), 2); + assert_eq!(d.wb.web_queries[0].sheet, "One"); + assert_eq!(d.wb.web_queries[1].sheet, "Two"); + } + + #[test] + fn refresh_writes_at_the_moved_table_s_own_place() { + let mut s = session_with_source(); + s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "Web"})).unwrap(); + // Move the sheet's contents down two rows (values plus the table range). + s.execute("sheet.activate", json!({"sheet": "Web"})).unwrap(); + s.execute("home.insertRows", json!({"rows": "1:2"})).unwrap(); + s.web_source = Some(Arc::new(|req: &WebRequest| { + Ok(crate::webquery::extract_tables( + "
SymPrice
A1
", + req.max_rows, + req.max_cols, + )) + })); + let r = s.execute("data.webRefresh", json!({})).unwrap(); + assert_eq!(r["refreshed"], 1); + // The header sits where the moved table is, and A1 stayed empty. + assert_eq!(a1(&s, "A3"), "Sym"); + assert_eq!(a1(&s, "A1"), ""); + } + #[test] fn errors_without_source_bad_url_or_missing_table() { let mut s = Session::new(); diff --git a/crates/engine/src/webquery.rs b/crates/engine/src/webquery.rs index fbfa2c34ed..01cbd63e16 100644 --- a/crates/engine/src/webquery.rs +++ b/crates/engine/src/webquery.rs @@ -102,10 +102,21 @@ pub fn extract_tables(html: &str, max_rows: usize, max_cols: usize) -> Vec 0 => t.push_text(&format!("<{tag}>")), Some(t) if name == "tr" => t.start_row(), - Some(t) => t.start_cell(), + Some(t) => { + let colspan = attr(body, "colspan").and_then(|v| v.trim().parse::().ok()).unwrap_or(1).clamp(1, max_cols); + let raw_row = attr(body, "rowspan").and_then(|v| v.trim().parse::().ok()).unwrap_or(1); + let rowspan = if raw_row == 0 { None } else { Some(raw_row.clamp(1, max_rows)) }; + t.start_cell(colspan, rowspan); + } None => {} } } + // A `rowspan="0"` cell covers the rest of its row group; the group ends here. + ("thead" | "tbody" | "tfoot", true) => { + if let Some(t) = current.as_mut() { + t.end_row_group(); + } + } _ => {} } } @@ -121,11 +132,25 @@ struct TableState { row: Vec, cell: String, cell_open: bool, + /// The open cell's `colspan` (>= 1) and `rowspan` (`None` for `rowspan="0"`: to this row group's end). + cell_span: (usize, Option), + /// Per column: the row index (exclusive) down to which a `rowspan` from above covers it. + covered_until: Vec, } impl TableState { fn new(name: String, max_rows: usize, max_cols: usize) -> Self { - Self { name, max_rows, max_cols, rows: Vec::new(), row: Vec::new(), cell: String::new(), cell_open: false } + Self { + name, + max_rows, + max_cols, + rows: Vec::new(), + row: Vec::new(), + cell: String::new(), + cell_open: false, + cell_span: (1, Some(1)), + covered_until: Vec::new(), + } } fn push_text(&mut self, text: &str) { @@ -134,9 +159,10 @@ impl TableState { } } - fn start_cell(&mut self) { + fn start_cell(&mut self, colspan: usize, rowspan: Option) { self.close_cell(); self.cell_open = true; + self.cell_span = (colspan, rowspan); } fn start_row(&mut self) { @@ -144,15 +170,60 @@ impl TableState { self.flush_row(); } - /// Close the current cell (collapsing whitespace) into the current row. + /// ``/`
1
", 10, 10)[0].rows == vec![vec!["1"]]); } + #[test] + fn colspan_and_rowspan_place_their_cells() { + let html = "\ + \ +
HC
R12
34
"; + let t = extract_tables(html, 100, 100); + assert_eq!(t[0].rows, vec![vec!["H", "", "C"], vec!["R", "1", "2"], vec!["", "3", "4"],]); + } + + #[test] + fn rowspan_zero_spans_its_own_row_group() { + let html = "\ + \ +
AB
C
xy
"; + let t = extract_tables(html, 100, 100); + assert_eq!(t[0].rows, vec![vec!["A", "B"], vec!["", "C"], vec!["x", "y"],]); + } + + #[test] + fn attr_matches_whole_names_not_suffixes() { + let html = "
1
"; + let t = extract_tables(html, 100, 100); + assert_eq!(t[0].name, "prices"); + } + #[test] fn nested_table_text_stays_in_the_cell() { let html = "
outer
inner
end
"; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 19a2155a6d..d3cbdf9d04 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2936,6 +2936,7 @@ fn from_web(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut if ui.button("Go").clicked() && !url.trim().is_empty() { d.values.insert("url".into(), json!(url.trim())); d.values.insert("tables".into(), Json::Null); + d.values.insert("pick".into(), json!(0)); let r = app.run("data.webPreview", json!({"url": url.trim()})); match r { Ok(res) => d.values.insert("tables".into(), res["tables"].clone()), @@ -3000,7 +3001,10 @@ fn from_web(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut }; match app.run("data.fromWeb", params) { Ok(r) => app.toast = Some((format!("Imported {} rows into {}", r["rows"], r["sheet"]), crate::now_ms())), - Err(e) => app.message = Some(("From Web".into(), crate::clean_error(&e))), + Err(e) => { + app.message = Some(("From Web".into(), crate::clean_error(&e))); + *confirm = false; + } } } if !load { diff --git a/crates/webquery/src/lib.rs b/crates/webquery/src/lib.rs index 3253a2fcca..3f5b339d27 100644 --- a/crates/webquery/src/lib.rs +++ b/crates/webquery/src/lib.rs @@ -31,9 +31,16 @@ pub fn fetch(req: &gridcraft_engine::webquery::WebRequest) -> Result String { - url.split_once('?').map_or_else(|| url.to_string(), |(base, _)| format!("{base}?…")) + let url = url.trim_start(); + let Some((scheme, rest)) = url.split_once("://") else { + return "…".into(); + }; + let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest); + let authority = authority.rsplit_once('@').map_or(authority, |(_, host)| host); + format!("{scheme}://{authority}") } /// One bounded GET: https only, no redirects, no cookies; size- and time-capped. From fa50e8b988f1436fbfd814187a4b752e5b4f14ec Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sun, 25 Oct 2026 16:41:55 +0000 Subject: [PATCH 03/31] From Web: the created table's name, and a per-table cell budget - The command result and the saved connection name the table that insert.table actually created (a generated name when the page table's was taken or invalid), not the rejected candidate - extract_tables caps each table at MAX_TABLE_CELLS (1M, like clip_html's pasted-HTML cap): colspan placeholders and the close() padding are charged against the budget, and a spent budget stops the read Co-authored-by: setzor --- crates/engine/src/cmd/web.rs | 27 +++++++++++++++---- crates/engine/src/webquery.rs | 49 +++++++++++++++++++++++++++++++---- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/web.rs b/crates/engine/src/cmd/web.rs index 3a1bd5f09f..a2b4b98bad 100644 --- a/crates/engine/src/cmd/web.rs +++ b/crates/engine/src/cmd/web.rs @@ -163,12 +163,15 @@ fn load_table_steps(s: &mut Session, req: &WebRequest, table: &WebTable, sheet_n s.execute("insert.table", json!({"range": range.a1(), "header": true, "name": generated})) })?; let _ = s.execute("home.autofitColumnWidth", json!({"cols": RangeRef::cols(0, w as u32 - 1).a1()})); + // The table that was actually created: its name may be a generated one when the page + // table's name was taken or not valid. The connection and the result both report it. + let created_name = { + let d = s.doc()?; + d.wb.sheet(sheet).and_then(|sh| sh.tables.iter().find(|t| t.range == range).map(|t| t.name.clone())).unwrap_or_else(|| tname.clone()) + }; if save { let synced = now_ms(); - let name = { - let d = s.doc()?; - d.wb.sheet(sheet).and_then(|sh| sh.tables.iter().find(|t| t.range == range).map(|t| t.name.clone())).unwrap_or_else(|| tname.clone()) - }; + let name = created_name.clone(); let q = gridcraft_model::WebQuery { name, url: req.url.clone(), table: table.name.clone(), sheet: sheet_name.clone(), last_synced: Some(synced) }; edit(s, |cx| { @@ -178,7 +181,7 @@ fn load_table_steps(s: &mut Session, req: &WebRequest, table: &WebTable, sheet_n Ok(()) })?; } - Ok(json!({"sheet": sheet_name, "table": tname, "rows": table.rows.len(), "cols": w, "tables_found": table_count(s)})) + Ok(json!({"sheet": sheet_name, "table": created_name, "rows": table.rows.len(), "cols": w, "tables_found": table_count(s)})) } fn table_count(s: &Session) -> usize { @@ -437,6 +440,20 @@ mod tests { assert_eq!(a1(&s, "A1"), ""); } + #[test] + fn the_result_and_connection_name_the_created_table() { + let mut s = session_with_source(); + // "prices" is a valid name, so the first import takes it; the second, from another + // sheet, must fall back to a generated name and report it. + s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "One"})).unwrap(); + let r = s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "Two"})).unwrap(); + let made = r["table"].as_str().unwrap(); + assert_ne!(made, "prices"); + assert!(made.starts_with("Web_Table")); + let d = s.active().unwrap(); + assert!(d.wb.web_queries.iter().any(|q| q.name == made)); + } + #[test] fn errors_without_source_bad_url_or_missing_table() { let mut s = Session::new(); diff --git a/crates/engine/src/webquery.rs b/crates/engine/src/webquery.rs index 01cbd63e16..4ad9dd64a6 100644 --- a/crates/engine/src/webquery.rs +++ b/crates/engine/src/webquery.rs @@ -15,6 +15,9 @@ pub const DEFAULT_MAX_BYTES: usize = 16 * 1024 * 1024; pub const DEFAULT_TIMEOUT_SECS: u64 = 30; /// Longest text a cell holds (Excel's limit). pub const MAX_TEXT: usize = 32_767; +/// Cells a single imported table holds at most, however wide its `colspan`s or its padding +/// (`clip_html.rs` keeps the same cap for pasted HTML). +pub const MAX_TABLE_CELLS: usize = 1_000_000; /// One page download: the URL and the caps the fetch must honour. #[derive(Clone, Debug, PartialEq, Eq)] @@ -98,6 +101,9 @@ pub fn extract_tables(html: &str, max_rows: usize, max_cols: usize) -> Vec { + if current.as_ref().is_some_and(|t| t.full()) { + continue; + } match current.as_mut() { // Inside a nested table the markup stays part of the enclosing cell's text. Some(t) if depth > 0 => t.push_text(&format!("<{tag}>")), @@ -136,6 +142,8 @@ struct TableState { cell_span: (usize, Option), /// Per column: the row index (exclusive) down to which a `rowspan` from above covers it. covered_until: Vec, + /// Cells still allowed before the table is cut short (padding included). + budget: usize, } impl TableState { @@ -150,6 +158,7 @@ impl TableState { cell_open: false, cell_span: (1, Some(1)), covered_until: Vec::new(), + budget: MAX_TABLE_CELLS.min(max_rows.saturating_mul(max_cols)), } } @@ -170,6 +179,17 @@ impl TableState { self.flush_row(); } + /// Charges one cell against the budget; false when it is spent (the table is then full). + fn spend(&mut self) -> bool { + match self.budget.checked_sub(1) { + Some(left) => { + self.budget = left; + true + } + None => false, + } + } + /// ``/``/``: a `rowspan="0"` cell covers no further rows. fn end_row_group(&mut self) { // The row being built belongs to the group too (its last cell may still be open). @@ -193,17 +213,17 @@ impl TableState { // The row being built is the next one to flush: index rows.len(). let r = self.rows.len(); while self.covered_until.get(self.row.len()).is_some_and(|&until| until > r) { - if self.row.len() >= self.max_cols { + if !self.spend() || self.row.len() >= self.max_cols { return; } self.row.push(String::new()); } let start = self.row.len(); - if self.row.len() < self.max_cols { + if self.spend() && self.row.len() < self.max_cols { self.row.push(text); } for _ in 1..colspan { - if self.row.len() >= self.max_cols { + if !self.spend() || self.row.len() >= self.max_cols { break; } self.row.push(String::new()); @@ -235,13 +255,21 @@ impl TableState { self.rows.push(std::mem::take(&mut self.row)); } - /// ``: close the open cell and row, pad every row to the table's width. + /// The budget is spent: nothing more is read into this table. + fn full(&self) -> bool { + self.budget == 0 + } + + /// ``: close the open cell and row, pad every row to the table's width (within the + /// cell budget; a hostile table pads to no more than it paid for). fn close(&mut self) { self.close_cell(); self.flush_row(); let w = self.rows.iter().map(Vec::len).max().unwrap_or(0).min(self.max_cols); for r in &mut self.rows { - r.resize(w, String::new()); + let pad = w.saturating_sub(r.len()).min(self.budget); + self.budget -= pad; + r.resize(r.len() + pad, String::new()); } } } @@ -443,6 +471,17 @@ mod tests { assert_eq!(t[0].rows, vec![vec!["A", "B"], vec!["", "C"], vec!["x", "y"],]); } + #[test] + fn a_hostile_table_cannot_blow_the_cell_budget() { + // One row, each cell spanning the whole column count: without a budget the padding + // alone would be rows * cols. + let html = "
a
"; + let t = extract_tables(html, 5_000_000, 20_000); + assert!(t[0].rows.len() <= 5_000_000); + let cells: usize = t[0].rows.iter().map(Vec::len).sum(); + assert!(cells <= 1_000_000, "cells = {cells}"); + } + #[test] fn attr_matches_whole_names_not_suffixes() { let html = "
1
"; From b1613c43bb35729753a371227ea118ced9b6d08f Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sun, 25 Oct 2026 16:56:57 +0000 Subject: [PATCH 04/31] From Web: a page cell never becomes a live formula (CSV injection) json_rows escapes cells that typed-input parsing would turn into formulas (= always; +, -, @ in formula-starting forms) with the leading apostrophe, which parses as the text itself. A plain -5 stays a number. Co-authored-by: setzor --- crates/engine/src/cmd/web.rs | 56 ++++++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/web.rs b/crates/engine/src/cmd/web.rs index a2b4b98bad..0d25a23481 100644 --- a/crates/engine/src/cmd/web.rs +++ b/crates/engine/src/cmd/web.rs @@ -189,9 +189,38 @@ fn table_count(s: &Session) -> usize { } /// The table as `range.setValues` rows: cell text parsed like typed input, so numbers, dates and -/// booleans keep their types (Excel's column detection). +/// booleans keep their types (Excel's column detection). A cell that would become a formula +/// (`=`, or `+`/`-`/`@` starting one) is escaped with the leading apostrophe, which parses as +/// the text itself: page content never becomes live formulas (CSV injection). fn json_rows(table: &WebTable) -> Vec> { - table.rows.iter().map(|row| row.iter().map(|c| if c.is_empty() { Json::Null } else { json!(c) }).collect()).collect() + table + .rows + .iter() + .map(|row| { + row.iter() + .map(|c| { + if c.is_empty() { + Json::Null + } else if would_be_formula(c) { + json!(format!("'{c}")) + } else { + json!(c) + } + }) + .collect() + }) + .collect() +} + +/// True when typed-input parsing would make `c` a formula: `=` always, and `+`/`-`/`@` only +/// in the forms that start one (a plain `-5` stays the number it is). Mirrors the check in +/// `input_to_cell` (cmd/edit.rs). +fn would_be_formula(c: &str) -> bool { + c.starts_with('=') + || (c.len() > 1 + && c.starts_with(['+', '-', '@']) + && gridcraft_core::parse::parse_number_text(c).is_none() + && c.chars().nth(1).is_some_and(|ch| ch.is_ascii_alphabetic() || ch == '(')) } fn web_preview(s: &mut Session, p: &Json) -> Result { @@ -454,6 +483,29 @@ mod tests { assert!(d.wb.web_queries.iter().any(|q| q.name == made)); } + #[test] + fn page_content_never_becomes_a_formula() { + let mut s = Session::new(); + s.execute("file.new", json!({})).unwrap(); + s.web_source = Some(Arc::new(|req: &WebRequest| { + Ok(crate::webquery::extract_tables( + "\ +
AB
=HYPERLINK(\"http://evil\")-5
", + req.max_rows, + req.max_cols, + )) + })); + s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "Web"})).unwrap(); + let d = s.active().unwrap(); + let sh = d.wb.active().unwrap(); + // The `=` cell is the text it came as, not a live formula. + let c = sh.cell(CellRef::parse("A2").unwrap()).unwrap(); + assert!(c.formula.is_none()); + assert_eq!(c.value, gridcraft_core::Value::text("=HYPERLINK(\"http://evil\")")); + // A plain negative number is still a number. + assert_eq!(sh.value(CellRef::parse("B2").unwrap()), Value::number(-5.0)); + } + #[test] fn errors_without_source_bad_url_or_missing_table() { let mut s = Session::new(); From b9648aca8b8e4620c2482b4a485b2a1eca96877d Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sun, 25 Oct 2026 16:58:38 +0000 Subject: [PATCH 05/31] webquery: cap a page at MAX_TABLES tables The per-table cell budget did not bound the number of tables, so a 16 MiB page of empty
repetitions retained hundreds of thousands of WebTables through preview and import. The read stops at 1,000 tables. Co-authored-by: setzor --- crates/engine/src/webquery.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/webquery.rs b/crates/engine/src/webquery.rs index 4ad9dd64a6..409f8d94e8 100644 --- a/crates/engine/src/webquery.rs +++ b/crates/engine/src/webquery.rs @@ -18,6 +18,9 @@ pub const MAX_TEXT: usize = 32_767; /// Cells a single imported table holds at most, however wide its `colspan`s or its padding /// (`clip_html.rs` keeps the same cap for pasted HTML). pub const MAX_TABLE_CELLS: usize = 1_000_000; +/// Tables a page holds at most. The download cap alone admits hundreds of thousands of empty +/// `
` repetitions, each a retained `WebTable`; this stops the read. +pub const MAX_TABLES: usize = 1_000; /// One page download: the URL and the caps the fetch must honour. #[derive(Clone, Debug, PartialEq, Eq)] @@ -40,7 +43,9 @@ pub type WebFetch = std::sync::Arc Result, /// The tables of `html`, in document order. Nested tables are flattened into the cell text of /// the enclosing table (like Excel's detection, a table inside a cell stays part of the cell). -/// `max_rows`/`max_cols` cap each table's size; longer tables are reported truncated. +/// `max_rows`/`max_cols` cap each table's size; longer tables are reported truncated. A page +/// holds at most [`MAX_TABLES`] tables and each at most [`MAX_TABLE_CELLS`] cells: a hostile +/// page cannot exhaust memory with thousands of tables or `colspan` padding. pub fn extract_tables(html: &str, max_rows: usize, max_cols: usize) -> Vec { let mut out: Vec = Vec::new(); if html.len() > DEFAULT_MAX_BYTES { @@ -98,6 +103,10 @@ pub fn extract_tables(html: &str, max_rows: usize, max_cols: usize) -> Vec= MAX_TABLES { + break; + } } } ("tr" | "td" | "th", false) => { @@ -471,6 +480,13 @@ mod tests { assert_eq!(t[0].rows, vec![vec!["A", "B"], vec!["", "C"], vec!["x", "y"],]); } + #[test] + fn a_page_of_tiny_tables_is_capped_at_the_aggregate_limit() { + let html = "
x
".repeat(5_000); + let t = extract_tables(&html, 100, 100); + assert_eq!(t.len(), MAX_TABLES); + } + #[test] fn a_hostile_table_cannot_blow_the_cell_budget() { // One row, each cell spanning the whole column count: without a budget the padding From b4d055ed731dbb4d6d77aa30cabbf29acedccd3d Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sun, 25 Oct 2026 17:25:19 +0000 Subject: [PATCH 06/31] =?UTF-8?q?From=20Web:=20finish=20the=20review=20aud?= =?UTF-8?q?it=20=E2=80=94=20page-wide=20cell=20budget,=20strict=20refresh?= =?UTF-8?q?=20lookup,=20dialog=20address=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extract_tables shares a MAX_PAGE_CELLS budget (4M) across all retained tables: 1,000 wide-colspan tables can no longer expand to ~200M padded cells before the table-count cap; the per-table cap is unchanged - rewrite_table matches only the connection's own table (by name, or the same name on the connection's sheet): the first-unrelated-table fallback that could overwrite it is gone - The From Web dialog with an empty address stays open with a message instead of closing silently Co-authored-by: setzor --- crates/engine/src/cmd/web.rs | 25 +++++++++++++++++++- crates/engine/src/webquery.rs | 44 +++++++++++++++++++++++++++++------ crates/ui-egui/src/dialogs.rs | 5 ++++ 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/web.rs b/crates/engine/src/cmd/web.rs index 0d25a23481..2c07643163 100644 --- a/crates/engine/src/cmd/web.rs +++ b/crates/engine/src/cmd/web.rs @@ -282,6 +282,8 @@ fn web_refresh(s: &mut Session, p: &Json) -> Result { /// write the new rows, and resize the table. Formulas and formatting outside the range are kept. fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTable) -> Result<(usize, usize)> { let d = s.doc()?; + // Only the connection's own table: a name match anywhere, else the same name on the + // connection's sheet. Never the first unrelated table that happens to be there. let Some((sheet, tname, old)) = d.wb.sheets .iter() @@ -289,7 +291,9 @@ fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTabl .find_map(|(si, sh)| sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&q.name)).map(|t| (si, t.name.clone(), t.range))) .or_else(|| { d.wb.sheets.iter().enumerate().find_map(|(si, sh)| { - sh.name.eq_ignore_ascii_case(&q.sheet).then(|| sh.tables.first().map(|t| (si, t.name.clone(), t.range))).flatten() + (sh.name.eq_ignore_ascii_case(&q.sheet) && sh.tables.iter().any(|t| t.name.eq_ignore_ascii_case(&q.name))) + .then(|| sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&q.name)).map(|t| (si, t.name.clone(), t.range))) + .flatten() }) }) else { @@ -506,6 +510,25 @@ mod tests { assert_eq!(sh.value(CellRef::parse("B2").unwrap()), Value::number(-5.0)); } + #[test] + fn refresh_never_falls_back_to_an_unrelated_table() { + let mut s = session_with_source(); + s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "Web"})).unwrap(); + // Rename the imported table so the connection's name no longer matches any table. + s.execute("sheet.activate", json!({"sheet": "Web"})).unwrap(); + let d = s.doc().unwrap(); + let tname = d.wb.active().unwrap().tables[0].name.clone(); + s.execute("table.rename", json!({"table": tname, "name": "Renamed"})).unwrap(); + // Another, unrelated table now sits on the sheet. + s.execute("range.setValues", json!({"range": "E1", "sheet": "Web", "values": [["keep"]]})).unwrap(); + s.execute("insert.table", json!({"range": "E1", "header": true, "name": "Unrelated"})).unwrap(); + let r = s.execute("data.webRefresh", json!({})).unwrap(); + // The connection's table is gone: refresh reports it, and E1 is untouched. + assert_eq!(r["refreshed"], 0); + assert!(!r["errors"].as_array().unwrap().is_empty()); + assert_eq!(a1(&s, "E1"), "keep"); + } + #[test] fn errors_without_source_bad_url_or_missing_table() { let mut s = Session::new(); diff --git a/crates/engine/src/webquery.rs b/crates/engine/src/webquery.rs index 409f8d94e8..17f9787281 100644 --- a/crates/engine/src/webquery.rs +++ b/crates/engine/src/webquery.rs @@ -21,6 +21,9 @@ pub const MAX_TABLE_CELLS: usize = 1_000_000; /// Tables a page holds at most. The download cap alone admits hundreds of thousands of empty /// `
` repetitions, each a retained `WebTable`; this stops the read. pub const MAX_TABLES: usize = 1_000; +/// Cells a page holds in all its tables together: 1,000 tables × wide `colspan` padding would +/// otherwise expand far past the download size before the table-count cap applies. +pub const MAX_PAGE_CELLS: usize = 4_000_000; /// One page download: the URL and the caps the fetch must honour. #[derive(Clone, Debug, PartialEq, Eq)] @@ -44,8 +47,9 @@ pub type WebFetch = std::sync::Arc Result, /// The tables of `html`, in document order. Nested tables are flattened into the cell text of /// the enclosing table (like Excel's detection, a table inside a cell stays part of the cell). /// `max_rows`/`max_cols` cap each table's size; longer tables are reported truncated. A page -/// holds at most [`MAX_TABLES`] tables and each at most [`MAX_TABLE_CELLS`] cells: a hostile -/// page cannot exhaust memory with thousands of tables or `colspan` padding. +/// holds at most [`MAX_TABLES`] tables, [`MAX_PAGE_CELLS`] cells across all of them, and each +/// table at most [`MAX_TABLE_CELLS`] cells: a hostile page cannot exhaust memory with +/// thousands of tables or `colspan` padding. pub fn extract_tables(html: &str, max_rows: usize, max_cols: usize) -> Vec { let mut out: Vec = Vec::new(); if html.len() > DEFAULT_MAX_BYTES { @@ -54,6 +58,9 @@ pub fn extract_tables(html: &str, max_rows: usize, max_cols: usize) -> Vec = None; let mut depth = 0usize; + // The page-wide cell budget, shared by every table: what remains when the last table + // closed is what the next one may take. + let mut page_budget: usize = MAX_PAGE_CELLS; while !rest.is_empty() { let Some(lt) = rest.find('<') else { break }; let text = rest.get(..lt).unwrap_or_default(); @@ -87,7 +94,7 @@ pub fn extract_tables(html: &str, max_rows: usize, max_cols: usize) -> Vec { let id = attr(body, "id"); let name = id.filter(|s| !s.trim().is_empty()).map(String::from).unwrap_or_else(|| format!("Table {}", out.len() + 1)); - current = Some(TableState::new(name, max_rows, max_cols)); + current = Some(TableState::new(name, max_rows, max_cols, page_budget)); depth = 0; } } @@ -100,11 +107,13 @@ pub fn extract_tables(html: &str, max_rows: usize, max_cols: usize) -> Vec= MAX_TABLES { + // A page of nothing but tiny tables is cut short at the aggregate cap, + // and a page of wide padded ones at the shared cell budget. + if out.len() >= MAX_TABLES || page_budget == 0 { break; } } @@ -153,10 +162,12 @@ struct TableState { covered_until: Vec, /// Cells still allowed before the table is cut short (padding included). budget: usize, + /// Cells this table has taken, so the page-wide budget can be updated when it closes. + spent: usize, } impl TableState { - fn new(name: String, max_rows: usize, max_cols: usize) -> Self { + fn new(name: String, max_rows: usize, max_cols: usize, page_budget: usize) -> Self { Self { name, max_rows, @@ -167,10 +178,16 @@ impl TableState { cell_open: false, cell_span: (1, Some(1)), covered_until: Vec::new(), - budget: MAX_TABLE_CELLS.min(max_rows.saturating_mul(max_cols)), + budget: MAX_TABLE_CELLS.min(max_rows.saturating_mul(max_cols)).min(page_budget), + spent: 0, } } + /// Cells this table has taken from the budgets. + fn spent(&self) -> usize { + self.spent + } + fn push_text(&mut self, text: &str) { if self.cell_open { self.cell.push_str(text); @@ -193,6 +210,7 @@ impl TableState { match self.budget.checked_sub(1) { Some(left) => { self.budget = left; + self.spent += 1; true } None => false, @@ -278,6 +296,7 @@ impl TableState { for r in &mut self.rows { let pad = w.saturating_sub(r.len()).min(self.budget); self.budget -= pad; + self.spent += pad; r.resize(r.len() + pad, String::new()); } } @@ -480,6 +499,17 @@ mod tests { assert_eq!(t[0].rows, vec![vec!["A", "B"], vec!["", "C"], vec!["x", "y"],]); } + #[test] + fn wide_colspan_padding_across_many_tables_shares_a_page_budget() { + // Each table pads one cell to 4,000 columns; without a page-wide budget 1,000 of + // them retain ~4,000,000 padded cells. + let html = "
x
".repeat(1_000); + let t = extract_tables(&html, DEFAULT_MAX_ROWS, MAX_COLS); + let cells: usize = t.iter().map(|tb| tb.rows.iter().map(Vec::len).sum::()).sum(); + assert!(cells <= MAX_PAGE_CELLS, "cells = {cells}"); + assert!(t.len() <= MAX_TABLES); + } + #[test] fn a_page_of_tiny_tables_is_capped_at_the_aggregate_limit() { let html = "
x
".repeat(5_000); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d3cbdf9d04..3be8b6ba1a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2992,6 +2992,11 @@ fn from_web(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut d.values.insert("url".into(), json!(url.clone())); let mut load = true; ok_cancel(ui, confirm, &mut load); + if *confirm && url.trim().is_empty() { + // OK with no address: stay open and say so, instead of closing with nothing. + app.message = Some(("From Web".into(), "Enter an https:// address first.".into())); + *confirm = false; + } if *confirm && !url.trim().is_empty() { let pick = d.values.get("pick").and_then(Json::as_u64).unwrap_or(0); let table = tables.get(pick as usize).and_then(|t| t["name"].as_str()).map(String::from); From 5e0744297da4898a8a731bebbd3aacc20145e885 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sun, 25 Oct 2026 17:54:07 +0000 Subject: [PATCH 07/31] From Web: keep which duplicate page table a connection imports A page can hold several tables with the same id. The connection now records the zero-based occurrence among same-named tables (serde default 0 for existing connections), data.fromWeb accepts an occurrence from the Navigator, and data.webRefresh re-imports the occurrence the user imported instead of the first same-named table. The page-budget test fixture exceeds the budget (colspan 4001), so the assertion catches a regression in the page-wide accounting. Co-authored-by: setzor --- crates/engine/src/cmd/web.rs | 60 +++++++++++++++++++++++++++-------- crates/engine/src/webquery.rs | 2 +- crates/model/src/features.rs | 3 ++ 3 files changed, 51 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/web.rs b/crates/engine/src/cmd/web.rs index 2c07643163..3633540976 100644 --- a/crates/engine/src/cmd/web.rs +++ b/crates/engine/src/cmd/web.rs @@ -79,9 +79,11 @@ fn from_web(s: &mut Session, p: &Json) -> Result { }; let tables = source(&req).map_err(EngineError::Other)?; let want = str_param(p, "table").map(str::trim).filter(|t| !t.is_empty()); - let table = pick_table(&tables, want).ok_or_else(|| EngineError::Other(no_table_message(&tables)))?; + let occurrence = p.get("occurrence").and_then(Json::as_u64).unwrap_or(0) as usize; + let table = pick_table(&tables, want, occurrence).ok_or_else(|| EngineError::Other(no_table_message(&tables)))?; + let occurrence = tables.iter().take_while(|t| !std::ptr::eq(*t, table)).filter(|t| t.name.eq_ignore_ascii_case(&table.name)).count(); let name = str_param(p, "sheet").map(|n| n.chars().take(31).collect::()).unwrap_or_else(|| default_sheet_name(&req.url)); - load_table(s, &req, table, &name, true) + load_table(s, &req, table, &name, true, occurrence) } /// The site's own name (`https://www.example.com/x` → `example`), capped at 31 characters. @@ -105,12 +107,20 @@ fn default_sheet_name(url: &str) -> String { base.chars().take(31).collect() } -/// The table `want` names (page name or 1-based index), else the first one. -fn pick_table<'a>(tables: &'a [WebTable], want: Option<&str>) -> Option<&'a WebTable> { +/// The table `want` names (page name or 1-based index), else the first one. A page can hold +/// several tables with the same name (duplicate ids): `occurrence` picks which one, zero-based. +fn pick_table<'a>(tables: &'a [WebTable], want: Option<&str>, occurrence: usize) -> Option<&'a WebTable> { match want { - Some(w) => { - tables.iter().find(|t| t.name.eq_ignore_ascii_case(w)).or_else(|| w.parse::().ok().and_then(|n| tables.get(n.saturating_sub(1)))) - } + Some(w) => tables + .iter() + .filter(|t| t.name.eq_ignore_ascii_case(w)) + .nth(occurrence) + .or_else(|| { + // No such occurrence: the page's tables changed; fall back to the last one + // with the name rather than nothing. + tables.iter().rfind(|t| t.name.eq_ignore_ascii_case(w)) + }) + .or_else(|| w.parse::().ok().and_then(|n| tables.get(n.saturating_sub(1)))), None => tables.first(), } } @@ -124,13 +134,13 @@ fn no_table_message(tables: &[WebTable]) -> String { } /// Writes `table` to a new sheet as a table and (when `save`) records the connection. -pub(crate) fn load_table(s: &mut Session, req: &WebRequest, table: &WebTable, sheet_name: &str, save: bool) -> Result { - s.grouped("From Web", |s| load_table_steps(s, req, table, sheet_name, save)) +pub(crate) fn load_table(s: &mut Session, req: &WebRequest, table: &WebTable, sheet_name: &str, save: bool, occurrence: usize) -> Result { + s.grouped("From Web", |s| load_table_steps(s, req, table, sheet_name, save, occurrence)) } /// The import as one undo step: a failure after the sheet was inserted rolls the whole /// workbook back, so no empty sheet or half-written table is left behind. -fn load_table_steps(s: &mut Session, req: &WebRequest, table: &WebTable, sheet_name: &str, save: bool) -> Result { +fn load_table_steps(s: &mut Session, req: &WebRequest, table: &WebTable, sheet_name: &str, save: bool, occurrence: usize) -> Result { let rows = json_rows(table); if rows.is_empty() { return Err(EngineError::Other("The table is empty.".into())); @@ -172,8 +182,14 @@ fn load_table_steps(s: &mut Session, req: &WebRequest, table: &WebTable, sheet_n if save { let synced = now_ms(); let name = created_name.clone(); - let q = - gridcraft_model::WebQuery { name, url: req.url.clone(), table: table.name.clone(), sheet: sheet_name.clone(), last_synced: Some(synced) }; + let q = gridcraft_model::WebQuery { + name, + url: req.url.clone(), + table: table.name.clone(), + occurrence, + sheet: sheet_name.clone(), + last_synced: Some(synced), + }; edit(s, |cx| { cx.wb.web_queries.retain(|x| !(x.sheet.eq_ignore_ascii_case(&q.sheet) && x.name.eq_ignore_ascii_case(&q.name))); cx.wb.web_queries.push(q.clone()); @@ -263,7 +279,7 @@ fn web_refresh(s: &mut Session, p: &Json) -> Result { for q in queries { let req = WebRequest { url: q.url.clone(), max_rows: DEFAULT_MAX_ROWS, max_cols: MAX_COLS, max_bytes: DEFAULT_MAX_BYTES }; match source(&req).map_err(EngineError::Other).and_then(|tables| { - let table = pick_table(&tables, Some(&q.table)).ok_or_else(|| EngineError::Other(no_table_message(&tables)))?; + let table = pick_table(&tables, Some(&q.table), q.occurrence).ok_or_else(|| EngineError::Other(no_table_message(&tables)))?; rewrite_table(s, &q, table) }) { Ok((rows, cols)) => { @@ -529,6 +545,24 @@ mod tests { assert_eq!(a1(&s, "E1"), "keep"); } + #[test] + fn refresh_re_imports_the_same_occurrence_of_a_duplicate_table_name() { + // The page holds two tables with the same id; the import takes the second one. + let page = "
Sym
first
\ +
Sym
second
"; + let mut s = Session::new(); + s.execute("file.new", json!({})).unwrap(); + s.web_source = Some(Arc::new(move |req: &WebRequest| Ok(crate::webquery::extract_tables(page, req.max_rows, req.max_cols)))); + s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "Web", "table": "prices", "occurrence": 1})).unwrap(); + assert_eq!(a1(&s, "A2"), "second"); + let d = s.active().unwrap(); + assert_eq!(d.wb.web_queries[0].occurrence, 1); + // Refresh keeps importing the second one, not the first same-named table. + let r = s.execute("data.webRefresh", json!({})).unwrap(); + assert_eq!(r["refreshed"], 1); + assert_eq!(a1(&s, "A2"), "second"); + } + #[test] fn errors_without_source_bad_url_or_missing_table() { let mut s = Session::new(); diff --git a/crates/engine/src/webquery.rs b/crates/engine/src/webquery.rs index 17f9787281..19b9953c3d 100644 --- a/crates/engine/src/webquery.rs +++ b/crates/engine/src/webquery.rs @@ -503,7 +503,7 @@ mod tests { fn wide_colspan_padding_across_many_tables_shares_a_page_budget() { // Each table pads one cell to 4,000 columns; without a page-wide budget 1,000 of // them retain ~4,000,000 padded cells. - let html = "
x
".repeat(1_000); + let html = "
x
".repeat(1_000); let t = extract_tables(&html, DEFAULT_MAX_ROWS, MAX_COLS); let cells: usize = t.iter().map(|tb| tb.rows.iter().map(Vec::len).sum::()).sum(); assert!(cells <= MAX_PAGE_CELLS, "cells = {cells}"); diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index b15d620c8e..792fb6804e 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -67,6 +67,9 @@ pub struct WebQuery { pub url: String, /// The page table's name, as the import saw it. pub table: String, + /// Which same-named page table the import took, zero-based: a page can hold duplicates. + #[serde(default)] + pub occurrence: usize, /// The sheet the import wrote. pub sheet: String, /// When the connection last synced, in ms since the epoch; `None` until the first sync. From c1519bbda5c06d5ed3775f2d552dce7ca0fdc563 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sun, 25 Oct 2026 19:23:30 +0000 Subject: [PATCH 08/31] From Web: strict occurrence lookup, Queries & Connections pane, docs - A saved occurrence the page no longer holds is no match: refresh reports the error and preserves the sheet's data instead of importing a different same-named table (test: a_vanished_occurrence_fails_refresh _and_keeps_the_data) - Data tab gains the Queries & Connections pane: every saved connection with its source and last-synced time, a per-connection Sync that runs data.webRefresh, a Refresh All, and refresh or unavailable-source errors shown in the same window (issue #1356's UI side) - data.queriesConnections lists the web connections (source, last sync) before the workbook tables - Docstrings for every function this PR adds or touches Co-authored-by: setzor --- crates/engine/src/cmd/extra.rs | 19 +++++++-- crates/engine/src/cmd/web.rs | 43 +++++++++++++++++--- crates/engine/src/webquery.rs | 1 + crates/ui-egui/src/dialogs.rs | 74 ++++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 28 +++++++++++++ crates/ui-egui/src/ribbon.rs | 12 ++++++ 6 files changed, 169 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 97bfe36a1d..68648840d0 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -961,9 +961,11 @@ fn from_table_range(s: &mut Session, p: &Json) -> Result { from_csv(s, &json!({"text": text, "delimiter": "\t", "sheet": format!("{} (2)", sh.name).chars().take(31).collect::()})) } +/// `data.queriesConnections`: the saved From Web connections (source, last sync) followed by +/// the workbook's imported tables — the Queries & Connections pane's list. fn queries(s: &mut Session, _: &Json) -> Result { let d = s.doc()?; - Ok(Json::Array( + let tables: Vec = d.wb.sheets .iter() .flat_map(|sh| { @@ -971,8 +973,19 @@ fn queries(s: &mut Session, _: &Json) -> Result { .iter() .map(move |t| json!({"name": t.name, "sheet": sh.name, "range": t.range.a1(), "rows": t.range.height().saturating_sub(1)})) }) - .collect(), - )) + .collect(); + // The saved From Web connections, with the source and the last sync the pane shows. + let web: Vec = + d.wb.web_queries + .iter() + .map(|q| { + json!({ + "name": q.name, "sheet": q.sheet, "url": q.url, "table": q.table, + "last_synced": q.last_synced.unwrap_or(0), "web": true, + }) + }) + .collect(); + Ok(Json::Array([web, tables].concat())) } fn record(s: &mut Session, p: &Json) -> Result { diff --git a/crates/engine/src/cmd/web.rs b/crates/engine/src/cmd/web.rs index 3633540976..4d48680336 100644 --- a/crates/engine/src/cmd/web.rs +++ b/crates/engine/src/cmd/web.rs @@ -11,6 +11,8 @@ use crate::webquery::{DEFAULT_MAX_BYTES, DEFAULT_MAX_ROWS, MAX_COLS, WebRequest, const PREVIEW_ROWS: usize = 10; const PREVIEW_COLS: usize = 8; +/// The From Web commands: import (`data.fromWeb`), the Navigator preview (`data.webPreview`) +/// and the saved-connection refresh (`data.webRefresh`). pub fn specs() -> Vec { vec![ cmd!( @@ -58,6 +60,8 @@ fn now_ms() -> u64 { } /// A page request from the command params, with the caps clamped. +/// A page request from the command params, with the caps clamped (shared by the three +/// commands). fn request(p: &Json) -> Result { const ID: &str = "data.fromWeb"; let url = str_param(p, "url").map(str::trim).filter(|u| u.starts_with("http")).ok_or_else(|| bad(ID, "missing `url`"))?; @@ -68,6 +72,8 @@ fn request(p: &Json) -> Result { Ok(WebRequest { url: url.to_string(), max_rows, max_cols: MAX_COLS, max_bytes: DEFAULT_MAX_BYTES }) } +/// `data.fromWeb`: import the page table (Navigator choice or the first) into a new sheet +/// and save its connection; with no `url` (interactive call) it opens the From Web dialog. fn from_web(s: &mut Session, p: &Json) -> Result { if str_param(p, "url").map(|u| u.trim().is_empty()).unwrap_or(true) { s.ui_requests.push(crate::UiRequest::Dialog("fromWeb".into(), json!({}))); @@ -111,20 +117,18 @@ fn default_sheet_name(url: &str) -> String { /// several tables with the same name (duplicate ids): `occurrence` picks which one, zero-based. fn pick_table<'a>(tables: &'a [WebTable], want: Option<&str>, occurrence: usize) -> Option<&'a WebTable> { match want { + // A missing occurrence is no match: the page's tables changed, and importing a + // different same-named one would silently replace the user's data. Some(w) => tables .iter() .filter(|t| t.name.eq_ignore_ascii_case(w)) .nth(occurrence) - .or_else(|| { - // No such occurrence: the page's tables changed; fall back to the last one - // with the name rather than nothing. - tables.iter().rfind(|t| t.name.eq_ignore_ascii_case(w)) - }) .or_else(|| w.parse::().ok().and_then(|n| tables.get(n.saturating_sub(1)))), None => tables.first(), } } +/// The error message for a table the page does not hold, naming the ones it does. fn no_table_message(tables: &[WebTable]) -> String { if tables.is_empty() { "No tables were found on the page.".into() @@ -200,6 +204,7 @@ fn load_table_steps(s: &mut Session, req: &WebRequest, table: &WebTable, sheet_n Ok(json!({"sheet": sheet_name, "table": created_name, "rows": table.rows.len(), "cols": w, "tables_found": table_count(s)})) } +/// How many web connections the workbook holds (the import result's `tables_found`). fn table_count(s: &Session) -> usize { s.doc().map(|d| d.wb.web_queries.len()).unwrap_or(0) } @@ -228,6 +233,9 @@ fn json_rows(table: &WebTable) -> Vec> { .collect() } +/// True when typed-input parsing would make `c` a formula: `=` always, and `+`/`-`/`@` only +/// in the forms that start one (a plain `-5` stays the number it is). Mirrors the check in +/// `input_to_cell` (cmd/edit.rs). /// True when typed-input parsing would make `c` a formula: `=` always, and `+`/`-`/`@` only /// in the forms that start one (a plain `-5` stays the number it is). Mirrors the check in /// `input_to_cell` (cmd/edit.rs). @@ -239,6 +247,8 @@ fn would_be_formula(c: &str) -> bool { && c.chars().nth(1).is_some_and(|ch| ch.is_ascii_alphabetic() || ch == '(')) } +/// `data.webPreview`: the page's detected tables, each with a name, size and preview rows +/// (the Navigator's list and preview pane). fn web_preview(s: &mut Session, p: &Json) -> Result { let req = request(p)?; let Some(source) = s.web_source.clone() else { @@ -256,6 +266,8 @@ fn web_preview(s: &mut Session, p: &Json) -> Result { Ok(json!({"url": req.url, "tables": tables.iter().map(preview).collect::>()})) } +/// `data.webRefresh`: re-fetch and rewrite the saved connections the params select (all by +/// default), reporting per-connection errors instead of failing the batch. fn web_refresh(s: &mut Session, p: &Json) -> Result { const ID: &str = "data.webRefresh"; let url = str_param(p, "url").map(str::trim).filter(|u| !u.is_empty()); @@ -296,6 +308,8 @@ fn web_refresh(s: &mut Session, p: &Json) -> Result { /// Re-fetch result over the sheet range the connection's table holds: clear the old range, /// write the new rows, and resize the table. Formulas and formatting outside the range are kept. +/// Re-fetch result over the connection's table: clear its range, write the new rows and +/// resize the table, as one undo step, at the table's own place. fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTable) -> Result<(usize, usize)> { let d = s.doc()?; // Only the connection's own table: a name match anywhere, else the same name on the @@ -563,6 +577,25 @@ mod tests { assert_eq!(a1(&s, "A2"), "second"); } + #[test] + fn a_vanished_occurrence_fails_refresh_and_keeps_the_data() { + let mut s = Session::new(); + s.execute("file.new", json!({})).unwrap(); + let page = "
first
\ +
second
"; + s.web_source = Some(Arc::new(move |req: &WebRequest| Ok(crate::webquery::extract_tables(page, req.max_rows, req.max_cols)))); + s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "Web", "table": "prices", "occurrence": 1})).unwrap(); + assert_eq!(a1(&s, "A1"), "second"); + // The page changes: only one table by that name is left. + let changed = "
changed
"; + s.web_source = Some(Arc::new(move |req: &WebRequest| Ok(crate::webquery::extract_tables(changed, req.max_rows, req.max_cols)))); + let r = s.execute("data.webRefresh", json!({})).unwrap(); + assert_eq!(r["refreshed"], 0); + assert!(!r["errors"].as_array().unwrap().is_empty()); + // The sheet still holds the table the user imported. + assert_eq!(a1(&s, "A1"), "second"); + } + #[test] fn errors_without_source_bad_url_or_missing_table() { let mut s = Session::new(); diff --git a/crates/engine/src/webquery.rs b/crates/engine/src/webquery.rs index 19b9953c3d..cd2764b3c4 100644 --- a/crates/engine/src/webquery.rs +++ b/crates/engine/src/webquery.rs @@ -353,6 +353,7 @@ fn decode_entities(s: &str) -> String { out } +/// The character a named HTML entity stands for. fn named_entity(name: &str) -> Option { Some(match name { "amp" => '&', diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 3be8b6ba1a..84c2716a07 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -538,6 +538,7 @@ impl Dialog { json!({"range": remove_duplicates_range(app, &sel), "header": true}), ), "fromWeb" => Dialog::custom("fromWeb", "From Web", json!({"url": "", "pick": 0})), + "queriesConnections" => Dialog::custom("queriesConnections", "Queries & Connections", json!({})), "advancedFilter" => { let list = app .session @@ -1303,6 +1304,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "selectData" => select_data(app, ui, &mut d, &mut confirm, &mut open), "commandSearch" => command_search(app, ui, &mut d, &mut confirm), "fromWeb" => from_web(app, ui, &mut d, &mut confirm, &mut open), + "queriesConnections" => queries_connections(app, ui, &mut d, &mut confirm, &mut open), "goalSeek" => goal_seek(app, ui, &mut d, &mut confirm), "pasteSpecial" => paste_special(app, ui, &mut d, &mut confirm, &mut open), "analysisToolPak" => analysis_toolpak(app, ui, &mut d, &mut confirm, &mut open), @@ -2925,6 +2927,78 @@ fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm } } +/// Data ▸ Queries & Connections: the workbook's saved From Web connections, each with a +/// one-click Sync, its last-synced time, and the refresh errors and unavailable-source +/// messages in the same window (Excel's Queries & Connections pane). +fn queries_connections(app: &mut SheetApp, ui: &mut egui::Ui, _d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let queries = app.run("data.queriesConnections", json!({})).ok().and_then(|r| r.as_array().cloned()).unwrap_or_default(); + if queries.is_empty() { + ui.label("No saved connections. Import a table with Data ▸ From Web to create one."); + } else { + egui::ScrollArea::vertical().max_height(280.0).id_salt("qc_list").show(ui, |ui| { + for q in &queries { + let name = q["name"].as_str().unwrap_or_default(); + let sheet = q["sheet"].as_str().unwrap_or_default(); + egui::CollapsingHeader::new(name).default_open(true).show(ui, |ui| { + ui.label(format!("Sheet: {sheet}")); + if let Some(url) = q.get("url").and_then(Json::as_str) { + ui.label(format!("Source: {}", crate::clean_url_for_display(url))); + } + let synced = q.get("last_synced").and_then(Json::as_u64).filter(|t| *t > 0); + match synced { + Some(ms) => ui.weak(format!("Last synced: {}", crate::format_sync_time(ms))), + None => ui.weak("Last synced: never"), + }; + ui.horizontal(|ui| { + if ui.button("Sync").clicked() { + match app.run("data.webRefresh", json!({"name": name})) { + Ok(r) => { + let refreshed = r["refreshed"].as_u64().unwrap_or(0); + let errors = r["errors"].as_array().map(Vec::len).unwrap_or(0); + if refreshed > 0 && errors == 0 { + app.toast = Some((format!("Synced {name}"), crate::now_ms())); + } else if let Some(e) = r["errors"].as_array().and_then(|a| a.first()) { + let msg = e["error"].as_str().unwrap_or_default(); + app.message = Some(("Queries & Connections".into(), format!("{name}: {msg}"))); + } + } + Err(e) => app.message = Some(("Queries & Connections".into(), crate::clean_error(&e))), + } + } + if ui.button("Go to table").clicked() { + let _ = app.run("sheet.activate", json!({"sheet": sheet})); + *open = false; + } + }); + }); + ui.separator(); + } + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Refresh All").clicked() { + match app.run("data.webRefresh", json!({})) { + Ok(r) => { + let refreshed = r["refreshed"].as_u64().unwrap_or(0); + let errors = r["errors"].as_array().cloned().unwrap_or_default(); + if errors.is_empty() { + app.toast = Some((format!("Synced {refreshed} connection(s)"), crate::now_ms())); + } else { + let msg = errors.iter().filter_map(|e| e["error"].as_str()).collect::>().join("; "); + app.message = Some(("Queries & Connections".into(), msg)); + } + } + Err(e) => app.message = Some(("Queries & Connections".into(), crate::clean_error(&e))), + } + } + }); + } + let mut load = true; + ok_cancel(ui, confirm, &mut load); + if *confirm || !load { + *open = false; + } +} + /// Data ▸ From Web: Excel's Navigator. Enter a URL, the page's detected tables are previewed /// on the right, and Load imports the selected one into a new sheet. fn from_web(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9bea918dc3..9099684e72 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1460,6 +1460,34 @@ pub fn clean_error(e: &str) -> String { e.to_string() } +/// A URL safe to show in the UI: the origin and path, without its query string. +pub fn clean_url_for_display(url: &str) -> String { + url.split(['?', '#']).next().unwrap_or(url).to_string() +} + +/// A last-synced timestamp as a local-time string ("2026-10-25 14:32"). +pub fn format_sync_time(ms: u64) -> String { + #[cfg(not(target_arch = "wasm32"))] + { + // std has no local-time formatting; show the offset from now in human terms. + let now = now_ms() as u64; + if ms <= now { + let ago = (now - ms) / 1000; + match ago { + 0..=59 => return "just now".into(), + 60..=3599 => return format!("{} minute(s) ago", ago / 60), + 3600..=86399 => return format!("{} hour(s) ago", ago / 3600), + _ => return format!("{} day(s) ago", ago / 86400), + } + } + "just now".into() + } + #[cfg(target_arch = "wasm32")] + { + "synced".into() + } +} + pub fn now_ms() -> f64 { gridcraft_engine::core::date::unix_now_ms().unwrap_or(0.0) } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index d3be564093..7e05ea4f18 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2217,6 +2217,18 @@ fn data(app: &mut SheetApp, ui: &mut Ui) { if web.clicked() { app.open_dialog("fromWeb", json!({})); } + ui.vertical(|ui| { + let qc = small_button(ui, Icon::Redo, "", "Queries & Connections (saved web tables, one-click Sync)", false); + control_keytip(app, ui, &qc, "data.queriesConnections"); + if qc.clicked() { + app.open_dialog("queriesConnections", json!({})); + } + let ra = small_button(ui, Icon::Redo, "", "Refresh All (re-sync every saved web table)", false); + control_keytip(app, ui, &ra, "data.refreshAll"); + if ra.clicked() { + act(app, "data.webRefresh", json!({})); + } + }); sep(ui); ui.vertical(|ui| { let __k = small_button(ui, Icon::SortAsc, "", "Sort A to Z", false); From 49ce811796f6ad191e44fe6768c66971cd3aa15a Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sun, 25 Oct 2026 20:34:09 +0000 Subject: [PATCH 09/31] From Web: refuse refresh overwrite outside old range; clear stale preview on URL edit Co-authored-by: setzor --- Cargo.lock | 57 +++++++++++++++++++++++++++++++++++ crates/engine/src/cmd/web.rs | 56 ++++++++++++++++++++++++++++++++-- crates/ui-egui/src/dialogs.rs | 8 +++++ 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eaaa245590..158687085b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1843,6 +1843,7 @@ dependencies = [ "fmv-macos-events", "gridcraft-engine", "gridcraft-ui-egui", + "gridcraft-webquery", "image", "log", "muda", @@ -1883,6 +1884,7 @@ version = "0.12.1" dependencies = [ "gridcraft-engine", "gridcraft-mcp", + "gridcraft-webquery", "log", "quick-xml", "serde_json", @@ -2009,6 +2011,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gridcraft-webquery" +version = "0.1.0" +dependencies = [ + "gridcraft-engine", + "log", + "ureq", +] + [[package]] name = "gridcraft-xlsx" version = "0.12.1" @@ -3925,7 +3936,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -5059,6 +5072,35 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" +dependencies = [ + "base64 0.23.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -5071,6 +5113,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5375,6 +5423,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" diff --git a/crates/engine/src/cmd/web.rs b/crates/engine/src/cmd/web.rs index 4d48680336..30681e084d 100644 --- a/crates/engine/src/cmd/web.rs +++ b/crates/engine/src/cmd/web.rs @@ -6,6 +6,8 @@ use serde_json::{Value as Json, json}; use super::*; use crate::webquery::{DEFAULT_MAX_BYTES, DEFAULT_MAX_ROWS, MAX_COLS, WebRequest, WebTable}; +use gridcraft_core::MAX_ROWS; +use super::analysis::OVERWRITE_ERROR; /// Preview rows and columns per table (the Navigator's preview pane). const PREVIEW_ROWS: usize = 10; @@ -334,6 +336,34 @@ fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTabl return Err(EngineError::Other("The table is empty.".into())); } let w = table.rows.iter().map(Vec::len).max().unwrap_or(1); + let (Some(end_row), Some(end_col)) = ( + old.start.row.checked_add(table.rows.len() as u32 - 1).filter(|r| *r < MAX_ROWS), + old.start.col.checked_add(w as u32 - 1).filter(|c| *c < gridcraft_core::MAX_COLS), + ) else { + return Err(EngineError::Other("The refreshed table doesn't fit on the sheet below and right of the table's top-left cell.".into())); + }; + let new_range = RangeRef::new(old.start, CellRef::new(end_row, end_col)); + // A refresh only owns the old table's own rectangle: refuse before any change if the + // expanded destination would overwrite other content (a user formula, another table). + if let Some(sh) = d.wb.sheet(sheet) { + let outside = new_range.height() > old.height() || new_range.width() > old.width(); + let clash = outside + && (new_range.start.row..=new_range.end.row).any(|row| { + sh.cells + .row(row, new_range.start.col, new_range.end.col) + .any(|(c, cell)| { + let at = (row, c); + (at < (old.start.row, old.start.col) || at > (old.end.row, old.end.col)) + && (!cell.value.is_empty() || cell.formula.is_some()) + }) + }); + if clash { + return Err(EngineError::Other(format!( + "The refreshed table would overwrite cells outside its old range {OVERWRITE_ERROR}: {}", + new_range.a1() + ))); + } + } // The rewrite is one undo step over the table's own place: a failure mid-way rolls the // old table and its data back instead of leaving the sheet empty. s.grouped("Refresh Web Table", |s| { @@ -351,8 +381,7 @@ fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTabl remove(s)?; // Write at the table's own origin, not A1: a moved table refreshes where it is. s.execute("range.setValues", json!({"range": old.start.a1(), "sheet": sheet, "values": rows}))?; - let range = RangeRef::new(old.start, CellRef::new(old.start.row + table.rows.len() as u32 - 1, old.start.col + w as u32 - 1)); - s.execute("insert.table", json!({"range": range.a1(), "header": true, "name": tname}))?; + s.execute("insert.table", json!({"range": new_range.a1(), "header": true, "name": tname}))?; let synced = now_ms(); edit(s, |cx| { if let Some(qq) = cx.wb.web_queries.iter_mut().find(|x| x.sheet.eq_ignore_ascii_case(&q.sheet) && x.name.eq_ignore_ascii_case(&q.name)) { @@ -482,6 +511,29 @@ mod tests { assert_eq!(d.wb.web_queries[1].sheet, "Two"); } + #[test] + fn refresh_refuses_to_overwrite_cells_outside_the_old_range() { + let mut s = session_with_source(); + s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "Web"})).unwrap(); + // A user formula sits just below the old table; the source now serves more rows. + s.execute("sheet.activate", json!({"sheet": "Web"})).unwrap(); + s.execute("cell.set", json!({"cell": "A3", "input": "=1+1"})).unwrap(); + s.web_source = Some(Arc::new(|req: &WebRequest| { + Ok(crate::webquery::extract_tables( + "
SymPrice
A1
B2
", + req.max_rows, + req.max_cols, + )) + })); + let r = s.execute("data.webRefresh", json!({})).unwrap(); + assert_eq!(r["refreshed"], 0); + let err = r["errors"][0]["error"].as_str().unwrap(); + assert!(err.contains("already holds data")); + // The formula and the old table are untouched. + assert_eq!(a1(&s, "A3"), "2"); + assert_eq!(a1(&s, "A2"), "ABCD"); + } + #[test] fn refresh_writes_at_the_moved_table_s_own_place() { let mut s = session_with_source(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 882727488a..30514c4c0f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3028,8 +3028,16 @@ fn from_web(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut egui::Grid::new("fw").show(ui, |ui| { ui.label("URL:"); ui.add(egui::TextEdit::singleline(&mut url).desired_width(360.0).hint_text("https://example.com/table.html")); + // An edited address invalidates the preview: don't let Load pair the old page's + // table names with the new URL. + if d.values.get("preview_url").and_then(Json::as_str).is_some_and(|u| u != url.trim()) { + d.values.remove("preview_url"); + d.values.remove("tables"); + d.values.remove("pick"); + } if ui.button("Go").clicked() && !url.trim().is_empty() { d.values.insert("url".into(), json!(url.trim())); + d.values.insert("preview_url".into(), json!(url.trim())); d.values.insert("tables".into(), Json::Null); d.values.insert("pick".into(), json!(0)); let r = app.run("data.webPreview", json!({"url": url.trim()})); From 1ea0634b67127507e55ed1744ff7fd88b3714b19 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 26 Oct 2026 03:23:55 +0000 Subject: [PATCH 10/31] From Web: test row and column bounds separately in the refresh clash check Co-authored-by: setzor --- crates/engine/src/cmd/web.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/web.rs b/crates/engine/src/cmd/web.rs index 30681e084d..c94c191216 100644 --- a/crates/engine/src/cmd/web.rs +++ b/crates/engine/src/cmd/web.rs @@ -352,8 +352,7 @@ fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTabl sh.cells .row(row, new_range.start.col, new_range.end.col) .any(|(c, cell)| { - let at = (row, c); - (at < (old.start.row, old.start.col) || at > (old.end.row, old.end.col)) + (row < old.start.row || row > old.end.row || c < old.start.col || c > old.end.col) && (!cell.value.is_empty() || cell.formula.is_some()) }) }); @@ -534,6 +533,27 @@ mod tests { assert_eq!(a1(&s, "A2"), "ABCD"); } + #[test] + fn refresh_refuses_to_overwrite_a_new_column_inside_the_old_rows() { + let mut s = session_with_source(); + s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "Web"})).unwrap(); + // The old table is A1:B2; the source now serves a wider table, and a user formula + // sits in the new column inside the old rows — the tuple-ordering blind spot. + s.execute("sheet.activate", json!({"sheet": "Web"})).unwrap(); + s.execute("cell.set", json!({"cell": "C2", "input": "=1+1"})).unwrap(); + s.web_source = Some(Arc::new(|req: &WebRequest| { + Ok(crate::webquery::extract_tables( + "
SymPriceChg
A12
", + req.max_rows, + req.max_cols, + )) + })); + let r = s.execute("data.webRefresh", json!({})).unwrap(); + assert_eq!(r["refreshed"], 0); + assert!(r["errors"][0]["error"].as_str().unwrap().contains("already holds data")); + assert_eq!(a1(&s, "C2"), "2"); + } + #[test] fn refresh_writes_at_the_moved_table_s_own_place() { let mut s = session_with_source(); From 8f3b75f84d9b3b0325320660986f7b18fb006fb2 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 26 Oct 2026 04:54:31 +0000 Subject: [PATCH 11/31] =?UTF-8?q?From=20Web:=20review=20cleanup=20?= =?UTF-8?q?=E2=80=94=20dedupe=20doc=20comments,=20drop=20dead=20code,=20fi?= =?UTF-8?q?x=20Refresh=20All=20keytip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: setzor --- crates/engine/src/cmd/web.rs | 42 +++++++++++------------------------- crates/ui-egui/src/ribbon.rs | 2 +- 2 files changed, 13 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/cmd/web.rs b/crates/engine/src/cmd/web.rs index c94c191216..d0ebdd541f 100644 --- a/crates/engine/src/cmd/web.rs +++ b/crates/engine/src/cmd/web.rs @@ -4,10 +4,10 @@ use serde_json::{Value as Json, json}; +use super::analysis::OVERWRITE_ERROR; use super::*; use crate::webquery::{DEFAULT_MAX_BYTES, DEFAULT_MAX_ROWS, MAX_COLS, WebRequest, WebTable}; use gridcraft_core::MAX_ROWS; -use super::analysis::OVERWRITE_ERROR; /// Preview rows and columns per table (the Navigator's preview pane). const PREVIEW_ROWS: usize = 10; @@ -61,9 +61,7 @@ fn now_ms() -> u64 { } } -/// A page request from the command params, with the caps clamped. -/// A page request from the command params, with the caps clamped (shared by the three -/// commands). +/// A page request from the command params, with the caps clamped (shared by the three commands). fn request(p: &Json) -> Result { const ID: &str = "data.fromWeb"; let url = str_param(p, "url").map(str::trim).filter(|u| u.starts_with("http")).ok_or_else(|| bad(ID, "missing `url`"))?; @@ -153,7 +151,7 @@ fn load_table_steps(s: &mut Session, req: &WebRequest, table: &WebTable, sheet_n } s.execute("home.insertSheet", json!({"name": sheet_name})).or_else(|_| s.execute("home.insertSheet", json!({})))?; let r = s.doc()?; - let sheet = r.wb.active_sheet; + let sheet = r.wb.active_sheet.get(); let sheet_name = r.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default(); s.execute("range.setValues", json!({"range": "A1", "sheet": sheet, "values": rows}))?; let w = table.rows.iter().map(Vec::len).max().unwrap_or(1); @@ -235,9 +233,6 @@ fn json_rows(table: &WebTable) -> Vec> { .collect() } -/// True when typed-input parsing would make `c` a formula: `=` always, and `+`/`-`/`@` only -/// in the forms that start one (a plain `-5` stays the number it is). Mirrors the check in -/// `input_to_cell` (cmd/edit.rs). /// True when typed-input parsing would make `c` a formula: `=` always, and `+`/`-`/`@` only /// in the forms that start one (a plain `-5` stays the number it is). Mirrors the check in /// `input_to_cell` (cmd/edit.rs). @@ -271,7 +266,6 @@ fn web_preview(s: &mut Session, p: &Json) -> Result { /// `data.webRefresh`: re-fetch and rewrite the saved connections the params select (all by /// default), reporting per-connection errors instead of failing the batch. fn web_refresh(s: &mut Session, p: &Json) -> Result { - const ID: &str = "data.webRefresh"; let url = str_param(p, "url").map(str::trim).filter(|u| !u.is_empty()); let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()); let Some(source) = s.web_source.clone() else { @@ -296,38 +290,28 @@ fn web_refresh(s: &mut Session, p: &Json) -> Result { let table = pick_table(&tables, Some(&q.table), q.occurrence).ok_or_else(|| EngineError::Other(no_table_message(&tables)))?; rewrite_table(s, &q, table) }) { - Ok((rows, cols)) => { + Ok((rows, _cols)) => { refreshed += 1; log::debug!("webRefresh {}: {} rows", q.name, rows); - let _ = cols; } Err(e) => errors.push(json!({"table": q.name, "error": e.to_string()})), } } - let _ = ID; Ok(json!({"refreshed": refreshed, "errors": errors})) } -/// Re-fetch result over the sheet range the connection's table holds: clear the old range, -/// write the new rows, and resize the table. Formulas and formatting outside the range are kept. /// Re-fetch result over the connection's table: clear its range, write the new rows and -/// resize the table, as one undo step, at the table's own place. +/// resize the table, as one undo step, at the table's own place. Formulas and formatting +/// outside the range are kept. fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTable) -> Result<(usize, usize)> { let d = s.doc()?; - // Only the connection's own table: a name match anywhere, else the same name on the - // connection's sheet. Never the first unrelated table that happens to be there. + // Only the connection's own table: the same name on the connection's sheet, else a name + // match anywhere. Never the first unrelated table that happens to be there. let Some((sheet, tname, old)) = d.wb.sheets .iter() .enumerate() .find_map(|(si, sh)| sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&q.name)).map(|t| (si, t.name.clone(), t.range))) - .or_else(|| { - d.wb.sheets.iter().enumerate().find_map(|(si, sh)| { - (sh.name.eq_ignore_ascii_case(&q.sheet) && sh.tables.iter().any(|t| t.name.eq_ignore_ascii_case(&q.name))) - .then(|| sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&q.name)).map(|t| (si, t.name.clone(), t.range))) - .flatten() - }) - }) else { return Err(EngineError::Other(format!("The sheet or table of the connection `{}` is gone.", q.name))); }; @@ -349,12 +333,10 @@ fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTabl let outside = new_range.height() > old.height() || new_range.width() > old.width(); let clash = outside && (new_range.start.row..=new_range.end.row).any(|row| { - sh.cells - .row(row, new_range.start.col, new_range.end.col) - .any(|(c, cell)| { - (row < old.start.row || row > old.end.row || c < old.start.col || c > old.end.col) - && (!cell.value.is_empty() || cell.formula.is_some()) - }) + sh.cells.row(row, new_range.start.col, new_range.end.col).any(|(c, cell)| { + (row < old.start.row || row > old.end.row || c < old.start.col || c > old.end.col) + && (!cell.value.is_empty() || cell.formula.is_some()) + }) }); if clash { return Err(EngineError::Other(format!( diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 893f77d352..0523ec069b 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2245,7 +2245,7 @@ fn data(app: &mut SheetApp, ui: &mut Ui) { app.open_dialog("queriesConnections", json!({})); } let ra = small_button(ui, Icon::Redo, "", "Refresh All (re-sync every saved web table)", false); - control_keytip(app, ui, &ra, "data.refreshAll"); + control_keytip(app, ui, &ra, "data.webRefresh"); if ra.clicked() { act(app, "data.webRefresh", json!({})); } From 7413c5a8bbd7b4f4a99b8d6e391aa74985cb304c Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 26 Oct 2026 05:42:34 +0000 Subject: [PATCH 12/31] From Web: refresh keeps the table's settings and totals row; detect oversized pages; pass the picked occurrence Co-authored-by: setzor --- crates/engine/src/cmd/insert.rs | 2 +- crates/engine/src/cmd/web.rs | 115 ++++++++++++++++++++++++++++---- crates/ui-egui/src/dialogs.rs | 11 ++- crates/webquery/src/lib.rs | 9 ++- 4 files changed, 118 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 8274dfeed0..33da776e15 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -435,7 +435,7 @@ fn table_flag(s: &mut Session, p: &Json, which: &str) -> Result { } /// Writes the totals row cells (labels and SUBTOTAL formulas). -fn write_totals(sh: &mut Sheet, ti: usize) { +pub(crate) fn write_totals(sh: &mut Sheet, ti: usize) { let Some(t) = sh.tables.get(ti).cloned() else { return }; if !t.totals_row { return; diff --git a/crates/engine/src/cmd/web.rs b/crates/engine/src/cmd/web.rs index d0ebdd541f..b68da40df5 100644 --- a/crates/engine/src/cmd/web.rs +++ b/crates/engine/src/cmd/web.rs @@ -5,9 +5,11 @@ use serde_json::{Value as Json, json}; use super::analysis::OVERWRITE_ERROR; +use super::insert::write_totals; use super::*; use crate::webquery::{DEFAULT_MAX_BYTES, DEFAULT_MAX_ROWS, MAX_COLS, WebRequest, WebTable}; use gridcraft_core::MAX_ROWS; +use gridcraft_model::{TableColumn, TotalsFn}; /// Preview rows and columns per table (the Navigator's preview pane). const PREVIEW_ROWS: usize = 10; @@ -328,11 +330,14 @@ fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTabl }; let new_range = RangeRef::new(old.start, CellRef::new(end_row, end_col)); // A refresh only owns the old table's own rectangle: refuse before any change if the - // expanded destination would overwrite other content (a user formula, another table). + // expanded destination would overwrite other content (a user formula, another table). The + // totals row follows the resized table, so the row below the new data is checked too. if let Some(sh) = d.wb.sheet(sheet) { - let outside = new_range.height() > old.height() || new_range.width() > old.width(); + let totals_row = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&tname)).is_some_and(|t| t.totals_row); + let last_row = if totals_row { (new_range.end.row + 1).min(MAX_ROWS - 1) } else { new_range.end.row }; + let outside = new_range.height() > old.height() || new_range.width() > old.width() || totals_row; let clash = outside - && (new_range.start.row..=new_range.end.row).any(|row| { + && (new_range.start.row..=last_row).any(|row| { sh.cells.row(row, new_range.start.col, new_range.end.col).any(|(c, cell)| { (row < old.start.row || row > old.end.row || c < old.start.col || c > old.end.col) && (!cell.value.is_empty() || cell.formula.is_some()) @@ -348,21 +353,62 @@ fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTabl // The rewrite is one undo step over the table's own place: a failure mid-way rolls the // old table and its data back instead of leaving the sheet empty. s.grouped("Refresh Web Table", |s| { - // Clear the old range, then drop the table so a new one can take the new shape. + // Clear only the old table's rectangle and rewrite the data; the table itself stays: + // its style, totals row, calculated-column and XML-binding metadata, filters and flags + // are the user's settings, not the page's, and a refresh must not reset them. s.execute("edit.clearContents", json!({"range": old.a1(), "sheet": sheet}))?; - let remove = |s: &mut Session| { + // Write at the table's own origin, not A1: a moved table refreshes where it is. + s.execute("range.setValues", json!({"range": old.start.a1(), "sheet": sheet, "values": rows}))?; + edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + let Some(ti) = sh.tables.iter().position(|t| t.name.eq_ignore_ascii_case(&tname)) else { return Ok(()) }; + // The columns follow the new header row: an unchanged name keeps its metadata + // (totals function and label, calculated-column formula, XML binding), a new one + // starts plain. sync_headers (run by `edit`) then renames the columns to match. + let old_cols = sh.tables[ti].columns.clone(); + let headers: Vec = (new_range.start.col..=new_range.end.col) + .map(|c| { + let v = sh.value(CellRef::new(new_range.start.row, c)); + v.as_text() + .map(str::to_string) + .filter(|t| !t.trim().is_empty()) + .unwrap_or_else(|| format!("Column{}", c - new_range.start.col + 1)) + }) + .collect(); + let t = &mut sh.tables[ti]; + t.columns = headers + .into_iter() + .map(|name| { + old_cols.iter().find(|col| col.name.eq_ignore_ascii_case(&name)).cloned().unwrap_or(TableColumn { + name, + totals: TotalsFn::None, + totals_label: None, + formula: None, + unique_name: None, + xml: None, + }) + }) + .collect(); + t.range = new_range; + cx.structural = true; + Ok(()) + })?; + // The totals row sits below the resized range: restore its SUBTOTAL formulas and + // labels, which clearContents took with the rest of the old rectangle. + let restore_totals = { + let d = s.doc()?; + d.wb.sheet(sheet).and_then(|sh| sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&tname))).filter(|t| t.totals_row).is_some() + }; + if restore_totals { edit(s, |cx| { - if let Some(sh) = cx.wb.sheet_mut(sheet) { - sh.tables.retain(|t| !t.name.eq_ignore_ascii_case(&tname)); - } + let sh = cx.sheet_mut(sheet)?; + let Some(ti) = sh.tables.iter().position(|t| t.name.eq_ignore_ascii_case(&tname)) else { return Ok(()) }; + sh.tables[ti].range.end.row = (sh.tables[ti].range.end.row + 1).min(MAX_ROWS - 1); + write_totals(sh, ti); cx.structural = true; Ok(()) - }) - }; - remove(s)?; - // Write at the table's own origin, not A1: a moved table refreshes where it is. - s.execute("range.setValues", json!({"range": old.start.a1(), "sheet": sheet, "values": rows}))?; - s.execute("insert.table", json!({"range": new_range.a1(), "header": true, "name": tname}))?; + })?; + } let synced = now_ms(); edit(s, |cx| { if let Some(qq) = cx.wb.web_queries.iter_mut().find(|x| x.sheet.eq_ignore_ascii_case(&q.sheet) && x.name.eq_ignore_ascii_case(&q.name)) { @@ -469,6 +515,47 @@ mod tests { assert!(sh.value(CellRef::parse("C1").unwrap()).is_empty()); } + #[test] + fn refresh_keeps_the_table_s_settings_and_totals_row() { + let mut s = session_with_source(); + s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "Web"})).unwrap(); + // The user restyles the table and adds a totals row. + s.execute("table.style", json!({"table": "prices", "style": "TableStyleMedium9"})).unwrap(); + s.execute("table.totalRow", json!({"table": "prices", "on": true})).unwrap(); + let id = { + let d = s.active().unwrap(); + d.wb.active().unwrap().tables[0].id + }; + assert_eq!(a1(&s, "B3"), "12.5"); + // The source now serves a bigger table. + s.web_source = Some(Arc::new(|req: &WebRequest| { + Ok(crate::webquery::extract_tables( + "
SymPrice
A1
B2
", + req.max_rows, + req.max_cols, + )) + })); + let r = s.execute("data.webRefresh", json!({})).unwrap(); + assert_eq!(r["refreshed"], 1); + assert!(r["errors"].as_array().unwrap().is_empty()); + let d = s.active().unwrap(); + let sh = d.wb.active().unwrap(); + let t = &sh.tables[0]; + // The table itself survived: same name, the user's style, the totals row with + // its SUBTOTAL function, and the resized range including the totals row. + assert_eq!(t.name, "prices"); + assert_eq!(t.id, id); + assert_eq!(t.style, "TableStyleMedium9"); + assert!(t.totals_row); + assert_eq!(t.range.height(), 4); + assert_eq!(t.columns[1].totals, gridcraft_model::TotalsFn::Sum); + // The refreshed data landed, and the totals row was rewritten below it. + assert_eq!(a1(&s, "B3"), "2"); + let d = s.active().unwrap(); + let sh = d.wb.active().unwrap(); + assert!(sh.cell(CellRef::parse("B4").unwrap()).unwrap().formula.is_some()); + } + #[test] fn default_sheet_name_is_the_site_not_the_tld() { let mut s = session_with_source(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 73bc529679..eb3910ae51 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3110,10 +3110,15 @@ fn from_web(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut *confirm = false; } if *confirm && !url.trim().is_empty() { - let pick = d.values.get("pick").and_then(Json::as_u64).unwrap_or(0); - let table = tables.get(pick as usize).and_then(|t| t["name"].as_str()).map(String::from); + let pick = d.values.get("pick").and_then(Json::as_u64).unwrap_or(0) as usize; + let table = tables.get(pick).and_then(|t| t["name"].as_str()).map(String::from); let params = match table { - Some(t) => json!({"url": url.trim(), "table": t}), + Some(t) => { + // A page can hold duplicate table names: which one was picked is which one to + // import, and the connection saves it so refreshes keep importing the same. + let occurrence = tables.iter().take(pick).filter(|o| o["name"].as_str().is_some_and(|n| n.eq_ignore_ascii_case(&t))).count(); + json!({"url": url.trim(), "table": t, "occurrence": occurrence}) + } None => json!({"url": url.trim()}), }; match app.run("data.fromWeb", params) { diff --git a/crates/webquery/src/lib.rs b/crates/webquery/src/lib.rs index 3f5b339d27..23c9d36145 100644 --- a/crates/webquery/src/lib.rs +++ b/crates/webquery/src/lib.rs @@ -67,7 +67,14 @@ fn http_get(req: &gridcraft_engine::webquery::WebRequest) -> Result = Vec::new(); - let _ = resp.into_body().into_reader().take(req.max_bytes as u64).read_to_end(&mut bytes).map_err(|e| e.to_string())?; + let _ = reader.by_ref().take(req.max_bytes as u64).read_to_end(&mut bytes).map_err(|e| e.to_string())?; + let mut extra = [0u8; 1]; + if reader.read(&mut extra).map_err(|e| e.to_string())? != 0 { + return Err(format!("The page exceeds the {} byte limit.", req.max_bytes)); + } Ok(String::from_utf8_lossy(&bytes).into_owned()) } From 8d7bf05df95ae0d90a84376f55a3d47f7fc88168 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 26 Oct 2026 06:19:47 +0000 Subject: [PATCH 13/31] From Web: match refresh columns by the fetched header text, not the parsed cell Co-authored-by: setzor --- crates/engine/src/cmd/web.rs | 39 +++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/web.rs b/crates/engine/src/cmd/web.rs index b68da40df5..605844ce25 100644 --- a/crates/engine/src/cmd/web.rs +++ b/crates/engine/src/cmd/web.rs @@ -366,13 +366,18 @@ fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTabl // (totals function and label, calculated-column formula, XML binding), a new one // starts plain. sync_headers (run by `edit`) then renames the columns to match. let old_cols = sh.tables[ti].columns.clone(); - let headers: Vec = (new_range.start.col..=new_range.end.col) - .map(|c| { - let v = sh.value(CellRef::new(new_range.start.row, c)); - v.as_text() - .map(str::to_string) - .filter(|t| !t.trim().is_empty()) - .unwrap_or_else(|| format!("Column{}", c - new_range.start.col + 1)) + // The names come from the fetched header row, not the cells: a numeric header + // (`2024`) is a number in the cell, and reading it as text would miss the column + // and drop its settings on every refresh. sync_headers writes them back as text. + let headers: Vec = (0..new_range.width() as usize) + .map(|i| { + table + .rows + .first() + .and_then(|row| row.get(i)) + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .unwrap_or_else(|| format!("Column{}", i + 1)) }) .collect(); let t = &mut sh.tables[ti]; @@ -556,6 +561,26 @@ mod tests { assert!(sh.cell(CellRef::parse("B4").unwrap()).unwrap().formula.is_some()); } + #[test] + fn refresh_keeps_the_settings_of_a_numeric_header_column() { + // A numeric header (`2024`) lands in the cell as a number: the column lookup must use + // the fetched header text, or the column's settings are dropped on every refresh. + let mut s = Session::new(); + s.execute("file.new", json!({})).unwrap(); + let page = "
Item2024
A1
"; + s.web_source = Some(Arc::new(move |req: &WebRequest| Ok(crate::webquery::extract_tables(page, req.max_rows, req.max_cols)))); + s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "Web"})).unwrap(); + // The user gives the numeric column a Sum totals function. + s.execute("table.totalFunction", json!({"table": "years", "column": "2024", "function": "sum"})).unwrap(); + let r = s.execute("data.webRefresh", json!({})).unwrap(); + assert_eq!(r["refreshed"], 1); + let d = s.active().unwrap(); + let sh = d.wb.active().unwrap(); + let t = &sh.tables[0]; + assert_eq!(t.columns[1].name, "2024"); + assert_eq!(t.columns[1].totals, gridcraft_model::TotalsFn::Sum); + } + #[test] fn default_sheet_name_is_the_site_not_the_tld() { let mut s = session_with_source(); From 39529f08f27fb34a6cc6a6a839baee3d89720ad3 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 26 Oct 2026 06:56:31 +0000 Subject: [PATCH 14/31] docs: document the functions the From Web diff touches Co-authored-by: setzor --- apps/gridcraft-cli/src/main.rs | 2 ++ apps/gridcraft/src/main.rs | 1 + crates/engine/src/cmd/extra.rs | 1 + crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/web.rs | 22 ++++++++++++++++++++++ crates/engine/src/lib.rs | 2 ++ crates/engine/src/webquery.rs | 18 ++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 4 ++++ crates/ui-egui/src/ribbon.rs | 2 ++ xtask/src/layers.rs | 1 + 10 files changed, 54 insertions(+) diff --git a/apps/gridcraft-cli/src/main.rs b/apps/gridcraft-cli/src/main.rs index 3606732c02..5b31b54b74 100644 --- a/apps/gridcraft-cli/src/main.rs +++ b/apps/gridcraft-cli/src/main.rs @@ -165,6 +165,7 @@ fn exec(s: &mut Session, id: &str, params: Value) -> Result { r } +/// A session with the file at `path` open, wired for web imports. fn open(path: &str) -> Result { let mut s = Session::new(); s.web_source = Some(gridcraft_webquery::provider()); @@ -172,6 +173,7 @@ fn open(path: &str) -> Result { Ok(s) } +/// A session with a fresh workbook, wired for web imports. fn blank() -> Session { let mut s = Session::new(); s.web_source = Some(gridcraft_webquery::provider()); diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index a253ca19a9..6ca08a3117 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -233,6 +233,7 @@ fn icon() -> Option { Some(egui::IconData { width: img.width(), height: img.height(), rgba: img.into_raw() }) } +/// The desktop app: logging, the window, the session with its web source, and the event loop. fn main() -> eframe::Result<()> { // First, so the panic hook and every start-up warning are recorded (`logging`). let logger = logging::install(); diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index e6b1c1ac51..3f8dee71e9 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -992,6 +992,7 @@ fn from_csv(s: &mut Session, p: &Json) -> Result { Ok(json!({"sheet": r["name"], "rows": rows.len(), "cols": w})) } +/// `data.fromTableRange`: the selected block as CSV text into a copy of its sheet. fn from_table_range(s: &mut Session, p: &Json) -> Result { let r = target_range(s, p)?; let d = s.doc()?; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 503ee26585..92d63774e0 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -129,6 +129,7 @@ macro_rules! cmd { } pub(crate) use cmd; +/// Every command the engine holds, in menu order, built once. pub fn command_specs() -> &'static [CommandSpec] { static SPECS: std::sync::OnceLock> = std::sync::OnceLock::new(); SPECS.get_or_init(|| { diff --git a/crates/engine/src/cmd/web.rs b/crates/engine/src/cmd/web.rs index 605844ce25..5d39d909fa 100644 --- a/crates/engine/src/cmd/web.rs +++ b/crates/engine/src/cmd/web.rs @@ -434,11 +434,13 @@ mod tests { use super::*; use gridcraft_core::Value; + /// A page with two tables: one named, one nameless. fn page() -> &'static str { "
SymPrice
ABCD12.50
\
x1
" } + /// A session whose web source serves `page`. fn session_with_source() -> Session { let mut s = Session::new(); s.execute("file.new", json!({})).unwrap(); @@ -449,6 +451,7 @@ mod tests { s } + /// The cell's displayed text on the active sheet. fn a1(s: &Session, at: &str) -> String { let d = s.active().unwrap(); let sh = d.wb.active().unwrap(); @@ -456,6 +459,7 @@ mod tests { } #[test] + /// The default import: the first table, a new sheet, a saved connection. fn imports_the_first_table_into_a_new_sheet_and_saves_the_connection() { let mut s = session_with_source(); let r = s.execute("data.fromWeb", json!({"url": "https://example.com/markets", "sheet": "Markets"})).unwrap(); @@ -471,6 +475,7 @@ mod tests { } #[test] + /// A named table is picked by name; its cells keep their types. fn imports_a_named_table_and_keeps_text_and_numbers() { let mut s = session_with_source(); s.execute("data.fromWeb", json!({"url": "https://example.com", "table": "Table 2"})).unwrap(); @@ -485,6 +490,7 @@ mod tests { } #[test] + /// The preview lists every table with its name and size. fn preview_lists_the_page_tables() { let mut s = session_with_source(); let r = s.execute("data.webPreview", json!({"url": "https://example.com"})).unwrap(); @@ -496,6 +502,7 @@ mod tests { } #[test] + /// A refresh rewrites the same rectangle with the new rows and resizes the table. fn refresh_rewrites_the_table_in_place() { let mut s = session_with_source(); s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "Web"})).unwrap(); @@ -521,6 +528,7 @@ mod tests { } #[test] + /// A refresh keeps the user's style and totals row instead of resetting them. fn refresh_keeps_the_table_s_settings_and_totals_row() { let mut s = session_with_source(); s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "Web"})).unwrap(); @@ -562,6 +570,7 @@ mod tests { } #[test] + /// A numeric header's column keeps its settings across a refresh. fn refresh_keeps_the_settings_of_a_numeric_header_column() { // A numeric header (`2024`) lands in the cell as a number: the column lookup must use // the fetched header text, or the column's settings are dropped on every refresh. @@ -582,6 +591,7 @@ mod tests { } #[test] + /// The default sheet name is the site's own name, not the TLD or a subdomain. fn default_sheet_name_is_the_site_not_the_tld() { let mut s = session_with_source(); s.execute("data.fromWeb", json!({"url": "https://www.example.com/markets"})).unwrap(); @@ -594,6 +604,7 @@ mod tests { } #[test] + /// A second import adds a connection rather than replacing the first. fn a_second_import_keeps_the_first_connection() { let mut s = session_with_source(); s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "One"})).unwrap(); @@ -605,6 +616,7 @@ mod tests { } #[test] + /// A refresh that would grow over other content is refused before any change. fn refresh_refuses_to_overwrite_cells_outside_the_old_range() { let mut s = session_with_source(); s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "Web"})).unwrap(); @@ -628,6 +640,7 @@ mod tests { } #[test] + /// The clash check tests rows and columns independently (a formula beside the old rows). fn refresh_refuses_to_overwrite_a_new_column_inside_the_old_rows() { let mut s = session_with_source(); s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "Web"})).unwrap(); @@ -649,6 +662,7 @@ mod tests { } #[test] + /// A moved table refreshes where it is, not at its old origin. fn refresh_writes_at_the_moved_table_s_own_place() { let mut s = session_with_source(); s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "Web"})).unwrap(); @@ -670,6 +684,7 @@ mod tests { } #[test] + /// The import result and the connection name the table that was actually created. fn the_result_and_connection_name_the_created_table() { let mut s = session_with_source(); // "prices" is a valid name, so the first import takes it; the second, from another @@ -684,6 +699,7 @@ mod tests { } #[test] + /// Page text that looks like a formula is imported as text, never live. fn page_content_never_becomes_a_formula() { let mut s = Session::new(); s.execute("file.new", json!({})).unwrap(); @@ -707,6 +723,7 @@ mod tests { } #[test] + /// With the connection's table gone, refresh reports it and touches nothing. fn refresh_never_falls_back_to_an_unrelated_table() { let mut s = session_with_source(); s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "Web"})).unwrap(); @@ -726,6 +743,7 @@ mod tests { } #[test] + /// Duplicate page names: the connection re-imports the occurrence it started with. fn refresh_re_imports_the_same_occurrence_of_a_duplicate_table_name() { // The page holds two tables with the same id; the import takes the second one. let page = "
Sym
first
\ @@ -744,6 +762,7 @@ mod tests { } #[test] + /// A vanished occurrence fails the refresh and keeps the sheet as it was. fn a_vanished_occurrence_fails_refresh_and_keeps_the_data() { let mut s = Session::new(); s.execute("file.new", json!({})).unwrap(); @@ -763,6 +782,7 @@ mod tests { } #[test] + /// No source, a non-http address and a missing table are errors, not imports. fn errors_without_source_bad_url_or_missing_table() { let mut s = Session::new(); s.execute("file.new", json!({})).unwrap(); @@ -777,12 +797,14 @@ mod tests { } #[test] + /// Headless `data.fromWeb` without a URL fails instead of opening the dialog. fn headless_empty_params_fail_instead_of_opening_a_dialog() { let mut s = session_with_source(); assert!(s.execute_headless("data.fromWeb", json!({})).is_err()); } #[test] + /// Every From Web command answers empty params with an error, not a panic. fn every_command_survives_empty_params() { let mut s = session_with_source(); for id in ["data.fromWeb", "data.webPreview", "data.webRefresh"] { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4be080d31d..d437142ebf 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -390,7 +390,9 @@ impl Default for StatusBarStats { } } +/// The shipped defaults: Enter moves down, autocomplete on, no macros loaded. impl Default for Prefs { + /// The preferences a fresh install starts with. fn default() -> Self { Prefs { enter_direction: "down".into(), diff --git a/crates/engine/src/webquery.rs b/crates/engine/src/webquery.rs index cd2764b3c4..eebdb3dc92 100644 --- a/crates/engine/src/webquery.rs +++ b/crates/engine/src/webquery.rs @@ -167,6 +167,8 @@ struct TableState { } impl TableState { + /// A table whose cell budget is the least of its own cap, its size caps and the page's + /// remaining share. fn new(name: String, max_rows: usize, max_cols: usize, page_budget: usize) -> Self { Self { name, @@ -188,18 +190,21 @@ impl TableState { self.spent } + /// Text between tags: part of the open cell, or nothing when none is. fn push_text(&mut self, text: &str) { if self.cell_open { self.cell.push_str(text); } } + /// ``/``: close the previous cell, open a new one with its spans. fn start_cell(&mut self, colspan: usize, rowspan: Option) { self.close_cell(); self.cell_open = true; self.cell_span = (colspan, rowspan); } + /// ``: close the previous cell and start the next row. fn start_row(&mut self) { self.close_cell(); self.flush_row(); @@ -274,6 +279,7 @@ impl TableState { } } + /// Take the built row into the table, or drop it when empty or past the row cap. fn flush_row(&mut self) { if self.row.is_empty() || self.rows.len() >= self.max_rows { self.row.clear(); @@ -440,6 +446,7 @@ mod tests { use super::*; #[test] + /// A named table takes its id for a name; others are numbered. fn extracts_named_and_unnamed_tables() { let html = "

intro

\
SymPrice
ABCD12.50
\ @@ -453,6 +460,7 @@ mod tests { } #[test] + /// Rows are padded to the widest; unclosed cells and rows survive broken markup. fn ragged_rows_are_padded_and_spans_ignored() { let html = "
abc
d
"; let t = extract_tables(html, 100, 100); @@ -460,6 +468,7 @@ mod tests { } #[test] + /// Cell text collapses whitespace and decodes entities; scripts never enter it. fn collapses_whitespace_decodes_entities_and_skips_scripts() { let html = "\
Red & Blue\n Widget AB
"; @@ -468,6 +477,7 @@ mod tests { } #[test] + /// The row and column caps truncate a too-large table instead of failing. fn honours_row_and_col_caps() { let html = "
12
34
"; assert_eq!(extract_tables(html, 1, 100)[0].rows, vec![vec!["1", "2"]]); @@ -475,6 +485,7 @@ mod tests { } #[test] + /// Empty or broken markup yields no tables and never panics. fn empty_or_broken_markup_yields_nothing_without_panicking() { assert!(extract_tables("", 10, 10).is_empty()); assert!(extract_tables("") && html.contains(">far")); - } - - #[test] - fn macros_are_saved_only_into_xlsm() { - let mut wb = Workbook::new(); - wb.vba_project = Some(b"vba".to_vec().into()); - let has_macros = |path: &str| { - let bytes = save_bytes(&wb, path).unwrap(); - gridcraft_xlsx::read_xlsx(&bytes).unwrap().0.vba_project.is_some() - }; - assert!(has_macros("book.xlsm")); - assert!(has_macros("BOOK.XLSM")); - assert!(!has_macros("book.xlsx"), "an .xlsx never carries a VBA project"); - assert!(has_macros("book.xltm"), "a macro-enabled template keeps the VBA project"); - assert!(!has_macros("book.xltx"), "a macro-free template never carries a VBA project"); - assert!(wb.vba_project.is_some(), "the open workbook keeps its macros"); - } - - #[test] - fn csv_named_history_does_not_get_the_reserved_sheet_name() { - for file in ["History.csv", "hIsToRy.csv", "history.tsv"] { - let (wb, _) = open_bytes(file, b"1\n").unwrap(); - let name = wb.sheets[0].name.clone(); - assert!(wb.check_sheet_name(&name, Some(0)).is_ok(), "{file} opened as {name:?}"); - } - let (wb, _) = open_bytes("Sales.csv", b"1\n").unwrap(); - assert_eq!(wb.sheets[0].name, "Sales"); - } - - /// A save that can't be put in place (here a folder already has the workbook's name, as - /// with a file another app holds locked) fails without leaving `.sctmp` behind. - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn failed_save_leaves_no_temporary_file() { - let dir = std::env::temp_dir().join(format!("gridcraft-write-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let blocked = dir.join("Book1.xlsx"); - std::fs::create_dir(&blocked).unwrap(); - assert!(write_file(blocked.to_str().unwrap(), b"workbook").is_err()); - assert!(!dir.join("Book1.xlsx.sctmp").exists(), "the temporary file is removed"); - // An ordinary save still works and leaves only the workbook. - let ok = dir.join("Book2.xlsx"); - write_file(ok.to_str().unwrap(), b"workbook").unwrap(); - assert_eq!(std::fs::read(&ok).unwrap(), b"workbook"); - assert!(!dir.join("Book2.xlsx.sctmp").exists()); - let _ = std::fs::remove_dir_all(&dir); - } - - /// While a script runs (#2178), file reads and writes are refused whatever command or - /// parameter asked for them; nesting and dropping the sandbox restore access. - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn script_sandbox_refuses_file_access_until_dropped() { - let dir = std::env::temp_dir().join(format!("gridcraft-sandbox-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let existing = dir.join("in.csv"); - std::fs::write(&existing, b"a,b\n").unwrap(); - let (existing, out) = (existing.to_string_lossy().to_string(), dir.join("out.xlsx").to_string_lossy().to_string()); - { - let _outer = ScriptSandbox::enter(); - { - let _inner = ScriptSandbox::enter(); - assert!(read_file(&existing).is_err()); - } - assert!(read_file(&existing).is_err(), "the outer sandbox still holds"); - assert!(write_file(&out, b"leak").is_err()); - assert!(!std::path::Path::new(&out).exists()); - } - assert_eq!(read_file(&existing).unwrap(), b"a,b\n"); - write_file(&out, b"ok").unwrap(); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a - /// root entry and the given stream names. - fn cfb(streams: &[&str]) -> Vec { - let mut b = vec![0u8; 512 * 3]; - let mut put = |at: usize, v: &[u8]| b[at..at + v.len()].copy_from_slice(v); - put(0, &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]); - put(30, &9u16.to_le_bytes()); - put(44, &1u32.to_le_bytes()); - put(48, &1u32.to_le_bytes()); - put(68, &0xFFFF_FFFEu32.to_le_bytes()); - for i in 1..109 { - put(76 + i * 4, &0xFFFF_FFFFu32.to_le_bytes()); - } - put(512, &0xFFFF_FFFDu32.to_le_bytes()); - put(516, &0xFFFF_FFFEu32.to_le_bytes()); - for (k, name) in std::iter::once("Root Entry").chain(streams.iter().copied()).enumerate() { - let units: Vec = name.encode_utf16().flat_map(u16::to_le_bytes).collect(); - put(1024 + k * 128, &units); - put(1024 + k * 128 + 64, &(units.len() as u16 + 2).to_le_bytes()); - } - b - } - - /// Cut-list rows as a CAD/CAM program exports them: `;`-separated, CRLF, part codes in the last - /// two columns, a part name containing a comma. - const CUT_LIST: &str = "1;511;184;P3_L1_led;2162PE;16;1MM_2162PE;1MM_2162PE;;;1244121406FB;\r\n\ - 1;562.5;510;Komoda, lijevo;2162PE;19;1MM_2162PE;;;;12441214071A;124412140710\r\n"; - - #[test] - fn csv_save_keeps_the_separator_it_was_opened_with() { - let (wb, _) = open_bytes("cut list.csv", CUT_LIST.as_bytes()).unwrap(); - assert_eq!(wb.csv_delimiter, Some(b';')); - assert_eq!(String::from_utf8(save_bytes(&wb, "cut list.csv").unwrap()).unwrap(), CUT_LIST); - // Save As another .csv name keeps it too; a comma file stays a comma file (quoting only - // fields that contain the separator in use). - assert_eq!(String::from_utf8(save_bytes(&wb, "copy.CSV").unwrap()).unwrap(), CUT_LIST); - let commas = "\"Komoda, lijevo\",b\r\n1,x;y\r\n"; - let (wb, _) = open_bytes("plain.csv", commas.as_bytes()).unwrap(); - assert_eq!(String::from_utf8(save_bytes(&wb, "plain.csv").unwrap()).unwrap(), commas); - // A tab-separated file named .csv round-trips with tabs. - let tabs = "a\tb\r\n1\t2\r\n"; - let (wb, _) = open_bytes("tabs.csv", tabs.as_bytes()).unwrap(); - assert_eq!(String::from_utf8(save_bytes(&wb, "tabs.csv").unwrap()).unwrap(), tabs); - } - - #[test] - fn csv_save_of_a_workbook_not_read_from_csv_uses_commas() { - let mut wb = Workbook::new(); - if let Some(sh) = wb.sheet_mut(0) { - sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::text("a")); - sh.set_value(CellRef::new(0, 1), gridcraft_core::Value::Number(1.0)); - } - assert_eq!(save_bytes(&wb, "new.csv").unwrap(), b"a,1\r\n"); - // Through .xlsx the CSV origin is gone, as in Excel. - let (from_csv, _) = open_bytes("cut list.csv", CUT_LIST.as_bytes()).unwrap(); - let (reopened, _) = open_bytes("book.xlsx", &save_bytes(&from_csv, "book.xlsx").unwrap()).unwrap(); - assert_eq!(reopened.csv_delimiter, None); - assert!(String::from_utf8(save_bytes(&reopened, "again.csv").unwrap()).unwrap().starts_with("1,511,184,")); - // A .tsv/.txt saved as .csv gets commas; as .tsv it keeps tabs. - let (tsv, _) = open_bytes("list.txt", b"a\tb\r\n").unwrap(); - assert_eq!(save_bytes(&tsv, "list.csv").unwrap(), b"a,b\r\n"); - assert_eq!(save_bytes(&tsv, "list.tsv").unwrap(), b"a\tb\r\n"); - } - - #[test] - fn encrypted_workbook_is_reported_not_misread() { - let file = gridcraft_xlsx::testing::encrypt_workbook(b"package", "pw"); - let msg = open_bytes("secret.xlsx", &file).unwrap_err().to_string(); - assert!(msg.contains(PASSWORD_REQUIRED), "got: {msg}"); - assert!(!msg.contains("zip"), "must not surface the zip error: {msg}"); - // A compound file naming the streams but unreadable is damage: no password is asked for, - // and none helps. - let file = cfb(&["EncryptionInfo", "EncryptedPackage"]); - for password in [None, Some("pw")] { - let msg = open_bytes_with_password("secret.xlsx", &file, password).unwrap_err().to_string(); - assert!(msg.contains("damaged") && !msg.contains(PASSWORD_REQUIRED), "got: {msg}"); - } - } - - #[test] - fn encryption_no_password_opens_is_reported_without_asking_for_one() { - let info: &[u8] = &[3, 0, 2, 0, 4, 0, 0, 0]; // Standard header flags: RC4, not AES - let file = gridcraft_xlsx::testing::compound_file(&[("EncryptionInfo", info), ("EncryptedPackage", &[0; 8])]); - let msg = open_bytes("rc4.xlsx", &file).unwrap_err().to_string(); - assert!(msg.contains("RC4") && !msg.contains(PASSWORD_REQUIRED), "got: {msg}"); - let irm = gridcraft_xlsx::testing::compound_file(&[("EncryptedPackage", &[0; 8])]); - let msg = open_bytes("irm.xlsx", &irm).unwrap_err().to_string(); - assert!(msg.contains("rights-management") && !msg.contains("damaged"), "got: {msg}"); - } - - #[test] - fn other_compound_files_are_not_called_encrypted() { - let err = open_bytes("old.doc", &cfb(&["WordDocument", "\u{5}SummaryInformation"])).unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("not an Excel workbook"), "got: {msg}"); - assert!(!msg.contains("password"), "got: {msg}"); - } - - /// Save As CSV/TSV writes cells as displayed: number formats applied, formula results, - /// whole values in narrow columns (never `#####`), quoting unchanged. - #[test] - fn csv_writes_displayed_text() { - use gridcraft_core::{CellError, Value}; - use gridcraft_model::{Cell, Formula, LineInfo, NumFmt, StyleId}; - let mut wb = Workbook::new(); - let mut styled = |code: &str| wb.styles.derive(StyleId::DEFAULT, |s| s.num_fmt = NumFmt::new(code)); - let (date, money, pct, frac, text, fixed, thousands) = - (styled("yyyy-mm-dd"), styled("$#,##0.00"), styled("0.0%"), styled("# ?/?"), styled("@"), styled("0.00"), styled("#,##0")); - let sh = wb.sheet_mut(0).unwrap(); - let cells = [ - ("A1", Cell { value: Value::Number(46023.0), formula: None, style: date }), - ("B1", Cell { value: Value::Number(1234.5), formula: None, style: money }), - ("C1", Cell { value: Value::Number(0.125), formula: None, style: pct }), - ("D1", Cell { value: Value::Number(1.5), formula: None, style: frac }), - ("E1", Cell { value: Value::text("007"), formula: None, style: text }), - ("F1", Cell { value: Value::Error(CellError::NA), formula: None, style: money }), - ("G1", Cell { value: Value::Bool(true), formula: None, style: StyleId::DEFAULT }), - ("A2", Cell { value: Value::Number(46024.0), style: date, ..Cell::formula(Formula::new("A1+1")) }), - ("B2", Cell { value: Value::Number(12345678.9), formula: None, style: fixed }), - ("C2", Cell { value: Value::Number(-0.25), formula: None, style: StyleId::DEFAULT }), - ("D2", Cell { value: Value::Number(1234567.0), formula: None, style: thousands }), - ]; - for (a, c) in cells { - sh.set_cell(CellRef::parse(a).unwrap(), c); - } - // Column B is far too narrow for its numbers: the grid shows #####, the file must not. - sh.cols.insert(1, LineInfo { size: Some(1.0), ..Default::default() }); - sh.show_formulas = true; - let csv = String::from_utf8(save_bytes(&wb, "out.csv").unwrap()).unwrap(); - assert_eq!(csv, "2026-01-01,\"$1,234.50\",12.5%,1 1/2,007,#N/A,TRUE\r\n2026-01-02,12345678.90,-0.25,\"1,234,567\",,,\r\n"); - let tsv = String::from_utf8(save_bytes(&wb, "out.tsv").unwrap()).unwrap(); - assert!(tsv.starts_with("2026-01-01\t$1,234.50\t12.5%\t"), "{tsv}"); - } - - #[test] - fn delimited_exports_preserve_general_precision_and_explicit_number_formats() { - use gridcraft_core::Value; - use gridcraft_model::{Cell, NumFmt, StyleId}; - let mut workbook = Workbook::new(); - let scientific = workbook.styles.derive(StyleId::DEFAULT, |style| style.num_fmt = NumFmt::new("0.00E+00")); - let sheet = workbook.sheet_mut(0).unwrap(); - sheet.set_cell(CellRef::new(0, 0), Cell { value: Value::Number(124412140710.0), ..Default::default() }); - sheet.set_cell(CellRef::new(0, 1), Cell { value: Value::Number(1.23456789012345), ..Default::default() }); - sheet.set_cell(CellRef::new(0, 2), Cell { value: Value::Number(124412140710.0), style: scientific, ..Default::default() }); - for (path, separator) in [("data.csv", ","), ("data.tsv", "\t")] { - let output = String::from_utf8(save_bytes(&workbook, path).unwrap()).unwrap(); - assert_eq!(output, format!("124412140710{separator}1.23456789012345{separator}1.24E+11\r\n")); - } - } - - #[test] - fn template_extension_saves_template_content_type() { - let bytes = save_bytes(&Workbook::new(), "Budget.xltx").unwrap(); - let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); - let mut types = String::new(); - std::io::Read::read_to_string(&mut zip.by_name("[Content_Types].xml").unwrap(), &mut types).unwrap(); - assert!( - types.contains( - r#"PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml""# - ), - "{types}" - ); - } - - /// Where `part`'s entry starts in the central directory of `package`. - fn central_entry(package: &[u8], part: &str) -> usize { - let mut at = 0; - while let Some(i) = package.get(at..).and_then(|rest| rest.windows(4).position(|w| w == b"PK\x01\x02")) { - let entry = at + i; - let len = u16::from_le_bytes([package[entry + 28], package[entry + 29]]) as usize; - if &package[entry + 46..entry + 46 + len] == part.as_bytes() { - return entry; - } - at = entry + 46; - } - panic!("{part} is not in the package"); - } - - /// `package` with `part` declaring `size` unpacked bytes: the reader refuses the part on the - /// declared size, so no real 512 MB part has to be built. - fn declaring(mut package: Vec, part: &str, size: u32) -> Vec { - let at = central_entry(&package, part); - package[at + 24..at + 28].copy_from_slice(&size.to_le_bytes()); - package - } - - #[test] - fn a_part_over_the_size_limit_says_how_to_get_the_data_in() { - let mut wb = Workbook::new(); - wb.sheet_mut(0).unwrap().set_value(CellRef::new(0, 0), gridcraft_core::Value::text("big")); - let package = save_bytes(&wb, "big.xlsx").unwrap(); - // The size reported in storytold/gridcraft#1242. - let msg = open_bytes("big.xlsx", &declaring(package.clone(), "xl/worksheets/sheet1.xml", 540_278_731)).unwrap_err().to_string(); - assert!(msg.starts_with("We can't open 'big.xlsx': "), "{msg}"); - assert!(msg.contains("xl/worksheets/sheet1.xml") && msg.contains("515 MB") && msg.contains("512 MB"), "{msg}"); - assert!(msg.contains("several sheets") && msg.contains("CSV"), "{msg}"); - assert!(!msg.contains("bytes"), "{msg}"); - // Splitting sheets doesn't shrink the shared strings, which every sheet uses: only CSV helps. - let msg = open_bytes("big.xlsx", &declaring(package.clone(), "xl/sharedStrings.xml", 540_278_731)).unwrap_err().to_string(); - assert!(msg.contains("xl/sharedStrings.xml") && msg.contains("515 MB") && msg.contains("CSV"), "{msg}"); - assert!(!msg.contains("several sheets"), "{msg}"); - // Just over the limit never reads as a size equal to it. - let msg = open_bytes("big.xlsx", &declaring(package, "xl/worksheets/sheet1.xml", 512 * 1024 * 1024 + 1)).unwrap_err().to_string(); - assert!(msg.contains("more than 512 MB"), "{msg}"); - // A package that fails for another reason keeps its own error, oversized part or not. - let mut broken = declaring(save_bytes(&wb, "big.xlsx").unwrap(), "xl/worksheets/sheet1.xml", 540_278_731); - let at = central_entry(&broken, "xl/workbook.xml"); - broken[at + 46 + "xl/workbook.xml".len() - 1] = b'x'; - let msg = open_bytes("big.xlsx", &broken).unwrap_err().to_string(); - assert!(msg.starts_with("We can't open 'big.xlsx': ") && !msg.contains("unpacks"), "{msg}"); - } - - #[test] - fn content_wins_over_a_mismatched_extension() { - let mut wb = Workbook::new(); - wb.sheet_mut(0).unwrap().set_value(CellRef::new(0, 0), gridcraft_core::Value::Number(7.0)); - let package = save_bytes(&wb, "book.xlsx").unwrap(); - for name in ["book.xlsx", "book.ods", "book.xlsb", "book.csv"] { - let (opened, _) = open_bytes(name, &package).unwrap_or_else(|e| panic!("{name}: {e}")); - assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 0)), gridcraft_core::Value::Number(7.0), "{name}"); - } - for name in ["data.xlsx", "data.csv"] { - let (opened, _) = open_bytes(name, b"a,b\n1,2\n").unwrap_or_else(|e| panic!("{name}: {e}")); - assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 1)), gridcraft_core::Value::text("b"), "{name}"); - } - } -} +mod tests; diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs new file mode 100644 index 0000000000..48c8e130ef --- /dev/null +++ b/crates/engine/src/io/tests.rs @@ -0,0 +1,562 @@ +use super::*; + +fn book(rtl: bool, cells: &[(&str, gridcraft_core::Value, gridcraft_model::HAlign)]) -> Workbook { + let mut wb = Workbook::new(); + let ids: Vec<_> = cells.iter().map(|(_, _, h)| wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.align.h = *h)).collect(); + let sh = wb.sheet_mut(0).unwrap(); + sh.right_to_left = rtl; + for ((at, value, _), id) in cells.iter().zip(ids) { + let c = CellRef::parse(at).unwrap(); + sh.set_value(c, value.clone()); + sh.set_style(c, id); + } + wb +} + +fn copied(wb: &Workbook, range: &str) -> String { + let r = RangeRef::parse(range).unwrap(); + 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 `"), "{html}"); + assert!(html.contains(""), "{html}"); + assert!(html.contains("left:105px;top:57px;width:10px;height:20px"), "{html}"); +} + +fn book_with_picture(anchor: &str, cell_at: (u32, u32)) -> Workbook { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.set_value(CellRef::new(cell_at.0, cell_at.1), gridcraft_core::Value::Number(1.0)); + sh.images.push(gridcraft_model::Image { + id: 1, + anchor: gridcraft_model::Anchor { + cell: CellRef::parse(anchor).unwrap(), + dx: 0.0, + dy: 0.0, + width: 10.0, + height: 10.0, + mode: Default::default(), + }, + data: vec![1].into(), + mime: "image/png".into(), + alt: String::new(), + }); + wb +} + +#[test] +fn html_export_places_images_past_hidden_columns_and_rows() { + let mut wb = book_with_picture("C3", (0, 0)); + let sh = wb.sheet_mut(0).unwrap(); + sh.set_value(CellRef::new(2, 2), gridcraft_core::Value::Number(2.0)); + sh.cols.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() }); + sh.rows.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() }); + let html = to_html(&wb, 0).unwrap(); + // Column B and row 2 take no room: C3 is one default column and row from A1. + assert_eq!(html.matches("
no end", 10, 10).is_empty()); @@ -483,6 +494,7 @@ mod tests { } #[test] + /// `colspan` pads a row and `rowspan` covers cells below. fn colspan_and_rowspan_place_their_cells() { let html = "\ \ @@ -492,6 +504,7 @@ mod tests { } #[test] + /// `rowspan="0"` covers to the end of its row group. fn rowspan_zero_spans_its_own_row_group() { let html = "
HC
R12
\ \ @@ -501,6 +514,7 @@ mod tests { } #[test] + /// Wide padded tables share one page-wide cell budget. fn wide_colspan_padding_across_many_tables_shares_a_page_budget() { // Each table pads one cell to 4,000 columns; without a page-wide budget 1,000 of // them retain ~4,000,000 padded cells. @@ -512,6 +526,7 @@ mod tests { } #[test] + /// Thousands of tiny tables are cut short at the aggregate cell cap. fn a_page_of_tiny_tables_is_capped_at_the_aggregate_limit() { let html = "
AB
C
x
".repeat(5_000); let t = extract_tables(&html, 100, 100); @@ -519,6 +534,7 @@ mod tests { } #[test] + /// A single hostile table cannot blow past its own cell budget. fn a_hostile_table_cannot_blow_the_cell_budget() { // One row, each cell spanning the whole column count: without a budget the padding // alone would be rows * cols. @@ -530,6 +546,7 @@ mod tests { } #[test] + /// Attribute names match whole, not as suffixes. fn attr_matches_whole_names_not_suffixes() { let html = "
1
"; let t = extract_tables(html, 100, 100); @@ -537,6 +554,7 @@ mod tests { } #[test] + /// A nested table's markup stays in the enclosing cell's text. fn nested_table_text_stays_in_the_cell() { let html = "
outer
inner
end
"; let t = extract_tables(html, 100, 100); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index eb3910ae51..d8362da28a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -182,6 +182,8 @@ impl Dialog { } } + /// Build the named dialog around the current selection: its fields are prefilled from + /// `params`, falling back to what the selection makes sensible. pub fn open(app: &mut SheetApp, name: &str, params: Json) -> Option { let sel = app.session.active().map(|d| d.selection.current().a1()).unwrap_or_default(); let active = app.session.active().map(|d| d.selection.active.a1()).unwrap_or_default(); @@ -2870,6 +2872,8 @@ fn manage_rules(app: &mut SheetApp, ui: &mut egui::Ui) { } } +/// The command search box: commands whose label or id matches what was +/// typed, keyboard-navigable, Enter runs the highlighted one. fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { let (down, up, home, end, pgup, pgdn) = ui.input_mut(|i| { ( diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 0523ec069b..bcb1649496 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2227,6 +2227,8 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { }); } +/// The Data tab: Get Data imports (CSV, From Web), Queries & Connections, Refresh All, +/// sorting, filtering and the data tools. fn data(app: &mut SheetApp, ui: &mut Ui) { let gd = big_button(ui, Icon::Folder, "Get Data\n(Text/CSV)", "Import a text or CSV file", false); control_keytip(app, ui, &gd, "data.getData"); diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 0347364514..c7007e33cc 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -15,6 +15,7 @@ pub enum Class { } impl Class { + /// The layer a crate sits at; exempt crates have none. fn layer(self) -> Option { match self { Class::Layer(l) => Some(l), From 4db1e3aebffa33a5b0becdbe7e8a8651cc5c3757 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Tue, 27 Oct 2026 03:51:02 +0000 Subject: [PATCH 15/31] Merge upstream main (layout refactor): re-apply From Web changes into the new module layout Co-authored-by: setzor --- .config/nextest.toml | 3 + apps/gridcraft-cli/src/check.rs | 1478 +----- apps/gridcraft-cli/src/check/calc.rs | 237 + apps/gridcraft-cli/src/check/fidelity.rs | 185 + apps/gridcraft-cli/src/check/package.rs | 329 ++ apps/gridcraft-cli/src/check/report.rs | 264 + apps/gridcraft-cli/src/check/tests.rs | 450 ++ apps/gridcraft/src/logging.rs | 301 +- apps/gridcraft/src/logging/tests.rs | 298 ++ crates/calc/src/eval.rs | 1445 +----- crates/calc/src/eval/array.rs | 180 + crates/calc/src/eval/call.rs | 424 ++ crates/calc/src/eval/format.rs | 74 + crates/calc/src/eval/lambda.rs | 198 + crates/calc/src/eval/reference_fns.rs | 407 ++ crates/calc/src/eval/special.rs | 167 + crates/calc/src/recalc.rs | 987 +--- crates/calc/src/recalc/graph.rs | 340 ++ crates/calc/src/recalc/pass_host.rs | 554 ++ crates/chart/src/render.rs | 1661 +----- crates/chart/src/render/cartesian.rs | 431 ++ crates/chart/src/render/frame.rs | 245 + crates/chart/src/render/kinds.rs | 505 ++ crates/chart/src/render/legend.rs | 161 + crates/chart/src/render/lines.rs | 157 + crates/chart/src/render/trend.rs | 166 + crates/engine/src/{cmd/mod.rs => cmd.rs} | 0 crates/engine/src/cmd/data.rs | 1908 +------ crates/engine/src/cmd/data/filter.rs | 316 ++ crates/engine/src/cmd/data/grouping.rs | 267 + crates/engine/src/cmd/data/sort.rs | 322 ++ crates/engine/src/cmd/data/tests.rs | 509 ++ crates/engine/src/cmd/data/tools.rs | 190 + crates/engine/src/cmd/data/validation.rs | 290 ++ crates/engine/src/cmd/edit.rs | 1876 +------ crates/engine/src/cmd/edit/cell.rs | 287 ++ crates/engine/src/cmd/edit/clipboard.rs | 269 + crates/engine/src/cmd/edit/fill.rs | 96 + crates/engine/src/cmd/edit/find.rs | 184 + crates/engine/src/cmd/edit/go_to.rs | 199 + crates/engine/src/cmd/edit/paste_special.rs | 351 ++ crates/engine/src/cmd/edit/selection.rs | 171 + crates/engine/src/cmd/edit/tests.rs | 305 ++ crates/engine/src/cmd/extra.rs | 1037 +--- crates/engine/src/cmd/extra/cells.rs | 147 + crates/engine/src/cmd/extra/chart.rs | 259 + crates/engine/src/cmd/extra/data.rs | 145 + crates/engine/src/cmd/extra/objects.rs | 146 + crates/engine/src/cmd/extra/scripts.rs | 146 + crates/engine/src/cmd/extra/window.rs | 184 + crates/engine/src/cmd/format.rs | 442 +- crates/engine/src/cmd/format/cell_styles.rs | 197 + crates/engine/src/cmd/format/sizes.rs | 243 + crates/engine/src/cmd/insert.rs | 1517 +----- crates/engine/src/cmd/insert/chart.rs | 420 ++ crates/engine/src/cmd/insert/cond_format.rs | 339 ++ crates/engine/src/cmd/insert/links.rs | 157 + crates/engine/src/cmd/insert/objects.rs | 264 + crates/engine/src/cmd/insert/table.rs | 331 ++ crates/engine/src/cmd/pivot.rs | 2450 +-------- crates/engine/src/cmd/pivot/charts.rs | 174 + crates/engine/src/cmd/pivot/create.rs | 340 ++ crates/engine/src/cmd/pivot/fields.rs | 541 ++ crates/engine/src/cmd/pivot/layout.rs | 289 ++ crates/engine/src/cmd/pivot/params.rs | 111 + crates/engine/src/cmd/pivot/tests.rs | 991 ++++ crates/engine/src/cmd/print.rs | 2093 +------- crates/engine/src/cmd/print/draw.rs | 467 ++ crates/engine/src/cmd/print/header_footer.rs | 261 + crates/engine/src/cmd/print/layout.rs | 364 ++ crates/engine/src/cmd/print/objects.rs | 177 + crates/engine/src/cmd/print/tests.rs | 567 ++ crates/engine/src/cmd/print/text.rs | 263 + crates/engine/src/cmd/sheet.rs | 288 +- crates/engine/src/cmd/sheet/tabs.rs | 285 + crates/engine/src/cmd/solver.rs | 2343 +-------- crates/engine/src/cmd/solver/evolutionary.rs | 255 + crates/engine/src/cmd/solver/lp.rs | 447 ++ crates/engine/src/cmd/solver/model.rs | 459 ++ crates/engine/src/cmd/solver/nlp.rs | 197 + crates/engine/src/cmd/solver/problem.rs | 144 + crates/engine/src/cmd/solver/reports.rs | 373 ++ crates/engine/src/cmd/solver/tests.rs | 470 ++ crates/engine/src/cmd/whatif.rs | 1027 +--- .../engine/src/cmd/whatif/advanced_filter.rs | 231 + crates/engine/src/cmd/whatif/consolidate.rs | 193 + crates/engine/src/cmd/whatif/scenarios.rs | 294 ++ crates/engine/src/cmd/whatif/tests.rs | 299 ++ crates/engine/src/io.rs | 570 +- crates/engine/src/io/tests.rs | 562 ++ crates/engine/src/lib.rs | 2 - crates/engine/src/pivot.rs | 1376 +---- crates/engine/src/pivot/charts.rs | 108 + crates/engine/src/pivot/compute.rs | 556 ++ crates/engine/src/pivot/items.rs | 268 + crates/engine/src/pivot/output.rs | 437 ++ crates/functions/src/engineering.rs | 786 +-- crates/functions/src/engineering/bessel.rs | 245 + crates/functions/src/engineering/complex.rs | 268 + crates/functions/src/engineering/units.rs | 286 + crates/functions/src/financial.rs | 1647 +----- crates/functions/src/financial/day_count.rs | 214 + .../functions/src/financial/depreciation.rs | 173 + crates/functions/src/financial/securities.rs | 512 ++ crates/functions/src/financial/tests.rs | 319 ++ crates/functions/src/financial/tvm.rs | 415 ++ crates/functions/src/math.rs | 926 +--- crates/functions/src/math/criteria.rs | 128 + crates/functions/src/math/matrix.rs | 182 + crates/functions/src/math/roman_base.rs | 121 + crates/functions/src/math/tests.rs | 492 ++ crates/functions/src/stat.rs | 2116 +------- crates/functions/src/stat/descriptive.rs | 547 ++ crates/functions/src/stat/distributions.rs | 702 +++ crates/functions/src/stat/regression.rs | 342 ++ crates/functions/src/stat/tests.rs | 508 ++ crates/model/src/features.rs | 396 +- crates/model/src/features/pivot.rs | 176 + crates/model/src/features/print.rs | 216 + crates/ui-egui/src/app.rs | 520 ++ crates/ui-egui/src/app/clipboard.rs | 77 + crates/ui-egui/src/app/commands.rs | 348 ++ crates/ui-egui/src/app/editing.rs | 211 + crates/ui-egui/src/app/files.rs | 127 + crates/ui-egui/src/app/fonts.rs | 112 + crates/ui-egui/src/app/input.rs | 33 + crates/ui-egui/src/app/keys.rs | 66 + crates/ui-egui/src/app/prefs.rs | 65 + crates/ui-egui/src/app/print.rs | 89 + crates/ui-egui/src/app/quit.rs | 96 + crates/ui-egui/src/app/view.rs | 56 + crates/ui-egui/src/backstage.rs | 334 +- crates/ui-egui/src/backstage/options.rs | 178 + crates/ui-egui/src/backstage/print.rs | 168 + crates/ui-egui/src/clock.rs | 25 + crates/ui-egui/src/code_editor.rs | 424 +- crates/ui-egui/src/code_editor/tests.rs | 368 ++ crates/ui-egui/src/control.rs | 1879 +------ crates/ui-egui/src/control/screenshots.rs | 741 +++ crates/ui-egui/src/control/synthetic.rs | 145 + crates/ui-egui/src/control/tests.rs | 979 ++++ crates/ui-egui/src/dialogs.rs | 2607 +--------- crates/ui-egui/src/dialogs/about.rs | 26 + crates/ui-egui/src/dialogs/analysis.rs | 344 ++ crates/ui-egui/src/dialogs/charts.rs | 187 + crates/ui-egui/src/dialogs/command_search.rs | 88 + .../ui-egui/src/dialogs/cond_format_rules.rs | 28 + crates/ui-egui/src/dialogs/data.rs | 327 ++ crates/ui-egui/src/dialogs/find.rs | 151 + crates/ui-egui/src/dialogs/format_cells.rs | 227 + crates/ui-egui/src/dialogs/insert_function.rs | 72 + crates/ui-egui/src/dialogs/macros.rs | 40 + crates/ui-egui/src/dialogs/names.rs | 134 + crates/ui-egui/src/dialogs/paste.rs | 139 + crates/ui-egui/src/dialogs/show.rs | 580 +++ crates/ui-egui/src/dialogs/spelling.rs | 54 + crates/ui-egui/src/dialogs/web.rs | 180 + crates/ui-egui/src/dialogs/zoom.rs | 39 + crates/ui-egui/src/grid.rs | 4590 +---------------- crates/ui-egui/src/grid/cell_paint.rs | 310 ++ crates/ui-egui/src/grid/cursor.rs | 138 + crates/ui-egui/src/grid/editor.rs | 161 + crates/ui-egui/src/grid/geo.rs | 237 + crates/ui-egui/src/grid/interact.rs | 904 ++++ crates/ui-egui/src/grid/keyboard.rs | 155 + crates/ui-egui/src/grid/menus.rs | 551 ++ crates/ui-egui/src/grid/quadrant.rs | 474 ++ crates/ui-egui/src/grid/selection.rs | 419 ++ crates/ui-egui/src/grid/show.rs | 861 ++++ crates/ui-egui/src/grid/tables.rs | 87 + crates/ui-egui/src/grid/tests.rs | 306 ++ crates/ui-egui/src/lib.rs | 1886 +------ crates/ui-egui/src/ribbon.rs | 2843 +--------- crates/ui-egui/src/ribbon/automate.rs | 33 + crates/ui-egui/src/ribbon/contextual.rs | 183 + crates/ui-egui/src/ribbon/controls.rs | 270 + crates/ui-egui/src/ribbon/data.rs | 156 + crates/ui-egui/src/ribbon/formulas.rs | 202 + crates/ui-egui/src/ribbon/galleries.rs | 145 + crates/ui-egui/src/ribbon/home.rs | 813 +++ crates/ui-egui/src/ribbon/insert.rs | 245 + crates/ui-egui/src/ribbon/page_layout.rs | 158 + crates/ui-egui/src/ribbon/review.rs | 82 + crates/ui-egui/src/ribbon/shortcuts.rs | 112 + crates/ui-egui/src/ribbon/title_bar.rs | 274 + crates/ui-egui/src/ribbon/view.rs | 182 + crates/ui-egui/src/services.rs | 61 + crates/ui-egui/src/state.rs | 40 + crates/ui-egui/src/theme.rs | 1254 +---- crates/ui-egui/src/theme/font_fallback.rs | 296 ++ crates/ui-egui/src/theme/font_setup.rs | 598 +++ crates/ui-egui/src/theme/tests.rs | 355 ++ crates/xlsx/src/passthrough.rs | 1216 +---- crates/xlsx/src/passthrough/read.rs | 688 +++ crates/xlsx/src/passthrough/rebase.rs | 199 + crates/xlsx/src/passthrough/write.rs | 325 ++ crates/xlsx/src/pivot.rs | 1262 +---- crates/xlsx/src/pivot/read.rs | 382 ++ crates/xlsx/src/pivot/write.rs | 876 ++++ crates/xlsx/src/sheet_read.rs | 453 +- crates/xlsx/src/sheet_read/parts.rs | 270 + crates/xlsx/src/sheet_read/rules.rs | 178 + crates/xlsx/src/sheet_write.rs | 616 +-- crates/xlsx/src/sheet_write/parts.rs | 373 ++ crates/xlsx/src/sheet_write/rules.rs | 238 + crates/xlsx/src/{tests/mod.rs => tests.rs} | 0 crates/xlsx/src/{xls/mod.rs => xls.rs} | 0 xtask/src/i18n_report.rs | 88 +- 208 files changed, 48418 insertions(+), 47541 deletions(-) create mode 100644 .config/nextest.toml create mode 100644 apps/gridcraft-cli/src/check/calc.rs create mode 100644 apps/gridcraft-cli/src/check/fidelity.rs create mode 100644 apps/gridcraft-cli/src/check/package.rs create mode 100644 apps/gridcraft-cli/src/check/report.rs create mode 100644 apps/gridcraft-cli/src/check/tests.rs create mode 100644 apps/gridcraft/src/logging/tests.rs create mode 100644 crates/calc/src/eval/array.rs create mode 100644 crates/calc/src/eval/call.rs create mode 100644 crates/calc/src/eval/format.rs create mode 100644 crates/calc/src/eval/lambda.rs create mode 100644 crates/calc/src/eval/reference_fns.rs create mode 100644 crates/calc/src/eval/special.rs create mode 100644 crates/calc/src/recalc/graph.rs create mode 100644 crates/calc/src/recalc/pass_host.rs create mode 100644 crates/chart/src/render/cartesian.rs create mode 100644 crates/chart/src/render/frame.rs create mode 100644 crates/chart/src/render/kinds.rs create mode 100644 crates/chart/src/render/legend.rs create mode 100644 crates/chart/src/render/lines.rs create mode 100644 crates/chart/src/render/trend.rs rename crates/engine/src/{cmd/mod.rs => cmd.rs} (100%) create mode 100644 crates/engine/src/cmd/data/filter.rs create mode 100644 crates/engine/src/cmd/data/grouping.rs create mode 100644 crates/engine/src/cmd/data/sort.rs create mode 100644 crates/engine/src/cmd/data/tests.rs create mode 100644 crates/engine/src/cmd/data/tools.rs create mode 100644 crates/engine/src/cmd/data/validation.rs create mode 100644 crates/engine/src/cmd/edit/cell.rs create mode 100644 crates/engine/src/cmd/edit/clipboard.rs create mode 100644 crates/engine/src/cmd/edit/fill.rs create mode 100644 crates/engine/src/cmd/edit/find.rs create mode 100644 crates/engine/src/cmd/edit/go_to.rs create mode 100644 crates/engine/src/cmd/edit/paste_special.rs create mode 100644 crates/engine/src/cmd/edit/selection.rs create mode 100644 crates/engine/src/cmd/edit/tests.rs create mode 100644 crates/engine/src/cmd/extra/cells.rs create mode 100644 crates/engine/src/cmd/extra/chart.rs create mode 100644 crates/engine/src/cmd/extra/data.rs create mode 100644 crates/engine/src/cmd/extra/objects.rs create mode 100644 crates/engine/src/cmd/extra/scripts.rs create mode 100644 crates/engine/src/cmd/extra/window.rs create mode 100644 crates/engine/src/cmd/format/cell_styles.rs create mode 100644 crates/engine/src/cmd/format/sizes.rs create mode 100644 crates/engine/src/cmd/insert/chart.rs create mode 100644 crates/engine/src/cmd/insert/cond_format.rs create mode 100644 crates/engine/src/cmd/insert/links.rs create mode 100644 crates/engine/src/cmd/insert/objects.rs create mode 100644 crates/engine/src/cmd/insert/table.rs create mode 100644 crates/engine/src/cmd/pivot/charts.rs create mode 100644 crates/engine/src/cmd/pivot/create.rs create mode 100644 crates/engine/src/cmd/pivot/fields.rs create mode 100644 crates/engine/src/cmd/pivot/layout.rs create mode 100644 crates/engine/src/cmd/pivot/params.rs create mode 100644 crates/engine/src/cmd/pivot/tests.rs create mode 100644 crates/engine/src/cmd/print/draw.rs create mode 100644 crates/engine/src/cmd/print/header_footer.rs create mode 100644 crates/engine/src/cmd/print/layout.rs create mode 100644 crates/engine/src/cmd/print/objects.rs create mode 100644 crates/engine/src/cmd/print/tests.rs create mode 100644 crates/engine/src/cmd/print/text.rs create mode 100644 crates/engine/src/cmd/sheet/tabs.rs create mode 100644 crates/engine/src/cmd/solver/evolutionary.rs create mode 100644 crates/engine/src/cmd/solver/lp.rs create mode 100644 crates/engine/src/cmd/solver/model.rs create mode 100644 crates/engine/src/cmd/solver/nlp.rs create mode 100644 crates/engine/src/cmd/solver/problem.rs create mode 100644 crates/engine/src/cmd/solver/reports.rs create mode 100644 crates/engine/src/cmd/solver/tests.rs create mode 100644 crates/engine/src/cmd/whatif/advanced_filter.rs create mode 100644 crates/engine/src/cmd/whatif/consolidate.rs create mode 100644 crates/engine/src/cmd/whatif/scenarios.rs create mode 100644 crates/engine/src/cmd/whatif/tests.rs create mode 100644 crates/engine/src/io/tests.rs create mode 100644 crates/engine/src/pivot/charts.rs create mode 100644 crates/engine/src/pivot/compute.rs create mode 100644 crates/engine/src/pivot/items.rs create mode 100644 crates/engine/src/pivot/output.rs create mode 100644 crates/functions/src/engineering/bessel.rs create mode 100644 crates/functions/src/engineering/complex.rs create mode 100644 crates/functions/src/engineering/units.rs create mode 100644 crates/functions/src/financial/day_count.rs create mode 100644 crates/functions/src/financial/depreciation.rs create mode 100644 crates/functions/src/financial/securities.rs create mode 100644 crates/functions/src/financial/tests.rs create mode 100644 crates/functions/src/financial/tvm.rs create mode 100644 crates/functions/src/math/criteria.rs create mode 100644 crates/functions/src/math/matrix.rs create mode 100644 crates/functions/src/math/roman_base.rs create mode 100644 crates/functions/src/math/tests.rs create mode 100644 crates/functions/src/stat/descriptive.rs create mode 100644 crates/functions/src/stat/distributions.rs create mode 100644 crates/functions/src/stat/regression.rs create mode 100644 crates/functions/src/stat/tests.rs create mode 100644 crates/model/src/features/pivot.rs create mode 100644 crates/model/src/features/print.rs create mode 100644 crates/ui-egui/src/app.rs create mode 100644 crates/ui-egui/src/app/clipboard.rs create mode 100644 crates/ui-egui/src/app/commands.rs create mode 100644 crates/ui-egui/src/app/editing.rs create mode 100644 crates/ui-egui/src/app/files.rs create mode 100644 crates/ui-egui/src/app/fonts.rs create mode 100644 crates/ui-egui/src/app/input.rs create mode 100644 crates/ui-egui/src/app/keys.rs create mode 100644 crates/ui-egui/src/app/prefs.rs create mode 100644 crates/ui-egui/src/app/print.rs create mode 100644 crates/ui-egui/src/app/quit.rs create mode 100644 crates/ui-egui/src/app/view.rs create mode 100644 crates/ui-egui/src/backstage/options.rs create mode 100644 crates/ui-egui/src/backstage/print.rs create mode 100644 crates/ui-egui/src/clock.rs create mode 100644 crates/ui-egui/src/code_editor/tests.rs create mode 100644 crates/ui-egui/src/control/screenshots.rs create mode 100644 crates/ui-egui/src/control/synthetic.rs create mode 100644 crates/ui-egui/src/control/tests.rs create mode 100644 crates/ui-egui/src/dialogs/about.rs create mode 100644 crates/ui-egui/src/dialogs/analysis.rs create mode 100644 crates/ui-egui/src/dialogs/charts.rs create mode 100644 crates/ui-egui/src/dialogs/command_search.rs create mode 100644 crates/ui-egui/src/dialogs/cond_format_rules.rs create mode 100644 crates/ui-egui/src/dialogs/data.rs create mode 100644 crates/ui-egui/src/dialogs/find.rs create mode 100644 crates/ui-egui/src/dialogs/format_cells.rs create mode 100644 crates/ui-egui/src/dialogs/insert_function.rs create mode 100644 crates/ui-egui/src/dialogs/macros.rs create mode 100644 crates/ui-egui/src/dialogs/names.rs create mode 100644 crates/ui-egui/src/dialogs/paste.rs create mode 100644 crates/ui-egui/src/dialogs/show.rs create mode 100644 crates/ui-egui/src/dialogs/spelling.rs create mode 100644 crates/ui-egui/src/dialogs/web.rs create mode 100644 crates/ui-egui/src/dialogs/zoom.rs create mode 100644 crates/ui-egui/src/grid/cell_paint.rs create mode 100644 crates/ui-egui/src/grid/cursor.rs create mode 100644 crates/ui-egui/src/grid/editor.rs create mode 100644 crates/ui-egui/src/grid/geo.rs create mode 100644 crates/ui-egui/src/grid/interact.rs create mode 100644 crates/ui-egui/src/grid/keyboard.rs create mode 100644 crates/ui-egui/src/grid/menus.rs create mode 100644 crates/ui-egui/src/grid/quadrant.rs create mode 100644 crates/ui-egui/src/grid/selection.rs create mode 100644 crates/ui-egui/src/grid/show.rs create mode 100644 crates/ui-egui/src/grid/tables.rs create mode 100644 crates/ui-egui/src/grid/tests.rs create mode 100644 crates/ui-egui/src/ribbon/automate.rs create mode 100644 crates/ui-egui/src/ribbon/contextual.rs create mode 100644 crates/ui-egui/src/ribbon/controls.rs create mode 100644 crates/ui-egui/src/ribbon/data.rs create mode 100644 crates/ui-egui/src/ribbon/formulas.rs create mode 100644 crates/ui-egui/src/ribbon/galleries.rs create mode 100644 crates/ui-egui/src/ribbon/home.rs create mode 100644 crates/ui-egui/src/ribbon/insert.rs create mode 100644 crates/ui-egui/src/ribbon/page_layout.rs create mode 100644 crates/ui-egui/src/ribbon/review.rs create mode 100644 crates/ui-egui/src/ribbon/shortcuts.rs create mode 100644 crates/ui-egui/src/ribbon/title_bar.rs create mode 100644 crates/ui-egui/src/ribbon/view.rs create mode 100644 crates/ui-egui/src/services.rs create mode 100644 crates/ui-egui/src/state.rs create mode 100644 crates/ui-egui/src/theme/font_fallback.rs create mode 100644 crates/ui-egui/src/theme/font_setup.rs create mode 100644 crates/ui-egui/src/theme/tests.rs create mode 100644 crates/xlsx/src/passthrough/read.rs create mode 100644 crates/xlsx/src/passthrough/rebase.rs create mode 100644 crates/xlsx/src/passthrough/write.rs create mode 100644 crates/xlsx/src/pivot/read.rs create mode 100644 crates/xlsx/src/pivot/write.rs create mode 100644 crates/xlsx/src/sheet_read/parts.rs create mode 100644 crates/xlsx/src/sheet_read/rules.rs create mode 100644 crates/xlsx/src/sheet_write/parts.rs create mode 100644 crates/xlsx/src/sheet_write/rules.rs rename crates/xlsx/src/{tests/mod.rs => tests.rs} (100%) rename crates/xlsx/src/{xls/mod.rs => xls.rs} (100%) diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000000..2a4cd2cb93 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,3 @@ +# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI +# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET). +nextest-version = "0.9.150" diff --git a/apps/gridcraft-cli/src/check.rs b/apps/gridcraft-cli/src/check.rs index bbd3103d8a..5fc37b14ce 100644 --- a/apps/gridcraft-cli/src/check.rs +++ b/apps/gridcraft-cli/src/check.rs @@ -26,22 +26,31 @@ use serde_json::{Value as Json, json}; use super::{Args, USAGE, number_text, pretty}; +mod calc; +mod fidelity; +mod package; +mod report; + +pub use calc::oracle; +#[cfg(test)] +use calc::volatile_names; +#[cfg(test)] +use calc::{bool_cached_as_number, values_match}; +#[cfg(test)] +use fidelity::diff_workbooks; +pub use fidelity::round_trip; +use package::full_calc_on_load; +#[cfg(test)] +use package::validate_package; +use report::print_human; +pub use report::report_json; + /// Version tag of the `--json` output; bumped when a field changes meaning or disappears. pub const SCHEMA: &str = "gridcraft-check/1"; - -/// Functions whose result depends on the clock, a random generator or the environment. -const VOLATILE: [&str; 6] = ["RAND", "RANDARRAY", "RANDBETWEEN", "NOW", "TODAY", "INFO"]; /// Directory recursion limit (symlink loops end here). const MAX_DEPTH: usize = 32; /// Most files collected from the inputs. const MAX_FILES: usize = 100_000; -/// Largest package part read while validating a written package. -const MAX_XML_PART: u64 = 64 * 1024 * 1024; -/// Most zip entries looked at while validating a written package. -const MAX_ENTRIES: usize = 200_000; -/// Relationship kinds (lower case) whose absence after a save isn't a loss: the calculation chain is -/// a cache Excel rebuilds, and GridCraft never writes it. -const REBUILT_KINDS: [&str; 1] = ["calcchain"]; #[derive(Clone, Copy, Debug)] pub struct Options { @@ -357,1452 +366,5 @@ fn check_inner(path: &str, bytes: &[u8], opts: &Options) -> FileReport { report } -// ---------------------------------------------------------------- calc oracle - -fn functions_of(e: &Expr) -> Vec { - let mut set = BTreeSet::new(); - e.walk(&mut |x| { - if let Expr::Call(n, _) = x { - set.insert(n.clone()); - } - }); - set.into_iter().collect() -} - -fn is_volatile(e: &Expr, volatile_names: &HashSet) -> bool { - let mut v = false; - e.walk(&mut |x| match x { - Expr::Call(n, args) => { - let filename = n == "CELL" && matches!(args.first(), Some(Expr::Text(t)) if t.eq_ignore_ascii_case("filename")); - if filename || VOLATILE.contains(&n.as_str()) { - v = true; - } - } - Expr::Name(n) if volatile_names.contains(&n.to_ascii_uppercase()) => v = true, - _ => {} - }); - v -} - -/// Upper-cased defined names whose formula is volatile, directly or through other names. -fn volatile_names(wb: &Workbook) -> HashSet { - let parsed: Vec<(String, Option)> = - wb.names.iter().map(|n| (n.name.to_ascii_uppercase(), gridcraft_engine::formula::parse(&n.formula).ok())).collect(); - let mut set = HashSet::new(); - // Each round adds at least one name or stops, so `len + 1` rounds reach the fixed point. - for _ in 0..=parsed.len() { - let before = set.len(); - for (name, e) in &parsed { - if !set.contains(name) && e.as_ref().is_some_and(|e| is_volatile(e, &set)) { - set.insert(name.clone()); - } - } - if set.len() == before { - break; - } - } - set -} - -/// The formula as the formula bar shows it. -fn formula_text(cell: &Cell) -> String { - cell.bar_text() -} - -fn cell_name(wb: &Workbook, (sheet, c): Key) -> String { - let name = wb.sheet(sheet).map(|s| s.name.as_str()).unwrap_or("?"); - format!("{}!{}", quote_sheet(name), c.a1()) -} - -/// Whether the producer cached GridCraft's logical result as the number 1 or 0. Excel types a -/// logical result `t="b"`; a writer that leaves the type out (`TRUE()1`, as -/// Business Central's "Open in Excel" exports do) stores the same value, read back as a number. -pub fn bool_cached_as_number(expected: &Value, actual: &Value) -> bool { - matches!((expected, actual), (Value::Number(n), Value::Bool(b)) if *n == if *b { 1.0 } else { 0.0 }) -} - -/// Whether GridCraft's `actual` agrees with the producer's `expected`. -pub fn values_match(expected: &Value, actual: &Value, tolerance: f64) -> bool { - match (expected, actual) { - (Value::Number(a), Value::Number(b)) => (a - b).abs() <= tolerance * a.abs().max(b.abs()).max(1.0), - (Value::Text(a), Value::Text(b)) => a == b, - (Value::Bool(a), Value::Bool(b)) => a == b, - (Value::Error(a), Value::Error(b)) => a == b, - _ => false, - } -} - -fn type_name(v: &Value) -> &'static str { - match v { - Value::Empty => "blank", - Value::Number(_) => "number", - Value::Text(_) => "text", - Value::Bool(_) => "bool", - Value::Error(e) => e.as_str(), - Value::Array(_) => "array", - } -} - -fn mismatch_kind(expected: &Value, actual: &Value) -> &'static str { - match (expected, actual) { - (_, Value::Empty) => "blank", - (Value::Number(_), Value::Number(_)) => "numeric", - (Value::Text(_), Value::Text(_)) => "text", - (Value::Bool(_), Value::Bool(_)) => "bool", - (Value::Error(_), _) | (_, Value::Error(_)) => "error", - _ => "type", - } -} - -/// Mismatched cells indexed by (sheet, column) → rows, for precedent-area queries. -struct MismatchIndex { - cols: HashMap<(usize, u32), BTreeSet>, - /// Columns with mismatches per sheet (for very wide areas). - sheet_cols: HashMap>, -} - -impl MismatchIndex { - fn new(keys: &[Key]) -> MismatchIndex { - let mut cols: HashMap<(usize, u32), BTreeSet> = HashMap::new(); - let mut sheet_cols: HashMap> = HashMap::new(); - for &(s, c) in keys { - cols.entry((s, c.col)).or_default().insert(c.row); - sheet_cols.entry(s).or_default().insert(c.col); - } - MismatchIndex { cols, sheet_cols } - } - /// Whether a mismatched cell other than `own` lies in the area. - fn any_in(&self, sheet: usize, r0: u32, r1: u32, c0: u32, c1: u32, own: Key) -> bool { - let Some(cols) = self.sheet_cols.get(&sheet) else { return false }; - cols.range(c0..=c1) - .any(|&col| self.cols.get(&(sheet, col)).is_some_and(|rows| rows.range(r0..=r1).any(|&row| (sheet, CellRef::new(row, col)) != own))) - } -} - -/// Compares every formula cell of `cached` (as the producer saved it) with `fresh` (recalculated -/// by GridCraft). Without `trust_cache` nothing is compared (see [`CalcReport::stale_cache`]). -pub fn oracle(cached: &Workbook, fresh: &Workbook, calc: &Calc, trust_cache: bool, opts: &Options) -> CalcReport { - let mut r = CalcReport { stale_cache: !trust_cache, ..Default::default() }; - let vnames = volatile_names(cached); - let mut volatile: HashSet = HashSet::new(); - for (si, sh) in cached.sheets.iter().enumerate() { - for (c, cell) in sh.cells.iter() { - if let Some(f) = &cell.formula { - r.formula_cells += 1; - if f.expr_arc().is_some_and(|e| is_volatile(&e, &vnames)) { - volatile.insert((si, c)); - } - } - } - } - // Everything downstream of a volatile formula is volatile too; each key is pushed once. - let mut stack: Vec = volatile.iter().copied().collect(); - let mut deps = Vec::new(); - while let Some((s, c)) = stack.pop() { - deps.clear(); - calc.graph.dependents(s, c, &mut deps); - if let Some(range) = fresh.sheet(s).and_then(|sh| sh.spill_ranges.get(&c)) { - calc.graph.dependents_of_range(s, *range, &mut deps); - } - for k in deps.drain(..) { - if volatile.insert(k) { - stack.push(k); - } - } - } - - let mut bad: Vec<(Key, Vec)> = Vec::new(); - for (si, sh) in cached.sheets.iter().enumerate() { - for (c, cell) in sh.cells.iter() { - let Some(f) = &cell.formula else { continue }; - if volatile.contains(&(si, c)) { - r.volatile += 1; - continue; - } - if cell.value.is_empty() || !trust_cache { - r.no_oracle += 1; - continue; - } - r.checked += 1; - let actual = fresh.sheet(si).and_then(|s| s.cell(c)).map(|x| x.value.scalar()).unwrap_or_default(); - let funcs = match f.expr_arc() { - Some(e) => { - let names = functions_of(&e); - if names.is_empty() { vec!["(no function)".to_string()] } else { names } - } - None => vec!["(unparsed)".to_string()], - }; - let bool_as_number = bool_cached_as_number(&cell.value, &actual); - let ok = bool_as_number || values_match(&cell.value, &actual, opts.tolerance); - if bool_as_number { - r.bool_as_number += 1; - } - for n in &funcs { - let st = r.functions.entry(n.clone()).or_default(); - st.checked += 1; - if !ok { - st.mismatched += 1; - } - } - if ok { - r.matched += 1; - } else { - r.mismatched += 1; - *r.signatures.entry(format!("{} → {}", type_name(&cell.value), type_name(&actual))).or_default() += 1; - bad.push(((si, c), funcs)); - } - } - } - - // A mismatch is primary when none of its precedent cells mismatch. - let keys: Vec = bad.iter().map(|(k, _)| *k).collect(); - let index = MismatchIndex::new(&keys); - let mut listed: Vec<(bool, Key, Vec)> = Vec::with_capacity(bad.len()); - for (k, funcs) in bad { - let upstream = calc - .graph - .precedents_of(k) - .iter() - .any(|a| index.any_in(a.sheet, a.range.start.row, a.range.end.row, a.range.start.col, a.range.end.col, k)); - if !upstream { - for n in &funcs { - r.functions.entry(n.clone()).or_default().primary += 1; - } - } - listed.push((!upstream, k, funcs)); - } - // Primary mismatches first, each group in sheet then row-major order (already sorted). - listed.sort_by_key(|(primary, _, _)| !*primary); - for (primary, k, functions) in listed.into_iter().take(opts.max_report) { - let cell = cached.sheet(k.0).and_then(|s| s.cell(k.1)); - let expected = cell.map(|c| c.value.clone()).unwrap_or_default(); - let actual = fresh.sheet(k.0).and_then(|s| s.cell(k.1)).map(|x| x.value.scalar()).unwrap_or_default(); - r.mismatches.push(Mismatch { - cell: cell_name(cached, k), - formula: cell.map(formula_text).unwrap_or_default(), - kind: mismatch_kind(&expected, &actual), - expected, - actual, - functions, - primary, - }); - } - r -} - -// ---------------------------------------------------------------- round trip - -/// Writes `wb` with GridCraft's XLSX writer, validates the package against itself and against -/// `source` (the file `wb` was read from), reads it back and compares. -pub fn round_trip(wb: &Workbook, source: &[u8], opts: &Options) -> RoundTrip { - let bytes = match gridcraft_engine::io::save_bytes(wb, "roundtrip.xlsx") { - Ok(b) => b, - Err(e) => return RoundTrip { error: Some(format!("write failed: {e}")), categories: vec![] }, - }; - let mut package = Diffs::new("package", opts.max_report); - match validate_package(&bytes) { - Ok(issues) => issues.into_iter().for_each(|i| package.push(|| i)), - Err(e) => package.push(|| e), - } - if let (Ok(before), Ok(after)) = (relationship_kinds(source), relationship_kinds(&bytes)) { - for (key, kind) in &before { - if !after.contains_key(key) && !REBUILT_KINDS.contains(&key.as_str()) { - package.push(|| format!("lost relationship {kind}")); - } - } - } - let back = match gridcraft_engine::io::open_bytes("roundtrip.xlsx", &bytes) { - Ok((b, _)) => b, - Err(e) => return RoundTrip { error: Some(format!("reading the written file failed: {e}")), categories: vec![package.done()] }, - }; - let mut categories = diff_workbooks(wb, &back, opts); - categories.push(package.done()); - RoundTrip { error: None, categories } -} - -fn value_show(v: &Value) -> String { - match v { - Value::Empty => "(blank)".to_string(), - Value::Text(t) => serde_json::to_string(t.as_ref()).unwrap_or_default(), - Value::Number(n) => number_text(*n), - Value::Bool(b) => if *b { "TRUE" } else { "FALSE" }.to_string(), - Value::Error(e) => e.as_str().to_string(), - Value::Array(_) => v.scalar().display(), - } -} - -fn opt_formula(c: Option<&Cell>) -> String { - c.filter(|c| c.formula.is_some()).map(formula_text).unwrap_or_default() -} - -/// Per-sheet comparison of two lists, reporting what one side has and the other lacks. -fn diff_sets(d: &mut Diffs, sheet: &str, a: &BTreeSet, b: &BTreeSet) { - for x in a.difference(b) { - d.push(|| format!("{sheet}: lost {x}")); - } - for x in b.difference(a) { - d.push(|| format!("{sheet}: gained {x}")); - } -} - -/// A sheet's conditional-format rules in precedence order, as comparable text. The priority number -/// itself is left out, so renumbering without changing the order isn't a difference. -fn cond_format_list(s: &Sheet) -> Vec { - let mut cfs: Vec<_> = s.cond_formats.iter().collect(); - cfs.sort_by_key(|c| c.priority); - cfs.iter().map(|c| format!("{:?} {:?} stopIfTrue={}", c.ranges, c.rule, c.stop_if_true)).collect() -} - -fn diff_count(d: &mut Diffs, sheet: &str, a: usize, b: usize) { - if a != b { - d.push(|| format!("{sheet}: {a} → {b}")); - } -} - -/// A value as the file format can hold it: `#SPILL!`/`#CALC!` are cached as `#VALUE!` and an -/// unresolved circular reference as 0 (what Excel writes), so those aren't round-trip losses. -fn as_cached(v: Value) -> Value { - match v { - Value::Error(CellError::Spill | CellError::Calc) => Value::Error(CellError::Value), - Value::Error(CellError::Circ) => Value::Number(0.0), - v => v, - } -} - -fn num_fmt<'a>(wb: &'a Workbook, c: Option<&Cell>) -> &'a str { - wb.styles.get(c.map(|c| c.style).unwrap_or_default()).num_fmt.as_str() -} - -/// Feature-by-feature comparison of a workbook (`a`) with what reading it back gave (`b`). Sheets -/// are paired by position. -pub fn diff_workbooks(a: &Workbook, b: &Workbook, opts: &Options) -> Vec { - let m = opts.max_report; - let mut sheets = Diffs::new("sheets", m); - let (an, bn): (Vec<&str>, Vec<&str>) = (a.sheets.iter().map(|s| s.name.as_str()).collect(), b.sheets.iter().map(|s| s.name.as_str()).collect()); - if an != bn { - sheets.push(|| format!("{an:?} → {bn:?}")); - } - let mut values = Diffs::new("values", m); - let mut formulas = Diffs::new("formulas", m); - let mut num_fmts = Diffs::new("numFmts", m); - let mut merges = Diffs::new("merges", m); - let mut tables = Diffs::new("tables", m); - let mut cond_formats = Diffs::new("condFormats", m); - let mut validations = Diffs::new("validations", m); - let mut charts = Diffs::new("charts", m); - let mut comments = Diffs::new("comments", m); - let mut hyperlinks = Diffs::new("hyperlinks", m); - let mut pivots = Diffs::new("pivots", m); - for (sa, sb) in a.sheets.iter().zip(b.sheets.iter()) { - let name = quote_sheet(&sa.name); - let mut cell = |c: CellRef, ca: Option<&Cell>, cb: Option<&Cell>| { - let cached = |x: Option<&Cell>| as_cached(x.map(|x| x.value.scalar()).unwrap_or_default()); - let (va, vb) = (cached(ca), cached(cb)); - if va != vb { - values.push(|| format!("{name}!{}: {} → {}", c.a1(), value_show(&va), value_show(&vb))); - } - let (fa, fb) = (opt_formula(ca), opt_formula(cb)); - if fa != fb { - formulas.push(|| format!("{name}!{}: {fa} → {fb}", c.a1())); - } - let (na, nb) = (num_fmt(a, ca), num_fmt(b, cb)); - if na != nb { - num_fmts.push(|| format!("{name}!{}: {na} → {nb}", c.a1())); - } - }; - for (c, ca) in sa.cells.iter() { - cell(c, Some(ca), sb.cell(c)); - } - for (c, cb) in sb.cells.iter() { - if sa.cell(c).is_none() { - cell(c, None, Some(cb)); - } - } - let set = |f: &dyn Fn(&Sheet) -> Vec, s: &Sheet| f(s).into_iter().collect::>(); - let merge_list = |s: &Sheet| -> Vec { s.merges.iter().map(|r| r.a1()).collect() }; - diff_sets(&mut merges, &name, &set(&merge_list, sa), &set(&merge_list, sb)); - let table_list = |s: &Sheet| -> Vec { - s.tables - .iter() - .map(|t| format!("{} {} [{}]", t.name, t.range.a1(), t.columns.iter().map(|c| c.name.as_str()).collect::>().join(", "))) - .collect() - }; - diff_sets(&mut tables, &name, &set(&table_list, sa), &set(&table_list, sb)); - let (cfa, cfb) = (cond_format_list(sa), cond_format_list(sb)); - if cfa.len() != cfb.len() { - cond_formats.push(|| format!("{name}: {} → {}", cfa.len(), cfb.len())); - } else if let Some((x, y)) = cfa.iter().zip(&cfb).find(|(x, y)| x != y) { - cond_formats.push(|| format!("{name}: {x} → {y}")); - } - diff_count(&mut validations, &name, sa.validations.len(), sb.validations.len()); - let chart_list = |s: &Sheet| s.charts.iter().map(|c| format!("{:?} ({} series)", c.kind, c.series.len())).collect::>(); - let (ca, cb) = (chart_list(sa), chart_list(sb)); - if ca != cb { - charts.push(|| format!("{name}: {ca:?} → {cb:?}")); - } - diff_count(&mut comments, &name, sa.comments.len(), sb.comments.len()); - diff_count(&mut hyperlinks, &name, sa.hyperlinks.len(), sb.hyperlinks.len()); - diff_count(&mut pivots, &name, sa.pivots.len(), sb.pivots.len()); - } - let mut names = Diffs::new("names", m); - let name_list = |wb: &Workbook| -> BTreeSet { - wb.names - .iter() - .map(|n| { - let scope = n.scope.map(|i| format!("{}!", quote_sheet(wb.sheet(i).map(|s| s.name.as_str()).unwrap_or("?")))).unwrap_or_default(); - format!("{scope}{}={}", n.name, n.formula) - }) - .collect() - }; - diff_sets(&mut names, "workbook", &name_list(a), &name_list(b)); - vec![ - sheets.done(), - values.done(), - formulas.done(), - num_fmts.done(), - merges.done(), - names.done(), - tables.done(), - cond_formats.done(), - validations.done(), - charts.done(), - comments.done(), - hyperlinks.done(), - pivots.done(), - ] -} - -// ---------------------------------------------------------------- package validation - -fn local_name(name: &[u8]) -> &[u8] { - match name.iter().rposition(|&b| b == b':') { - Some(i) => name.get(i + 1..).unwrap_or(name), - None => name, - } -} - -/// `(element local name, attributes)` for every start/empty element of an XML part. -fn xml_elements(xml: &[u8]) -> Result)>, String> { - use quick_xml::events::Event; - let mut reader = quick_xml::Reader::from_reader(xml); - let mut buf = Vec::new(); - let mut out = Vec::new(); - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(e)) | Ok(Event::Empty(e)) => { - let attrs = e - .attributes() - .with_checks(false) - .flatten() - .map(|a| { - let v = match a.normalized_value(quick_xml::XmlVersion::Explicit1_0) { - Ok(v) => v.into_owned(), - Err(_) => String::from_utf8_lossy(&a.value).into_owned(), - }; - (String::from_utf8_lossy(local_name(a.key.as_ref())).into_owned(), v) - }) - .collect(); - out.push((String::from_utf8_lossy(local_name(e.name().as_ref())).into_owned(), attrs)); - } - Ok(Event::Eof) => break, - Ok(_) => {} - Err(e) => return Err(format!("invalid XML: {e}")), - } - buf.clear(); - } - Ok(out) -} - -fn attr<'a>(attrs: &'a [(String, String)], k: &str) -> Option<&'a str> { - attrs.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str()) -} - -/// Lower-cased part name without a leading slash: OPC part names compare case-insensitively. -fn part_key(name: &str) -> String { - name.trim_start_matches('/').to_ascii_lowercase() -} - -/// `%XX` escapes in a relationship target. -fn percent_decode(s: &str) -> String { - let b = s.as_bytes(); - let mut out = Vec::with_capacity(b.len()); - let mut i = 0; - while let Some(&c) = b.get(i) { - let hex = |j: usize| b.get(j).and_then(|h| (*h as char).to_digit(16)); - match (c, hex(i + 1), hex(i + 2)) { - (b'%', Some(h), Some(l)) => { - out.push((h * 16 + l) as u8); - i += 3; - } - _ => { - out.push(c); - i += 1; - } - } - } - String::from_utf8_lossy(&out).into_owned() -} - -/// Resolves a relationship target against the source part's directory. -fn resolve_target(source_dir: &str, target: &str) -> String { - let target = percent_decode(target.split('#').next().unwrap_or(target)); - let joined = match target.strip_prefix('/') { - Some(abs) => abs.to_string(), - None if source_dir.is_empty() => target, - None => format!("{source_dir}/{target}"), - }; - let mut parts: Vec<&str> = Vec::new(); - for seg in joined.split('/') { - match seg { - "" | "." => {} - ".." => { - parts.pop(); - } - s => parts.push(s), - } - } - parts.join("/") -} - -type Zip<'a> = zip::ZipArchive>; - -fn zip_names(zip: &mut Zip<'_>) -> Vec { - (0..zip.len().min(MAX_ENTRIES)).filter_map(|i| zip.by_index_raw(i).ok().filter(|f| !f.is_dir()).map(|f| f.name().to_string())).collect() -} - -fn read_zip_part(zip: &mut Zip<'_>, name: &str) -> Result, String> { - let f = zip.by_name(name).map_err(|e| format!("{name}: {e}"))?; - let mut buf = Vec::new(); - f.take(MAX_XML_PART).read_to_end(&mut buf).map_err(|e| format!("{name}: {e}"))?; - Ok(buf) -} - -/// Whether the producer flagged its cached results as stale with ``: -/// libraries that don't calculate write it, and store 0 or nothing as every formula's value. -fn full_calc_on_load(bytes: &[u8]) -> bool { - let Ok(mut zip) = zip::ZipArchive::new(Cursor::new(bytes)) else { return false }; - let names = zip_names(&mut zip); - let find = |key: &str| names.iter().find(|n| part_key(n) == key).cloned(); - let mut workbook = "xl/workbook.xml".to_string(); - if let Some(rels) = find("_rels/.rels") - && let Ok(xml) = read_zip_part(&mut zip, &rels) - && let Ok(els) = xml_elements(&xml) - && let Some(target) = els - .iter() - .find(|(e, a)| e == "Relationship" && attr(a, "Type").is_some_and(|t| t.ends_with("/officeDocument"))) - .and_then(|(_, a)| attr(a, "Target")) - { - workbook = resolve_target("", target); - } - let Some(name) = find(&part_key(&workbook)) else { return false }; - let Ok(xml) = read_zip_part(&mut zip, &name) else { return false }; - xml_elements(&xml) - .ok() - .into_iter() - .flatten() - .any(|(e, a)| e == "calcPr" && attr(&a, "fullCalcOnLoad").is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))) -} - -/// Problems Excel would repair in a written package: parts without a content type, content-type -/// overrides for missing parts, relationship files for missing parts, duplicate relationship ids -/// and internal relationships pointing at missing parts. `Err` when the package can't be read. -pub fn validate_package(bytes: &[u8]) -> Result, 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 parts: HashSet = names.iter().map(|n| part_key(n)).collect(); - let mut read_part = |name: &str| read_zip_part(&mut zip, name); - let mut issues = Vec::new(); - let ct_name = names.iter().find(|n| part_key(n) == "[content_types].xml").cloned(); - let (mut defaults, mut overrides) = (HashSet::new(), HashSet::new()); - match ct_name { - None => issues.push("no [Content_Types].xml".to_string()), - Some(n) => { - for (el, attrs) in xml_elements(&read_part(&n)?).map_err(|e| format!("[Content_Types].xml: {e}"))? { - match el.as_str() { - "Default" => { - if let Some(x) = attr(&attrs, "Extension") { - defaults.insert(x.to_ascii_lowercase()); - } - } - "Override" => { - if let Some(p) = attr(&attrs, "PartName") { - overrides.insert(part_key(p)); - } - } - _ => {} - } - } - } - } - let mut sorted: Vec<&String> = names.iter().collect(); - sorted.sort(); - for n in &sorted { - let key = part_key(n); - if key == "[content_types].xml" { - continue; - } - let ext = key.rsplit('/').next().and_then(|f| f.rsplit_once('.')).map(|(_, e)| e.to_string()); - if !overrides.contains(&key) && !ext.is_some_and(|e| defaults.contains(&e)) { - issues.push(format!("part {n} has no content type")); - } - } - let mut missing_overrides: Vec<&String> = overrides.iter().filter(|p| !parts.contains(*p)).collect(); - missing_overrides.sort(); - for p in missing_overrides { - issues.push(format!("content type override for missing part /{p}")); - } - for n in sorted { - let key = part_key(n); - let Some(rels_file) = key.strip_suffix(".rels") else { continue }; - // `dir/_rels/name.rels` describes `dir/name`; `_rels/.rels` the package itself. - let (dir, file) = match rels_file.rsplit_once('/') { - Some((d, f)) => (d, f), - None => continue, - }; - let Some(src_dir) = dir.strip_suffix("_rels").map(|d| d.trim_end_matches('/')) else { continue }; - let source = if src_dir.is_empty() { file.to_string() } else { format!("{src_dir}/{file}") }; - if !source.is_empty() && !parts.contains(&source) { - issues.push(format!("{n} describes missing part {source}")); - } - let mut ids = HashSet::new(); - for (el, attrs) in xml_elements(&read_part(n)?).map_err(|e| format!("{n}: {e}"))? { - if el != "Relationship" { - continue; - } - let id = attr(&attrs, "Id").unwrap_or(""); - if !ids.insert(id.to_string()) { - issues.push(format!("{n}: duplicate relationship id {id}")); - } - if attr(&attrs, "TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External")) { - continue; - } - let target = attr(&attrs, "Target").unwrap_or(""); - let resolved = resolve_target(src_dir, target); - if !parts.contains(&resolved.to_ascii_lowercase()) { - let kind = attr(&attrs, "Type").and_then(|t| t.rsplit('/').next()).unwrap_or("?"); - issues.push(format!("{n}: {id} ({kind}) targets missing part {resolved}")); - } - } - } - Ok(issues) -} - -/// Every relationship kind (last segment of the type, the same in the transitional and strict -/// namespaces) in the package's relationship parts, keyed in lower case. Relationships that carry -/// nothing are left out, as a save may rightly drop them: a link to a place in the workbook -/// (`#Sheet1!A1`, written as a cell's `location`) and a target part with nothing in it. -fn relationship_kinds(bytes: &[u8]) -> Result, 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 = 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 }; - let Ok(els) = read_zip_part(&mut zip, n).and_then(|xml| xml_elements(&xml)) else { continue }; - for (_, attrs) in els.iter().filter(|(el, _)| el == "Relationship") { - let Some(kind) = attr(attrs, "Type").and_then(|t| t.rsplit('/').next()) else { continue }; - let target = attr(attrs, "Target").unwrap_or(""); - if kind.eq_ignore_ascii_case("hyperlink") && target.starts_with('#') { - continue; - } - 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, &mut verdicts, &mut budget) - { - continue; - } - kinds.entry(kind.to_ascii_lowercase()).or_insert_with(|| kind.to_string()); - } - } - Ok(kinds) -} - -/// The directory of the part a rels part describes: `xl` for `xl/_rels/workbook.xml.rels`, the -/// empty string for the package's `_rels/.rels`. -fn rels_source_dir(key: &str) -> Option { - let (dir, _) = key.strip_suffix(".rels")?.rsplit_once('/')?; - Some(dir.strip_suffix("_rels")?.trim_end_matches('/').to_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. -/// `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 { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(e)) | Ok(Event::Empty(e)) => { - let found = match wanted { - Some(w) => local_name(e.name().as_ref()) == w, - None => in_root, - }; - if found { - return true; - } - in_root = true; - } - Ok(Event::Eof) => return false, - Ok(_) => {} - Err(_) => return true, - } - buf.clear(); - } -} - -// ---------------------------------------------------------------- output - -#[derive(Default)] -struct Totals { - files: usize, - opened: usize, - failed: usize, - formula_cells: usize, - checked: usize, - matched: usize, - mismatched: usize, - no_oracle: usize, - volatile: usize, - bool_as_number: usize, - round_trip_failed: usize, - round_trip_differing: usize, - categories: BTreeMap<&'static str, usize>, - functions: BTreeMap, - signatures: BTreeMap, -} - -fn totals(reports: &[FileReport]) -> Totals { - let mut t = Totals { files: reports.len(), ..Default::default() }; - for r in reports { - if r.error.is_some() { - t.failed += 1; - continue; - } - t.opened += 1; - t.formula_cells += r.calc.formula_cells; - t.checked += r.calc.checked; - t.matched += r.calc.matched; - t.mismatched += r.calc.mismatched; - t.no_oracle += r.calc.no_oracle; - t.volatile += r.calc.volatile; - t.bool_as_number += r.calc.bool_as_number; - if r.round_trip.error.is_some() { - t.round_trip_failed += 1; - } - if !r.round_trip.is_clean() { - t.round_trip_differing += 1; - } - for c in r.round_trip.differing() { - *t.categories.entry(c).or_default() += 1; - } - for (n, s) in &r.calc.functions { - t.functions.entry(n.clone()).or_default().add(s); - } - for (sig, n) in &r.calc.signatures { - *t.signatures.entry(sig.clone()).or_default() += n; - } - } - t -} - -/// Functions with mismatches, worst first: most primary mismatches, then most mismatches. -fn ranked(functions: &BTreeMap) -> Vec<(&String, &FnStat)> { - let mut v: Vec<(&String, &FnStat)> = functions.iter().filter(|(_, s)| s.mismatched > 0).collect(); - v.sort_by(|a, b| b.1.primary.cmp(&a.1.primary).then(b.1.mismatched.cmp(&a.1.mismatched)).then(a.0.cmp(b.0))); - v -} - -fn functions_json(functions: &BTreeMap) -> Json { - let mut v: Vec<(&String, &FnStat)> = functions.iter().collect(); - v.sort_by(|a, b| b.1.primary.cmp(&a.1.primary).then(b.1.mismatched.cmp(&a.1.mismatched)).then(a.0.cmp(b.0))); - Json::Array(v.into_iter().map(|(n, s)| json!({"name": n, "checked": s.checked, "mismatched": s.mismatched, "primary": s.primary})).collect()) -} - -pub fn report_json(reports: &[FileReport], opts: &Options) -> Json { - let files: Vec = reports - .iter() - .map(|r| { - let c = &r.calc; - let mismatches: Vec = c - .mismatches - .iter() - .map(|m| { - json!({ - "cell": m.cell, "formula": m.formula, "expected": value_json(&m.expected), "actual": value_json(&m.actual), - "kind": m.kind, "functions": m.functions, "primary": m.primary, - }) - }) - .collect(); - let categories: serde_json::Map = r - .round_trip - .categories - .iter() - .map(|cat| (cat.name.to_string(), json!({"equal": cat.diffs == 0, "diffs": cat.diffs, "examples": cat.examples}))) - .collect(); - json!({ - "path": r.path, - "opened": r.error.is_none(), - "error": r.error, - "warnings": r.warnings, - "calc": { - "formulaCells": c.formula_cells, "checked": c.checked, "matched": c.matched, "mismatched": c.mismatched, - "noOracle": c.no_oracle, "staleCache": c.stale_cache, "volatile": c.volatile, "boolAsNumber": c.bool_as_number, - "mismatches": mismatches, "functions": functions_json(&c.functions), - "signatures": c.signatures, - }, - "roundTrip": {"error": r.round_trip.error, "differing": r.round_trip.differing(), "categories": categories}, - }) - }) - .collect(); - let t = totals(reports); - json!({ - "schema": SCHEMA, - "tolerance": opts.tolerance, - "files": files, - "totals": { - "files": t.files, "opened": t.opened, "failedToOpen": t.failed, "formulaCells": t.formula_cells, "checked": t.checked, - "matched": t.matched, "mismatched": t.mismatched, "noOracle": t.no_oracle, "volatile": t.volatile, "boolAsNumber": t.bool_as_number, - "roundTripFailed": t.round_trip_failed, "roundTripDiffering": t.round_trip_differing, "categoriesDiffering": t.categories, - }, - "functions": functions_json(&t.functions), - "signatures": t.signatures, - }) -} - -/// One report line: line breaks and tabs shown escaped so that they stay visible as differences. -fn one_line(s: &str) -> String { - s.replace('\n', "\\n").replace('\r', "\\r").replace('\t', "\\t") -} - -/// `…tail` of a long label. -fn fit(s: &str, w: usize) -> String { - let n = s.chars().count(); - if n <= w { s.to_string() } else { format!("…{}", s.chars().skip(n + 1 - w).collect::()) } -} - -fn print_human(reports: &[FileReport], opts: &Options) { - for r in reports { - outln!("== {}", r.path); - if let Some(e) = &r.error { - outln!(" can't open: {e}"); - continue; - } - let c = &r.calc; - outln!( - " formulas {}: {} checked, {} matched, {} mismatched, {} no oracle, {} volatile", - c.formula_cells, - c.checked, - c.matched, - c.mismatched, - c.no_oracle, - c.volatile - ); - if c.stale_cache { - outln!(" (the producer asked for a full recalculation on load: its cached values aren't results)"); - } - if c.bool_as_number > 0 { - outln!(" ({} matched TRUE/FALSE results were saved by the producer as 1/0, without t=\"b\")", c.bool_as_number); - } - for m in &c.mismatches { - let tag = if m.primary { "mismatch" } else { " (downstream)" }; - outln!( - " {tag} {} {} expected {} got {} [{}]", - m.cell, - one_line(&m.formula), - value_show(&m.expected), - value_show(&m.actual), - m.functions.join(" ") - ); - } - if c.mismatched > c.mismatches.len() { - outln!(" … {} more mismatches", c.mismatched - c.mismatches.len()); - } - let rt = &r.round_trip; - if let Some(e) = &rt.error { - outln!(" round trip: {e}"); - } - let differing: Vec<&Category> = rt.categories.iter().filter(|c| c.diffs > 0).collect(); - if differing.is_empty() && rt.error.is_none() { - outln!(" round trip: all equal"); - } - for cat in differing { - outln!(" round trip {}: {} difference(s)", cat.name, cat.diffs); - for e in &cat.examples { - outln!(" {}", one_line(e)); - } - if cat.diffs > cat.examples.len() { - outln!(" … {} more", cat.diffs - cat.examples.len()); - } - } - } - outln!(""); - let w = reports.iter().map(|r| r.path.chars().count()).max().unwrap_or(4).clamp(4, 50); - outln!( - "{:8} {:>8} {:>8} {:>8} {:>9} {:>8} round trip", - "file", - "formulas", - "checked", - "matched", - "mismatch", - "no-oracle", - "volatile" - ); - for r in reports { - let label = fit(&r.path, w); - if let Some(e) = &r.error { - outln!("{label: "FAILED".to_string(), - (None, d) if d.is_empty() => "ok".to_string(), - (None, d) => d.join(","), - }; - outln!( - "{label:8} {:>8} {:>8} {:>8} {:>9} {:>8} {rt}", - c.formula_cells, - c.checked, - c.matched, - c.mismatched, - c.no_oracle, - c.volatile - ); - } - let t = totals(reports); - let top = ranked(&t.functions); - if !top.is_empty() { - outln!(""); - outln!("top mismatching functions (primary = no mismatching precedent):"); - outln!(" {:<20} {:>8} {:>10} {:>8}", "function", "primary", "mismatched", "checked"); - for (n, s) in top.iter().take(opts.max_report.max(1)) { - outln!(" {:<20} {:>8} {:>10} {:>8}", n, s.primary, s.mismatched, s.checked); - } - } - let mut sigs: Vec<(&String, &usize)> = t.signatures.iter().collect(); - sigs.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0))); - if !sigs.is_empty() { - outln!(""); - outln!("mismatch signatures (expected → GridCraft):"); - for (sig, n) in sigs.iter().take(opts.max_report.max(1)) { - outln!(" {n:>8} {sig}"); - } - } - outln!(""); - outln!("files: {} ({} opened, {} failed to open)", t.files, t.opened, t.failed); - outln!( - "formula cells: {} ({} checked: {} matched, {} mismatched; {} no oracle; {} volatile)", - t.formula_cells, - t.checked, - t.matched, - t.mismatched, - t.no_oracle, - t.volatile - ); - if t.bool_as_number > 0 { - outln!("booleans saved as 1/0 by the producer: {} (counted as matched)", t.bool_as_number); - } - let cats: Vec = t.categories.iter().map(|(c, n)| format!("{c} {n}")).collect(); - outln!( - "round trip: {} of {} files differ{}{}", - t.round_trip_differing, - t.opened, - if t.round_trip_failed > 0 { format!(" ({} failed)", t.round_trip_failed) } else { String::new() }, - if cats.is_empty() { String::new() } else { format!("; categories (files): {}", cats.join(", ")) } - ); -} - #[cfg(test)] -mod tests { - #![allow(clippy::unwrap_used)] - use std::io::Write as _; - use std::sync::Arc; - - use gridcraft_engine::model::Formula; - - use super::*; - - #[test] - fn discovery_reports_a_directory_read_error() { - 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(); - 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()); - assert!(discovery.failures[0].error.as_ref().unwrap().contains("cannot read directory")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn discovery_reports_omissions_only_after_the_file_limit() { - let mut discovery = Discovery { files: vec![PathBuf::from("book.xlsx"); MAX_FILES - 1], ..Default::default() }; - assert!(discovery.add_file(PathBuf::from("last.xlsx"))); - assert!(discovery.failures.is_empty()); - assert!(!discovery.add_file(PathBuf::from("omitted.xlsx"))); - assert_eq!(discovery.files.len(), MAX_FILES); - assert_eq!(discovery.failures[0].path, "omitted.xlsx"); - assert!(discovery.failures[0].error.as_ref().unwrap().contains("file limit")); - } - - fn at(a1: &str) -> CellRef { - CellRef::parse(a1).unwrap() - } - - /// A workbook whose cells are `(A1, input, cached value)`: an input starting with `=` is a - /// formula with that cached value, anything else a constant. - fn book(cells: &[(&str, &str, Value)]) -> Workbook { - let mut wb = Workbook::new(); - let sh = wb.sheet_mut(0).unwrap(); - for (a1, input, cached) in cells { - let cell = if input.starts_with('=') { - Cell { value: cached.clone(), formula: Some(Arc::new(Formula::new(input))), ..Default::default() } - } else { - Cell::value(cached.clone()) - }; - sh.set_cell(at(a1), cell); - } - wb - } - - fn check(wb: &Workbook, opts: &Options) -> FileReport { - let bytes = gridcraft_engine::io::save_bytes(wb, "t.xlsx").unwrap(); - check_bytes("t.xlsx", &bytes, opts) - } - - fn n(x: f64) -> Value { - Value::number(x) - } - - #[test] - fn matching_cached_values_pass() { - let wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("C1", "=A1&\"x\"", Value::text("2x")), ("D1", "=A1>1", Value::Bool(true))]); - let r = check(&wb, &Options::default()); - assert_eq!(r.error, None); - assert_eq!((r.calc.formula_cells, r.calc.checked, r.calc.matched, r.calc.mismatched), (3, 3, 3, 0)); - assert!(r.is_clean(), "{r:?}"); - } - - #[test] - fn wrong_cached_value_is_a_mismatch_and_ranks_its_function() { - // Excel "said" SUM gives 7; GridCraft computes 6. C1 reads B1 and is consistent with - // Excel's 7, so it mismatches too, but only as a downstream effect. - let wb = book(&[("A1", "2", n(2.0)), ("A2", "4", n(4.0)), ("B1", "=SUM(A1:A2)", n(7.0)), ("C1", "=B1+1", n(8.0))]); - let r = check(&wb, &Options::default()); - assert_eq!((r.calc.checked, r.calc.mismatched), (2, 2)); - let first = &r.calc.mismatches[0]; - assert_eq!(first.cell, "Sheet1!B1"); - assert_eq!(first.formula, "=SUM(A1:A2)"); - assert_eq!((first.expected.clone(), first.actual.clone(), first.kind, first.primary), (n(7.0), n(6.0), "numeric", true)); - assert!(!r.calc.mismatches[1].primary); - assert_eq!(r.calc.functions["SUM"], FnStat { checked: 1, mismatched: 1, primary: 1 }); - assert_eq!(r.calc.functions["(no function)"], FnStat { checked: 1, mismatched: 1, primary: 0 }); - assert!(!r.is_clean()); - } - - #[test] - fn errors_compare_by_kind() { - let wb = book(&[("A1", "=1/0", Value::Error(CellError::Div0)), ("A2", "=1/0", Value::Error(CellError::NA)), ("A3", "=1/0", n(0.0))]); - let r = check(&wb, &Options::default()); - assert_eq!((r.calc.matched, r.calc.mismatched), (1, 2)); - assert!(r.calc.mismatches.iter().all(|m| m.kind == "error")); - assert_eq!(r.calc.signatures.get("#N/A → #DIV/0!"), Some(&1)); - assert_eq!(r.calc.signatures.get("number → #DIV/0!"), Some(&1)); - assert!(values_match(&Value::Error(CellError::NA), &Value::Error(CellError::NA), 0.0)); - assert!(!values_match(&Value::Error(CellError::NA), &Value::Error(CellError::Value), 0.0)); - } - - #[test] - fn numbers_compare_within_tolerance() { - assert!(values_match(&n(3.0000000001), &n(3.0), 1e-9)); - assert!(!values_match(&n(3.0000000001), &n(3.0), 1e-12)); - // Absolute near zero. - assert!(values_match(&n(1e-12), &n(0.0), 1e-9)); - assert!(values_match(&n(1e12 + 1.0), &n(1e12), 1e-9)); - assert!(!values_match(&n(0.0), &Value::Empty, 1.0)); - assert!(!values_match(&Value::text("1"), &n(1.0), 1.0)); - assert!(values_match(&Value::text("a"), &Value::text("a"), 0.0)); - assert!(!values_match(&Value::text("a"), &Value::text("A"), 0.0)); - assert!(!values_match(&Value::Bool(true), &n(1.0), 1.0)); - let wb = book(&[("A1", "=1+2", n(3.0000000001))]); - assert_eq!(check(&wb, &Options::default()).calc.matched, 1); - assert_eq!(check(&wb, &Options { tolerance: 1e-12, ..Default::default() }).calc.mismatched, 1); - } - - /// Business Central writes TRUE and FALSE as `TRUE()1`, without `t="b"`: - /// the cache holds the right value under a number type, so it matches and is counted apart. - #[test] - fn booleans_cached_as_numbers_match_and_are_counted_apart() { - let wb = book(&[ - ("A1", "=TRUE()", n(1.0)), - ("B1", "=FALSE()", n(0.0)), - ("C1", "=TRUE()", Value::Bool(true)), - ("D1", "=1=1", n(0.0)), - ("E1", "=TRUE()+0", n(1.0)), - ]); - let r = check(&wb, &Options::default()); - assert_eq!((r.calc.checked, r.calc.matched, r.calc.mismatched), (5, 4, 1)); - // A cached 0 for TRUE is still a mismatch, and a number result is compared as a number. - assert_eq!(r.calc.mismatches[0].cell, "Sheet1!D1"); - assert_eq!(r.calc.signatures.get("number → bool"), Some(&1)); - assert_eq!(r.calc.bool_as_number, 2); - let v = report_json(std::slice::from_ref(&r), &Options::default()); - assert_eq!((v["files"][0]["calc"]["boolAsNumber"].clone(), v["totals"]["boolAsNumber"].clone()), (json!(2), json!(2))); - assert!(bool_cached_as_number(&n(1.0), &Value::Bool(true))); - assert!(!bool_cached_as_number(&n(1.0), &Value::Bool(false))); - assert!(!bool_cached_as_number(&n(2.0), &Value::Bool(true))); - assert!(!bool_cached_as_number(&Value::Bool(true), &n(1.0))); - } - - #[test] - fn missing_cached_values_and_volatile_formulas_are_skipped() { - let wb = - book(&[("A1", "=1+1", Value::Empty), ("B1", "=NOW()", n(1.0)), ("B2", "=B1+1", n(2.0)), ("B3", "=CELL(\"filename\")", Value::text("x"))]); - let r = check(&wb, &Options::default()); - assert_eq!((r.calc.formula_cells, r.calc.checked, r.calc.no_oracle, r.calc.volatile), (4, 0, 1, 3)); - } - - #[test] - fn full_calc_on_load_means_no_oracle() { - // A formula without a cached value makes GridCraft's writer flag the file - // `fullCalcOnLoad`, like libraries that store 0 for every formula: B1's wrong "cached" - // value must not count as a mismatch then. - let wb = book(&[("A1", "=1+1", Value::Empty), ("B1", "=2+2", n(0.0))]); - let bytes = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap(); - assert!(full_calc_on_load(&bytes)); - let r = check_bytes("t.xlsx", &bytes, &Options::default()); - assert!(r.calc.stale_cache); - assert_eq!((r.calc.checked, r.calc.no_oracle, r.calc.mismatched), (0, 2, 0)); - let fresh = gridcraft_engine::io::save_bytes(&book(&[("B1", "=2+2", n(0.0))]), "t.xlsx").unwrap(); - assert!(!full_calc_on_load(&fresh)); - assert!(!full_calc_on_load(b"not a zip")); - } - - #[test] - fn volatile_names_propagate() { - let mut wb = book(&[("A1", "=Stamp+1", n(1.0))]); - wb.names.push(gridcraft_engine::model::DefinedName { - name: "Stamp".into(), - scope: None, - formula: "TODAY()".into(), - comment: String::new(), - hidden: false, - }); - assert!(volatile_names(&wb).contains("STAMP")); - assert_eq!(check(&wb, &Options::default()).calc.volatile, 1); - } - - #[test] - fn round_trip_of_a_simple_book_is_clean() { - let mut wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("A2", "x", Value::text("x"))]); - wb.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap()); - let source = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap(); - let rt = round_trip(&wb, &source, &Options::default()); - assert_eq!(rt.error, None); - assert!(rt.is_clean(), "{rt:?}"); - assert!(rt.categories.iter().any(|c| c.name == "package")); - } - - #[test] - fn diff_detects_lost_features() { - let mut a = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0))]); - a.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap()); - let mut b = a.clone(); - let sh = b.sheet_mut(0).unwrap(); - sh.merges.clear(); - sh.set_cell(at("A1"), Cell::value(n(3.0))); - sh.set_cell(at("B1"), Cell::value(n(6.0))); - b.sheets.push(Arc::new(Sheet::new("Extra"))); - let cats = diff_workbooks(&a, &b, &Options { max_report: 1, ..Default::default() }); - let get = |name: &str| cats.iter().find(|c| c.name == name).unwrap(); - assert_eq!(get("values").diffs, 1); - assert_eq!(get("values").examples, ["Sheet1!A1: 2 → 3"]); - assert_eq!(get("formulas").examples, ["Sheet1!B1: =A1*3 → "]); - assert_eq!(get("merges").examples, ["Sheet1: lost C1:D2"]); - assert_eq!(get("sheets").diffs, 1); - assert_eq!(get("tables").diffs, 0); - } - - #[test] - fn diff_detects_changed_cond_format_rule() { - use gridcraft_engine::model::{CfRule, CfValueKind, Color, CondFormat}; - let bar = |gradient: bool, priority: u32| CondFormat { - ranges: vec![gridcraft_engine::core::RangeRef::parse("A1").unwrap()], - rule: CfRule::DataBar { min: CfValueKind::Min, max: CfValueKind::Max, color: Color::Rgb(0x638EC6), gradient, show_value: true }, - priority, - stop_if_true: false, - }; - let with = |cf: CondFormat| { - let mut wb = book(&[("A1", "2", n(2.0))]); - wb.sheet_mut(0).unwrap().cond_formats.push(cf); - wb - }; - let diffs = - |a: &Workbook, b: &Workbook| diff_workbooks(a, b, &Options::default()).into_iter().find(|c| c.name == "condFormats").unwrap().diffs; - assert_eq!(diffs(&with(bar(false, 1)), &with(bar(true, 1))), 1); - assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 1))), 0); - assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 5))), 0); - } - - fn zip_of(parts: &[(&str, &str)]) -> Vec { - let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); - for (name, body) in parts { - z.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); - z.write_all(body.as_bytes()).unwrap(); - } - z.finish().unwrap().into_inner() - } - - #[test] - fn package_validation_finds_what_excel_would_repair() { - let bytes = zip_of(&[ - ( - "[Content_Types].xml", - r#""#, - ), - ("_rels/.rels", r#""#), - ("xl/workbook.xml", ""), - ("xl/styles.xml", ""), - ( - "xl/_rels/workbook.xml.rels", - r#""#, - ), - ]); - let issues = validate_package(&bytes).unwrap(); - assert_eq!( - issues, - [ - "part xl/styles.xml has no content type", - "content type override for missing part /xl/gone.xml", - "xl/_rels/workbook.xml.rels: rId2 (worksheet) targets missing part xl/worksheets/sheet1.xml", - "xl/_rels/workbook.xml.rels: duplicate relationship id rId2", - ] - ); - assert!(validate_package(b"not a zip").is_err()); - let own = gridcraft_engine::io::save_bytes(&book(&[("A1", "=1", n(1.0))]), "t.xlsx").unwrap(); - assert_eq!(validate_package(&own).unwrap(), Vec::::new()); - } - - const WB_RELS: &str = "xl/_rels/workbook.xml.rels"; - const SHEET_RELS: &str = "xl/worksheets/_rels/sheet1.xml.rels"; - const MADE_UP: &str = r#""#; - /// Not empty: an empty part isn't a loss. - const MADE_UP_PART: (&str, &str) = ("xl/madeUp.xml", ""); - - /// `bytes` with relationships added to rels parts (created when missing) and `parts` added. - fn with_rels(bytes: &[u8], rels: &[(&str, &str)], parts: &[(&str, &str)]) -> Vec { - let mut src = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); - let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); - let mut pending: Vec<(&str, &str)> = rels.to_vec(); - for i in 0..src.len() { - let mut f = src.by_index(i).unwrap(); - let name = f.name().to_string(); - let mut body = String::new(); - f.read_to_string(&mut body).unwrap(); - if let Some(at) = pending.iter().position(|(r, _)| *r == name) { - body = body.replace("", &format!("{}", pending.remove(at).1)); - } - out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); - out.write_all(body.as_bytes()).unwrap(); - } - let created = pending.iter().map(|(r, extra)| { - (*r, format!(r#"{extra}"#)) - }); - for (name, body) in created.collect::>().iter().map(|(n, b)| (*n, b.as_str())).chain(parts.iter().copied()) { - out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); - out.write_all(body.as_bytes()).unwrap(); - } - out.finish().unwrap().into_inner() - } - - /// The `package` examples of checking `source`. - fn package_of(source: &[u8]) -> Vec { - let r = check_bytes("t.xlsx", source, &Options::default()); - r.round_trip.categories.iter().find(|c| c.name == "package").unwrap().examples.clone() - } - - fn plain() -> Vec { - gridcraft_engine::io::save_bytes(&book(&[("A1", "1", n(1.0))]), "t.xlsx").unwrap() - } - - #[test] - fn package_reports_relationship_kinds_the_output_lost() { - // A kind GridCraft doesn't know is dropped on save; a calculation chain is never written - // (Excel rebuilds it), so its absence isn't a loss. - let calc_chain = r#""#; - let source = with_rels(&plain(), &[(WB_RELS, &format!("{MADE_UP}{calc_chain}"))], &[MADE_UP_PART, ("xl/calcChain.xml", "")]); - assert_eq!(package_of(&source), ["lost relationship madeUp"]); - assert!(check_bytes("t.xlsx", &plain(), &Options::default()).round_trip.is_clean()); - } - - // In the three tests below the made-up kind stays lost, so the comparison is known to run. - - #[test] - fn an_internal_link_written_as_a_relationship_is_not_a_loss() { - // openpyxl writes `#Sheet1!B2` as an external relationship; GridCraft writes `location`. - let link = r##""##; - let source = with_rels(&plain(), &[(WB_RELS, MADE_UP), (SHEET_RELS, link)], &[MADE_UP_PART]); - assert_eq!(package_of(&source), ["lost relationship madeUp"]); - } - - #[test] - fn comments_without_a_comment_are_not_a_loss() { - let rels = r#""#; - let comments = r#"A"#; - let vml = r#""#; - let source = with_rels( - &plain(), - &[(WB_RELS, MADE_UP), (SHEET_RELS, rels)], - &[MADE_UP_PART, ("xl/comments1.xml", comments), ("xl/drawings/vmlDrawing1.vml", vml)], - ); - assert_eq!(package_of(&source), ["lost relationship madeUp"]); - } - - #[test] - fn empty_drawings_and_person_lists_are_not_a_loss() { - let drawing = r#""#; - let person = - r#""#; - let source = with_rels( - &plain(), - &[(WB_RELS, &format!("{MADE_UP}{person}")), (SHEET_RELS, drawing)], - &[ - MADE_UP_PART, - ("xl/drawings/drawing1.xml", r#""#), - ("xl/persons/person.xml", r#""#), - ], - ); - assert_eq!(package_of(&source), ["lost relationship madeUp"]); - } - - #[test] - fn json_lists_warning_messages() { - let with = FileReport { path: "a.ods".into(), warnings: vec!["ODS data-only import".into()], ..Default::default() }; - let without = FileReport { path: "b.xlsx".into(), ..Default::default() }; - let v = report_json(&[with, without], &Options::default()); - assert_eq!(v["files"][0]["warnings"], json!(["ODS data-only import"])); - assert_eq!(v["files"][1]["warnings"], json!([])); - } - - #[test] - fn unopenable_files_are_reported() { - let r = check_bytes("broken.xlsx", b"PK\x03\x04 not really", &Options::default()); - assert!(r.error.is_some()); - assert!(!r.is_clean()); - } - - #[test] - fn json_schema_keys() { - let wb = book(&[("A1", "=1+1", n(3.0))]); - let reports = vec![check(&wb, &Options::default()), check_bytes("bad.xlsx", b"", &Options::default())]; - let v = report_json(&reports, &Options::default()); - let keys = |v: &Json| v.as_object().unwrap().keys().cloned().collect::>(); - let set = |k: &[&str]| k.iter().map(|s| s.to_string()).collect::>(); - assert_eq!(keys(&v), set(&["schema", "tolerance", "files", "totals", "functions", "signatures"])); - assert_eq!(v["schema"], SCHEMA); - let f = &v["files"][0]; - assert_eq!(keys(f), set(&["path", "opened", "error", "warnings", "calc", "roundTrip"])); - assert_eq!( - keys(&f["calc"]), - set(&[ - "formulaCells", - "checked", - "matched", - "mismatched", - "noOracle", - "staleCache", - "volatile", - "boolAsNumber", - "mismatches", - "functions", - "signatures" - ]) - ); - assert_eq!(f["calc"]["signatures"], json!({"number → number": 1})); - assert_eq!(keys(&f["calc"]["mismatches"][0]), set(&["cell", "formula", "expected", "actual", "kind", "functions", "primary"])); - assert_eq!(f["calc"]["mismatches"][0]["expected"], json!(3.0)); - assert_eq!(keys(&f["roundTrip"]), set(&["error", "differing", "categories"])); - assert_eq!( - keys(&f["roundTrip"]["categories"]), - set(&[ - "sheets", - "values", - "formulas", - "numFmts", - "merges", - "names", - "tables", - "condFormats", - "validations", - "charts", - "comments", - "hyperlinks", - "pivots", - "package" - ]) - ); - assert_eq!(keys(&f["roundTrip"]["categories"]["values"]), set(&["equal", "diffs", "examples"])); - assert_eq!(v["files"][1]["opened"], json!(false)); - assert_eq!( - keys(&v["totals"]), - set(&[ - "files", - "opened", - "failedToOpen", - "formulaCells", - "checked", - "matched", - "mismatched", - "noOracle", - "volatile", - "boolAsNumber", - "roundTripFailed", - "roundTripDiffering", - "categoriesDiffering" - ]) - ); - assert_eq!(keys(&v["functions"][0]), set(&["name", "checked", "mismatched", "primary"])); - } -} +mod tests; diff --git a/apps/gridcraft-cli/src/check/calc.rs b/apps/gridcraft-cli/src/check/calc.rs new file mode 100644 index 0000000000..a3f33d2963 --- /dev/null +++ b/apps/gridcraft-cli/src/check/calc.rs @@ -0,0 +1,237 @@ +use super::*; + +/// Functions whose result depends on the clock, a random generator or the environment. +const VOLATILE: [&str; 6] = ["RAND", "RANDARRAY", "RANDBETWEEN", "NOW", "TODAY", "INFO"]; + +// ---------------------------------------------------------------- calc oracle + +fn functions_of(e: &Expr) -> Vec { + let mut set = BTreeSet::new(); + e.walk(&mut |x| { + if let Expr::Call(n, _) = x { + set.insert(n.clone()); + } + }); + set.into_iter().collect() +} + +fn is_volatile(e: &Expr, volatile_names: &HashSet) -> bool { + let mut v = false; + e.walk(&mut |x| match x { + Expr::Call(n, args) => { + let filename = n == "CELL" && matches!(args.first(), Some(Expr::Text(t)) if t.eq_ignore_ascii_case("filename")); + if filename || VOLATILE.contains(&n.as_str()) { + v = true; + } + } + Expr::Name(n) if volatile_names.contains(&n.to_ascii_uppercase()) => v = true, + _ => {} + }); + v +} + +/// Upper-cased defined names whose formula is volatile, directly or through other names. +pub(super) fn volatile_names(wb: &Workbook) -> HashSet { + let parsed: Vec<(String, Option)> = + wb.names.iter().map(|n| (n.name.to_ascii_uppercase(), gridcraft_engine::formula::parse(&n.formula).ok())).collect(); + let mut set = HashSet::new(); + // Each round adds at least one name or stops, so `len + 1` rounds reach the fixed point. + for _ in 0..=parsed.len() { + let before = set.len(); + for (name, e) in &parsed { + if !set.contains(name) && e.as_ref().is_some_and(|e| is_volatile(e, &set)) { + set.insert(name.clone()); + } + } + if set.len() == before { + break; + } + } + set +} + +/// The formula as the formula bar shows it. +pub(super) fn formula_text(cell: &Cell) -> String { + cell.bar_text() +} + +fn cell_name(wb: &Workbook, (sheet, c): Key) -> String { + let name = wb.sheet(sheet).map(|s| s.name.as_str()).unwrap_or("?"); + format!("{}!{}", quote_sheet(name), c.a1()) +} + +/// Whether the producer cached GridCraft's logical result as the number 1 or 0. Excel types a +/// logical result `t="b"`; a writer that leaves the type out (`TRUE()1`, as +/// Business Central's "Open in Excel" exports do) stores the same value, read back as a number. +pub fn bool_cached_as_number(expected: &Value, actual: &Value) -> bool { + matches!((expected, actual), (Value::Number(n), Value::Bool(b)) if *n == if *b { 1.0 } else { 0.0 }) +} + +/// Whether GridCraft's `actual` agrees with the producer's `expected`. +pub fn values_match(expected: &Value, actual: &Value, tolerance: f64) -> bool { + match (expected, actual) { + (Value::Number(a), Value::Number(b)) => (a - b).abs() <= tolerance * a.abs().max(b.abs()).max(1.0), + (Value::Text(a), Value::Text(b)) => a == b, + (Value::Bool(a), Value::Bool(b)) => a == b, + (Value::Error(a), Value::Error(b)) => a == b, + _ => false, + } +} + +fn type_name(v: &Value) -> &'static str { + match v { + Value::Empty => "blank", + Value::Number(_) => "number", + Value::Text(_) => "text", + Value::Bool(_) => "bool", + Value::Error(e) => e.as_str(), + Value::Array(_) => "array", + } +} + +fn mismatch_kind(expected: &Value, actual: &Value) -> &'static str { + match (expected, actual) { + (_, Value::Empty) => "blank", + (Value::Number(_), Value::Number(_)) => "numeric", + (Value::Text(_), Value::Text(_)) => "text", + (Value::Bool(_), Value::Bool(_)) => "bool", + (Value::Error(_), _) | (_, Value::Error(_)) => "error", + _ => "type", + } +} + +/// Mismatched cells indexed by (sheet, column) → rows, for precedent-area queries. +struct MismatchIndex { + cols: HashMap<(usize, u32), BTreeSet>, + /// Columns with mismatches per sheet (for very wide areas). + sheet_cols: HashMap>, +} + +impl MismatchIndex { + fn new(keys: &[Key]) -> MismatchIndex { + let mut cols: HashMap<(usize, u32), BTreeSet> = HashMap::new(); + let mut sheet_cols: HashMap> = HashMap::new(); + for &(s, c) in keys { + cols.entry((s, c.col)).or_default().insert(c.row); + sheet_cols.entry(s).or_default().insert(c.col); + } + MismatchIndex { cols, sheet_cols } + } + /// Whether a mismatched cell other than `own` lies in the area. + fn any_in(&self, sheet: usize, r0: u32, r1: u32, c0: u32, c1: u32, own: Key) -> bool { + let Some(cols) = self.sheet_cols.get(&sheet) else { return false }; + cols.range(c0..=c1) + .any(|&col| self.cols.get(&(sheet, col)).is_some_and(|rows| rows.range(r0..=r1).any(|&row| (sheet, CellRef::new(row, col)) != own))) + } +} + +/// Compares every formula cell of `cached` (as the producer saved it) with `fresh` (recalculated +/// by GridCraft). Without `trust_cache` nothing is compared (see [`CalcReport::stale_cache`]). +pub fn oracle(cached: &Workbook, fresh: &Workbook, calc: &Calc, trust_cache: bool, opts: &Options) -> CalcReport { + let mut r = CalcReport { stale_cache: !trust_cache, ..Default::default() }; + let vnames = volatile_names(cached); + let mut volatile: HashSet = HashSet::new(); + for (si, sh) in cached.sheets.iter().enumerate() { + for (c, cell) in sh.cells.iter() { + if let Some(f) = &cell.formula { + r.formula_cells += 1; + if f.expr_arc().is_some_and(|e| is_volatile(&e, &vnames)) { + volatile.insert((si, c)); + } + } + } + } + // Everything downstream of a volatile formula is volatile too; each key is pushed once. + let mut stack: Vec = volatile.iter().copied().collect(); + let mut deps = Vec::new(); + while let Some((s, c)) = stack.pop() { + deps.clear(); + calc.graph.dependents(s, c, &mut deps); + if let Some(range) = fresh.sheet(s).and_then(|sh| sh.spill_ranges.get(&c)) { + calc.graph.dependents_of_range(s, *range, &mut deps); + } + for k in deps.drain(..) { + if volatile.insert(k) { + stack.push(k); + } + } + } + + let mut bad: Vec<(Key, Vec)> = Vec::new(); + for (si, sh) in cached.sheets.iter().enumerate() { + for (c, cell) in sh.cells.iter() { + let Some(f) = &cell.formula else { continue }; + if volatile.contains(&(si, c)) { + r.volatile += 1; + continue; + } + if cell.value.is_empty() || !trust_cache { + r.no_oracle += 1; + continue; + } + r.checked += 1; + let actual = fresh.sheet(si).and_then(|s| s.cell(c)).map(|x| x.value.scalar()).unwrap_or_default(); + let funcs = match f.expr_arc() { + Some(e) => { + let names = functions_of(&e); + if names.is_empty() { vec!["(no function)".to_string()] } else { names } + } + None => vec!["(unparsed)".to_string()], + }; + let bool_as_number = bool_cached_as_number(&cell.value, &actual); + let ok = bool_as_number || values_match(&cell.value, &actual, opts.tolerance); + if bool_as_number { + r.bool_as_number += 1; + } + for n in &funcs { + let st = r.functions.entry(n.clone()).or_default(); + st.checked += 1; + if !ok { + st.mismatched += 1; + } + } + if ok { + r.matched += 1; + } else { + r.mismatched += 1; + *r.signatures.entry(format!("{} → {}", type_name(&cell.value), type_name(&actual))).or_default() += 1; + bad.push(((si, c), funcs)); + } + } + } + + // A mismatch is primary when none of its precedent cells mismatch. + let keys: Vec = bad.iter().map(|(k, _)| *k).collect(); + let index = MismatchIndex::new(&keys); + let mut listed: Vec<(bool, Key, Vec)> = Vec::with_capacity(bad.len()); + for (k, funcs) in bad { + let upstream = calc + .graph + .precedents_of(k) + .iter() + .any(|a| index.any_in(a.sheet, a.range.start.row, a.range.end.row, a.range.start.col, a.range.end.col, k)); + if !upstream { + for n in &funcs { + r.functions.entry(n.clone()).or_default().primary += 1; + } + } + listed.push((!upstream, k, funcs)); + } + // Primary mismatches first, each group in sheet then row-major order (already sorted). + listed.sort_by_key(|(primary, _, _)| !*primary); + for (primary, k, functions) in listed.into_iter().take(opts.max_report) { + let cell = cached.sheet(k.0).and_then(|s| s.cell(k.1)); + let expected = cell.map(|c| c.value.clone()).unwrap_or_default(); + let actual = fresh.sheet(k.0).and_then(|s| s.cell(k.1)).map(|x| x.value.scalar()).unwrap_or_default(); + r.mismatches.push(Mismatch { + cell: cell_name(cached, k), + formula: cell.map(formula_text).unwrap_or_default(), + kind: mismatch_kind(&expected, &actual), + expected, + actual, + functions, + primary, + }); + } + r +} diff --git a/apps/gridcraft-cli/src/check/fidelity.rs b/apps/gridcraft-cli/src/check/fidelity.rs new file mode 100644 index 0000000000..446a8949a9 --- /dev/null +++ b/apps/gridcraft-cli/src/check/fidelity.rs @@ -0,0 +1,185 @@ +use super::calc::formula_text; +use super::package::{REBUILT_KINDS, relationship_kinds, validate_package}; +use super::*; + +// ---------------------------------------------------------------- round trip + +/// Writes `wb` with GridCraft's XLSX writer, validates the package against itself and against +/// `source` (the file `wb` was read from), reads it back and compares. +pub fn round_trip(wb: &Workbook, source: &[u8], opts: &Options) -> RoundTrip { + let bytes = match gridcraft_engine::io::save_bytes(wb, "roundtrip.xlsx") { + Ok(b) => b, + Err(e) => return RoundTrip { error: Some(format!("write failed: {e}")), categories: vec![] }, + }; + let mut package = Diffs::new("package", opts.max_report); + match validate_package(&bytes) { + Ok(issues) => issues.into_iter().for_each(|i| package.push(|| i)), + Err(e) => package.push(|| e), + } + if let (Ok(before), Ok(after)) = (relationship_kinds(source), relationship_kinds(&bytes)) { + for (key, kind) in &before { + if !after.contains_key(key) && !REBUILT_KINDS.contains(&key.as_str()) { + package.push(|| format!("lost relationship {kind}")); + } + } + } + let back = match gridcraft_engine::io::open_bytes("roundtrip.xlsx", &bytes) { + Ok((b, _)) => b, + Err(e) => return RoundTrip { error: Some(format!("reading the written file failed: {e}")), categories: vec![package.done()] }, + }; + let mut categories = diff_workbooks(wb, &back, opts); + categories.push(package.done()); + RoundTrip { error: None, categories } +} + +pub(super) fn value_show(v: &Value) -> String { + match v { + Value::Empty => "(blank)".to_string(), + Value::Text(t) => serde_json::to_string(t.as_ref()).unwrap_or_default(), + Value::Number(n) => number_text(*n), + Value::Bool(b) => if *b { "TRUE" } else { "FALSE" }.to_string(), + Value::Error(e) => e.as_str().to_string(), + Value::Array(_) => v.scalar().display(), + } +} + +fn opt_formula(c: Option<&Cell>) -> String { + c.filter(|c| c.formula.is_some()).map(formula_text).unwrap_or_default() +} + +/// Per-sheet comparison of two lists, reporting what one side has and the other lacks. +fn diff_sets(d: &mut Diffs, sheet: &str, a: &BTreeSet, b: &BTreeSet) { + for x in a.difference(b) { + d.push(|| format!("{sheet}: lost {x}")); + } + for x in b.difference(a) { + d.push(|| format!("{sheet}: gained {x}")); + } +} + +/// A sheet's conditional-format rules in precedence order, as comparable text. The priority number +/// itself is left out, so renumbering without changing the order isn't a difference. +fn cond_format_list(s: &Sheet) -> Vec { + let mut cfs: Vec<_> = s.cond_formats.iter().collect(); + cfs.sort_by_key(|c| c.priority); + cfs.iter().map(|c| format!("{:?} {:?} stopIfTrue={}", c.ranges, c.rule, c.stop_if_true)).collect() +} + +fn diff_count(d: &mut Diffs, sheet: &str, a: usize, b: usize) { + if a != b { + d.push(|| format!("{sheet}: {a} → {b}")); + } +} + +/// A value as the file format can hold it: `#SPILL!`/`#CALC!` are cached as `#VALUE!` and an +/// unresolved circular reference as 0 (what Excel writes), so those aren't round-trip losses. +fn as_cached(v: Value) -> Value { + match v { + Value::Error(CellError::Spill | CellError::Calc) => Value::Error(CellError::Value), + Value::Error(CellError::Circ) => Value::Number(0.0), + v => v, + } +} + +fn num_fmt<'a>(wb: &'a Workbook, c: Option<&Cell>) -> &'a str { + wb.styles.get(c.map(|c| c.style).unwrap_or_default()).num_fmt.as_str() +} + +/// Feature-by-feature comparison of a workbook (`a`) with what reading it back gave (`b`). Sheets +/// are paired by position. +pub fn diff_workbooks(a: &Workbook, b: &Workbook, opts: &Options) -> Vec { + let m = opts.max_report; + let mut sheets = Diffs::new("sheets", m); + let (an, bn): (Vec<&str>, Vec<&str>) = (a.sheets.iter().map(|s| s.name.as_str()).collect(), b.sheets.iter().map(|s| s.name.as_str()).collect()); + if an != bn { + sheets.push(|| format!("{an:?} → {bn:?}")); + } + let mut values = Diffs::new("values", m); + let mut formulas = Diffs::new("formulas", m); + let mut num_fmts = Diffs::new("numFmts", m); + let mut merges = Diffs::new("merges", m); + let mut tables = Diffs::new("tables", m); + let mut cond_formats = Diffs::new("condFormats", m); + let mut validations = Diffs::new("validations", m); + let mut charts = Diffs::new("charts", m); + let mut comments = Diffs::new("comments", m); + let mut hyperlinks = Diffs::new("hyperlinks", m); + let mut pivots = Diffs::new("pivots", m); + for (sa, sb) in a.sheets.iter().zip(b.sheets.iter()) { + let name = quote_sheet(&sa.name); + let mut cell = |c: CellRef, ca: Option<&Cell>, cb: Option<&Cell>| { + let cached = |x: Option<&Cell>| as_cached(x.map(|x| x.value.scalar()).unwrap_or_default()); + let (va, vb) = (cached(ca), cached(cb)); + if va != vb { + values.push(|| format!("{name}!{}: {} → {}", c.a1(), value_show(&va), value_show(&vb))); + } + let (fa, fb) = (opt_formula(ca), opt_formula(cb)); + if fa != fb { + formulas.push(|| format!("{name}!{}: {fa} → {fb}", c.a1())); + } + let (na, nb) = (num_fmt(a, ca), num_fmt(b, cb)); + if na != nb { + num_fmts.push(|| format!("{name}!{}: {na} → {nb}", c.a1())); + } + }; + for (c, ca) in sa.cells.iter() { + cell(c, Some(ca), sb.cell(c)); + } + for (c, cb) in sb.cells.iter() { + if sa.cell(c).is_none() { + cell(c, None, Some(cb)); + } + } + let set = |f: &dyn Fn(&Sheet) -> Vec, s: &Sheet| f(s).into_iter().collect::>(); + let merge_list = |s: &Sheet| -> Vec { s.merges.iter().map(|r| r.a1()).collect() }; + diff_sets(&mut merges, &name, &set(&merge_list, sa), &set(&merge_list, sb)); + let table_list = |s: &Sheet| -> Vec { + s.tables + .iter() + .map(|t| format!("{} {} [{}]", t.name, t.range.a1(), t.columns.iter().map(|c| c.name.as_str()).collect::>().join(", "))) + .collect() + }; + diff_sets(&mut tables, &name, &set(&table_list, sa), &set(&table_list, sb)); + let (cfa, cfb) = (cond_format_list(sa), cond_format_list(sb)); + if cfa.len() != cfb.len() { + cond_formats.push(|| format!("{name}: {} → {}", cfa.len(), cfb.len())); + } else if let Some((x, y)) = cfa.iter().zip(&cfb).find(|(x, y)| x != y) { + cond_formats.push(|| format!("{name}: {x} → {y}")); + } + diff_count(&mut validations, &name, sa.validations.len(), sb.validations.len()); + let chart_list = |s: &Sheet| s.charts.iter().map(|c| format!("{:?} ({} series)", c.kind, c.series.len())).collect::>(); + let (ca, cb) = (chart_list(sa), chart_list(sb)); + if ca != cb { + charts.push(|| format!("{name}: {ca:?} → {cb:?}")); + } + diff_count(&mut comments, &name, sa.comments.len(), sb.comments.len()); + diff_count(&mut hyperlinks, &name, sa.hyperlinks.len(), sb.hyperlinks.len()); + diff_count(&mut pivots, &name, sa.pivots.len(), sb.pivots.len()); + } + let mut names = Diffs::new("names", m); + let name_list = |wb: &Workbook| -> BTreeSet { + wb.names + .iter() + .map(|n| { + let scope = n.scope.map(|i| format!("{}!", quote_sheet(wb.sheet(i).map(|s| s.name.as_str()).unwrap_or("?")))).unwrap_or_default(); + format!("{scope}{}={}", n.name, n.formula) + }) + .collect() + }; + diff_sets(&mut names, "workbook", &name_list(a), &name_list(b)); + vec![ + sheets.done(), + values.done(), + formulas.done(), + num_fmts.done(), + merges.done(), + names.done(), + tables.done(), + cond_formats.done(), + validations.done(), + charts.done(), + comments.done(), + hyperlinks.done(), + pivots.done(), + ] +} diff --git a/apps/gridcraft-cli/src/check/package.rs b/apps/gridcraft-cli/src/check/package.rs new file mode 100644 index 0000000000..bcbbef10fd --- /dev/null +++ b/apps/gridcraft-cli/src/check/package.rs @@ -0,0 +1,329 @@ +use super::*; + +/// Largest package part read while validating a written package. +const MAX_XML_PART: u64 = 64 * 1024 * 1024; + +/// Most zip entries looked at while validating a written package. +const MAX_ENTRIES: usize = 200_000; + +/// Relationship kinds (lower case) whose absence after a save isn't a loss: the calculation chain is +/// a cache Excel rebuilds, and GridCraft never writes it. +pub(super) const REBUILT_KINDS: [&str; 1] = ["calcchain"]; + +// ---------------------------------------------------------------- package validation + +fn local_name(name: &[u8]) -> &[u8] { + match name.iter().rposition(|&b| b == b':') { + Some(i) => name.get(i + 1..).unwrap_or(name), + None => name, + } +} + +/// `(element local name, attributes)` for every start/empty element of an XML part. +fn xml_elements(xml: &[u8]) -> Result)>, String> { + use quick_xml::events::Event; + let mut reader = quick_xml::Reader::from_reader(xml); + let mut buf = Vec::new(); + let mut out = Vec::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) | Ok(Event::Empty(e)) => { + let attrs = e + .attributes() + .with_checks(false) + .flatten() + .map(|a| { + let v = match a.normalized_value(quick_xml::XmlVersion::Explicit1_0) { + Ok(v) => v.into_owned(), + Err(_) => String::from_utf8_lossy(&a.value).into_owned(), + }; + (String::from_utf8_lossy(local_name(a.key.as_ref())).into_owned(), v) + }) + .collect(); + out.push((String::from_utf8_lossy(local_name(e.name().as_ref())).into_owned(), attrs)); + } + Ok(Event::Eof) => break, + Ok(_) => {} + Err(e) => return Err(format!("invalid XML: {e}")), + } + buf.clear(); + } + Ok(out) +} + +fn attr<'a>(attrs: &'a [(String, String)], k: &str) -> Option<&'a str> { + attrs.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str()) +} + +/// Lower-cased part name without a leading slash: OPC part names compare case-insensitively. +fn part_key(name: &str) -> String { + name.trim_start_matches('/').to_ascii_lowercase() +} + +/// `%XX` escapes in a relationship target. +fn percent_decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while let Some(&c) = b.get(i) { + let hex = |j: usize| b.get(j).and_then(|h| (*h as char).to_digit(16)); + match (c, hex(i + 1), hex(i + 2)) { + (b'%', Some(h), Some(l)) => { + out.push((h * 16 + l) as u8); + i += 3; + } + _ => { + out.push(c); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +/// Resolves a relationship target against the source part's directory. +fn resolve_target(source_dir: &str, target: &str) -> String { + let target = percent_decode(target.split('#').next().unwrap_or(target)); + let joined = match target.strip_prefix('/') { + Some(abs) => abs.to_string(), + None if source_dir.is_empty() => target, + None => format!("{source_dir}/{target}"), + }; + let mut parts: Vec<&str> = Vec::new(); + for seg in joined.split('/') { + match seg { + "" | "." => {} + ".." => { + parts.pop(); + } + s => parts.push(s), + } + } + parts.join("/") +} + +type Zip<'a> = zip::ZipArchive>; + +fn zip_names(zip: &mut Zip<'_>) -> Vec { + (0..zip.len().min(MAX_ENTRIES)).filter_map(|i| zip.by_index_raw(i).ok().filter(|f| !f.is_dir()).map(|f| f.name().to_string())).collect() +} + +fn read_zip_part(zip: &mut Zip<'_>, name: &str) -> Result, String> { + let f = zip.by_name(name).map_err(|e| format!("{name}: {e}"))?; + let mut buf = Vec::new(); + f.take(MAX_XML_PART).read_to_end(&mut buf).map_err(|e| format!("{name}: {e}"))?; + Ok(buf) +} + +/// Whether the producer flagged its cached results as stale with ``: +/// libraries that don't calculate write it, and store 0 or nothing as every formula's value. +pub(super) fn full_calc_on_load(bytes: &[u8]) -> bool { + let Ok(mut zip) = zip::ZipArchive::new(Cursor::new(bytes)) else { return false }; + let names = zip_names(&mut zip); + let find = |key: &str| names.iter().find(|n| part_key(n) == key).cloned(); + let mut workbook = "xl/workbook.xml".to_string(); + if let Some(rels) = find("_rels/.rels") + && let Ok(xml) = read_zip_part(&mut zip, &rels) + && let Ok(els) = xml_elements(&xml) + && let Some(target) = els + .iter() + .find(|(e, a)| e == "Relationship" && attr(a, "Type").is_some_and(|t| t.ends_with("/officeDocument"))) + .and_then(|(_, a)| attr(a, "Target")) + { + workbook = resolve_target("", target); + } + let Some(name) = find(&part_key(&workbook)) else { return false }; + let Ok(xml) = read_zip_part(&mut zip, &name) else { return false }; + xml_elements(&xml) + .ok() + .into_iter() + .flatten() + .any(|(e, a)| e == "calcPr" && attr(&a, "fullCalcOnLoad").is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))) +} + +/// Problems Excel would repair in a written package: parts without a content type, content-type +/// overrides for missing parts, relationship files for missing parts, duplicate relationship ids +/// and internal relationships pointing at missing parts. `Err` when the package can't be read. +pub fn validate_package(bytes: &[u8]) -> Result, 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 parts: HashSet = names.iter().map(|n| part_key(n)).collect(); + let mut read_part = |name: &str| read_zip_part(&mut zip, name); + let mut issues = Vec::new(); + let ct_name = names.iter().find(|n| part_key(n) == "[content_types].xml").cloned(); + let (mut defaults, mut overrides) = (HashSet::new(), HashSet::new()); + match ct_name { + None => issues.push("no [Content_Types].xml".to_string()), + Some(n) => { + for (el, attrs) in xml_elements(&read_part(&n)?).map_err(|e| format!("[Content_Types].xml: {e}"))? { + match el.as_str() { + "Default" => { + if let Some(x) = attr(&attrs, "Extension") { + defaults.insert(x.to_ascii_lowercase()); + } + } + "Override" => { + if let Some(p) = attr(&attrs, "PartName") { + overrides.insert(part_key(p)); + } + } + _ => {} + } + } + } + } + let mut sorted: Vec<&String> = names.iter().collect(); + sorted.sort(); + for n in &sorted { + let key = part_key(n); + if key == "[content_types].xml" { + continue; + } + let ext = key.rsplit('/').next().and_then(|f| f.rsplit_once('.')).map(|(_, e)| e.to_string()); + if !overrides.contains(&key) && !ext.is_some_and(|e| defaults.contains(&e)) { + issues.push(format!("part {n} has no content type")); + } + } + let mut missing_overrides: Vec<&String> = overrides.iter().filter(|p| !parts.contains(*p)).collect(); + missing_overrides.sort(); + for p in missing_overrides { + issues.push(format!("content type override for missing part /{p}")); + } + for n in sorted { + let key = part_key(n); + let Some(rels_file) = key.strip_suffix(".rels") else { continue }; + // `dir/_rels/name.rels` describes `dir/name`; `_rels/.rels` the package itself. + let (dir, file) = match rels_file.rsplit_once('/') { + Some((d, f)) => (d, f), + None => continue, + }; + let Some(src_dir) = dir.strip_suffix("_rels").map(|d| d.trim_end_matches('/')) else { continue }; + let source = if src_dir.is_empty() { file.to_string() } else { format!("{src_dir}/{file}") }; + if !source.is_empty() && !parts.contains(&source) { + issues.push(format!("{n} describes missing part {source}")); + } + let mut ids = HashSet::new(); + for (el, attrs) in xml_elements(&read_part(n)?).map_err(|e| format!("{n}: {e}"))? { + if el != "Relationship" { + continue; + } + let id = attr(&attrs, "Id").unwrap_or(""); + if !ids.insert(id.to_string()) { + issues.push(format!("{n}: duplicate relationship id {id}")); + } + if attr(&attrs, "TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External")) { + continue; + } + let target = attr(&attrs, "Target").unwrap_or(""); + let resolved = resolve_target(src_dir, target); + if !parts.contains(&resolved.to_ascii_lowercase()) { + let kind = attr(&attrs, "Type").and_then(|t| t.rsplit('/').next()).unwrap_or("?"); + issues.push(format!("{n}: {id} ({kind}) targets missing part {resolved}")); + } + } + } + Ok(issues) +} + +/// Every relationship kind (last segment of the type, the same in the transitional and strict +/// namespaces) in the package's relationship parts, keyed in lower case. Relationships that carry +/// nothing are left out, as a save may rightly drop them: a link to a place in the workbook +/// (`#Sheet1!A1`, written as a cell's `location`) and a target part with nothing in it. +pub(super) fn relationship_kinds(bytes: &[u8]) -> Result, 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 = 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 }; + let Ok(els) = read_zip_part(&mut zip, n).and_then(|xml| xml_elements(&xml)) else { continue }; + for (_, attrs) in els.iter().filter(|(el, _)| el == "Relationship") { + let Some(kind) = attr(attrs, "Type").and_then(|t| t.rsplit('/').next()) else { continue }; + let target = attr(attrs, "Target").unwrap_or(""); + if kind.eq_ignore_ascii_case("hyperlink") && target.starts_with('#') { + continue; + } + 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, &mut verdicts, &mut budget) + { + continue; + } + kinds.entry(kind.to_ascii_lowercase()).or_insert_with(|| kind.to_string()); + } + } + Ok(kinds) +} + +/// The directory of the part a rels part describes: `xl` for `xl/_rels/workbook.xml.rels`, the +/// empty string for the package's `_rels/.rels`. +fn rels_source_dir(key: &str) -> Option { + let (dir, _) = key.strip_suffix(".rels")?.rsplit_once('/')?; + Some(dir.strip_suffix("_rels")?.trim_end_matches('/').to_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. +/// `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 { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) | Ok(Event::Empty(e)) => { + let found = match wanted { + Some(w) => local_name(e.name().as_ref()) == w, + None => in_root, + }; + if found { + return true; + } + in_root = true; + } + Ok(Event::Eof) => return false, + Ok(_) => {} + Err(_) => return true, + } + buf.clear(); + } +} diff --git a/apps/gridcraft-cli/src/check/report.rs b/apps/gridcraft-cli/src/check/report.rs new file mode 100644 index 0000000000..54227e7970 --- /dev/null +++ b/apps/gridcraft-cli/src/check/report.rs @@ -0,0 +1,264 @@ +use super::fidelity::value_show; +use super::*; + +// ---------------------------------------------------------------- output + +#[derive(Default)] +struct Totals { + files: usize, + opened: usize, + failed: usize, + formula_cells: usize, + checked: usize, + matched: usize, + mismatched: usize, + no_oracle: usize, + volatile: usize, + bool_as_number: usize, + round_trip_failed: usize, + round_trip_differing: usize, + categories: BTreeMap<&'static str, usize>, + functions: BTreeMap, + signatures: BTreeMap, +} + +fn totals(reports: &[FileReport]) -> Totals { + let mut t = Totals { files: reports.len(), ..Default::default() }; + for r in reports { + if r.error.is_some() { + t.failed += 1; + continue; + } + t.opened += 1; + t.formula_cells += r.calc.formula_cells; + t.checked += r.calc.checked; + t.matched += r.calc.matched; + t.mismatched += r.calc.mismatched; + t.no_oracle += r.calc.no_oracle; + t.volatile += r.calc.volatile; + t.bool_as_number += r.calc.bool_as_number; + if r.round_trip.error.is_some() { + t.round_trip_failed += 1; + } + if !r.round_trip.is_clean() { + t.round_trip_differing += 1; + } + for c in r.round_trip.differing() { + *t.categories.entry(c).or_default() += 1; + } + for (n, s) in &r.calc.functions { + t.functions.entry(n.clone()).or_default().add(s); + } + for (sig, n) in &r.calc.signatures { + *t.signatures.entry(sig.clone()).or_default() += n; + } + } + t +} + +/// Functions with mismatches, worst first: most primary mismatches, then most mismatches. +fn ranked(functions: &BTreeMap) -> Vec<(&String, &FnStat)> { + let mut v: Vec<(&String, &FnStat)> = functions.iter().filter(|(_, s)| s.mismatched > 0).collect(); + v.sort_by(|a, b| b.1.primary.cmp(&a.1.primary).then(b.1.mismatched.cmp(&a.1.mismatched)).then(a.0.cmp(b.0))); + v +} + +fn functions_json(functions: &BTreeMap) -> Json { + let mut v: Vec<(&String, &FnStat)> = functions.iter().collect(); + v.sort_by(|a, b| b.1.primary.cmp(&a.1.primary).then(b.1.mismatched.cmp(&a.1.mismatched)).then(a.0.cmp(b.0))); + Json::Array(v.into_iter().map(|(n, s)| json!({"name": n, "checked": s.checked, "mismatched": s.mismatched, "primary": s.primary})).collect()) +} + +pub fn report_json(reports: &[FileReport], opts: &Options) -> Json { + let files: Vec = reports + .iter() + .map(|r| { + let c = &r.calc; + let mismatches: Vec = c + .mismatches + .iter() + .map(|m| { + json!({ + "cell": m.cell, "formula": m.formula, "expected": value_json(&m.expected), "actual": value_json(&m.actual), + "kind": m.kind, "functions": m.functions, "primary": m.primary, + }) + }) + .collect(); + let categories: serde_json::Map = r + .round_trip + .categories + .iter() + .map(|cat| (cat.name.to_string(), json!({"equal": cat.diffs == 0, "diffs": cat.diffs, "examples": cat.examples}))) + .collect(); + json!({ + "path": r.path, + "opened": r.error.is_none(), + "error": r.error, + "warnings": r.warnings, + "calc": { + "formulaCells": c.formula_cells, "checked": c.checked, "matched": c.matched, "mismatched": c.mismatched, + "noOracle": c.no_oracle, "staleCache": c.stale_cache, "volatile": c.volatile, "boolAsNumber": c.bool_as_number, + "mismatches": mismatches, "functions": functions_json(&c.functions), + "signatures": c.signatures, + }, + "roundTrip": {"error": r.round_trip.error, "differing": r.round_trip.differing(), "categories": categories}, + }) + }) + .collect(); + let t = totals(reports); + json!({ + "schema": SCHEMA, + "tolerance": opts.tolerance, + "files": files, + "totals": { + "files": t.files, "opened": t.opened, "failedToOpen": t.failed, "formulaCells": t.formula_cells, "checked": t.checked, + "matched": t.matched, "mismatched": t.mismatched, "noOracle": t.no_oracle, "volatile": t.volatile, "boolAsNumber": t.bool_as_number, + "roundTripFailed": t.round_trip_failed, "roundTripDiffering": t.round_trip_differing, "categoriesDiffering": t.categories, + }, + "functions": functions_json(&t.functions), + "signatures": t.signatures, + }) +} + +/// One report line: line breaks and tabs shown escaped so that they stay visible as differences. +fn one_line(s: &str) -> String { + s.replace('\n', "\\n").replace('\r', "\\r").replace('\t', "\\t") +} + +/// `…tail` of a long label. +fn fit(s: &str, w: usize) -> String { + let n = s.chars().count(); + if n <= w { s.to_string() } else { format!("…{}", s.chars().skip(n + 1 - w).collect::()) } +} + +pub(super) fn print_human(reports: &[FileReport], opts: &Options) { + for r in reports { + outln!("== {}", r.path); + if let Some(e) = &r.error { + outln!(" can't open: {e}"); + continue; + } + let c = &r.calc; + outln!( + " formulas {}: {} checked, {} matched, {} mismatched, {} no oracle, {} volatile", + c.formula_cells, + c.checked, + c.matched, + c.mismatched, + c.no_oracle, + c.volatile + ); + if c.stale_cache { + outln!(" (the producer asked for a full recalculation on load: its cached values aren't results)"); + } + if c.bool_as_number > 0 { + outln!(" ({} matched TRUE/FALSE results were saved by the producer as 1/0, without t=\"b\")", c.bool_as_number); + } + for m in &c.mismatches { + let tag = if m.primary { "mismatch" } else { " (downstream)" }; + outln!( + " {tag} {} {} expected {} got {} [{}]", + m.cell, + one_line(&m.formula), + value_show(&m.expected), + value_show(&m.actual), + m.functions.join(" ") + ); + } + if c.mismatched > c.mismatches.len() { + outln!(" … {} more mismatches", c.mismatched - c.mismatches.len()); + } + let rt = &r.round_trip; + if let Some(e) = &rt.error { + outln!(" round trip: {e}"); + } + let differing: Vec<&Category> = rt.categories.iter().filter(|c| c.diffs > 0).collect(); + if differing.is_empty() && rt.error.is_none() { + outln!(" round trip: all equal"); + } + for cat in differing { + outln!(" round trip {}: {} difference(s)", cat.name, cat.diffs); + for e in &cat.examples { + outln!(" {}", one_line(e)); + } + if cat.diffs > cat.examples.len() { + outln!(" … {} more", cat.diffs - cat.examples.len()); + } + } + } + outln!(""); + let w = reports.iter().map(|r| r.path.chars().count()).max().unwrap_or(4).clamp(4, 50); + outln!( + "{:8} {:>8} {:>8} {:>8} {:>9} {:>8} round trip", + "file", + "formulas", + "checked", + "matched", + "mismatch", + "no-oracle", + "volatile" + ); + for r in reports { + let label = fit(&r.path, w); + if let Some(e) = &r.error { + outln!("{label: "FAILED".to_string(), + (None, d) if d.is_empty() => "ok".to_string(), + (None, d) => d.join(","), + }; + outln!( + "{label:8} {:>8} {:>8} {:>8} {:>9} {:>8} {rt}", + c.formula_cells, + c.checked, + c.matched, + c.mismatched, + c.no_oracle, + c.volatile + ); + } + let t = totals(reports); + let top = ranked(&t.functions); + if !top.is_empty() { + outln!(""); + outln!("top mismatching functions (primary = no mismatching precedent):"); + outln!(" {:<20} {:>8} {:>10} {:>8}", "function", "primary", "mismatched", "checked"); + for (n, s) in top.iter().take(opts.max_report.max(1)) { + outln!(" {:<20} {:>8} {:>10} {:>8}", n, s.primary, s.mismatched, s.checked); + } + } + let mut sigs: Vec<(&String, &usize)> = t.signatures.iter().collect(); + sigs.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0))); + if !sigs.is_empty() { + outln!(""); + outln!("mismatch signatures (expected → GridCraft):"); + for (sig, n) in sigs.iter().take(opts.max_report.max(1)) { + outln!(" {n:>8} {sig}"); + } + } + outln!(""); + outln!("files: {} ({} opened, {} failed to open)", t.files, t.opened, t.failed); + outln!( + "formula cells: {} ({} checked: {} matched, {} mismatched; {} no oracle; {} volatile)", + t.formula_cells, + t.checked, + t.matched, + t.mismatched, + t.no_oracle, + t.volatile + ); + if t.bool_as_number > 0 { + outln!("booleans saved as 1/0 by the producer: {} (counted as matched)", t.bool_as_number); + } + let cats: Vec = t.categories.iter().map(|(c, n)| format!("{c} {n}")).collect(); + outln!( + "round trip: {} of {} files differ{}{}", + t.round_trip_differing, + t.opened, + if t.round_trip_failed > 0 { format!(" ({} failed)", t.round_trip_failed) } else { String::new() }, + if cats.is_empty() { String::new() } else { format!("; categories (files): {}", cats.join(", ")) } + ); +} diff --git a/apps/gridcraft-cli/src/check/tests.rs b/apps/gridcraft-cli/src/check/tests.rs new file mode 100644 index 0000000000..68c6f8fb58 --- /dev/null +++ b/apps/gridcraft-cli/src/check/tests.rs @@ -0,0 +1,450 @@ +#![allow(clippy::unwrap_used)] +use std::io::Write as _; +use std::sync::Arc; + +use gridcraft_engine::model::Formula; + +use super::*; + +#[test] +fn discovery_reports_a_directory_read_error() { + 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(); + 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()); + assert!(discovery.failures[0].error.as_ref().unwrap().contains("cannot read directory")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn discovery_reports_omissions_only_after_the_file_limit() { + let mut discovery = Discovery { files: vec![PathBuf::from("book.xlsx"); MAX_FILES - 1], ..Default::default() }; + assert!(discovery.add_file(PathBuf::from("last.xlsx"))); + assert!(discovery.failures.is_empty()); + assert!(!discovery.add_file(PathBuf::from("omitted.xlsx"))); + assert_eq!(discovery.files.len(), MAX_FILES); + assert_eq!(discovery.failures[0].path, "omitted.xlsx"); + assert!(discovery.failures[0].error.as_ref().unwrap().contains("file limit")); +} + +fn at(a1: &str) -> CellRef { + CellRef::parse(a1).unwrap() +} + +/// A workbook whose cells are `(A1, input, cached value)`: an input starting with `=` is a +/// formula with that cached value, anything else a constant. +fn book(cells: &[(&str, &str, Value)]) -> Workbook { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + for (a1, input, cached) in cells { + let cell = if input.starts_with('=') { + Cell { value: cached.clone(), formula: Some(Arc::new(Formula::new(input))), ..Default::default() } + } else { + Cell::value(cached.clone()) + }; + sh.set_cell(at(a1), cell); + } + wb +} + +fn check(wb: &Workbook, opts: &Options) -> FileReport { + let bytes = gridcraft_engine::io::save_bytes(wb, "t.xlsx").unwrap(); + check_bytes("t.xlsx", &bytes, opts) +} + +fn n(x: f64) -> Value { + Value::number(x) +} + +#[test] +fn matching_cached_values_pass() { + let wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("C1", "=A1&\"x\"", Value::text("2x")), ("D1", "=A1>1", Value::Bool(true))]); + let r = check(&wb, &Options::default()); + assert_eq!(r.error, None); + assert_eq!((r.calc.formula_cells, r.calc.checked, r.calc.matched, r.calc.mismatched), (3, 3, 3, 0)); + assert!(r.is_clean(), "{r:?}"); +} + +#[test] +fn wrong_cached_value_is_a_mismatch_and_ranks_its_function() { + // Excel "said" SUM gives 7; GridCraft computes 6. C1 reads B1 and is consistent with + // Excel's 7, so it mismatches too, but only as a downstream effect. + let wb = book(&[("A1", "2", n(2.0)), ("A2", "4", n(4.0)), ("B1", "=SUM(A1:A2)", n(7.0)), ("C1", "=B1+1", n(8.0))]); + let r = check(&wb, &Options::default()); + assert_eq!((r.calc.checked, r.calc.mismatched), (2, 2)); + let first = &r.calc.mismatches[0]; + assert_eq!(first.cell, "Sheet1!B1"); + assert_eq!(first.formula, "=SUM(A1:A2)"); + assert_eq!((first.expected.clone(), first.actual.clone(), first.kind, first.primary), (n(7.0), n(6.0), "numeric", true)); + assert!(!r.calc.mismatches[1].primary); + assert_eq!(r.calc.functions["SUM"], FnStat { checked: 1, mismatched: 1, primary: 1 }); + assert_eq!(r.calc.functions["(no function)"], FnStat { checked: 1, mismatched: 1, primary: 0 }); + assert!(!r.is_clean()); +} + +#[test] +fn errors_compare_by_kind() { + let wb = book(&[("A1", "=1/0", Value::Error(CellError::Div0)), ("A2", "=1/0", Value::Error(CellError::NA)), ("A3", "=1/0", n(0.0))]); + let r = check(&wb, &Options::default()); + assert_eq!((r.calc.matched, r.calc.mismatched), (1, 2)); + assert!(r.calc.mismatches.iter().all(|m| m.kind == "error")); + assert_eq!(r.calc.signatures.get("#N/A → #DIV/0!"), Some(&1)); + assert_eq!(r.calc.signatures.get("number → #DIV/0!"), Some(&1)); + assert!(values_match(&Value::Error(CellError::NA), &Value::Error(CellError::NA), 0.0)); + assert!(!values_match(&Value::Error(CellError::NA), &Value::Error(CellError::Value), 0.0)); +} + +#[test] +fn numbers_compare_within_tolerance() { + assert!(values_match(&n(3.0000000001), &n(3.0), 1e-9)); + assert!(!values_match(&n(3.0000000001), &n(3.0), 1e-12)); + // Absolute near zero. + assert!(values_match(&n(1e-12), &n(0.0), 1e-9)); + assert!(values_match(&n(1e12 + 1.0), &n(1e12), 1e-9)); + assert!(!values_match(&n(0.0), &Value::Empty, 1.0)); + assert!(!values_match(&Value::text("1"), &n(1.0), 1.0)); + assert!(values_match(&Value::text("a"), &Value::text("a"), 0.0)); + assert!(!values_match(&Value::text("a"), &Value::text("A"), 0.0)); + assert!(!values_match(&Value::Bool(true), &n(1.0), 1.0)); + let wb = book(&[("A1", "=1+2", n(3.0000000001))]); + assert_eq!(check(&wb, &Options::default()).calc.matched, 1); + assert_eq!(check(&wb, &Options { tolerance: 1e-12, ..Default::default() }).calc.mismatched, 1); +} + +/// Business Central writes TRUE and FALSE as `TRUE()1`, without `t="b"`: +/// the cache holds the right value under a number type, so it matches and is counted apart. +#[test] +fn booleans_cached_as_numbers_match_and_are_counted_apart() { + let wb = book(&[ + ("A1", "=TRUE()", n(1.0)), + ("B1", "=FALSE()", n(0.0)), + ("C1", "=TRUE()", Value::Bool(true)), + ("D1", "=1=1", n(0.0)), + ("E1", "=TRUE()+0", n(1.0)), + ]); + let r = check(&wb, &Options::default()); + assert_eq!((r.calc.checked, r.calc.matched, r.calc.mismatched), (5, 4, 1)); + // A cached 0 for TRUE is still a mismatch, and a number result is compared as a number. + assert_eq!(r.calc.mismatches[0].cell, "Sheet1!D1"); + assert_eq!(r.calc.signatures.get("number → bool"), Some(&1)); + assert_eq!(r.calc.bool_as_number, 2); + let v = report_json(std::slice::from_ref(&r), &Options::default()); + assert_eq!((v["files"][0]["calc"]["boolAsNumber"].clone(), v["totals"]["boolAsNumber"].clone()), (json!(2), json!(2))); + assert!(bool_cached_as_number(&n(1.0), &Value::Bool(true))); + assert!(!bool_cached_as_number(&n(1.0), &Value::Bool(false))); + assert!(!bool_cached_as_number(&n(2.0), &Value::Bool(true))); + assert!(!bool_cached_as_number(&Value::Bool(true), &n(1.0))); +} + +#[test] +fn missing_cached_values_and_volatile_formulas_are_skipped() { + let wb = + book(&[("A1", "=1+1", Value::Empty), ("B1", "=NOW()", n(1.0)), ("B2", "=B1+1", n(2.0)), ("B3", "=CELL(\"filename\")", Value::text("x"))]); + let r = check(&wb, &Options::default()); + assert_eq!((r.calc.formula_cells, r.calc.checked, r.calc.no_oracle, r.calc.volatile), (4, 0, 1, 3)); +} + +#[test] +fn full_calc_on_load_means_no_oracle() { + // A formula without a cached value makes GridCraft's writer flag the file + // `fullCalcOnLoad`, like libraries that store 0 for every formula: B1's wrong "cached" + // value must not count as a mismatch then. + let wb = book(&[("A1", "=1+1", Value::Empty), ("B1", "=2+2", n(0.0))]); + let bytes = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap(); + assert!(full_calc_on_load(&bytes)); + let r = check_bytes("t.xlsx", &bytes, &Options::default()); + assert!(r.calc.stale_cache); + assert_eq!((r.calc.checked, r.calc.no_oracle, r.calc.mismatched), (0, 2, 0)); + let fresh = gridcraft_engine::io::save_bytes(&book(&[("B1", "=2+2", n(0.0))]), "t.xlsx").unwrap(); + assert!(!full_calc_on_load(&fresh)); + assert!(!full_calc_on_load(b"not a zip")); +} + +#[test] +fn volatile_names_propagate() { + let mut wb = book(&[("A1", "=Stamp+1", n(1.0))]); + wb.names.push(gridcraft_engine::model::DefinedName { + name: "Stamp".into(), + scope: None, + formula: "TODAY()".into(), + comment: String::new(), + hidden: false, + }); + assert!(volatile_names(&wb).contains("STAMP")); + assert_eq!(check(&wb, &Options::default()).calc.volatile, 1); +} + +#[test] +fn round_trip_of_a_simple_book_is_clean() { + let mut wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("A2", "x", Value::text("x"))]); + wb.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap()); + let source = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap(); + let rt = round_trip(&wb, &source, &Options::default()); + assert_eq!(rt.error, None); + assert!(rt.is_clean(), "{rt:?}"); + assert!(rt.categories.iter().any(|c| c.name == "package")); +} + +#[test] +fn diff_detects_lost_features() { + let mut a = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0))]); + a.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap()); + let mut b = a.clone(); + let sh = b.sheet_mut(0).unwrap(); + sh.merges.clear(); + sh.set_cell(at("A1"), Cell::value(n(3.0))); + sh.set_cell(at("B1"), Cell::value(n(6.0))); + b.sheets.push(Arc::new(Sheet::new("Extra"))); + let cats = diff_workbooks(&a, &b, &Options { max_report: 1, ..Default::default() }); + let get = |name: &str| cats.iter().find(|c| c.name == name).unwrap(); + assert_eq!(get("values").diffs, 1); + assert_eq!(get("values").examples, ["Sheet1!A1: 2 → 3"]); + assert_eq!(get("formulas").examples, ["Sheet1!B1: =A1*3 → "]); + assert_eq!(get("merges").examples, ["Sheet1: lost C1:D2"]); + assert_eq!(get("sheets").diffs, 1); + assert_eq!(get("tables").diffs, 0); +} + +#[test] +fn diff_detects_changed_cond_format_rule() { + use gridcraft_engine::model::{CfRule, CfValueKind, Color, CondFormat}; + let bar = |gradient: bool, priority: u32| CondFormat { + ranges: vec![gridcraft_engine::core::RangeRef::parse("A1").unwrap()], + rule: CfRule::DataBar { min: CfValueKind::Min, max: CfValueKind::Max, color: Color::Rgb(0x638EC6), gradient, show_value: true }, + priority, + stop_if_true: false, + }; + let with = |cf: CondFormat| { + let mut wb = book(&[("A1", "2", n(2.0))]); + wb.sheet_mut(0).unwrap().cond_formats.push(cf); + wb + }; + let diffs = |a: &Workbook, b: &Workbook| diff_workbooks(a, b, &Options::default()).into_iter().find(|c| c.name == "condFormats").unwrap().diffs; + assert_eq!(diffs(&with(bar(false, 1)), &with(bar(true, 1))), 1); + assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 1))), 0); + assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 5))), 0); +} + +fn zip_of(parts: &[(&str, &str)]) -> Vec { + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + for (name, body) in parts { + z.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(body.as_bytes()).unwrap(); + } + z.finish().unwrap().into_inner() +} + +#[test] +fn package_validation_finds_what_excel_would_repair() { + let bytes = zip_of(&[ + ( + "[Content_Types].xml", + r#""#, + ), + ("_rels/.rels", r#""#), + ("xl/workbook.xml", ""), + ("xl/styles.xml", ""), + ( + "xl/_rels/workbook.xml.rels", + r#""#, + ), + ]); + let issues = validate_package(&bytes).unwrap(); + assert_eq!( + issues, + [ + "part xl/styles.xml has no content type", + "content type override for missing part /xl/gone.xml", + "xl/_rels/workbook.xml.rels: rId2 (worksheet) targets missing part xl/worksheets/sheet1.xml", + "xl/_rels/workbook.xml.rels: duplicate relationship id rId2", + ] + ); + assert!(validate_package(b"not a zip").is_err()); + let own = gridcraft_engine::io::save_bytes(&book(&[("A1", "=1", n(1.0))]), "t.xlsx").unwrap(); + assert_eq!(validate_package(&own).unwrap(), Vec::::new()); +} + +const WB_RELS: &str = "xl/_rels/workbook.xml.rels"; +const SHEET_RELS: &str = "xl/worksheets/_rels/sheet1.xml.rels"; +const MADE_UP: &str = r#""#; +/// Not empty: an empty part isn't a loss. +const MADE_UP_PART: (&str, &str) = ("xl/madeUp.xml", ""); + +/// `bytes` with relationships added to rels parts (created when missing) and `parts` added. +fn with_rels(bytes: &[u8], rels: &[(&str, &str)], parts: &[(&str, &str)]) -> Vec { + let mut src = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let mut pending: Vec<(&str, &str)> = rels.to_vec(); + for i in 0..src.len() { + let mut f = src.by_index(i).unwrap(); + let name = f.name().to_string(); + let mut body = String::new(); + f.read_to_string(&mut body).unwrap(); + if let Some(at) = pending.iter().position(|(r, _)| *r == name) { + body = body.replace("", &format!("{}", pending.remove(at).1)); + } + out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(body.as_bytes()).unwrap(); + } + let created = pending.iter().map(|(r, extra)| { + (*r, format!(r#"{extra}"#)) + }); + for (name, body) in created.collect::>().iter().map(|(n, b)| (*n, b.as_str())).chain(parts.iter().copied()) { + out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(body.as_bytes()).unwrap(); + } + out.finish().unwrap().into_inner() +} + +/// The `package` examples of checking `source`. +fn package_of(source: &[u8]) -> Vec { + let r = check_bytes("t.xlsx", source, &Options::default()); + r.round_trip.categories.iter().find(|c| c.name == "package").unwrap().examples.clone() +} + +fn plain() -> Vec { + gridcraft_engine::io::save_bytes(&book(&[("A1", "1", n(1.0))]), "t.xlsx").unwrap() +} + +#[test] +fn package_reports_relationship_kinds_the_output_lost() { + // A kind GridCraft doesn't know is dropped on save; a calculation chain is never written + // (Excel rebuilds it), so its absence isn't a loss. + let calc_chain = + r#""#; + let source = with_rels(&plain(), &[(WB_RELS, &format!("{MADE_UP}{calc_chain}"))], &[MADE_UP_PART, ("xl/calcChain.xml", "")]); + assert_eq!(package_of(&source), ["lost relationship madeUp"]); + assert!(check_bytes("t.xlsx", &plain(), &Options::default()).round_trip.is_clean()); +} + +// In the three tests below the made-up kind stays lost, so the comparison is known to run. + +#[test] +fn an_internal_link_written_as_a_relationship_is_not_a_loss() { + // openpyxl writes `#Sheet1!B2` as an external relationship; GridCraft writes `location`. + let link = r##""##; + let source = with_rels(&plain(), &[(WB_RELS, MADE_UP), (SHEET_RELS, link)], &[MADE_UP_PART]); + assert_eq!(package_of(&source), ["lost relationship madeUp"]); +} + +#[test] +fn comments_without_a_comment_are_not_a_loss() { + let rels = r#""#; + let comments = r#"A"#; + let vml = r#""#; + let source = with_rels( + &plain(), + &[(WB_RELS, MADE_UP), (SHEET_RELS, rels)], + &[MADE_UP_PART, ("xl/comments1.xml", comments), ("xl/drawings/vmlDrawing1.vml", vml)], + ); + assert_eq!(package_of(&source), ["lost relationship madeUp"]); +} + +#[test] +fn empty_drawings_and_person_lists_are_not_a_loss() { + let drawing = r#""#; + let person = r#""#; + let source = with_rels( + &plain(), + &[(WB_RELS, &format!("{MADE_UP}{person}")), (SHEET_RELS, drawing)], + &[ + MADE_UP_PART, + ("xl/drawings/drawing1.xml", r#""#), + ("xl/persons/person.xml", r#""#), + ], + ); + assert_eq!(package_of(&source), ["lost relationship madeUp"]); +} + +#[test] +fn json_lists_warning_messages() { + let with = FileReport { path: "a.ods".into(), warnings: vec!["ODS data-only import".into()], ..Default::default() }; + let without = FileReport { path: "b.xlsx".into(), ..Default::default() }; + let v = report_json(&[with, without], &Options::default()); + assert_eq!(v["files"][0]["warnings"], json!(["ODS data-only import"])); + assert_eq!(v["files"][1]["warnings"], json!([])); +} + +#[test] +fn unopenable_files_are_reported() { + let r = check_bytes("broken.xlsx", b"PK\x03\x04 not really", &Options::default()); + assert!(r.error.is_some()); + assert!(!r.is_clean()); +} + +#[test] +fn json_schema_keys() { + let wb = book(&[("A1", "=1+1", n(3.0))]); + let reports = vec![check(&wb, &Options::default()), check_bytes("bad.xlsx", b"", &Options::default())]; + let v = report_json(&reports, &Options::default()); + let keys = |v: &Json| v.as_object().unwrap().keys().cloned().collect::>(); + let set = |k: &[&str]| k.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(keys(&v), set(&["schema", "tolerance", "files", "totals", "functions", "signatures"])); + assert_eq!(v["schema"], SCHEMA); + let f = &v["files"][0]; + assert_eq!(keys(f), set(&["path", "opened", "error", "warnings", "calc", "roundTrip"])); + assert_eq!( + keys(&f["calc"]), + set(&[ + "formulaCells", + "checked", + "matched", + "mismatched", + "noOracle", + "staleCache", + "volatile", + "boolAsNumber", + "mismatches", + "functions", + "signatures" + ]) + ); + assert_eq!(f["calc"]["signatures"], json!({"number → number": 1})); + assert_eq!(keys(&f["calc"]["mismatches"][0]), set(&["cell", "formula", "expected", "actual", "kind", "functions", "primary"])); + assert_eq!(f["calc"]["mismatches"][0]["expected"], json!(3.0)); + assert_eq!(keys(&f["roundTrip"]), set(&["error", "differing", "categories"])); + assert_eq!( + keys(&f["roundTrip"]["categories"]), + set(&[ + "sheets", + "values", + "formulas", + "numFmts", + "merges", + "names", + "tables", + "condFormats", + "validations", + "charts", + "comments", + "hyperlinks", + "pivots", + "package" + ]) + ); + assert_eq!(keys(&f["roundTrip"]["categories"]["values"]), set(&["equal", "diffs", "examples"])); + assert_eq!(v["files"][1]["opened"], json!(false)); + assert_eq!( + keys(&v["totals"]), + set(&[ + "files", + "opened", + "failedToOpen", + "formulaCells", + "checked", + "matched", + "mismatched", + "noOracle", + "volatile", + "boolAsNumber", + "roundTripFailed", + "roundTripDiffering", + "categoriesDiffering" + ]) + ); + assert_eq!(keys(&v["functions"][0]), set(&["name", "checked", "mismatched", "primary"])); +} diff --git a/apps/gridcraft/src/logging.rs b/apps/gridcraft/src/logging.rs index a0f62d164a..4137973787 100644 --- a/apps/gridcraft/src/logging.rs +++ b/apps/gridcraft/src/logging.rs @@ -295,303 +295,4 @@ pub fn install() -> Option<&'static AppLogger> { } #[cfg(test)] -mod tests { - use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::time::{Duration, UNIX_EPOCH}; - - fn temp_dir(tag: &str) -> PathBuf { - static N: AtomicUsize = AtomicUsize::new(0); - let d = std::env::temp_dir().join(format!("gridcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); - let _ = std::fs::remove_dir_all(&d); - d - } - - fn read(path: &Path) -> String { - std::fs::read_to_string(path).expect("read log") - } - - fn record(logger: &AppLogger, level: log::Level, target: &str, msg: &str) { - use log::Log; - logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build()); - } - - #[test] - fn the_default_filter_shows_gridcraft_info_and_other_crates_warnings() { - let f = Filter::parse(DEFAULT_FILTER); - assert_eq!(f.level_for("gridcraft"), LevelFilter::Info); - assert_eq!(f.level_for("gridcraft::control_server"), LevelFilter::Info); - assert_eq!(f.level_for("gridcraft_engine::sample"), LevelFilter::Info); - assert_eq!(f.level_for("gridcraft_ui_egui::control"), LevelFilter::Info); - assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn); - assert_eq!(f.level_for("naga"), LevelFilter::Warn); - assert_eq!(f.max(), LevelFilter::Info); - } - - #[test] - fn directives_follow_env_logger_and_the_most_specific_one_wins() { - let f = Filter::parse("info,wgpu_core=error,gridcraft_calc=trace,gridcraft_calc::recalc=off"); - assert_eq!(f.level_for("eframe"), LevelFilter::Info); - assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error); - assert_eq!(f.level_for("gridcraft_calc::graph"), LevelFilter::Trace); - assert_eq!(f.level_for("gridcraft_calc::recalc"), LevelFilter::Off); - assert_eq!(f.max(), LevelFilter::Trace); - // A module name is matched at `::` boundaries, not as a bare prefix. - assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info); - // A trailing `*` is a prefix. - assert_eq!(Filter::parse("gridcraft*=debug").level_for("gridcraft_xlsx::read"), LevelFilter::Debug); - assert_eq!(Filter::parse("gridcraft*=debug").level_for("eframe"), LevelFilter::Error); - // A bare target name sets that target to the most verbose level, as env_logger does. - assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace); - assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error); - // Levels are case-insensitive and surrounding blanks are ignored. - assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("gridcraft"), LevelFilter::Debug); - assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("eframe"), LevelFilter::Warn); - } - - #[test] - fn hostile_specs_never_panic_and_fall_back_sensibly() { - let long = "x".repeat(100_000); - let many = "a=info,".repeat(10_000); - for spec in [ - "", - ",,,", - "=", - "==", - "=debug", - "gridcraft=", - "nonsense=loud", - "🦀=info", - "*", - "*=", - "=*", - "a=b=c", - "warn,,gridcraft=DEBUG", - "\0", - "é::ü=trace", - &long, - &many, - ] { - let f = Filter::parse(spec); - for target in ["", "gridcraft", "🦀", "a::b", "*"] { - let _ = f.level_for(target); - } - let _ = f.max(); - } - assert_eq!(Filter::parse("warn,,gridcraft=DEBUG").level_for("gridcraft"), LevelFilter::Debug); - // An unknown level is skipped: that target keeps the default. - assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error); - assert_eq!(Filter::parse("gridcraft=").level_for("gridcraft"), LevelFilter::Error); - assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error); - assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info); - } - - #[test] - fn timestamps_are_utc_with_milliseconds() { - assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z"); - // Leap day, and the day after it. - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z"); - // 2000 is a leap year (divisible by 400), 2100 is not. - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); - // Before the epoch (a clock set wrong) clamps instead of panicking. - assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); - // A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than - // collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built - // with a checked add and only asserted where it exists. - if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { - assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); - } - if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { - assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); - } - // Years past 9999 keep the plain zero-padded year (no `+`). - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); - } - - #[test] - fn a_line_carries_time_level_thread_target_and_message() { - assert_eq!( - format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "gridcraft", "book.xlsx: file not found"), - "2026-10-08T07:59:17.728Z WARN [main] gridcraft: book.xlsx: file not found\n" - ); - // A multi-line message keeps its lines; the record still ends in one newline. - assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n")); - } - - #[test] - fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { - let dir = temp_dir("rotate"); - for run in 1..=4 { - let (path, _file) = rotate(&dir).expect("rotate"); - assert_eq!(path, dir.join(LOG_FILE)); - assert_eq!(read(&path), ""); - std::fs::write(&path, format!("run {run}")).expect("write"); - } - assert_eq!(read(&dir.join(LOG_FILE)), "run 4"); - assert_eq!(read(&dir.join("gridcraft.1.log")), "run 3"); - assert_eq!(read(&dir.join("gridcraft.2.log")), "run 2"); - assert!(!dir.join("gridcraft.3.log").exists(), "only {KEEP} old logs are kept"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { - let dir = temp_dir("blocked"); - std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); - std::fs::write(&dir, "a file where the directory should be").expect("block"); - assert!(rotate(&dir).is_err()); - let _ = std::fs::remove_file(&dir); - } - - #[test] - fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { - let dir = temp_dir("pending"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(MAX_FILE_BYTES); - sink.write("early 1\n"); - sink.write("early 2\n"); - sink.attach(file); - sink.write("late\n"); - assert_eq!(read(&path), "early 1\nearly 2\nlate\n"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { - let dir = temp_dir("bounded"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(MAX_FILE_BYTES); - for i in 0..MAX_PENDING + 5 { - sink.write(&format!("line {i}\n")); - } - sink.attach(file); - let text = read(&path); - assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING); - assert!(text.contains("5 earlier log lines were dropped"), "{text}"); - assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n"))); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn exactly_a_full_pending_buffer_drops_nothing() { - let dir = temp_dir("full"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(MAX_FILE_BYTES); - for i in 0..MAX_PENDING { - sink.write(&format!("line {i}\n")); - } - sink.attach(file); - let text = read(&path); - assert_eq!(text.lines().count(), MAX_PENDING); - assert!(!text.contains("dropped"), "{text}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn without_a_file_nothing_is_kept_in_memory() { - let mut sink = Sink::new(MAX_FILE_BYTES); - sink.write("early\n"); - sink.no_file(); - sink.write("late\n"); - assert!(sink.pending.is_empty() && sink.dropped == 0); - } - - #[test] - fn the_file_stops_at_its_size_cap_with_one_note() { - let dir = temp_dir("cap"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(100); - sink.attach(file); - for i in 0..50 { - sink.write(&format!("line {i:02} with some padding\n")); - } - let text = read(&path); - assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}"); - assert_eq!(text.matches("log file reached").count(), 1, "{text}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_logger_filters_records_and_writes_them_to_the_attached_file() { - use log::Log; - let dir = temp_dir("logger"); - let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); - record(&logger, log::Level::Info, "gridcraft", "before the settings directory"); - record(&logger, log::Level::Info, "wgpu_core::device", "too chatty"); - let path = logger.attach_dir(&dir).expect("attach"); - assert_eq!(path, dir.join(LOG_FILE)); - record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning"); - record(&logger, log::Level::Error, "gridcraft", "internal error: boom"); - record(&logger, log::Level::Debug, "gridcraft", "below info"); - let text = read(&path); - let lines: Vec<&str> = text.lines().collect(); - assert_eq!(lines.len(), 3, "{text}"); - assert!(lines[0].contains(" INFO [") && lines[0].ends_with("gridcraft: before the settings directory"), "{text}"); - assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}"); - assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("gridcraft: internal error: boom"), "{text}"); - assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("gridcraft_ui_egui").build())); - assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build())); - // Attaching again rotates: the first log becomes `.1`. - logger.attach_dir(&dir).expect("attach again"); - assert_eq!(read(&dir.join("gridcraft.1.log")), text); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn records_from_many_threads_are_whole_lines() { - let dir = temp_dir("threads"); - let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); - let path = logger.attach_dir(&dir).expect("attach"); - let workers: Vec<_> = (0..8) - .map(|t| { - let l = std::sync::Arc::clone(&logger); - std::thread::spawn(move || { - for i in 0..100 { - record(&l, log::Level::Info, "gridcraft", &format!("thread {t} record {i}")); - } - }) - }) - .collect(); - for w in workers { - w.join().expect("worker"); - } - let text = read(&path); - assert_eq!(text.lines().count(), 800); - assert!(text.lines().all(|l| l.contains(" gridcraft: thread ")), "{text}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() { - let dir = temp_dir("unusable"); - std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); - std::fs::write(&dir, "not a directory").expect("block"); - let logger = AppLogger::new(Filter::parse("info"), false); - assert!(logger.attach_dir(&dir).is_err()); - record(&logger, log::Level::Error, "x", "still fine"); - // Nothing waits in memory for a file that won't come. - assert!(logger.sink.lock().expect("sink").pending.is_empty()); - let _ = std::fs::remove_file(&dir); - } - - #[test] - fn a_poisoned_sink_still_logs() { - let dir = temp_dir("poison"); - let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); - let l = std::sync::Arc::clone(&logger); - let _ = std::thread::spawn(move || { - let _guard = l.sink.lock().expect("lock"); - panic!("poison the sink"); - }) - .join(); - assert!(logger.sink.is_poisoned()); - let path = logger.attach_dir(&dir).expect("attach"); - record(&logger, log::Level::Warn, "gridcraft", "after the poison"); - assert!(read(&path).contains("gridcraft: after the poison")); - let _ = std::fs::remove_dir_all(&dir); - } -} +mod tests; diff --git a/apps/gridcraft/src/logging/tests.rs b/apps/gridcraft/src/logging/tests.rs new file mode 100644 index 0000000000..bfc0f81a18 --- /dev/null +++ b/apps/gridcraft/src/logging/tests.rs @@ -0,0 +1,298 @@ +use super::*; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, UNIX_EPOCH}; + +fn temp_dir(tag: &str) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let d = std::env::temp_dir().join(format!("gridcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + d +} + +fn read(path: &Path) -> String { + std::fs::read_to_string(path).expect("read log") +} + +fn record(logger: &AppLogger, level: log::Level, target: &str, msg: &str) { + use log::Log; + logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build()); +} + +#[test] +fn the_default_filter_shows_gridcraft_info_and_other_crates_warnings() { + let f = Filter::parse(DEFAULT_FILTER); + assert_eq!(f.level_for("gridcraft"), LevelFilter::Info); + assert_eq!(f.level_for("gridcraft::control_server"), LevelFilter::Info); + assert_eq!(f.level_for("gridcraft_engine::sample"), LevelFilter::Info); + assert_eq!(f.level_for("gridcraft_ui_egui::control"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn); + assert_eq!(f.level_for("naga"), LevelFilter::Warn); + assert_eq!(f.max(), LevelFilter::Info); +} + +#[test] +fn directives_follow_env_logger_and_the_most_specific_one_wins() { + let f = Filter::parse("info,wgpu_core=error,gridcraft_calc=trace,gridcraft_calc::recalc=off"); + assert_eq!(f.level_for("eframe"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error); + assert_eq!(f.level_for("gridcraft_calc::graph"), LevelFilter::Trace); + assert_eq!(f.level_for("gridcraft_calc::recalc"), LevelFilter::Off); + assert_eq!(f.max(), LevelFilter::Trace); + // A module name is matched at `::` boundaries, not as a bare prefix. + assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info); + // A trailing `*` is a prefix. + assert_eq!(Filter::parse("gridcraft*=debug").level_for("gridcraft_xlsx::read"), LevelFilter::Debug); + assert_eq!(Filter::parse("gridcraft*=debug").level_for("eframe"), LevelFilter::Error); + // A bare target name sets that target to the most verbose level, as env_logger does. + assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace); + assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error); + // Levels are case-insensitive and surrounding blanks are ignored. + assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("gridcraft"), LevelFilter::Debug); + assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("eframe"), LevelFilter::Warn); +} + +#[test] +fn hostile_specs_never_panic_and_fall_back_sensibly() { + let long = "x".repeat(100_000); + let many = "a=info,".repeat(10_000); + for spec in [ + "", + ",,,", + "=", + "==", + "=debug", + "gridcraft=", + "nonsense=loud", + "🦀=info", + "*", + "*=", + "=*", + "a=b=c", + "warn,,gridcraft=DEBUG", + "\0", + "é::ü=trace", + &long, + &many, + ] { + let f = Filter::parse(spec); + for target in ["", "gridcraft", "🦀", "a::b", "*"] { + let _ = f.level_for(target); + } + let _ = f.max(); + } + assert_eq!(Filter::parse("warn,,gridcraft=DEBUG").level_for("gridcraft"), LevelFilter::Debug); + // An unknown level is skipped: that target keeps the default. + assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error); + assert_eq!(Filter::parse("gridcraft=").level_for("gridcraft"), LevelFilter::Error); + assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error); + assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info); +} + +#[test] +fn timestamps_are_utc_with_milliseconds() { + assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z"); + // Leap day, and the day after it. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z"); + // 2000 is a leap year (divisible by 400), 2100 is not. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); + // Before the epoch (a clock set wrong) clamps instead of panicking. + assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than + // collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built + // with a checked add and only asserted where it exists. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); +} + +#[test] +fn a_line_carries_time_level_thread_target_and_message() { + assert_eq!( + format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "gridcraft", "book.xlsx: file not found"), + "2026-10-08T07:59:17.728Z WARN [main] gridcraft: book.xlsx: file not found\n" + ); + // A multi-line message keeps its lines; the record still ends in one newline. + assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n")); +} + +#[test] +fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { + let dir = temp_dir("rotate"); + for run in 1..=4 { + let (path, _file) = rotate(&dir).expect("rotate"); + assert_eq!(path, dir.join(LOG_FILE)); + assert_eq!(read(&path), ""); + std::fs::write(&path, format!("run {run}")).expect("write"); + } + assert_eq!(read(&dir.join(LOG_FILE)), "run 4"); + assert_eq!(read(&dir.join("gridcraft.1.log")), "run 3"); + assert_eq!(read(&dir.join("gridcraft.2.log")), "run 2"); + assert!(!dir.join("gridcraft.3.log").exists(), "only {KEEP} old logs are kept"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { + let dir = temp_dir("blocked"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "a file where the directory should be").expect("block"); + assert!(rotate(&dir).is_err()); + let _ = std::fs::remove_file(&dir); +} + +#[test] +fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { + let dir = temp_dir("pending"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early 1\n"); + sink.write("early 2\n"); + sink.attach(file); + sink.write("late\n"); + assert_eq!(read(&path), "early 1\nearly 2\nlate\n"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { + let dir = temp_dir("bounded"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING + 5 { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING); + assert!(text.contains("5 earlier log lines were dropped"), "{text}"); + assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n"))); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn exactly_a_full_pending_buffer_drops_nothing() { + let dir = temp_dir("full"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().count(), MAX_PENDING); + assert!(!text.contains("dropped"), "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn without_a_file_nothing_is_kept_in_memory() { + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early\n"); + sink.no_file(); + sink.write("late\n"); + assert!(sink.pending.is_empty() && sink.dropped == 0); +} + +#[test] +fn the_file_stops_at_its_size_cap_with_one_note() { + let dir = temp_dir("cap"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(100); + sink.attach(file); + for i in 0..50 { + sink.write(&format!("line {i:02} with some padding\n")); + } + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}"); + assert_eq!(text.matches("log file reached").count(), 1, "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_logger_filters_records_and_writes_them_to_the_attached_file() { + use log::Log; + let dir = temp_dir("logger"); + let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); + record(&logger, log::Level::Info, "gridcraft", "before the settings directory"); + record(&logger, log::Level::Info, "wgpu_core::device", "too chatty"); + let path = logger.attach_dir(&dir).expect("attach"); + assert_eq!(path, dir.join(LOG_FILE)); + record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning"); + record(&logger, log::Level::Error, "gridcraft", "internal error: boom"); + record(&logger, log::Level::Debug, "gridcraft", "below info"); + let text = read(&path); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 3, "{text}"); + assert!(lines[0].contains(" INFO [") && lines[0].ends_with("gridcraft: before the settings directory"), "{text}"); + assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}"); + assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("gridcraft: internal error: boom"), "{text}"); + assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("gridcraft_ui_egui").build())); + assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build())); + // Attaching again rotates: the first log becomes `.1`. + logger.attach_dir(&dir).expect("attach again"); + assert_eq!(read(&dir.join("gridcraft.1.log")), text); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn records_from_many_threads_are_whole_lines() { + let dir = temp_dir("threads"); + let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); + let path = logger.attach_dir(&dir).expect("attach"); + let workers: Vec<_> = (0..8) + .map(|t| { + let l = std::sync::Arc::clone(&logger); + std::thread::spawn(move || { + for i in 0..100 { + record(&l, log::Level::Info, "gridcraft", &format!("thread {t} record {i}")); + } + }) + }) + .collect(); + for w in workers { + w.join().expect("worker"); + } + let text = read(&path); + assert_eq!(text.lines().count(), 800); + assert!(text.lines().all(|l| l.contains(" gridcraft: thread ")), "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() { + let dir = temp_dir("unusable"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "not a directory").expect("block"); + let logger = AppLogger::new(Filter::parse("info"), false); + assert!(logger.attach_dir(&dir).is_err()); + record(&logger, log::Level::Error, "x", "still fine"); + // Nothing waits in memory for a file that won't come. + assert!(logger.sink.lock().expect("sink").pending.is_empty()); + let _ = std::fs::remove_file(&dir); +} + +#[test] +fn a_poisoned_sink_still_logs() { + let dir = temp_dir("poison"); + let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); + let l = std::sync::Arc::clone(&logger); + let _ = std::thread::spawn(move || { + let _guard = l.sink.lock().expect("lock"); + panic!("poison the sink"); + }) + .join(); + assert!(logger.sink.is_poisoned()); + let path = logger.attach_dir(&dir).expect("attach"); + record(&logger, log::Level::Warn, "gridcraft", "after the poison"); + assert!(read(&path).contains("gridcraft: after the poison")); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 4bdb3a30d4..45d81cccac 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -11,6 +11,18 @@ use gridcraft_formula::{BinOp, Expr, RefKind, Reference, SheetSel, StructItem, S use gridcraft_functions::{Arg, Ctx}; use gridcraft_model::Workbook; +mod array; +mod call; +mod format; +mod lambda; +mod reference_fns; +mod special; + +use array::map_unary; +pub use array::{binary_values, zip2}; +pub use format::r1c1_to_a1; +pub use special::{SPECIAL_FUNCTIONS, is_known_function, precedents, resized_sum_range}; + /// Largest array a reference may expand to. pub const MAX_CELLS: u64 = 16_000_000; /// References up to this many cells (one whole row) are always read at their full size. @@ -615,1021 +627,6 @@ impl<'h> Evaluator<'h> { } } } - - fn call(&mut self, name: &str, args: &[Expr]) -> Ev { - match name { - "IF" => { - if args.is_empty() || args.len() > 3 { - return err(CellError::Value); - } - let c = self.arg_val(args, 0).unwrap_or_default(); - if let Value::Array(arr) = &c { - // Array condition: element-wise. - let t = self.arg_val(args, 1).unwrap_or(Value::Bool(true)); - let f = if args.len() > 2 { self.arg_val(args, 2).unwrap_or(Value::Bool(false)) } else { Value::Bool(false) }; - return Ev::V(zip3(arr, &t, &f, |c, t, f| match c.to_bool() { - Ok(true) => t.clone(), - Ok(false) => f.clone(), - Err(e) => Value::Error(e), - })); - } - match c.to_bool() { - Ok(true) => match args.get(1) { - Some(Expr::Missing) => Ev::V(Value::Number(0.0)), - Some(e) => self.eval(e), - None => Ev::V(Value::Bool(true)), - }, - Ok(false) => match args.get(2) { - Some(Expr::Missing) => Ev::V(Value::Number(0.0)), - Some(e) => self.eval(e), - None => Ev::V(Value::Bool(false)), - }, - Err(e) => err(e), - } - } - "IFS" => { - if args.len() < 2 || !args.len().is_multiple_of(2) { - return err(CellError::Value); - } - for pair in args.chunks(2) { - let [c, v] = pair else { break }; - let cv = self.value(c); - match cv.scalar().to_bool() { - Ok(true) => return self.eval(v), - Ok(false) => {} - Err(e) => return err(e), - } - } - err(CellError::NA) - } - "IFERROR" | "IFNA" => { - if args.len() != 2 { - return err(CellError::Value); - } - let v = self.arg_ev(args, 0).unwrap_or(Ev::V(Value::Empty)); - let na_only = name == "IFNA"; - let catch = |e: CellError| if na_only { e == CellError::NA } else { true }; - match &v { - Ev::V(Value::Error(e)) if catch(*e) => self.eval(&args[1]), - Ev::V(Value::Array(a)) if a.data.iter().any(|x| x.as_error().is_some_and(catch)) => { - let alt = self.arg_val(args, 1).unwrap_or_default(); - let data = a.data.iter().map(|x| if x.as_error().is_some_and(catch) { alt.scalar() } else { x.clone() }).collect(); - Ev::V(Array::new(a.rows, a.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))) - } - Ev::R(_) => { - let val = self.deref(v.clone()); - match val { - Value::Error(e) if catch(e) => self.eval(&args[1]), - Value::Array(ref a) if a.data.iter().any(|x| x.as_error().is_some_and(catch)) => { - let alt = self.arg_val(args, 1).unwrap_or_default(); - let data = a.data.iter().map(|x| if x.as_error().is_some_and(catch) { alt.scalar() } else { x.clone() }).collect(); - Ev::V(Array::new(a.rows, a.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))) - } - _ => v, - } - } - _ => v, - } - } - "CHOOSE" => { - if args.len() < 2 { - return err(CellError::Value); - } - let index = self.value(&args[0]); - if let Value::Array(_) = index { - // An array of indexes picks element by element: `CHOOSE({1,2},A1:A3,B1:B3)` - // is a two-column array. The library CHOOSE lifts over its arguments. - let Some(spec) = gridcraft_functions::lookup(name) else { return err(CellError::Name) }; - let mut evaluated = vec![Arg { value: index, from_ref: false }]; - for a in &args[1..] { - let value = self.value(a); - evaluated.push(Arg { value, from_ref: false }); - } - let mut ctx = FnCtx { ev: self, criteria: None, lambda: None }; - return gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V); - } - let i = match index.to_number() { - Ok(n) => n.trunc() as i64, - Err(e) => return err(e), - }; - if i < 1 || i as usize >= args.len() { - return err(CellError::Value); - } - self.eval(&args[i as usize]) - } - "SWITCH" => { - if args.len() < 3 { - return err(CellError::Value); - } - let v = self.arg_val(args, 0).unwrap_or_default().scalar(); - if let Value::Error(e) = v { - return err(e); - } - let rest = &args[1..]; - let mut i = 0; - while i + 1 < rest.len() { - let cand = self.value(&rest[i]).scalar(); - if !cand.is_error() && compare(&v, &cand) == std::cmp::Ordering::Equal && same_kind(&v, &cand) { - return self.eval(&rest[i + 1]); - } - i += 2; - } - if rest.len() % 2 == 1 { rest.last().map_or(err(CellError::NA), |d| self.eval(d)) } else { err(CellError::NA) } - } - "ROW" | "COLUMN" => { - let is_row = name == "ROW"; - if args.is_empty() || matches!(args.first(), Some(Expr::Missing)) { - return Ev::V(Value::number(if is_row { self.at.row + 1 } else { self.at.col + 1 } as f64)); - } - match self.eval(&args[0]) { - Ev::R(a) => { - let Some(a) = a.first() else { return err(CellError::Ref) }; - let r = self.trim_lines(a); - if is_row { - let rows: Vec = (r.start.row..=r.end.row).map(|x| Value::number(x as f64 + 1.0)).collect(); - if rows.len() == 1 { Ev::V(rows.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::column(rows).into()) } - } else { - let cols: Vec = (r.start.col..=r.end.col).map(|x| Value::number(x as f64 + 1.0)).collect(); - if cols.len() == 1 { Ev::V(cols.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::row(cols).into()) } - } - } - Ev::V(Value::Error(e)) => err(e), - _ => err(CellError::Value), - } - } - "ROWS" | "COLUMNS" => { - if args.len() != 1 { - return err(CellError::Value); - } - let is_rows = name == "ROWS"; - match self.eval(&args[0]) { - Ev::R(a) => match a.as_slice() { - [a] => Ev::V(Value::number(if is_rows { a.range.height() } else { a.range.width() } as f64)), - _ => err(CellError::Ref), - }, - Ev::V(Value::Array(arr)) => Ev::V(Value::number(if is_rows { arr.rows } else { arr.cols } as f64)), - Ev::V(Value::Error(e)) => err(e), - Ev::V(_) => Ev::V(Value::Number(1.0)), - Ev::L(_) => err(CellError::Value), - } - } - "AREAS" => match args.first().map(|a| self.eval(a)) { - Some(Ev::R(a)) => Ev::V(Value::number(a.len() as f64)), - Some(Ev::V(Value::Error(e))) => err(e), - _ => err(CellError::Value), - }, - "ISREF" => Ev::V(Value::Bool(matches!(args.first().map(|a| self.eval(a)), Some(Ev::R(_))))), - "ISFORMULA" | "FORMULATEXT" => { - let Some(Ev::R(a)) = args.first().map(|a| self.eval(a)) else { return err(CellError::NA) }; - let Some(a) = a.first() else { return err(CellError::NA) }; - let f = self.host.workbook().sheet(a.sheet).and_then(|s| s.cell(a.range.start)).and_then(|c| c.formula.clone()); - if name == "ISFORMULA" { - Ev::V(Value::Bool(f.is_some())) - } else { - match f { - Some(f) => Ev::V(Value::text(format!("={}", f.text))), - None => err(CellError::NA), - } - } - } - "SHEET" | "SHEETS" => { - let wb = self.host.workbook(); - if args.is_empty() { - return Ev::V(Value::number(if name == "SHEET" { self.sheet + 1 } else { wb.sheets.len() } as f64)); - } - match self.eval(&args[0]) { - Ev::R(a) => { - if name == "SHEET" { - a.first().map_or(err(CellError::Ref), |a| Ev::V(Value::number(a.sheet as f64 + 1.0))) - } else { - let mut s: Vec = a.iter().map(|x| x.sheet).collect(); - s.dedup(); - Ev::V(Value::number(s.len() as f64)) - } - } - Ev::V(Value::Text(t)) if name == "SHEET" => { - let wb = self.host.workbook(); - wb.sheet_index(&t).map_or(err(CellError::NA), |i| Ev::V(Value::number(i as f64 + 1.0))) - } - _ => err(CellError::NA), - } - } - "OFFSET" => self.offset(args), - "INDIRECT" => self.indirect(args), - "INDEX" => self.index(args), - "XLOOKUP" => self.xlookup(args), - "CELL" => self.cell_info(args), - "TRIMRANGE" => self.trimrange(args), - "SUBTOTAL" | "AGGREGATE" => self.subtotal(name, args), - "TEXT" => { - if args.len() != 2 { - return err(CellError::Value); - } - let v = self.arg_val(args, 0).unwrap_or_default(); - let f = self.arg_val(args, 1).unwrap_or_default(); - let sys = self.host.workbook().date_system; - let one = |v: &Value, f: &Value| -> Value { - if let Value::Error(e) = v { - return Value::Error(*e); - } - let code = match f.to_text() { - Ok(c) => c, - Err(e) => return Value::Error(e), - }; - match gridcraft_numfmt::text_function(v, &code, sys) { - Ok(s) => Value::text(s), - Err(e) => Value::Error(e), - } - }; - Ev::V(zip2(&v, &f, one)) - } - "LET" => self.let_(args), - "LAMBDA" => { - if args.is_empty() { - return err(CellError::Value); - } - let mut params = Vec::new(); - for p in &args[..args.len() - 1] { - match p { - Expr::Name(n) => params.push(n.trim_start_matches('[').trim_end_matches(']').to_string()), - _ => return err(CellError::Value), - } - } - let Some(body) = args.last() else { return err(CellError::Value) }; - Ev::L(Arc::new(Lambda { params, body: body.clone(), env: self.env.clone() })) - } - "ISOMITTED" => { - let r = matches!(args.first(), Some(Expr::Name(n)) if self.env.iter().rev().find(|(k, _)| gridcraft_formula::same_name(k, n)).is_some_and(|(_, v)| matches!(v, Ev::V(Value::Empty)))); - Ev::V(Value::Bool(r)) - } - "MAP" | "REDUCE" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" => self.higher_order(name, args), - "GROUPBY" | "PIVOTBY" => { - // The aggregation may be a function given by name (`SUM`): pass it as its name. - let i = if name == "GROUPBY" { 2 } else { 3 }; - match args.get(i).and_then(|a| self.function_by_name(a)) { - Some(f) => { - let mut args = args.to_vec(); - if let Some(a) = args.get_mut(i) { - *a = Expr::Text(f.into()); - } - self.builtin(name, &args) - } - None => self.builtin(name, args), - } - } - "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" => { - self.host.note_volatile(); - self.builtin(name, args) - } - _ => self.builtin(name, args), - } - } - - 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. - 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); - } - if let Some(kind) = Agg::of(spec.name) { - return self.aggregate(kind, args); - } - let mut evaluated = Vec::with_capacity(args.len()); - // SUMIF/AVERAGEIF: the size of the criteria range (argument 1), which the sum range takes. - let mut criteria_shape = None; - // Database functions: the criteria range (argument 2), whose formulas are computed criteria. - let mut criteria = None; - // GROUPBY/PIVOTBY: the LAMBDA given as their function. - let mut lambda = None; - let lambda_at = match name { - "GROUPBY" => Some(2), - "PIVOTBY" => Some(3), - _ => None, - }; - for (i, a) in args.iter().enumerate() { - let mut ev = self.eval(a); - if lambda_at == Some(i) - && let Ev::L(l) = &ev - { - lambda = Some(l.clone()); - ev = Ev::V(Value::Empty); - } - if spec.category == gridcraft_functions::Category::Database - && i == 2 - && let Ev::R(areas) = &ev - && let [area] = areas.as_slice() - { - criteria = Some(*area); - } - if resizes_sum_range(name, args.len()) { - if i == 0 { - criteria_shape = ev_shape(&ev); - } else if i == 2 - && let (Some((h, w)), Ev::R(areas)) = (criteria_shape, &ev) - && let [area] = areas.as_slice() - { - ev = Ev::R(vec![Area { sheet: area.sheet, range: resized_sum_range(area.range, h, w) }]); - } - } - let from_ref = matches!(ev, Ev::R(_)); - let value = match ev { - Ev::R(areas) if areas.len() > 1 => { - // Multi-area references (unions): flatten into one row for aggregates. - let mut vals = Vec::new(); - for ar in &areas { - match self.area_value(ar) { - Value::Array(a) => vals.extend(a.data.iter().cloned()), - v => vals.push(v), - } - } - Value::from(Array::row(vals)) - } - ev => self.deref(ev), - }; - evaluated.push(Arg { value, from_ref }); - } - let mut ctx = FnCtx { ev: self, criteria, lambda }; - gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V) - } - - /// Performs aggregation operations without copying its ranges: blocks are folded over the stored - /// cells, so no array is built and there is no `MAX_CELLS` limit. Arguments are evaluated and read one - /// after another, as the array path does, so volatile formulas (like `RAND`) see the same order. - /// Only `SUM`, `COUNT` and `AVG` are supported currently. - fn aggregate(&mut self, kind: Agg, args: &[Expr]) -> Ev { - let mut acc = Acc::default(); - let mut first_error = None; - for a in args { - let ev = self.eval(a); - let steps = match ev { - Ev::R(areas) => areas.iter().map(|area| self.aggregate_area(kind, area, &mut acc)).collect(), - ev => { - let value = self.deref(ev); - vec![self.aggregate_value(kind, value, false, &mut acc)] - } - }; - if first_error.is_none() { - first_error = steps.into_iter().find_map(Result::err); - } - } - first_error.unwrap_or_else(|| Ev::V(acc.finish(kind))) - } - - /// Adds one area to `acc`; `Err` is the result the function ends with. - fn aggregate_area(&mut self, kind: Agg, a: &Area, acc: &mut Acc) -> Result<(), Ev> { - if !a.range.is_single() { - let mut error = None; - let walked = self.host.visit_range(a.sheet, a.range, &mut |v| match acc.add_cell(kind, v) { - Ok(()) => true, - Err(e) => { - error = Some(e); - false - } - }); - if walked { - return error.map_or(Ok(()), |e| Err(err(e))); - } - } - let value = self.area_value(a); - self.aggregate_value(kind, value, true, acc) - } - - /// Adds one argument's value by calling `SUM` and `COUNT` on it, so coercion and error rules - /// stay in one place (`AVERAGE` of an argument is its `SUM` over its `COUNT`). - fn aggregate_value(&mut self, kind: Agg, value: Value, from_ref: bool, acc: &mut Acc) -> Result<(), Ev> { - let (Some(sum), Some(count)) = (gridcraft_functions::lookup("SUM"), gridcraft_functions::lookup("COUNT")) else { - return Err(err(CellError::Name)); - }; - let args = [Arg { value, from_ref }]; - if kind != Agg::Count { - match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) { - Value::Number(n) => acc.sum += n, - other => return Err(Ev::V(other)), - } - } - if kind != Agg::Sum { - match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) { - Value::Number(n) => acc.count = acc.count.saturating_add(n as u64), - other => return Err(Ev::V(other)), - } - } - Ok(()) - } - - pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev { - // Too many or too few argument positions is #VALUE!. An explicitly empty position, as in - // `(1,)`, is `Expr::Missing` and still counts as a position (it binds as omitted). `f()` - // parses to no arguments, which for a one-parameter lambda can also be read as one empty - // position, so that call still binds the parameter as omitted. - let positions_ok = args.len() == l.params.len() || (args.is_empty() && l.params.len() == 1); - if !positions_ok { - return err(CellError::Value); - } - let mut bound = Vec::with_capacity(l.params.len()); - for (i, p) in l.params.iter().enumerate() { - let v = match args.get(i) { - Some(Expr::Missing) | None => Ev::V(Value::Empty), - Some(e) => self.eval(e), - }; - bound.push((p.clone(), v)); - } - self.call_lambda_values(l, bound) - } - - 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 - } - - fn let_(&mut self, args: &[Expr]) -> Ev { - if args.len() < 3 || args.len().is_multiple_of(2) { - return err(CellError::Value); - } - let base = self.env.len(); - for pair in args[..args.len() - 1].chunks(2) { - let [Expr::Name(n), v] = pair else { - self.env.truncate(base); - return err(CellError::Value); - }; - let val = self.eval(v); - self.env.push((n.clone(), val)); - } - let r = match args.last() { - Some(body) => self.eval(body), - None => err(CellError::Value), - }; - self.env.truncate(base); - r - } - - /// A worksheet function given by name where a function is expected (`BYROW(A1:C3,SUM)`, - /// `GROUPBY(A2:A9,B2:B9,SUM)`): its name, unless a LET/LAMBDA variable or a defined name has - /// that name. - fn function_by_name(&self, e: &Expr) -> Option { - let Expr::Name(n) = e else { return None }; - if self.env.iter().any(|(k, _)| gridcraft_formula::same_name(k, n)) || self.host.workbook().name(n, self.sheet).is_some() { - return None; - } - let u = n.to_ascii_uppercase(); - gridcraft_functions::lookup(&u).map(|_| u) - } - - /// The function argument of a LAMBDA helper; a function given by name takes `arity` - /// arguments. - fn lambda_arg(&mut self, e: Option<&Expr>, arity: usize) -> Option> { - if let Some(f) = e.and_then(|x| self.function_by_name(x)) { - let params: Vec = (0..arity).map(|i| format!("_xleta.{i}")).collect(); - let body = Expr::Call(f, params.iter().map(|p| Expr::Name(p.clone())).collect()); - return Some(Arc::new(Lambda { params, body, env: Vec::new() })); - } - match e.map(|x| self.eval(x)) { - Some(Ev::L(l)) => Some(l), - _ => None, - } - } - - fn higher_order(&mut self, name: &str, args: &[Expr]) -> Ev { - let as_array = |v: Value| -> Arc { - match v { - Value::Array(a) => a, - other => Arc::new(Array::scalar(other)), - } - }; - match name { - "MAP" => { - if args.len() < 2 { - return err(CellError::Value); - } - let Some(l) = self.lambda_arg(args.last(), args.len() - 1) else { return err(CellError::Value) }; - let arrays: Vec> = args[..args.len() - 1].iter().map(|a| as_array(self.value(a))).collect(); - let rows = arrays.iter().map(|a| a.rows).max().unwrap_or(1); - let cols = arrays.iter().map(|a| a.cols).max().unwrap_or(1); - let Some(n) = broadcast_len(rows, cols) else { return err(CellError::Num) }; - // The LAMBDA takes one parameter per array. - if l.params.len() != arrays.len() { - return err(CellError::Value); - } - let mut out = Vec::with_capacity(n); - for r in 0..rows { - for c in 0..cols { - let bound = l.params.iter().zip(arrays.iter()).map(|(p, a)| (p.clone(), Ev::V(a.get_broadcast(r, c)))).collect(); - let v = self.call_lambda_values(&l, bound); - let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) }; - out.push(one); - } - } - Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc))) - } - "REDUCE" | "SCAN" => { - if args.len() != 3 { - return err(CellError::Value); - } - let mut acc = self.value(&args[0]); - let arr = as_array(self.value(&args[1])); - let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) }; - if l.params.len() != 2 { - return err(CellError::Value); - } - let mut scan = Vec::with_capacity(arr.data.len()); - for v in arr.data.iter() { - let bound = vec![(l.params[0].clone(), Ev::V(acc.clone())), (l.params[1].clone(), Ev::V(v.clone()))]; - let r = self.call_lambda_values(&l, bound); - acc = self.deref(r); - if name == "SCAN" { - let Some(one) = single(acc.clone()) else { return err(CellError::Calc) }; - scan.push(one); - } - } - if name == "SCAN" { - Ev::V(Array::new(arr.rows, arr.cols, scan).map(Value::from).unwrap_or(Value::Error(CellError::Calc))) - } else { - Ev::V(acc) - } - } - "BYROW" | "BYCOL" => { - if args.len() != 2 { - return err(CellError::Value); - } - let arr = as_array(self.value(&args[0])); - let Some(l) = self.lambda_arg(args.get(1), 1) else { return err(CellError::Value) }; - let Some(p) = l.params.first().cloned() else { return err(CellError::Value) }; - if l.params.len() != 1 { - return err(CellError::Value); - } - let by_row = name == "BYROW"; - let n = if by_row { arr.rows } else { arr.cols }; - let mut out = Vec::with_capacity(n); - for i in 0..n { - let slice: Vec = if by_row { - (0..arr.cols).map(|c| arr.get(i, c).cloned().unwrap_or_default()).collect() - } else { - (0..arr.rows).map(|r| arr.get(r, i).cloned().unwrap_or_default()).collect() - }; - let a = if by_row { Array::row(slice) } else { Array::column(slice) }; - let r = self.call_lambda_values(&l, vec![(p.clone(), Ev::V(a.into()))]); - let Some(one) = single(self.deref(r)) else { return err(CellError::Calc) }; - out.push(one); - } - let a = if by_row { Array::column(out) } else { Array::row(out) }; - Ev::V(a.into()) - } - "MAKEARRAY" => { - if args.len() != 3 { - return err(CellError::Value); - } - let (rows, cols) = match (self.arg_num(args, 0, 0.0), self.arg_num(args, 1, 0.0)) { - (Ok(r), Ok(c)) => (r.trunc(), c.trunc()), - (Err(e), _) | (_, Err(e)) => return err(e), - }; - if rows < 1.0 || cols < 1.0 || rows * cols > MAX_CELLS as f64 { - return err(CellError::Value); - } - let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) }; - if l.params.len() != 2 { - return err(CellError::Value); - } - let (rows, cols) = (rows as usize, cols as usize); - let mut out = Vec::with_capacity(rows * cols); - for r in 0..rows { - for c in 0..cols { - let bound = vec![ - (l.params[0].clone(), Ev::V(Value::number(r as f64 + 1.0))), - (l.params[1].clone(), Ev::V(Value::number(c as f64 + 1.0))), - ]; - let v = self.call_lambda_values(&l, bound); - let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) }; - out.push(one); - } - } - Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc))) - } - _ => err(CellError::Name), - } - } - - /// Whole rows/columns trimmed for ROW()/COLUMN() arrays. - fn trim_lines(&mut self, a: &Area) -> RangeRef { - if a.range.is_full_cols() || a.range.is_full_rows() { self.trim(a) } else { a.range } - } - - fn offset(&mut self, args: &[Expr]) -> Ev { - self.host.note_volatile(); - if args.len() < 3 || args.len() > 5 { - return err(CellError::Value); - } - let base = match self.eval(&args[0]) { - Ev::R(a) if a.len() == 1 => a[0], - Ev::V(Value::Error(e)) => return err(e), - _ => return err(CellError::Value), - }; - let rows = match self.arg_num(args, 1, 0.0) { - Ok(n) => n.trunc() as i64, - Err(e) => return err(e), - }; - let cols = match self.arg_num(args, 2, 0.0) { - Ok(n) => n.trunc() as i64, - Err(e) => return err(e), - }; - let h = match self.arg_num(args, 3, base.range.height() as f64) { - Ok(n) => n.trunc() as i64, - Err(e) => return err(e), - }; - let w = match self.arg_num(args, 4, base.range.width() as f64) { - Ok(n) => n.trunc() as i64, - Err(e) => return err(e), - }; - if h == 0 || w == 0 { - return err(CellError::Ref); - } - let r0 = (base.range.start.row as i64).saturating_add(rows); - let c0 = (base.range.start.col as i64).saturating_add(cols); - let (r0, r1) = if h > 0 { (r0, r0.saturating_add(h - 1)) } else { (r0.saturating_add(h + 1), r0) }; - let (c0, c1) = if w > 0 { (c0, c0.saturating_add(w - 1)) } else { (c0.saturating_add(w + 1), c0) }; - if r0 < 0 || c0 < 0 || r1 >= MAX_ROWS as i64 || c1 >= MAX_COLS as i64 { - return err(CellError::Ref); - } - Ev::R(vec![Area { sheet: base.sheet, range: RangeRef::new(CellRef::new(r0 as u32, c0 as u32), CellRef::new(r1 as u32, c1 as u32)) }]) - } - - fn indirect(&mut self, args: &[Expr]) -> Ev { - self.host.note_volatile(); - if args.is_empty() || args.len() > 2 { - return err(CellError::Value); - } - let text = match self.arg_val(args, 0).map(|v| v.scalar().to_text()) { - Some(Ok(t)) => t, - Some(Err(e)) => return err(e), - None => return err(CellError::Value), - }; - let a1 = match args.get(1) { - None | Some(Expr::Missing) => true, - Some(e) => { - let v = self.value(e); - v.scalar().to_bool().unwrap_or(true) - } - }; - // With a1 = FALSE, text that isn't R1C1 can still be a name, but never an A1 reference. - let (text, a1_ok) = if a1 { - (text, true) - } else { - match r1c1_to_a1(&text, self.at) { - Ok(Some(a1_text)) => (a1_text, true), - Ok(None) => (text, false), - Err(e) => return err(e), - } - }; - match gridcraft_formula::parse(&text) { - Ok(Expr::Ref(_)) if !a1_ok => err(CellError::Ref), - Ok(e @ (Expr::Ref(_) | Expr::Name(_) | Expr::Struct(_))) => match self.eval(&e) { - r @ Ev::R(_) => r, - _ => err(CellError::Ref), - }, - _ => err(CellError::Ref), - } - } - - fn index(&mut self, args: &[Expr]) -> Ev { - if args.is_empty() || args.len() > 4 { - return err(CellError::Value); - } - let src = self.eval(&args[0]); - let mut arg = |i: usize| match args.get(i) { - None | Some(Expr::Missing) => None, - Some(e) => Some(self.value(e)), - }; - let (rows, cols) = (arg(1), arg(2)); - let area_n = match self.arg_num(args, 3, 1.0) { - Ok(n) => n.trunc() as i64, - Err(e) => return err(e), - }; - let two = args.len() == 2; - let num = |v: &Option| v.as_ref().map_or(Ok(0.0), |v| v.to_number()).map(|n| n.trunc() as i64); - // Arrays of positions pick a value for each: INDEX(A1:C10,{1;2},{1,3}) is 2×2. - if let (Some(Value::Array(_)), _) | (_, Some(Value::Array(_))) = (&rows, &cols) { - let as_array = |v: &Option| match v { - Some(Value::Array(a)) => Arc::clone(a), - Some(v) => Arc::new(Array::scalar(v.clone())), - None => Arc::new(Array::scalar(Value::Number(0.0))), - }; - let (ra, ca) = (as_array(&rows), as_array(&cols)); - let (h, w) = (ra.rows.max(ca.rows), ra.cols.max(ca.cols)); - if (h as u64).saturating_mul(w as u64) > MAX_CELLS { - return err(CellError::Num); - } - let mut out = Vec::with_capacity(h * w); - for r in 0..h { - for c in 0..w { - let pos = (num(&Some(ra.get_broadcast(r, c))), num(&Some(ca.get_broadcast(r, c)))); - let v = match pos { - (Ok(row), Ok(col)) => { - let one = self.index_one(src.clone(), row, col, area_n, two); - match self.deref(one) { - Value::Array(a) if a.rows * a.cols > 1 => Value::Error(CellError::Calc), - v => v.scalar(), - } - } - (Err(e), _) | (_, Err(e)) => Value::Error(e), - }; - out.push(v); - } - } - return Ev::V(Array::new(h, w, out).map(Value::from).unwrap_or(Value::Error(CellError::Value))); - } - let (row, col) = match (num(&rows), num(&cols)) { - (Ok(r), Ok(c)) => (r, c), - (Err(e), _) | (_, Err(e)) => return err(e), - }; - self.index_one(src, row, col, area_n, two) - } - - /// INDEX for one row and column position (0 = the whole column or row). - fn index_one(&mut self, src: Ev, row: i64, col: i64, area_n: i64, two_args: bool) -> Ev { - if row < 0 || col < 0 { - return err(CellError::Value); - } - match src { - Ev::R(areas) => { - let area = usize::try_from((area_n as u64).saturating_sub(1)).ok(); - let Some(a) = area.and_then(|i| areas.get(i)).copied() else { return err(CellError::Ref) }; - let r = a.range; - // A single row or column with one index picks along it. An area with several rows - // and columns needs both: `INDEX(A1:C3,2)` is #REF! in Excel (an empty third - // argument, `INDEX(A1:C3,2,)`, still means the whole row). Arrays differ: - // `INDEX({1,2;3,4},2)` is the row. - if two_args && r.height() > 1 && r.width() > 1 { - return err(CellError::Ref); - } - let (row, col) = if two_args && r.height() == 1 { (1, row) } else { (row, col) }; - if row as u64 > r.height() as u64 || col as u64 > r.width() as u64 { - return err(CellError::Ref); - } - let (r0, r1) = if row == 0 { (r.start.row, r.end.row) } else { (r.start.row + row as u32 - 1, r.start.row + row as u32 - 1) }; - let (c0, c1) = if col == 0 { (r.start.col, r.end.col) } else { (r.start.col + col as u32 - 1, r.start.col + col as u32 - 1) }; - Ev::R(vec![Area { sheet: a.sheet, range: RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)) }]) - } - Ev::V(Value::Array(arr)) => { - let (row, col) = if two_args && arr.rows == 1 { (1, row) } else { (row, col) }; - if row as usize > arr.rows || col as usize > arr.cols { - return err(CellError::Ref); - } - match (row, col) { - (0, 0) => Ev::V(Value::Array(arr)), - (0, c) => Ev::V(Array::column((0..arr.rows).map(|r| arr.get(r, c as usize - 1).cloned().unwrap_or_default()).collect()).into()), - (r, 0) => Ev::V(Array::row((0..arr.cols).map(|c| arr.get(r as usize - 1, c).cloned().unwrap_or_default()).collect()).into()), - (r, c) => Ev::V(arr.get(r as usize - 1, c as usize - 1).cloned().unwrap_or(Value::Error(CellError::Ref))), - } - } - Ev::V(Value::Error(e)) => err(e), - Ev::V(v) => { - if row <= 1 && col <= 1 { - Ev::V(v) - } else { - err(CellError::Ref) - } - } - Ev::L(_) => err(CellError::Value), - } - } - - /// XLOOKUP whose result range is a reference returns a reference, as in Excel, so that it can - /// end a range (`SUM(XLOOKUP(a,…):XLOOKUP(b,…))`) or feed CELL. Everything else (arrays of - /// lookup values, array arguments, errors) goes to the library function. - fn xlookup(&mut self, args: &[Expr]) -> Ev { - if (3..=6).contains(&args.len()) - && let (Ev::R(look), Ev::R(ret)) = (self.eval(&args[1]), self.eval(&args[2])) - && let ([l], [r]) = (look.as_slice(), ret.as_slice()) - && let Some(spec) = gridcraft_functions::lookup("XMATCH") - { - let key = self.value(&args[0]); - let (l, r) = (*l, *r); - let vertical = l.range.width() == 1 && l.range.height() > 1; - let horizontal = l.range.height() == 1 && l.range.width() > 1; - let same_len = if vertical { r.range.height() == l.range.height() } else { r.range.width() == l.range.width() }; - if !matches!(key, Value::Array(_)) && (vertical || horizontal) && same_len { - let mut xargs = vec![Arg { value: key, from_ref: false }, Arg { value: self.area_value(&l), from_ref: true }]; - // An empty match_mode before a given search_mode is the default (0), not the end. - let given = |i: usize| args.get(i).is_some_and(|e| !matches!(e, Expr::Missing)); - let last = if given(5) { - 5 - } else if given(4) { - 4 - } else { - 3 - }; - for i in 4..=last { - match args.get(i) { - Some(Expr::Missing) | None => xargs.push(Arg { value: Value::Number(0.0), from_ref: false }), - Some(e) => { - let value = self.value(e); - xargs.push(Arg { value, from_ref: false }); - } - } - } - let mut ctx = FnCtx { ev: self, criteria: None, lambda: None }; - return match gridcraft_functions::call(spec, &xargs, &mut ctx) { - Value::Number(p) if p >= 1.0 => { - let k = p as u32 - 1; - let range = if vertical { - RangeRef::new( - CellRef::new(r.range.start.row + k, r.range.start.col), - CellRef::new(r.range.start.row + k, r.range.end.col), - ) - } else { - RangeRef::new( - CellRef::new(r.range.start.row, r.range.start.col + k), - CellRef::new(r.range.end.row, r.range.start.col + k), - ) - }; - Ev::R(vec![Area { sheet: r.sheet, range }]) - } - Value::Error(CellError::NA) if args.get(3).is_some_and(|e| !matches!(e, Expr::Missing)) => self.eval(&args[3]), - Value::Error(e) => err(e), - _ => err(CellError::Value), - }; - } - } - self.builtin("XLOOKUP", args) - } - - fn cell_info(&mut self, args: &[Expr]) -> Ev { - let kind = match self.arg_val(args, 0).map(|v| v.scalar().to_text()) { - Some(Ok(t)) => t.to_ascii_lowercase(), - Some(Err(e)) => return err(e), - None => return err(CellError::Value), - }; - let area = match args.get(1).map(|a| self.eval(a)) { - Some(Ev::R(a)) => a.first().copied(), - None => Some(Area { sheet: self.sheet, range: RangeRef::cell(self.at) }), - _ => return err(CellError::Value), - }; - let Some(a) = area else { return err(CellError::Ref) }; - let c = a.range.start; - let wb = self.host.workbook(); - let sheet = wb.sheet(a.sheet); - match kind.as_str() { - "address" => Ev::V(Value::text(format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1))), - "row" => Ev::V(Value::number(c.row as f64 + 1.0)), - "col" => Ev::V(Value::number(c.col as f64 + 1.0)), - "contents" => Ev::V(self.host.cell_value(a.sheet, c)), - "type" => { - let v = self.host.cell_value(a.sheet, c); - Ev::V(Value::text(match v { - Value::Empty => "b", - Value::Text(_) => "l", - _ => "v", - })) - } - "width" => Ev::V(Value::number(sheet.map_or(8.43, |s| gridcraft_model::points_to_chars(s.col_width(c.col) as f64)).round())), - // Excel's `/[]`, which the classic sheet-name formula splits on - // the `]`. Empty until the workbook has a file of its own, as in Excel (#1489). - "filename" => Ev::V(Value::text(match &wb.source_path { - Some(file) => { - let name = file.file_name().unwrap_or_default().to_string_lossy(); - let on = sheet.map_or("", |s| s.name.as_str()); - file.parent().unwrap_or(std::path::Path::new("")).join(format!("[{name}]{on}")).to_string_lossy().into_owned() - } - None => String::new(), - })), - "sheetname" => Ev::V(Value::text(sheet.map(|s| s.name.clone()).unwrap_or_default())), - "protect" => Ev::V(Value::number(if sheet.is_some_and(|s| wb.styles.get(s.style_id(c)).protection.locked) { 1.0 } else { 0.0 })), - "format" => { - let code = sheet.map(|s| wb.styles.get(s.style_id(c)).num_fmt.as_str().to_string()).unwrap_or_default(); - Ev::V(Value::text(cell_format_code(&code))) - } - "color" | "parentheses" => Ev::V(Value::Number(0.0)), - "prefix" => Ev::V(Value::text("")), - _ => err(CellError::Value), - } - } - - fn subtotal(&mut self, name: &str, args: &[Expr]) -> Ev { - // SUBTOTAL needs function_num and ref1; AGGREGATE needs function_num, options and ref1. - let min_args = if name == "AGGREGATE" { 3 } else { 2 }; - if args.len() < min_args { - return err(CellError::Value); - } - let code = match self.arg_num(args, 0, 0.0) { - Ok(n) => n.trunc() as u32, - Err(e) => return err(e), - }; - let (func, options, rest) = if name == "AGGREGATE" { - let opt = match self.arg_num(args, 1, 0.0) { - Ok(n) => n.trunc() as u32, - Err(e) => return err(e), - }; - (code, opt, &args[2..]) - } else { - let (f, ignore_hidden) = if code > 100 { (code - 100, true) } else { (code, false) }; - (f, if ignore_hidden { 5 } else { 0 }, &args[1..]) - }; - if !(1..=19).contains(&func) || (name == "SUBTOTAL" && func > 11) { - return err(CellError::Value); - } - let skip_hidden = matches!(options, 1 | 3 | 5 | 7); - // SUBTOTAL leaves out rows a filter hides whatever the function number (Microsoft's - // SUBTOTAL documentation); 101-111 also leave out rows hidden by hand. - let skip_filtered = name == "SUBTOTAL"; - let skip_errors = matches!(options, 2 | 3 | 6 | 7); - // SUBTOTAL always leaves out nested SUBTOTAL/AGGREGATE cells (101-111 too); AGGREGATE does so for options 0-3. - let skip_nested = name == "SUBTOTAL" || options <= 3; - let mut values = Vec::new(); - let mut k = None; - let refs = if name == "AGGREGATE" && func >= 14 { &rest[..1.min(rest.len())] } else { rest }; - if name == "AGGREGATE" && func >= 14 { - k = rest.get(1).map(|e| self.value(e)); - } - // Every cell visited (even one skipped as hidden, nested or an error) and every value - // counts against one budget for the whole call. All arguments are evaluated (references - // stay lazy) and charged before any cell is read: a value stored in XFD1048576 makes - // `A:XFD` trim to the whole sheet, ~17 billion cells (#2175). - enum Input { - Cells(usize, RangeRef), - Values(Value), - } - let mut inputs = Vec::new(); - let mut budget = MAX_CELLS; - for e in refs { - match self.eval(e) { - Ev::R(areas) => { - for a in areas { - let range = self.trim(&a); - budget = match budget.checked_sub(range.count()) { - Some(left) => left, - None => return err(CellError::Num), - }; - inputs.push(Input::Cells(a.sheet, range)); - } - } - Ev::V(v) => { - let n = match &v { - Value::Array(a) => u64::try_from(a.data.len()).unwrap_or(u64::MAX), - _ => 1, - }; - budget = match budget.checked_sub(n) { - Some(left) => left, - None => return err(CellError::Num), - }; - inputs.push(Input::Values(v)); - } - Ev::L(_) => return err(CellError::Value), - } - } - for input in inputs { - match input { - Input::Cells(sheet, range) => { - for c in range.iter() { - let (hidden, nested) = { - let wb = self.host.workbook(); - let s = wb.sheet(sheet); - let hidden = - s.is_some_and(|s| if skip_hidden { s.is_row_hidden(c.row) } else { skip_filtered && s.is_row_filtered(c.row) }); - let nested = skip_nested - && s.and_then(|s| s.cell(c)).and_then(|cell| cell.formula.as_ref()).is_some_and(|f| { - let u = f.text.to_ascii_uppercase(); - u.contains("SUBTOTAL(") || u.contains("AGGREGATE(") - }); - (hidden, nested) - }; - if hidden || nested { - continue; - } - let v = self.host.cell_value(sheet, c); - if skip_errors && v.is_error() { - continue; - } - values.push(v); - } - } - Input::Values(Value::Array(a)) => values.extend(a.data.iter().filter(|v| !(skip_errors && v.is_error())).cloned()), - Input::Values(v) => values.push(v), - } - } - if let Some(k) = k { - return Ev::V(gridcraft_functions::aggregate_values_k(func, &values, &k)); - } - Ev::V(gridcraft_functions::aggregate_values(func, &values)) - } } trait Pipe: Sized { @@ -1639,424 +636,6 @@ trait Pipe: Sized { } impl Pipe for T {} -/// CELL("format") code for a number format. -fn cell_format_code(code: &str) -> String { - let c = code.to_ascii_lowercase(); - if c == "general" { - return "G".into(); - } - if c.contains('%') { - return format!("P{}", decimals(&c)); - } - if c.contains('e') && c.contains('0') { - return format!("S{}", decimals(&c)); - } - if c.contains('$') { - return format!("C{}", decimals(&c)); - } - if c.contains('y') || c.contains('d') { - return "D1".into(); - } - if c.contains('h') { - return "D9".into(); - } - if c.contains(',') { - return format!(",{}", decimals(&c)); - } - format!("F{}", decimals(&c)) -} - -fn decimals(c: &str) -> usize { - c.split_once('.').map_or(0, |(_, d)| d.chars().take_while(|ch| *ch == '0').count()) -} - -fn same_kind(a: &Value, b: &Value) -> bool { - std::mem::discriminant(a) == std::mem::discriminant(b) || (a.is_empty() || b.is_empty()) -} - -/// Converts an R1C1 reference text to A1 (for INDIRECT(…, FALSE)). `Ok(None)` means the text is -/// not R1C1; `Err` means it is R1C1 but points past the sheet. -pub fn r1c1_to_a1(s: &str, at: CellRef) -> Result, CellError> { - let (sheet, body) = match s.rsplit_once('!') { - Some((sh, b)) => (format!("{sh}!"), b), - None => (String::new(), s), - }; - let conv = |part: &str| -> Result, CellError> { - let u = part.to_ascii_uppercase(); - let Some((rpart, cpart)) = u.strip_prefix('R').and_then(|rest| rest.split_once('C')) else { return Ok(None) }; - // `None`: not R1C1. `Some(None)`: R1C1, but past the sheet. - let num = |p: &str, base: u32, max: u32| -> Option> { - let (v, absolute) = if p.is_empty() { - (Some(i64::from(base)), false) - } else if let Some(inner) = p.strip_prefix('[').and_then(|x| x.strip_suffix(']')) { - (i64::from(base).checked_add(inner.parse().ok()?), false) - } else { - (p.parse::().ok()?.checked_sub(1).map(i64::from), true) - }; - Some(v.filter(|v| (0..i64::from(max)).contains(v)).map(|v| (v as u32, absolute))) - }; - let (Some(r), Some(c)) = (num(rpart, at.row, MAX_ROWS), num(cpart, at.col, MAX_COLS)) else { return Ok(None) }; - let (Some((r, ra)), Some((c, ca))) = (r, c) else { return Err(CellError::Ref) }; - Ok(Some(format!("{}{}{}{}", if ca { "$" } else { "" }, gridcraft_core::col_to_letters(c), if ra { "$" } else { "" }, r + 1))) - }; - let out = match body.split_once(':') { - Some((a, b)) => match (conv(a)?, conv(b)?) { - (Some(a), Some(b)) => format!("{a}:{b}"), - _ => return Ok(None), - }, - None => match conv(body)? { - Some(x) => x, - None => return Ok(None), - }, - }; - Ok(Some(format!("{sheet}{out}"))) -} - -/// The aggregates that can fold a reference without building an array. -#[derive(Clone, Copy, PartialEq, Eq)] -enum Agg { - Sum, - Count, - Average, -} - -impl Agg { - fn of(name: &str) -> Option { - match name { - "SUM" => Some(Agg::Sum), - "COUNT" => Some(Agg::Count), - "AVERAGE" => Some(Agg::Average), - _ => None, - } - } -} - -/// What a folded aggregate carries from one value to the next. -#[derive(Default)] -struct Acc { - sum: f64, - count: u64, -} - -impl Acc { - /// Takes a value found inside a reference: only numbers count. `Err` is an error value that - /// ends `SUM` and `AVERAGE`; `COUNT` skips errors. - fn add_cell(&mut self, kind: Agg, v: &Value) -> Result<(), CellError> { - match v { - Value::Number(n) => { - self.sum += n; - self.count = self.count.saturating_add(1); - Ok(()) - } - Value::Error(e) if kind != Agg::Count => Err(*e), - _ => Ok(()), - } - } - - fn finish(&self, kind: Agg) -> Value { - match kind { - Agg::Sum => Value::number(self.sum), - Agg::Count => Value::number(self.count as f64), - Agg::Average if self.count == 0 => Value::Error(CellError::Div0), - Agg::Average => Value::number(self.sum / self.count as f64), - } - } -} - -// ---------------------------------------------------------------- operators - -fn map_unary(v: &Value, f: &dyn Fn(f64) -> Value, plus: bool, sys: gridcraft_core::DateSystem) -> Value { - match v { - Value::Array(a) => Value::from(Array { rows: a.rows, cols: a.cols, data: a.data.iter().map(|x| map_unary(x, f, plus, sys)).collect() }), - Value::Error(e) => Value::Error(*e), - Value::Text(_) if plus => v.clone(), - other => match other.to_number_in(sys) { - Ok(n) => f(n), - Err(e) => Value::Error(e), - }, - } -} - -/// Element-wise combination with Excel's broadcasting. -pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value { - match (a, b) { - (Value::Array(x), Value::Array(y)) => { - let rows = broadcast_dim(x.rows, y.rows); - let cols = broadcast_dim(x.cols, y.cols); - let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) }; - let mut data = Vec::with_capacity(n); - for r in 0..rows { - for c in 0..cols { - data.push(f(&x.get_broadcast(r, c), &y.get_broadcast(r, c))); - } - } - Value::from(Array { rows, cols, data }) - } - (Value::Array(x), s) => Value::from(Array { rows: x.rows, cols: x.cols, data: x.data.iter().map(|v| f(v, s)).collect() }), - (s, Value::Array(y)) => Value::from(Array { rows: y.rows, cols: y.cols, data: y.data.iter().map(|v| f(s, v)).collect() }), - (x, y) => f(x, y), - } -} - -/// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`]. -/// Each operand passes its own size cap, but their broadcast product can be far larger -/// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating. -fn broadcast_len(rows: usize, cols: usize) -> Option { - rows.checked_mul(cols).filter(|&n| n as u64 <= MAX_CELLS) -} - -fn broadcast_dim(a: usize, b: usize) -> usize { - if a == 1 { - b - } else if b == 1 { - a - } else { - a.max(b) - } -} - -fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &Value) -> Value) -> Value { - let dims = |v: &Value| if let Value::Array(a) = v { (a.rows, a.cols) } else { (1, 1) }; - let (tr, tc) = dims(t); - let (fr, fc) = dims(f); - let rows = broadcast_dim(broadcast_dim(c.rows, tr), fr); - let cols = broadcast_dim(broadcast_dim(c.cols, tc), fc); - let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) }; - let get = |v: &Value, r: usize, col: usize| if let Value::Array(a) = v { a.get_broadcast(r, col) } else { v.clone() }; - let mut data = Vec::with_capacity(n); - for r in 0..rows { - for col in 0..cols { - data.push(g(&c.get_broadcast(r, col), &get(t, r, col), &get(f, r, col))); - } - } - Value::from(Array { rows, cols, data }) -} - -/// One cell of a LAMBDA helper's result, or `None` for an array of more than one value: arrays -/// can't nest, so the whole helper gives `#CALC!`, as in Excel. -fn single(v: Value) -> Option { - match v { - Value::Array(a) if a.rows * a.cols > 1 => None, - v => Some(v.scalar()), - } -} - -pub fn binary_values(op: BinOp, x: &Value, y: &Value, sys: gridcraft_core::DateSystem) -> Value { - zip2(x, y, |a, b| binary_scalar(op, a, b, sys)) -} - -fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSystem) -> Value { - if let Value::Error(e) = a { - return Value::Error(*e); - } - if let Value::Error(e) = b { - return Value::Error(*e); - } - let num = |f: fn(f64, f64) -> Value| match (a.to_number_in(sys), b.to_number_in(sys)) { - (Ok(x), Ok(y)) => f(x, y), - (Err(e), _) | (_, Err(e)) => Value::Error(e), - }; - use std::cmp::Ordering::*; - match op { - BinOp::Add => num(|x, y| Value::number(x + y)), - BinOp::Sub => num(|x, y| Value::number(x - y)), - BinOp::Mul => num(|x, y| Value::number(x * y)), - BinOp::Div => num(|x, y| if y == 0.0 { Value::Error(CellError::Div0) } else { Value::number(x / y) }), - BinOp::Pow => num(|x, y| { - if x == 0.0 && y == 0.0 { - Value::Error(CellError::Num) - } else if x == 0.0 && y < 0.0 { - Value::Error(CellError::Div0) - } else { - Value::number(x.powf(y)) - } - }), - BinOp::Concat => match (a.to_text(), b.to_text()) { - (Ok(x), Ok(y)) => { - if x.len() + y.len() > 32767 * 4 { - Value::Error(CellError::Value) - } else { - Value::text(x + &y) - } - } - (Err(e), _) | (_, Err(e)) => Value::Error(e), - }, - BinOp::Eq => Value::Bool(compare(a, b) == Equal), - BinOp::Ne => Value::Bool(compare(a, b) != Equal), - BinOp::Lt => Value::Bool(compare(a, b) == Less), - BinOp::Gt => Value::Bool(compare(a, b) == Greater), - BinOp::Le => Value::Bool(compare(a, b) != Greater), - BinOp::Ge => Value::Bool(compare(a, b) != Less), - BinOp::Range | BinOp::Union | BinOp::Intersect => Value::Error(CellError::Value), - } -} - -/// SUMIF and AVERAGEIF with a sum range: Excel sizes the sum range like the criteria range. -fn resizes_sum_range(name: &str, args: usize) -> bool { - args == 3 && matches!(name, "SUMIF" | "AVERAGEIF") -} - -/// Height and width of a single-area reference or an array argument. -fn ev_shape(ev: &Ev) -> Option<(u32, u32)> { - match ev { - Ev::R(areas) => match areas.as_slice() { - [a] => Some((a.range.height(), a.range.width())), - _ => None, - }, - Ev::V(Value::Array(a)) => Some((u32::try_from(a.rows).unwrap_or(u32::MAX), u32::try_from(a.cols).unwrap_or(u32::MAX))), - Ev::V(_) => Some((1, 1)), - Ev::L(_) => None, - } -} - -/// The range SUMIF/AVERAGEIF add up: from the sum range's top-left cell, as many rows and columns -/// as the criteria range (`height` × `width`), cut off at the sheet's edge. -pub fn resized_sum_range(sum: RangeRef, height: u32, width: u32) -> RangeRef { - let row = sum.start.row.saturating_add(height.saturating_sub(1)).min(MAX_ROWS - 1); - let col = sum.start.col.saturating_add(width.saturating_sub(1)).min(MAX_COLS - 1); - RangeRef::new(sum.start, CellRef::new(row, col)) -} - -/// Collects the static references of an expression with their sheets resolved; `dynamic` is -/// set when the formula uses INDIRECT/OFFSET/names/tables whose targets can change. -pub fn precedents(wb: &Workbook, sheet: usize, e: &Expr) -> (Vec, bool) { - let mut out = Vec::new(); - let mut dynamic = false; - let push = |out: &mut Vec, r: &Reference, range: RangeRef| match &r.sheet { - SheetSel::Current => out.push(Area { sheet, range }), - SheetSel::Named(n) => { - if let Some(i) = wb.sheet_index(n) { - out.push(Area { sheet: i, range }); - } - } - SheetSel::Span(a, b) => { - if let (Some(i), Some(j)) = (wb.sheet_index(a), wb.sheet_index(b)) { - for s in i.min(j)..=i.max(j) { - out.push(Area { sheet: s, range }); - } - } - } - }; - e.walk(&mut |x| match x { - Expr::Ref(r) => push(&mut out, r, r.range()), - // The cells SUMIF/AVERAGEIF really read from the sum range (see `resized_sum_range`); when - // the criteria range's size isn't known statically, recalculate the formula every time. - Expr::Call(n, args) if resizes_sum_range(n, args.len()) => match (args.first(), args.get(2)) { - (Some(Expr::Ref(c)), Some(Expr::Ref(s))) => { - let (crit, sum) = (c.range(), s.range()); - push(&mut out, s, resized_sum_range(sum, crit.height(), crit.width())); - } - (_, Some(Expr::Number(_) | Expr::Text(_) | Expr::Bool(_) | Expr::Error(_) | Expr::Array(_) | Expr::Missing)) => {} - _ => dynamic = true, - }, - Expr::Call(n, _) - if matches!(n.as_str(), "INDIRECT" | "OFFSET" | "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" | "CELL" | "INFO") => - { - dynamic = true - } - Expr::Name(_) | Expr::Struct(_) => dynamic = true, - Expr::Call(n, _) if gridcraft_functions::lookup(n).is_none() && !is_special(n) => dynamic = true, - // A range ending in a function's result (`A1:INDEX(B1:B9,n)`) covers the cells between - // the references inside it, not only those references. - Expr::Binary(BinOp::Range, a, b) if !matches!((&**a, &**b), (Expr::Ref(_), Expr::Ref(_))) => { - let mut inner = precedents(wb, sheet, a).0; - inner.extend(precedents(wb, sheet, b).0); - let mut boxes: Vec = Vec::new(); - for area in inner { - match boxes.iter_mut().find(|x| x.sheet == area.sheet) { - Some(x) => x.range = x.range.union(&area.range), - None => boxes.push(area), - } - } - out.extend(boxes); - } - _ => {} - }); - (out, dynamic) -} - -fn is_special(n: &str) -> bool { - matches!( - n, - "IF" | "IFS" - | "IFERROR" - | "IFNA" - | "CHOOSE" - | "SWITCH" - | "ROW" - | "COLUMN" - | "ROWS" - | "COLUMNS" - | "AREAS" - | "ISREF" - | "ISFORMULA" - | "FORMULATEXT" - | "SHEET" - | "SHEETS" - | "OFFSET" - | "INDIRECT" - | "INDEX" - | "CELL" - | "TRIMRANGE" - | "SUBTOTAL" - | "AGGREGATE" - | "TEXT" - | "LET" - | "LAMBDA" - | "ISOMITTED" - | "MAP" - | "REDUCE" - | "SCAN" - | "BYROW" - | "BYCOL" - | "MAKEARRAY" - ) -} - -/// Names of functions the evaluator implements itself (merged into the function list for UI). -pub const SPECIAL_FUNCTIONS: &[&str] = &[ - "IF", - "IFS", - "IFERROR", - "IFNA", - "CHOOSE", - "SWITCH", - "ROW", - "COLUMN", - "ROWS", - "COLUMNS", - "AREAS", - "ISREF", - "ISFORMULA", - "FORMULATEXT", - "SHEET", - "SHEETS", - "OFFSET", - "INDIRECT", - "INDEX", - "CELL", - "TRIMRANGE", - "SUBTOTAL", - "AGGREGATE", - "TEXT", - "LET", - "LAMBDA", - "ISOMITTED", - "MAP", - "REDUCE", - "SCAN", - "BYROW", - "BYCOL", - "MAKEARRAY", -]; - -pub fn is_known_function(n: &str) -> bool { - is_special(&n.to_ascii_uppercase()) || gridcraft_functions::lookup(n).is_some() -} - -#[allow(dead_code)] -fn unused(_: RefKind) {} - #[cfg(test)] mod fuel_tests { use super::*; diff --git a/crates/calc/src/eval/array.rs b/crates/calc/src/eval/array.rs new file mode 100644 index 0000000000..b17a8c70fa --- /dev/null +++ b/crates/calc/src/eval/array.rs @@ -0,0 +1,180 @@ +use super::*; + +/// The aggregates that can fold a reference without building an array. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum Agg { + Sum, + Count, + Average, +} + +impl Agg { + pub(super) fn of(name: &str) -> Option { + match name { + "SUM" => Some(Agg::Sum), + "COUNT" => Some(Agg::Count), + "AVERAGE" => Some(Agg::Average), + _ => None, + } + } +} + +/// What a folded aggregate carries from one value to the next. +#[derive(Default)] +pub(super) struct Acc { + pub(super) sum: f64, + pub(super) count: u64, +} + +impl Acc { + /// Takes a value found inside a reference: only numbers count. `Err` is an error value that + /// ends `SUM` and `AVERAGE`; `COUNT` skips errors. + pub(super) fn add_cell(&mut self, kind: Agg, v: &Value) -> Result<(), CellError> { + match v { + Value::Number(n) => { + self.sum += n; + self.count = self.count.saturating_add(1); + Ok(()) + } + Value::Error(e) if kind != Agg::Count => Err(*e), + _ => Ok(()), + } + } + + pub(super) fn finish(&self, kind: Agg) -> Value { + match kind { + Agg::Sum => Value::number(self.sum), + Agg::Count => Value::number(self.count as f64), + Agg::Average if self.count == 0 => Value::Error(CellError::Div0), + Agg::Average => Value::number(self.sum / self.count as f64), + } + } +} + +// ---------------------------------------------------------------- operators + +pub(super) fn map_unary(v: &Value, f: &dyn Fn(f64) -> Value, plus: bool, sys: gridcraft_core::DateSystem) -> Value { + match v { + Value::Array(a) => Value::from(Array { rows: a.rows, cols: a.cols, data: a.data.iter().map(|x| map_unary(x, f, plus, sys)).collect() }), + Value::Error(e) => Value::Error(*e), + Value::Text(_) if plus => v.clone(), + other => match other.to_number_in(sys) { + Ok(n) => f(n), + Err(e) => Value::Error(e), + }, + } +} + +/// Element-wise combination with Excel's broadcasting. +pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value { + match (a, b) { + (Value::Array(x), Value::Array(y)) => { + let rows = broadcast_dim(x.rows, y.rows); + let cols = broadcast_dim(x.cols, y.cols); + let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) }; + let mut data = Vec::with_capacity(n); + for r in 0..rows { + for c in 0..cols { + data.push(f(&x.get_broadcast(r, c), &y.get_broadcast(r, c))); + } + } + Value::from(Array { rows, cols, data }) + } + (Value::Array(x), s) => Value::from(Array { rows: x.rows, cols: x.cols, data: x.data.iter().map(|v| f(v, s)).collect() }), + (s, Value::Array(y)) => Value::from(Array { rows: y.rows, cols: y.cols, data: y.data.iter().map(|v| f(s, v)).collect() }), + (x, y) => f(x, y), + } +} + +/// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`]. +/// Each operand passes its own size cap, but their broadcast product can be far larger +/// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating. +pub(super) fn broadcast_len(rows: usize, cols: usize) -> Option { + rows.checked_mul(cols).filter(|&n| n as u64 <= MAX_CELLS) +} + +fn broadcast_dim(a: usize, b: usize) -> usize { + if a == 1 { + b + } else if b == 1 { + a + } else { + a.max(b) + } +} + +pub(super) fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &Value) -> Value) -> Value { + let dims = |v: &Value| if let Value::Array(a) = v { (a.rows, a.cols) } else { (1, 1) }; + let (tr, tc) = dims(t); + let (fr, fc) = dims(f); + let rows = broadcast_dim(broadcast_dim(c.rows, tr), fr); + let cols = broadcast_dim(broadcast_dim(c.cols, tc), fc); + let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) }; + let get = |v: &Value, r: usize, col: usize| if let Value::Array(a) = v { a.get_broadcast(r, col) } else { v.clone() }; + let mut data = Vec::with_capacity(n); + for r in 0..rows { + for col in 0..cols { + data.push(g(&c.get_broadcast(r, col), &get(t, r, col), &get(f, r, col))); + } + } + Value::from(Array { rows, cols, data }) +} + +/// One cell of a LAMBDA helper's result, or `None` for an array of more than one value: arrays +/// can't nest, so the whole helper gives `#CALC!`, as in Excel. +pub(super) fn single(v: Value) -> Option { + match v { + Value::Array(a) if a.rows * a.cols > 1 => None, + v => Some(v.scalar()), + } +} + +pub fn binary_values(op: BinOp, x: &Value, y: &Value, sys: gridcraft_core::DateSystem) -> Value { + zip2(x, y, |a, b| binary_scalar(op, a, b, sys)) +} + +fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSystem) -> Value { + if let Value::Error(e) = a { + return Value::Error(*e); + } + if let Value::Error(e) = b { + return Value::Error(*e); + } + let num = |f: fn(f64, f64) -> Value| match (a.to_number_in(sys), b.to_number_in(sys)) { + (Ok(x), Ok(y)) => f(x, y), + (Err(e), _) | (_, Err(e)) => Value::Error(e), + }; + use std::cmp::Ordering::*; + match op { + BinOp::Add => num(|x, y| Value::number(x + y)), + BinOp::Sub => num(|x, y| Value::number(x - y)), + BinOp::Mul => num(|x, y| Value::number(x * y)), + BinOp::Div => num(|x, y| if y == 0.0 { Value::Error(CellError::Div0) } else { Value::number(x / y) }), + BinOp::Pow => num(|x, y| { + if x == 0.0 && y == 0.0 { + Value::Error(CellError::Num) + } else if x == 0.0 && y < 0.0 { + Value::Error(CellError::Div0) + } else { + Value::number(x.powf(y)) + } + }), + BinOp::Concat => match (a.to_text(), b.to_text()) { + (Ok(x), Ok(y)) => { + if x.len() + y.len() > 32767 * 4 { + Value::Error(CellError::Value) + } else { + Value::text(x + &y) + } + } + (Err(e), _) | (_, Err(e)) => Value::Error(e), + }, + BinOp::Eq => Value::Bool(compare(a, b) == Equal), + BinOp::Ne => Value::Bool(compare(a, b) != Equal), + BinOp::Lt => Value::Bool(compare(a, b) == Less), + BinOp::Gt => Value::Bool(compare(a, b) == Greater), + BinOp::Le => Value::Bool(compare(a, b) != Greater), + BinOp::Ge => Value::Bool(compare(a, b) != Less), + BinOp::Range | BinOp::Union | BinOp::Intersect => Value::Error(CellError::Value), + } +} diff --git a/crates/calc/src/eval/call.rs b/crates/calc/src/eval/call.rs new file mode 100644 index 0000000000..9013ca882a --- /dev/null +++ b/crates/calc/src/eval/call.rs @@ -0,0 +1,424 @@ +use super::array::{Acc, Agg, zip2, zip3}; +use super::format::same_kind; +use super::special::{ev_shape, resized_sum_range, resizes_sum_range}; +use super::*; + +impl<'h> Evaluator<'h> { + pub(super) fn call(&mut self, name: &str, args: &[Expr]) -> Ev { + match name { + "IF" => { + if args.is_empty() || args.len() > 3 { + return err(CellError::Value); + } + let c = self.arg_val(args, 0).unwrap_or_default(); + if let Value::Array(arr) = &c { + // Array condition: element-wise. + let t = self.arg_val(args, 1).unwrap_or(Value::Bool(true)); + let f = if args.len() > 2 { self.arg_val(args, 2).unwrap_or(Value::Bool(false)) } else { Value::Bool(false) }; + return Ev::V(zip3(arr, &t, &f, |c, t, f| match c.to_bool() { + Ok(true) => t.clone(), + Ok(false) => f.clone(), + Err(e) => Value::Error(e), + })); + } + match c.to_bool() { + Ok(true) => match args.get(1) { + Some(Expr::Missing) => Ev::V(Value::Number(0.0)), + Some(e) => self.eval(e), + None => Ev::V(Value::Bool(true)), + }, + Ok(false) => match args.get(2) { + Some(Expr::Missing) => Ev::V(Value::Number(0.0)), + Some(e) => self.eval(e), + None => Ev::V(Value::Bool(false)), + }, + Err(e) => err(e), + } + } + "IFS" => { + if args.len() < 2 || !args.len().is_multiple_of(2) { + return err(CellError::Value); + } + for pair in args.chunks(2) { + let [c, v] = pair else { break }; + let cv = self.value(c); + match cv.scalar().to_bool() { + Ok(true) => return self.eval(v), + Ok(false) => {} + Err(e) => return err(e), + } + } + err(CellError::NA) + } + "IFERROR" | "IFNA" => { + if args.len() != 2 { + return err(CellError::Value); + } + let v = self.arg_ev(args, 0).unwrap_or(Ev::V(Value::Empty)); + let na_only = name == "IFNA"; + let catch = |e: CellError| if na_only { e == CellError::NA } else { true }; + match &v { + Ev::V(Value::Error(e)) if catch(*e) => self.eval(&args[1]), + Ev::V(Value::Array(a)) if a.data.iter().any(|x| x.as_error().is_some_and(catch)) => { + let alt = self.arg_val(args, 1).unwrap_or_default(); + let data = a.data.iter().map(|x| if x.as_error().is_some_and(catch) { alt.scalar() } else { x.clone() }).collect(); + Ev::V(Array::new(a.rows, a.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))) + } + Ev::R(_) => { + let val = self.deref(v.clone()); + match val { + Value::Error(e) if catch(e) => self.eval(&args[1]), + Value::Array(ref a) if a.data.iter().any(|x| x.as_error().is_some_and(catch)) => { + let alt = self.arg_val(args, 1).unwrap_or_default(); + let data = a.data.iter().map(|x| if x.as_error().is_some_and(catch) { alt.scalar() } else { x.clone() }).collect(); + Ev::V(Array::new(a.rows, a.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))) + } + _ => v, + } + } + _ => v, + } + } + "CHOOSE" => { + if args.len() < 2 { + return err(CellError::Value); + } + let index = self.value(&args[0]); + if let Value::Array(_) = index { + // An array of indexes picks element by element: `CHOOSE({1,2},A1:A3,B1:B3)` + // is a two-column array. The library CHOOSE lifts over its arguments. + let Some(spec) = gridcraft_functions::lookup(name) else { return err(CellError::Name) }; + let mut evaluated = vec![Arg { value: index, from_ref: false }]; + for a in &args[1..] { + let value = self.value(a); + evaluated.push(Arg { value, from_ref: false }); + } + let mut ctx = FnCtx { ev: self, criteria: None, lambda: None }; + return gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V); + } + let i = match index.to_number() { + Ok(n) => n.trunc() as i64, + Err(e) => return err(e), + }; + if i < 1 || i as usize >= args.len() { + return err(CellError::Value); + } + self.eval(&args[i as usize]) + } + "SWITCH" => { + if args.len() < 3 { + return err(CellError::Value); + } + let v = self.arg_val(args, 0).unwrap_or_default().scalar(); + if let Value::Error(e) = v { + return err(e); + } + let rest = &args[1..]; + let mut i = 0; + while i + 1 < rest.len() { + let cand = self.value(&rest[i]).scalar(); + if !cand.is_error() && compare(&v, &cand) == std::cmp::Ordering::Equal && same_kind(&v, &cand) { + return self.eval(&rest[i + 1]); + } + i += 2; + } + if rest.len() % 2 == 1 { rest.last().map_or(err(CellError::NA), |d| self.eval(d)) } else { err(CellError::NA) } + } + "ROW" | "COLUMN" => { + let is_row = name == "ROW"; + if args.is_empty() || matches!(args.first(), Some(Expr::Missing)) { + return Ev::V(Value::number(if is_row { self.at.row + 1 } else { self.at.col + 1 } as f64)); + } + match self.eval(&args[0]) { + Ev::R(a) => { + let Some(a) = a.first() else { return err(CellError::Ref) }; + let r = self.trim_lines(a); + if is_row { + let rows: Vec = (r.start.row..=r.end.row).map(|x| Value::number(x as f64 + 1.0)).collect(); + if rows.len() == 1 { Ev::V(rows.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::column(rows).into()) } + } else { + let cols: Vec = (r.start.col..=r.end.col).map(|x| Value::number(x as f64 + 1.0)).collect(); + if cols.len() == 1 { Ev::V(cols.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::row(cols).into()) } + } + } + Ev::V(Value::Error(e)) => err(e), + _ => err(CellError::Value), + } + } + "ROWS" | "COLUMNS" => { + if args.len() != 1 { + return err(CellError::Value); + } + let is_rows = name == "ROWS"; + match self.eval(&args[0]) { + Ev::R(a) => match a.as_slice() { + [a] => Ev::V(Value::number(if is_rows { a.range.height() } else { a.range.width() } as f64)), + _ => err(CellError::Ref), + }, + Ev::V(Value::Array(arr)) => Ev::V(Value::number(if is_rows { arr.rows } else { arr.cols } as f64)), + Ev::V(Value::Error(e)) => err(e), + Ev::V(_) => Ev::V(Value::Number(1.0)), + Ev::L(_) => err(CellError::Value), + } + } + "AREAS" => match args.first().map(|a| self.eval(a)) { + Some(Ev::R(a)) => Ev::V(Value::number(a.len() as f64)), + Some(Ev::V(Value::Error(e))) => err(e), + _ => err(CellError::Value), + }, + "ISREF" => Ev::V(Value::Bool(matches!(args.first().map(|a| self.eval(a)), Some(Ev::R(_))))), + "ISFORMULA" | "FORMULATEXT" => { + let Some(Ev::R(a)) = args.first().map(|a| self.eval(a)) else { return err(CellError::NA) }; + let Some(a) = a.first() else { return err(CellError::NA) }; + let f = self.host.workbook().sheet(a.sheet).and_then(|s| s.cell(a.range.start)).and_then(|c| c.formula.clone()); + if name == "ISFORMULA" { + Ev::V(Value::Bool(f.is_some())) + } else { + match f { + Some(f) => Ev::V(Value::text(format!("={}", f.text))), + None => err(CellError::NA), + } + } + } + "SHEET" | "SHEETS" => { + let wb = self.host.workbook(); + if args.is_empty() { + return Ev::V(Value::number(if name == "SHEET" { self.sheet + 1 } else { wb.sheets.len() } as f64)); + } + match self.eval(&args[0]) { + Ev::R(a) => { + if name == "SHEET" { + a.first().map_or(err(CellError::Ref), |a| Ev::V(Value::number(a.sheet as f64 + 1.0))) + } else { + let mut s: Vec = a.iter().map(|x| x.sheet).collect(); + s.dedup(); + Ev::V(Value::number(s.len() as f64)) + } + } + Ev::V(Value::Text(t)) if name == "SHEET" => { + let wb = self.host.workbook(); + wb.sheet_index(&t).map_or(err(CellError::NA), |i| Ev::V(Value::number(i as f64 + 1.0))) + } + _ => err(CellError::NA), + } + } + "OFFSET" => self.offset(args), + "INDIRECT" => self.indirect(args), + "INDEX" => self.index(args), + "XLOOKUP" => self.xlookup(args), + "CELL" => self.cell_info(args), + "TRIMRANGE" => self.trimrange(args), + "SUBTOTAL" | "AGGREGATE" => self.subtotal(name, args), + "TEXT" => { + if args.len() != 2 { + return err(CellError::Value); + } + let v = self.arg_val(args, 0).unwrap_or_default(); + let f = self.arg_val(args, 1).unwrap_or_default(); + let sys = self.host.workbook().date_system; + let one = |v: &Value, f: &Value| -> Value { + if let Value::Error(e) = v { + return Value::Error(*e); + } + let code = match f.to_text() { + Ok(c) => c, + Err(e) => return Value::Error(e), + }; + match gridcraft_numfmt::text_function(v, &code, sys) { + Ok(s) => Value::text(s), + Err(e) => Value::Error(e), + } + }; + Ev::V(zip2(&v, &f, one)) + } + "LET" => self.let_(args), + "LAMBDA" => { + if args.is_empty() { + return err(CellError::Value); + } + let mut params = Vec::new(); + for p in &args[..args.len() - 1] { + match p { + Expr::Name(n) => params.push(n.trim_start_matches('[').trim_end_matches(']').to_string()), + _ => return err(CellError::Value), + } + } + let Some(body) = args.last() else { return err(CellError::Value) }; + Ev::L(Arc::new(Lambda { params, body: body.clone(), env: self.env.clone() })) + } + "ISOMITTED" => { + let r = matches!(args.first(), Some(Expr::Name(n)) if self.env.iter().rev().find(|(k, _)| gridcraft_formula::same_name(k, n)).is_some_and(|(_, v)| matches!(v, Ev::V(Value::Empty)))); + Ev::V(Value::Bool(r)) + } + "MAP" | "REDUCE" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" => self.higher_order(name, args), + "GROUPBY" | "PIVOTBY" => { + // The aggregation may be a function given by name (`SUM`): pass it as its name. + let i = if name == "GROUPBY" { 2 } else { 3 }; + match args.get(i).and_then(|a| self.function_by_name(a)) { + Some(f) => { + let mut args = args.to_vec(); + if let Some(a) = args.get_mut(i) { + *a = Expr::Text(f.into()); + } + self.builtin(name, &args) + } + None => self.builtin(name, args), + } + } + "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" => { + self.host.note_volatile(); + self.builtin(name, args) + } + _ => self.builtin(name, args), + } + } + + pub(super) 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. + 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); + } + if let Some(kind) = Agg::of(spec.name) { + return self.aggregate(kind, args); + } + let mut evaluated = Vec::with_capacity(args.len()); + // SUMIF/AVERAGEIF: the size of the criteria range (argument 1), which the sum range takes. + let mut criteria_shape = None; + // Database functions: the criteria range (argument 2), whose formulas are computed criteria. + let mut criteria = None; + // GROUPBY/PIVOTBY: the LAMBDA given as their function. + let mut lambda = None; + let lambda_at = match name { + "GROUPBY" => Some(2), + "PIVOTBY" => Some(3), + _ => None, + }; + for (i, a) in args.iter().enumerate() { + let mut ev = self.eval(a); + if lambda_at == Some(i) + && let Ev::L(l) = &ev + { + lambda = Some(l.clone()); + ev = Ev::V(Value::Empty); + } + if spec.category == gridcraft_functions::Category::Database + && i == 2 + && let Ev::R(areas) = &ev + && let [area] = areas.as_slice() + { + criteria = Some(*area); + } + if resizes_sum_range(name, args.len()) { + if i == 0 { + criteria_shape = ev_shape(&ev); + } else if i == 2 + && let (Some((h, w)), Ev::R(areas)) = (criteria_shape, &ev) + && let [area] = areas.as_slice() + { + ev = Ev::R(vec![Area { sheet: area.sheet, range: resized_sum_range(area.range, h, w) }]); + } + } + let from_ref = matches!(ev, Ev::R(_)); + let value = match ev { + Ev::R(areas) if areas.len() > 1 => { + // Multi-area references (unions): flatten into one row for aggregates. + let mut vals = Vec::new(); + for ar in &areas { + match self.area_value(ar) { + Value::Array(a) => vals.extend(a.data.iter().cloned()), + v => vals.push(v), + } + } + Value::from(Array::row(vals)) + } + ev => self.deref(ev), + }; + evaluated.push(Arg { value, from_ref }); + } + let mut ctx = FnCtx { ev: self, criteria, lambda }; + gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V) + } + + /// Performs aggregation operations without copying its ranges: blocks are folded over the stored + /// cells, so no array is built and there is no `MAX_CELLS` limit. Arguments are evaluated and read one + /// after another, as the array path does, so volatile formulas (like `RAND`) see the same order. + /// Only `SUM`, `COUNT` and `AVG` are supported currently. + fn aggregate(&mut self, kind: Agg, args: &[Expr]) -> Ev { + let mut acc = Acc::default(); + let mut first_error = None; + for a in args { + let ev = self.eval(a); + let steps = match ev { + Ev::R(areas) => areas.iter().map(|area| self.aggregate_area(kind, area, &mut acc)).collect(), + ev => { + let value = self.deref(ev); + vec![self.aggregate_value(kind, value, false, &mut acc)] + } + }; + if first_error.is_none() { + first_error = steps.into_iter().find_map(Result::err); + } + } + first_error.unwrap_or_else(|| Ev::V(acc.finish(kind))) + } + + /// Adds one area to `acc`; `Err` is the result the function ends with. + fn aggregate_area(&mut self, kind: Agg, a: &Area, acc: &mut Acc) -> Result<(), Ev> { + if !a.range.is_single() { + let mut error = None; + let walked = self.host.visit_range(a.sheet, a.range, &mut |v| match acc.add_cell(kind, v) { + Ok(()) => true, + Err(e) => { + error = Some(e); + false + } + }); + if walked { + return error.map_or(Ok(()), |e| Err(err(e))); + } + } + let value = self.area_value(a); + self.aggregate_value(kind, value, true, acc) + } + + /// Adds one argument's value by calling `SUM` and `COUNT` on it, so coercion and error rules + /// stay in one place (`AVERAGE` of an argument is its `SUM` over its `COUNT`). + fn aggregate_value(&mut self, kind: Agg, value: Value, from_ref: bool, acc: &mut Acc) -> Result<(), Ev> { + let (Some(sum), Some(count)) = (gridcraft_functions::lookup("SUM"), gridcraft_functions::lookup("COUNT")) else { + return Err(err(CellError::Name)); + }; + let args = [Arg { value, from_ref }]; + if kind != Agg::Count { + match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) { + Value::Number(n) => acc.sum += n, + other => return Err(Ev::V(other)), + } + } + if kind != Agg::Sum { + match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) { + Value::Number(n) => acc.count = acc.count.saturating_add(n as u64), + other => return Err(Ev::V(other)), + } + } + Ok(()) + } + + /// A worksheet function given by name where a function is expected (`BYROW(A1:C3,SUM)`, + /// `GROUPBY(A2:A9,B2:B9,SUM)`): its name, unless a LET/LAMBDA variable or a defined name has + /// that name. + pub(super) fn function_by_name(&self, e: &Expr) -> Option { + let Expr::Name(n) = e else { return None }; + if self.env.iter().any(|(k, _)| gridcraft_formula::same_name(k, n)) || self.host.workbook().name(n, self.sheet).is_some() { + return None; + } + let u = n.to_ascii_uppercase(); + gridcraft_functions::lookup(&u).map(|_| u) + } +} diff --git a/crates/calc/src/eval/format.rs b/crates/calc/src/eval/format.rs new file mode 100644 index 0000000000..a4c52b4925 --- /dev/null +++ b/crates/calc/src/eval/format.rs @@ -0,0 +1,74 @@ +use super::*; + +/// CELL("format") code for a number format. +pub(super) fn cell_format_code(code: &str) -> String { + let c = code.to_ascii_lowercase(); + if c == "general" { + return "G".into(); + } + if c.contains('%') { + return format!("P{}", decimals(&c)); + } + if c.contains('e') && c.contains('0') { + return format!("S{}", decimals(&c)); + } + if c.contains('$') { + return format!("C{}", decimals(&c)); + } + if c.contains('y') || c.contains('d') { + return "D1".into(); + } + if c.contains('h') { + return "D9".into(); + } + if c.contains(',') { + return format!(",{}", decimals(&c)); + } + format!("F{}", decimals(&c)) +} + +fn decimals(c: &str) -> usize { + c.split_once('.').map_or(0, |(_, d)| d.chars().take_while(|ch| *ch == '0').count()) +} + +pub(super) fn same_kind(a: &Value, b: &Value) -> bool { + std::mem::discriminant(a) == std::mem::discriminant(b) || (a.is_empty() || b.is_empty()) +} + +/// Converts an R1C1 reference text to A1 (for INDIRECT(…, FALSE)). `Ok(None)` means the text is +/// not R1C1; `Err` means it is R1C1 but points past the sheet. +pub fn r1c1_to_a1(s: &str, at: CellRef) -> Result, CellError> { + let (sheet, body) = match s.rsplit_once('!') { + Some((sh, b)) => (format!("{sh}!"), b), + None => (String::new(), s), + }; + let conv = |part: &str| -> Result, CellError> { + let u = part.to_ascii_uppercase(); + let Some((rpart, cpart)) = u.strip_prefix('R').and_then(|rest| rest.split_once('C')) else { return Ok(None) }; + // `None`: not R1C1. `Some(None)`: R1C1, but past the sheet. + let num = |p: &str, base: u32, max: u32| -> Option> { + let (v, absolute) = if p.is_empty() { + (Some(i64::from(base)), false) + } else if let Some(inner) = p.strip_prefix('[').and_then(|x| x.strip_suffix(']')) { + (i64::from(base).checked_add(inner.parse().ok()?), false) + } else { + (p.parse::().ok()?.checked_sub(1).map(i64::from), true) + }; + Some(v.filter(|v| (0..i64::from(max)).contains(v)).map(|v| (v as u32, absolute))) + }; + let (Some(r), Some(c)) = (num(rpart, at.row, MAX_ROWS), num(cpart, at.col, MAX_COLS)) else { return Ok(None) }; + let (Some((r, ra)), Some((c, ca))) = (r, c) else { return Err(CellError::Ref) }; + Ok(Some(format!("{}{}{}{}", if ca { "$" } else { "" }, gridcraft_core::col_to_letters(c), if ra { "$" } else { "" }, r + 1))) + }; + let out = match body.split_once(':') { + Some((a, b)) => match (conv(a)?, conv(b)?) { + (Some(a), Some(b)) => format!("{a}:{b}"), + _ => return Ok(None), + }, + None => match conv(body)? { + Some(x) => x, + None => return Ok(None), + }, + }; + Ok(Some(format!("{sheet}{out}"))) +} diff --git a/crates/calc/src/eval/lambda.rs b/crates/calc/src/eval/lambda.rs new file mode 100644 index 0000000000..45f99b4884 --- /dev/null +++ b/crates/calc/src/eval/lambda.rs @@ -0,0 +1,198 @@ +use super::array::{broadcast_len, single}; +use super::*; + +impl<'h> Evaluator<'h> { + pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev { + // Too many or too few argument positions is #VALUE!. An explicitly empty position, as in + // `(1,)`, is `Expr::Missing` and still counts as a position (it binds as omitted). `f()` + // parses to no arguments, which for a one-parameter lambda can also be read as one empty + // position, so that call still binds the parameter as omitted. + let positions_ok = args.len() == l.params.len() || (args.is_empty() && l.params.len() == 1); + if !positions_ok { + return err(CellError::Value); + } + let mut bound = Vec::with_capacity(l.params.len()); + for (i, p) in l.params.iter().enumerate() { + let v = match args.get(i) { + Some(Expr::Missing) | None => Ev::V(Value::Empty), + Some(e) => self.eval(e), + }; + bound.push((p.clone(), v)); + } + self.call_lambda_values(l, bound) + } + + pub(super) 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 + } + + pub(super) fn let_(&mut self, args: &[Expr]) -> Ev { + if args.len() < 3 || args.len().is_multiple_of(2) { + return err(CellError::Value); + } + let base = self.env.len(); + for pair in args[..args.len() - 1].chunks(2) { + let [Expr::Name(n), v] = pair else { + self.env.truncate(base); + return err(CellError::Value); + }; + let val = self.eval(v); + self.env.push((n.clone(), val)); + } + let r = match args.last() { + Some(body) => self.eval(body), + None => err(CellError::Value), + }; + self.env.truncate(base); + r + } + + /// The function argument of a LAMBDA helper; a function given by name takes `arity` + /// arguments. + fn lambda_arg(&mut self, e: Option<&Expr>, arity: usize) -> Option> { + if let Some(f) = e.and_then(|x| self.function_by_name(x)) { + let params: Vec = (0..arity).map(|i| format!("_xleta.{i}")).collect(); + let body = Expr::Call(f, params.iter().map(|p| Expr::Name(p.clone())).collect()); + return Some(Arc::new(Lambda { params, body, env: Vec::new() })); + } + match e.map(|x| self.eval(x)) { + Some(Ev::L(l)) => Some(l), + _ => None, + } + } + + pub(super) fn higher_order(&mut self, name: &str, args: &[Expr]) -> Ev { + let as_array = |v: Value| -> Arc { + match v { + Value::Array(a) => a, + other => Arc::new(Array::scalar(other)), + } + }; + match name { + "MAP" => { + if args.len() < 2 { + return err(CellError::Value); + } + let Some(l) = self.lambda_arg(args.last(), args.len() - 1) else { return err(CellError::Value) }; + let arrays: Vec> = args[..args.len() - 1].iter().map(|a| as_array(self.value(a))).collect(); + let rows = arrays.iter().map(|a| a.rows).max().unwrap_or(1); + let cols = arrays.iter().map(|a| a.cols).max().unwrap_or(1); + let Some(n) = broadcast_len(rows, cols) else { return err(CellError::Num) }; + // The LAMBDA takes one parameter per array. + if l.params.len() != arrays.len() { + return err(CellError::Value); + } + let mut out = Vec::with_capacity(n); + for r in 0..rows { + for c in 0..cols { + let bound = l.params.iter().zip(arrays.iter()).map(|(p, a)| (p.clone(), Ev::V(a.get_broadcast(r, c)))).collect(); + let v = self.call_lambda_values(&l, bound); + let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) }; + out.push(one); + } + } + Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc))) + } + "REDUCE" | "SCAN" => { + if args.len() != 3 { + return err(CellError::Value); + } + let mut acc = self.value(&args[0]); + let arr = as_array(self.value(&args[1])); + let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) }; + if l.params.len() != 2 { + return err(CellError::Value); + } + let mut scan = Vec::with_capacity(arr.data.len()); + for v in arr.data.iter() { + let bound = vec![(l.params[0].clone(), Ev::V(acc.clone())), (l.params[1].clone(), Ev::V(v.clone()))]; + let r = self.call_lambda_values(&l, bound); + acc = self.deref(r); + if name == "SCAN" { + let Some(one) = single(acc.clone()) else { return err(CellError::Calc) }; + scan.push(one); + } + } + if name == "SCAN" { + Ev::V(Array::new(arr.rows, arr.cols, scan).map(Value::from).unwrap_or(Value::Error(CellError::Calc))) + } else { + Ev::V(acc) + } + } + "BYROW" | "BYCOL" => { + if args.len() != 2 { + return err(CellError::Value); + } + let arr = as_array(self.value(&args[0])); + let Some(l) = self.lambda_arg(args.get(1), 1) else { return err(CellError::Value) }; + let Some(p) = l.params.first().cloned() else { return err(CellError::Value) }; + if l.params.len() != 1 { + return err(CellError::Value); + } + let by_row = name == "BYROW"; + let n = if by_row { arr.rows } else { arr.cols }; + let mut out = Vec::with_capacity(n); + for i in 0..n { + let slice: Vec = if by_row { + (0..arr.cols).map(|c| arr.get(i, c).cloned().unwrap_or_default()).collect() + } else { + (0..arr.rows).map(|r| arr.get(r, i).cloned().unwrap_or_default()).collect() + }; + let a = if by_row { Array::row(slice) } else { Array::column(slice) }; + let r = self.call_lambda_values(&l, vec![(p.clone(), Ev::V(a.into()))]); + let Some(one) = single(self.deref(r)) else { return err(CellError::Calc) }; + out.push(one); + } + let a = if by_row { Array::column(out) } else { Array::row(out) }; + Ev::V(a.into()) + } + "MAKEARRAY" => { + if args.len() != 3 { + return err(CellError::Value); + } + let (rows, cols) = match (self.arg_num(args, 0, 0.0), self.arg_num(args, 1, 0.0)) { + (Ok(r), Ok(c)) => (r.trunc(), c.trunc()), + (Err(e), _) | (_, Err(e)) => return err(e), + }; + if rows < 1.0 || cols < 1.0 || rows * cols > MAX_CELLS as f64 { + return err(CellError::Value); + } + let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) }; + if l.params.len() != 2 { + return err(CellError::Value); + } + let (rows, cols) = (rows as usize, cols as usize); + let mut out = Vec::with_capacity(rows * cols); + for r in 0..rows { + for c in 0..cols { + let bound = vec![ + (l.params[0].clone(), Ev::V(Value::number(r as f64 + 1.0))), + (l.params[1].clone(), Ev::V(Value::number(c as f64 + 1.0))), + ]; + let v = self.call_lambda_values(&l, bound); + let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) }; + out.push(one); + } + } + Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc))) + } + _ => err(CellError::Name), + } + } +} diff --git a/crates/calc/src/eval/reference_fns.rs b/crates/calc/src/eval/reference_fns.rs new file mode 100644 index 0000000000..a21797c23f --- /dev/null +++ b/crates/calc/src/eval/reference_fns.rs @@ -0,0 +1,407 @@ +use super::format::{cell_format_code, r1c1_to_a1}; +use super::*; + +impl<'h> Evaluator<'h> { + /// Whole rows/columns trimmed for ROW()/COLUMN() arrays. + pub(super) fn trim_lines(&mut self, a: &Area) -> RangeRef { + if a.range.is_full_cols() || a.range.is_full_rows() { self.trim(a) } else { a.range } + } + + pub(super) fn offset(&mut self, args: &[Expr]) -> Ev { + self.host.note_volatile(); + if args.len() < 3 || args.len() > 5 { + return err(CellError::Value); + } + let base = match self.eval(&args[0]) { + Ev::R(a) if a.len() == 1 => a[0], + Ev::V(Value::Error(e)) => return err(e), + _ => return err(CellError::Value), + }; + let rows = match self.arg_num(args, 1, 0.0) { + Ok(n) => n.trunc() as i64, + Err(e) => return err(e), + }; + let cols = match self.arg_num(args, 2, 0.0) { + Ok(n) => n.trunc() as i64, + Err(e) => return err(e), + }; + let h = match self.arg_num(args, 3, base.range.height() as f64) { + Ok(n) => n.trunc() as i64, + Err(e) => return err(e), + }; + let w = match self.arg_num(args, 4, base.range.width() as f64) { + Ok(n) => n.trunc() as i64, + Err(e) => return err(e), + }; + if h == 0 || w == 0 { + return err(CellError::Ref); + } + let r0 = (base.range.start.row as i64).saturating_add(rows); + let c0 = (base.range.start.col as i64).saturating_add(cols); + let (r0, r1) = if h > 0 { (r0, r0.saturating_add(h - 1)) } else { (r0.saturating_add(h + 1), r0) }; + let (c0, c1) = if w > 0 { (c0, c0.saturating_add(w - 1)) } else { (c0.saturating_add(w + 1), c0) }; + if r0 < 0 || c0 < 0 || r1 >= MAX_ROWS as i64 || c1 >= MAX_COLS as i64 { + return err(CellError::Ref); + } + Ev::R(vec![Area { sheet: base.sheet, range: RangeRef::new(CellRef::new(r0 as u32, c0 as u32), CellRef::new(r1 as u32, c1 as u32)) }]) + } + + pub(super) fn indirect(&mut self, args: &[Expr]) -> Ev { + self.host.note_volatile(); + if args.is_empty() || args.len() > 2 { + return err(CellError::Value); + } + let text = match self.arg_val(args, 0).map(|v| v.scalar().to_text()) { + Some(Ok(t)) => t, + Some(Err(e)) => return err(e), + None => return err(CellError::Value), + }; + let a1 = match args.get(1) { + None | Some(Expr::Missing) => true, + Some(e) => { + let v = self.value(e); + v.scalar().to_bool().unwrap_or(true) + } + }; + // With a1 = FALSE, text that isn't R1C1 can still be a name, but never an A1 reference. + let (text, a1_ok) = if a1 { + (text, true) + } else { + match r1c1_to_a1(&text, self.at) { + Ok(Some(a1_text)) => (a1_text, true), + Ok(None) => (text, false), + Err(e) => return err(e), + } + }; + match gridcraft_formula::parse(&text) { + Ok(Expr::Ref(_)) if !a1_ok => err(CellError::Ref), + Ok(e @ (Expr::Ref(_) | Expr::Name(_) | Expr::Struct(_))) => match self.eval(&e) { + r @ Ev::R(_) => r, + _ => err(CellError::Ref), + }, + _ => err(CellError::Ref), + } + } + + pub(super) fn index(&mut self, args: &[Expr]) -> Ev { + if args.is_empty() || args.len() > 4 { + return err(CellError::Value); + } + let src = self.eval(&args[0]); + let mut arg = |i: usize| match args.get(i) { + None | Some(Expr::Missing) => None, + Some(e) => Some(self.value(e)), + }; + let (rows, cols) = (arg(1), arg(2)); + let area_n = match self.arg_num(args, 3, 1.0) { + Ok(n) => n.trunc() as i64, + Err(e) => return err(e), + }; + let two = args.len() == 2; + let num = |v: &Option| v.as_ref().map_or(Ok(0.0), |v| v.to_number()).map(|n| n.trunc() as i64); + // Arrays of positions pick a value for each: INDEX(A1:C10,{1;2},{1,3}) is 2×2. + if let (Some(Value::Array(_)), _) | (_, Some(Value::Array(_))) = (&rows, &cols) { + let as_array = |v: &Option| match v { + Some(Value::Array(a)) => Arc::clone(a), + Some(v) => Arc::new(Array::scalar(v.clone())), + None => Arc::new(Array::scalar(Value::Number(0.0))), + }; + let (ra, ca) = (as_array(&rows), as_array(&cols)); + let (h, w) = (ra.rows.max(ca.rows), ra.cols.max(ca.cols)); + if (h as u64).saturating_mul(w as u64) > MAX_CELLS { + return err(CellError::Num); + } + let mut out = Vec::with_capacity(h * w); + for r in 0..h { + for c in 0..w { + let pos = (num(&Some(ra.get_broadcast(r, c))), num(&Some(ca.get_broadcast(r, c)))); + let v = match pos { + (Ok(row), Ok(col)) => { + let one = self.index_one(src.clone(), row, col, area_n, two); + match self.deref(one) { + Value::Array(a) if a.rows * a.cols > 1 => Value::Error(CellError::Calc), + v => v.scalar(), + } + } + (Err(e), _) | (_, Err(e)) => Value::Error(e), + }; + out.push(v); + } + } + return Ev::V(Array::new(h, w, out).map(Value::from).unwrap_or(Value::Error(CellError::Value))); + } + let (row, col) = match (num(&rows), num(&cols)) { + (Ok(r), Ok(c)) => (r, c), + (Err(e), _) | (_, Err(e)) => return err(e), + }; + self.index_one(src, row, col, area_n, two) + } + + /// INDEX for one row and column position (0 = the whole column or row). + fn index_one(&mut self, src: Ev, row: i64, col: i64, area_n: i64, two_args: bool) -> Ev { + if row < 0 || col < 0 { + return err(CellError::Value); + } + match src { + Ev::R(areas) => { + let area = usize::try_from((area_n as u64).saturating_sub(1)).ok(); + let Some(a) = area.and_then(|i| areas.get(i)).copied() else { return err(CellError::Ref) }; + let r = a.range; + // A single row or column with one index picks along it. An area with several rows + // and columns needs both: `INDEX(A1:C3,2)` is #REF! in Excel (an empty third + // argument, `INDEX(A1:C3,2,)`, still means the whole row). Arrays differ: + // `INDEX({1,2;3,4},2)` is the row. + if two_args && r.height() > 1 && r.width() > 1 { + return err(CellError::Ref); + } + let (row, col) = if two_args && r.height() == 1 { (1, row) } else { (row, col) }; + if row as u64 > r.height() as u64 || col as u64 > r.width() as u64 { + return err(CellError::Ref); + } + let (r0, r1) = if row == 0 { (r.start.row, r.end.row) } else { (r.start.row + row as u32 - 1, r.start.row + row as u32 - 1) }; + let (c0, c1) = if col == 0 { (r.start.col, r.end.col) } else { (r.start.col + col as u32 - 1, r.start.col + col as u32 - 1) }; + Ev::R(vec![Area { sheet: a.sheet, range: RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)) }]) + } + Ev::V(Value::Array(arr)) => { + let (row, col) = if two_args && arr.rows == 1 { (1, row) } else { (row, col) }; + if row as usize > arr.rows || col as usize > arr.cols { + return err(CellError::Ref); + } + match (row, col) { + (0, 0) => Ev::V(Value::Array(arr)), + (0, c) => Ev::V(Array::column((0..arr.rows).map(|r| arr.get(r, c as usize - 1).cloned().unwrap_or_default()).collect()).into()), + (r, 0) => Ev::V(Array::row((0..arr.cols).map(|c| arr.get(r as usize - 1, c).cloned().unwrap_or_default()).collect()).into()), + (r, c) => Ev::V(arr.get(r as usize - 1, c as usize - 1).cloned().unwrap_or(Value::Error(CellError::Ref))), + } + } + Ev::V(Value::Error(e)) => err(e), + Ev::V(v) => { + if row <= 1 && col <= 1 { + Ev::V(v) + } else { + err(CellError::Ref) + } + } + Ev::L(_) => err(CellError::Value), + } + } + + /// XLOOKUP whose result range is a reference returns a reference, as in Excel, so that it can + /// end a range (`SUM(XLOOKUP(a,…):XLOOKUP(b,…))`) or feed CELL. Everything else (arrays of + /// lookup values, array arguments, errors) goes to the library function. + pub(super) fn xlookup(&mut self, args: &[Expr]) -> Ev { + if (3..=6).contains(&args.len()) + && let (Ev::R(look), Ev::R(ret)) = (self.eval(&args[1]), self.eval(&args[2])) + && let ([l], [r]) = (look.as_slice(), ret.as_slice()) + && let Some(spec) = gridcraft_functions::lookup("XMATCH") + { + let key = self.value(&args[0]); + let (l, r) = (*l, *r); + let vertical = l.range.width() == 1 && l.range.height() > 1; + let horizontal = l.range.height() == 1 && l.range.width() > 1; + let same_len = if vertical { r.range.height() == l.range.height() } else { r.range.width() == l.range.width() }; + if !matches!(key, Value::Array(_)) && (vertical || horizontal) && same_len { + let mut xargs = vec![Arg { value: key, from_ref: false }, Arg { value: self.area_value(&l), from_ref: true }]; + // An empty match_mode before a given search_mode is the default (0), not the end. + let given = |i: usize| args.get(i).is_some_and(|e| !matches!(e, Expr::Missing)); + let last = if given(5) { + 5 + } else if given(4) { + 4 + } else { + 3 + }; + for i in 4..=last { + match args.get(i) { + Some(Expr::Missing) | None => xargs.push(Arg { value: Value::Number(0.0), from_ref: false }), + Some(e) => { + let value = self.value(e); + xargs.push(Arg { value, from_ref: false }); + } + } + } + let mut ctx = FnCtx { ev: self, criteria: None, lambda: None }; + return match gridcraft_functions::call(spec, &xargs, &mut ctx) { + Value::Number(p) if p >= 1.0 => { + let k = p as u32 - 1; + let range = if vertical { + RangeRef::new( + CellRef::new(r.range.start.row + k, r.range.start.col), + CellRef::new(r.range.start.row + k, r.range.end.col), + ) + } else { + RangeRef::new( + CellRef::new(r.range.start.row, r.range.start.col + k), + CellRef::new(r.range.end.row, r.range.start.col + k), + ) + }; + Ev::R(vec![Area { sheet: r.sheet, range }]) + } + Value::Error(CellError::NA) if args.get(3).is_some_and(|e| !matches!(e, Expr::Missing)) => self.eval(&args[3]), + Value::Error(e) => err(e), + _ => err(CellError::Value), + }; + } + } + self.builtin("XLOOKUP", args) + } + + pub(super) fn cell_info(&mut self, args: &[Expr]) -> Ev { + let kind = match self.arg_val(args, 0).map(|v| v.scalar().to_text()) { + Some(Ok(t)) => t.to_ascii_lowercase(), + Some(Err(e)) => return err(e), + None => return err(CellError::Value), + }; + let area = match args.get(1).map(|a| self.eval(a)) { + Some(Ev::R(a)) => a.first().copied(), + None => Some(Area { sheet: self.sheet, range: RangeRef::cell(self.at) }), + _ => return err(CellError::Value), + }; + let Some(a) = area else { return err(CellError::Ref) }; + let c = a.range.start; + let wb = self.host.workbook(); + let sheet = wb.sheet(a.sheet); + match kind.as_str() { + "address" => Ev::V(Value::text(format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1))), + "row" => Ev::V(Value::number(c.row as f64 + 1.0)), + "col" => Ev::V(Value::number(c.col as f64 + 1.0)), + "contents" => Ev::V(self.host.cell_value(a.sheet, c)), + "type" => { + let v = self.host.cell_value(a.sheet, c); + Ev::V(Value::text(match v { + Value::Empty => "b", + Value::Text(_) => "l", + _ => "v", + })) + } + "width" => Ev::V(Value::number(sheet.map_or(8.43, |s| gridcraft_model::points_to_chars(s.col_width(c.col) as f64)).round())), + // Excel's `/[]`, which the classic sheet-name formula splits on + // the `]`. Empty until the workbook has a file of its own, as in Excel (#1489). + "filename" => Ev::V(Value::text(match &wb.source_path { + Some(file) => { + let name = file.file_name().unwrap_or_default().to_string_lossy(); + let on = sheet.map_or("", |s| s.name.as_str()); + file.parent().unwrap_or(std::path::Path::new("")).join(format!("[{name}]{on}")).to_string_lossy().into_owned() + } + None => String::new(), + })), + "sheetname" => Ev::V(Value::text(sheet.map(|s| s.name.clone()).unwrap_or_default())), + "protect" => Ev::V(Value::number(if sheet.is_some_and(|s| wb.styles.get(s.style_id(c)).protection.locked) { 1.0 } else { 0.0 })), + "format" => { + let code = sheet.map(|s| wb.styles.get(s.style_id(c)).num_fmt.as_str().to_string()).unwrap_or_default(); + Ev::V(Value::text(cell_format_code(&code))) + } + "color" | "parentheses" => Ev::V(Value::Number(0.0)), + "prefix" => Ev::V(Value::text("")), + _ => err(CellError::Value), + } + } + + pub(super) fn subtotal(&mut self, name: &str, args: &[Expr]) -> Ev { + // SUBTOTAL needs function_num and ref1; AGGREGATE needs function_num, options and ref1. + let min_args = if name == "AGGREGATE" { 3 } else { 2 }; + if args.len() < min_args { + return err(CellError::Value); + } + let code = match self.arg_num(args, 0, 0.0) { + Ok(n) => n.trunc() as u32, + Err(e) => return err(e), + }; + let (func, options, rest) = if name == "AGGREGATE" { + let opt = match self.arg_num(args, 1, 0.0) { + Ok(n) => n.trunc() as u32, + Err(e) => return err(e), + }; + (code, opt, &args[2..]) + } else { + let (f, ignore_hidden) = if code > 100 { (code - 100, true) } else { (code, false) }; + (f, if ignore_hidden { 5 } else { 0 }, &args[1..]) + }; + if !(1..=19).contains(&func) || (name == "SUBTOTAL" && func > 11) { + return err(CellError::Value); + } + let skip_hidden = matches!(options, 1 | 3 | 5 | 7); + // SUBTOTAL leaves out rows a filter hides whatever the function number (Microsoft's + // SUBTOTAL documentation); 101-111 also leave out rows hidden by hand. + let skip_filtered = name == "SUBTOTAL"; + let skip_errors = matches!(options, 2 | 3 | 6 | 7); + // SUBTOTAL always leaves out nested SUBTOTAL/AGGREGATE cells (101-111 too); AGGREGATE does so for options 0-3. + let skip_nested = name == "SUBTOTAL" || options <= 3; + let mut values = Vec::new(); + let mut k = None; + let refs = if name == "AGGREGATE" && func >= 14 { &rest[..1.min(rest.len())] } else { rest }; + if name == "AGGREGATE" && func >= 14 { + k = rest.get(1).map(|e| self.value(e)); + } + // Every cell visited (even one skipped as hidden, nested or an error) and every value + // counts against one budget for the whole call. All arguments are evaluated (references + // stay lazy) and charged before any cell is read: a value stored in XFD1048576 makes + // `A:XFD` trim to the whole sheet, ~17 billion cells (#2175). + enum Input { + Cells(usize, RangeRef), + Values(Value), + } + let mut inputs = Vec::new(); + let mut budget = MAX_CELLS; + for e in refs { + match self.eval(e) { + Ev::R(areas) => { + for a in areas { + let range = self.trim(&a); + budget = match budget.checked_sub(range.count()) { + Some(left) => left, + None => return err(CellError::Num), + }; + inputs.push(Input::Cells(a.sheet, range)); + } + } + Ev::V(v) => { + let n = match &v { + Value::Array(a) => u64::try_from(a.data.len()).unwrap_or(u64::MAX), + _ => 1, + }; + budget = match budget.checked_sub(n) { + Some(left) => left, + None => return err(CellError::Num), + }; + inputs.push(Input::Values(v)); + } + Ev::L(_) => return err(CellError::Value), + } + } + for input in inputs { + match input { + Input::Cells(sheet, range) => { + for c in range.iter() { + let (hidden, nested) = { + let wb = self.host.workbook(); + let s = wb.sheet(sheet); + let hidden = + s.is_some_and(|s| if skip_hidden { s.is_row_hidden(c.row) } else { skip_filtered && s.is_row_filtered(c.row) }); + let nested = skip_nested + && s.and_then(|s| s.cell(c)).and_then(|cell| cell.formula.as_ref()).is_some_and(|f| { + let u = f.text.to_ascii_uppercase(); + u.contains("SUBTOTAL(") || u.contains("AGGREGATE(") + }); + (hidden, nested) + }; + if hidden || nested { + continue; + } + let v = self.host.cell_value(sheet, c); + if skip_errors && v.is_error() { + continue; + } + values.push(v); + } + } + Input::Values(Value::Array(a)) => values.extend(a.data.iter().filter(|v| !(skip_errors && v.is_error())).cloned()), + Input::Values(v) => values.push(v), + } + } + if let Some(k) = k { + return Ev::V(gridcraft_functions::aggregate_values_k(func, &values, &k)); + } + Ev::V(gridcraft_functions::aggregate_values(func, &values)) + } +} diff --git a/crates/calc/src/eval/special.rs b/crates/calc/src/eval/special.rs new file mode 100644 index 0000000000..f68badbfcf --- /dev/null +++ b/crates/calc/src/eval/special.rs @@ -0,0 +1,167 @@ +use super::*; + +/// SUMIF and AVERAGEIF with a sum range: Excel sizes the sum range like the criteria range. +pub(super) fn resizes_sum_range(name: &str, args: usize) -> bool { + args == 3 && matches!(name, "SUMIF" | "AVERAGEIF") +} + +/// Height and width of a single-area reference or an array argument. +pub(super) fn ev_shape(ev: &Ev) -> Option<(u32, u32)> { + match ev { + Ev::R(areas) => match areas.as_slice() { + [a] => Some((a.range.height(), a.range.width())), + _ => None, + }, + Ev::V(Value::Array(a)) => Some((u32::try_from(a.rows).unwrap_or(u32::MAX), u32::try_from(a.cols).unwrap_or(u32::MAX))), + Ev::V(_) => Some((1, 1)), + Ev::L(_) => None, + } +} + +/// The range SUMIF/AVERAGEIF add up: from the sum range's top-left cell, as many rows and columns +/// as the criteria range (`height` × `width`), cut off at the sheet's edge. +pub fn resized_sum_range(sum: RangeRef, height: u32, width: u32) -> RangeRef { + let row = sum.start.row.saturating_add(height.saturating_sub(1)).min(MAX_ROWS - 1); + let col = sum.start.col.saturating_add(width.saturating_sub(1)).min(MAX_COLS - 1); + RangeRef::new(sum.start, CellRef::new(row, col)) +} + +/// Collects the static references of an expression with their sheets resolved; `dynamic` is +/// set when the formula uses INDIRECT/OFFSET/names/tables whose targets can change. +pub fn precedents(wb: &Workbook, sheet: usize, e: &Expr) -> (Vec, bool) { + let mut out = Vec::new(); + let mut dynamic = false; + let push = |out: &mut Vec, r: &Reference, range: RangeRef| match &r.sheet { + SheetSel::Current => out.push(Area { sheet, range }), + SheetSel::Named(n) => { + if let Some(i) = wb.sheet_index(n) { + out.push(Area { sheet: i, range }); + } + } + SheetSel::Span(a, b) => { + if let (Some(i), Some(j)) = (wb.sheet_index(a), wb.sheet_index(b)) { + for s in i.min(j)..=i.max(j) { + out.push(Area { sheet: s, range }); + } + } + } + }; + e.walk(&mut |x| match x { + Expr::Ref(r) => push(&mut out, r, r.range()), + // The cells SUMIF/AVERAGEIF really read from the sum range (see `resized_sum_range`); when + // the criteria range's size isn't known statically, recalculate the formula every time. + Expr::Call(n, args) if resizes_sum_range(n, args.len()) => match (args.first(), args.get(2)) { + (Some(Expr::Ref(c)), Some(Expr::Ref(s))) => { + let (crit, sum) = (c.range(), s.range()); + push(&mut out, s, resized_sum_range(sum, crit.height(), crit.width())); + } + (_, Some(Expr::Number(_) | Expr::Text(_) | Expr::Bool(_) | Expr::Error(_) | Expr::Array(_) | Expr::Missing)) => {} + _ => dynamic = true, + }, + Expr::Call(n, _) + if matches!(n.as_str(), "INDIRECT" | "OFFSET" | "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" | "CELL" | "INFO") => + { + dynamic = true + } + Expr::Name(_) | Expr::Struct(_) => dynamic = true, + Expr::Call(n, _) if gridcraft_functions::lookup(n).is_none() && !is_special(n) => dynamic = true, + // A range ending in a function's result (`A1:INDEX(B1:B9,n)`) covers the cells between + // the references inside it, not only those references. + Expr::Binary(BinOp::Range, a, b) if !matches!((&**a, &**b), (Expr::Ref(_), Expr::Ref(_))) => { + let mut inner = precedents(wb, sheet, a).0; + inner.extend(precedents(wb, sheet, b).0); + let mut boxes: Vec = Vec::new(); + for area in inner { + match boxes.iter_mut().find(|x| x.sheet == area.sheet) { + Some(x) => x.range = x.range.union(&area.range), + None => boxes.push(area), + } + } + out.extend(boxes); + } + _ => {} + }); + (out, dynamic) +} + +fn is_special(n: &str) -> bool { + matches!( + n, + "IF" | "IFS" + | "IFERROR" + | "IFNA" + | "CHOOSE" + | "SWITCH" + | "ROW" + | "COLUMN" + | "ROWS" + | "COLUMNS" + | "AREAS" + | "ISREF" + | "ISFORMULA" + | "FORMULATEXT" + | "SHEET" + | "SHEETS" + | "OFFSET" + | "INDIRECT" + | "INDEX" + | "CELL" + | "TRIMRANGE" + | "SUBTOTAL" + | "AGGREGATE" + | "TEXT" + | "LET" + | "LAMBDA" + | "ISOMITTED" + | "MAP" + | "REDUCE" + | "SCAN" + | "BYROW" + | "BYCOL" + | "MAKEARRAY" + ) +} + +/// Names of functions the evaluator implements itself (merged into the function list for UI). +pub const SPECIAL_FUNCTIONS: &[&str] = &[ + "IF", + "IFS", + "IFERROR", + "IFNA", + "CHOOSE", + "SWITCH", + "ROW", + "COLUMN", + "ROWS", + "COLUMNS", + "AREAS", + "ISREF", + "ISFORMULA", + "FORMULATEXT", + "SHEET", + "SHEETS", + "OFFSET", + "INDIRECT", + "INDEX", + "CELL", + "TRIMRANGE", + "SUBTOTAL", + "AGGREGATE", + "TEXT", + "LET", + "LAMBDA", + "ISOMITTED", + "MAP", + "REDUCE", + "SCAN", + "BYROW", + "BYCOL", + "MAKEARRAY", +]; + +pub fn is_known_function(n: &str) -> bool { + is_special(&n.to_ascii_uppercase()) || gridcraft_functions::lookup(n).is_some() +} + +#[allow(dead_code)] +fn unused(_: RefKind) {} diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 4cbbcc8b4f..b7f403131c 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -13,359 +13,20 @@ use std::collections::{BTreeSet, VecDeque}; use std::hash::{BuildHasherDefault, Hasher}; -/// A fast, non-cryptographic hasher for cell keys (FxHash-style multiply-rotate). -#[derive(Default, Clone, Copy)] -pub struct FxHasher(u64); - -impl Hasher for FxHasher { - fn finish(&self) -> u64 { - self.0 - } - fn write(&mut self, bytes: &[u8]) { - for b in bytes { - self.write_u64(*b as u64); - } - } - fn write_u64(&mut self, i: u64) { - self.0 = (self.0.rotate_left(5) ^ i).wrapping_mul(0x51_7c_c1_b7_27_22_0a_95); - } - fn write_u32(&mut self, i: u32) { - self.write_u64(i as u64); - } - fn write_usize(&mut self, i: usize) { - self.write_u64(i as u64); - } -} - -type HashMap = std::collections::HashMap>; -type HashSet = std::collections::HashSet>; - use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value}; use gridcraft_formula::Expr; use gridcraft_model::{CalcMode, Workbook}; use crate::eval::{Area, Evaluator, Host, precedents, stack_position}; -pub type Key = (usize, CellRef); - -#[derive(Clone, Debug, Default)] -struct Node { - areas: Vec, - dynamic: bool, -} - -/// Ranges wider than this many columns go to the per-sheet wide index instead of per-column ones. -const WIDE: u32 = 64; - -/// Insertions an [`Intervals`] keeps aside (scanned by every query) before merging them in. -const PENDING: usize = 32; - -/// Formulas a formula may calculate on demand one inside the other (each needing one another's -/// value before it is done). Each level takes stack, so a chain deeper than this is suspended and -/// calculated from the top instead (see [`PassHost::calculate`]): long dependency chains calculate -/// whatever order they come in, with bounded stack. -const MAX_NESTED: usize = 64; - -/// Formula evaluations iterative calculation may spend on one recalculation (passes × formulas -/// evaluated again), however high Maximum Iterations is. -const MAX_ITERATION_WORK: usize = 10_000_000; - -/// Row intervals `[r0, r1]`, each with a payload, answering "which intervals overlap rows -/// `a..=b`?" in O(log n + k). -/// -/// The intervals are sorted by start with an implicit segment tree of their largest end, so a query -/// only descends into subtrees that hold a match. Removals mark entries dead (re-inserting one -/// revives it); insertions wait in a small sorted set. [`Intervals::settle`] merges them and drops -/// dead entries once either grows, so mutations cost O(log n) amortised. -#[derive(Clone, Debug)] -struct Intervals { - /// Sorted, without duplicates. - items: Vec<(u32, u32, T)>, - dead: Vec, - dead_count: usize, - /// Segment tree over `items`: node `i` has children `2i` and `2i + 1`, leaves start at - /// `max_end.len() / 2`. Each node holds the largest end below it (dead entries included). - max_end: Vec, - /// Inserted since the last merge. - pending: BTreeSet<(u32, u32, T)>, -} - -impl Default for Intervals { - fn default() -> Self { - Intervals { items: Vec::new(), dead: Vec::new(), dead_count: 0, max_end: Vec::new(), pending: BTreeSet::new() } - } -} - -impl Intervals { - fn is_empty(&self) -> bool { - self.items.len() == self.dead_count && self.pending.is_empty() - } - - fn insert(&mut self, r0: u32, r1: u32, t: T) { - let it = (r0, r1, t); - if let Ok(i) = self.items.binary_search(&it) { - if let Some(d) = self.dead.get_mut(i) - && *d - { - *d = false; - self.dead_count -= 1; - } - return; - } - self.pending.insert(it); - } - - fn remove(&mut self, r0: u32, r1: u32, t: T) { - let it = (r0, r1, t); - if let Ok(i) = self.items.binary_search(&it) { - if let Some(d) = self.dead.get_mut(i) - && !*d - { - *d = true; - self.dead_count += 1; - } - return; - } - self.pending.remove(&it); - } - - /// Merges pending insertions and drops dead entries when there are enough of them. - fn settle(&mut self) { - if self.pending.len() <= PENDING && self.dead_count <= (self.items.len() / 2).max(PENDING) { - return; - } - let mut all: Vec<(u32, u32, T)> = Vec::with_capacity(self.items.len() - self.dead_count + self.pending.len()); - all.extend(self.items.iter().zip(&self.dead).filter(|(_, d)| !**d).map(|(it, _)| *it)); - all.extend(std::mem::take(&mut self.pending)); - // Two sorted runs: the stable sort merges them in linear time. - all.sort(); - all.dedup(); - let size = all.len().next_power_of_two(); - let mut max_end = vec![0; 2 * size]; - if let Some(leaves) = max_end.get_mut(size..) { - for (slot, it) in leaves.iter_mut().zip(&all) { - *slot = it.1; - } - } - for i in (1..size).rev() { - let m = max_end.get(2 * i).copied().unwrap_or(0).max(max_end.get(2 * i + 1).copied().unwrap_or(0)); - if let Some(slot) = max_end.get_mut(i) { - *slot = m; - } - } - self.dead = vec![false; all.len()]; - self.dead_count = 0; - self.items = all; - self.max_end = max_end; - } - - /// Calls `f` for every interval overlapping rows `a..=b` (`r0 <= b && r1 >= a`). Returns the - /// number of tree nodes visited. - fn query(&self, a: u32, b: u32, f: &mut dyn FnMut(&T)) -> usize { - for it in &self.pending { - if it.0 <= b && it.1 >= a { - f(&it.2); - } - } - // Entries before `p` start at or before row `b`. - let p = self.items.partition_point(|it| it.0 <= b); - if p == 0 { - return 0; - } - self.walk(1, 0, self.max_end.len() / 2, p, a, f) - } - - fn walk(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, f: &mut dyn FnMut(&T)) -> usize { - if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) { - return 1; - } - if hi - lo <= 1 { - if let (Some(it), Some(false)) = (self.items.get(lo), self.dead.get(lo)) - && it.1 >= a - { - f(&it.2); - } - return 1; - } - let mid = lo + (hi - lo) / 2; - 1 + self.walk(2 * node, lo, mid, p, a, f) + self.walk(2 * node + 1, mid, hi, p, a, f) - } -} - -#[derive(Clone, Debug, Default)] -pub struct Graph { - nodes: HashMap, - /// single cell → dependents - cell_deps: HashMap>, - /// (sheet, col) → row intervals of the ranges (up to `WIDE` columns) that cover the column - col_deps: HashMap<(usize, u32), Intervals>, - /// sheet → row intervals of wider ranges, with their columns: (first, last, dependent) - wide_deps: HashMap>, - dynamic: HashSet, - /// Range indexes changed since the last [`Graph::settle`]. - unsettled_cols: HashSet<(usize, u32)>, - unsettled_wide: HashSet, -} +mod graph; +mod pass_host; -impl Graph { - pub fn len(&self) -> usize { - self.nodes.len() - } - pub fn is_empty(&self) -> bool { - self.nodes.is_empty() - } - - /// Index entries of a formula's precedents, without duplicates: single cells, column - /// intervals and wide ranges. - fn entries(areas: &[Area]) -> (Vec, Vec<(usize, u32, u32, u32)>, Vec<(usize, RangeRef)>) { - let (mut cells, mut cols, mut wide) = (Vec::new(), Vec::new(), Vec::new()); - for a in areas { - if a.range.is_single() { - cells.push((a.sheet, a.range.start)); - } else if a.range.width() > WIDE { - wide.push((a.sheet, a.range)); - } else { - for c in a.range.start.col..=a.range.end.col { - cols.push((a.sheet, c, a.range.start.row, a.range.end.row)); - } - } - } - cells.sort_unstable(); - cells.dedup(); - cols.sort_unstable(); - cols.dedup(); - wide.sort_unstable_by_key(|(s, r)| (*s, r.start, r.end)); - wide.dedup(); - (cells, cols, wide) - } - - fn insert(&mut self, k: Key, node: Node) { - self.remove(k); - let (cells, cols, wide) = Self::entries(&node.areas); - for c in cells { - self.cell_deps.entry(c).or_default().insert(k); - } - for (s, c, r0, r1) in cols { - self.col_deps.entry((s, c)).or_default().insert(r0, r1, k); - self.unsettled_cols.insert((s, c)); - } - for (s, r) in wide { - self.wide_deps.entry(s).or_default().insert(r.start.row, r.end.row, (r.start.col, r.end.col, k)); - self.unsettled_wide.insert(s); - } - if node.dynamic { - self.dynamic.insert(k); - } - self.nodes.insert(k, node); - } - - fn remove(&mut self, k: Key) { - let Some(old) = self.nodes.remove(&k) else { return }; - let (cells, cols, wide) = Self::entries(&old.areas); - for c in cells { - if let Some(v) = self.cell_deps.get_mut(&c) { - v.remove(&k); - if v.is_empty() { - self.cell_deps.remove(&c); - } - } - } - for (s, c, r0, r1) in cols { - if let Some(v) = self.col_deps.get_mut(&(s, c)) { - v.remove(r0, r1, k); - self.unsettled_cols.insert((s, c)); - } - } - for (s, r) in wide { - if let Some(v) = self.wide_deps.get_mut(&s) { - v.remove(r.start.row, r.end.row, (r.start.col, r.end.col, k)); - self.unsettled_wide.insert(s); - } - } - self.dynamic.remove(&k); - } - - /// Tidies the range indexes changed by `insert`/`remove` (queries stay correct without it, - /// just slower). - fn settle(&mut self) { - for key in std::mem::take(&mut self.unsettled_cols) { - if let Some(v) = self.col_deps.get_mut(&key) { - v.settle(); - if v.is_empty() { - self.col_deps.remove(&key); - } - } - } - for key in std::mem::take(&mut self.unsettled_wide) { - if let Some(v) = self.wide_deps.get_mut(&key) { - v.settle(); - if v.is_empty() { - self.wide_deps.remove(&key); - } - } - } - } - - /// Formulas that read cell `c` of `sheet`. - pub fn dependents(&self, sheet: usize, c: CellRef, out: &mut Vec) { - if let Some(v) = self.cell_deps.get(&(sheet, c)) { - out.extend(v.iter().copied()); - } - if let Some(v) = self.col_deps.get(&(sheet, c.col)) { - v.query(c.row, c.row, &mut |k| out.push(*k)); - } - if let Some(v) = self.wide_deps.get(&sheet) { - v.query(c.row, c.row, &mut |(c0, c1, k)| { - if *c0 <= c.col && c.col <= *c1 { - out.push(*k); - } - }); - } - } - - /// Formulas that read any cell of `range` (a formula can be listed more than once). - pub fn dependents_of_range(&self, sheet: usize, range: RangeRef, out: &mut Vec) { - if range.count() <= 4096 { - for c in range.iter() { - if let Some(v) = self.cell_deps.get(&(sheet, c)) { - out.extend(v.iter().copied()); - } - } - } else { - for ((s, c), v) in &self.cell_deps { - if *s == sheet && range.contains(*c) { - out.extend(v.iter().copied()); - } - } - } - let (r0, r1) = (range.start.row, range.end.row); - if (range.width() as usize) <= self.col_deps.len() { - for col in range.start.col..=range.end.col { - if let Some(v) = self.col_deps.get(&(sheet, col)) { - v.query(r0, r1, &mut |k| out.push(*k)); - } - } - } else { - for ((s, col), v) in &self.col_deps { - if *s == sheet && range.start.col <= *col && *col <= range.end.col { - v.query(r0, r1, &mut |k| out.push(*k)); - } - } - } - if let Some(v) = self.wide_deps.get(&sheet) { - v.query(r0, r1, &mut |(c0, c1, k)| { - if *c0 <= range.end.col && range.start.col <= *c1 { - out.push(*k); - } - }); - } - } - - /// Precedent areas of a formula cell (for Trace Precedents). - pub fn precedents_of(&self, k: Key) -> Vec { - self.nodes.get(&k).map(|n| n.areas.clone()).unwrap_or_default() - } -} +#[cfg(test)] +use graph::Intervals; +pub use graph::{FxHasher, Graph, Key}; +use graph::{HashMap, HashSet, Node}; +use pass_host::{PassHost, spills_as_before}; /// Calculation state kept beside a workbook. #[derive(Clone, Debug)] @@ -385,546 +46,68 @@ impl Default for Calc { } } -struct PassHost<'a> { - wb: &'a Workbook, - exprs: &'a HashMap>, - results: HashMap, - pending: HashSet, - in_progress: HashSet, - /// New spills found during the pass: anchor → array. - spills: HashMap>, - /// Used range per sheet: the workbook doesn't change during a pass, and working it out walks - /// every row, so once per sheet rather than once per range read. - used: HashMap>, - rng: &'a mut u64, - now: f64, - cycle: bool, - cycles: Vec, - depth: usize, - /// A formula needed at [`MAX_NESTED`] levels deep: the evaluations in progress are abandoned - /// and it is calculated first. - suspend: Option, - /// Formulas whose evaluation was abandoned for another one, to be calculated again after it. - /// They all need it, so reading one of them while it is calculated is a circular reference, - /// as reading a formula in progress is. - waiting: HashSet, - /// The formulas abandoned since the last suspension. - abandoned: Vec, - /// Other formulas read at [`MAX_NESTED`] levels deep by the evaluations being abandoned. They - /// are calculated along with the suspended one, rather than one per restart (a wide range - /// read at the limit would restart the abandoned evaluations once per cell in it). - also: Vec, - /// Suspensions so far (tests check that a wide read at the limit needs one, not one per cell). - #[cfg(test)] - suspensions: usize, - /// The formulas in `cycles`. - cycle_seen: HashSet, - /// Iterative calculation: a circular reference reads the cell's previous value instead of - /// giving `#CIRC!`. - iterate: bool, - /// Values from the previous iteration; cells not in it read their stored value. - prev: HashMap, - /// Formulas being evaluated, innermost last (kept only when iterating). - stack: Vec, - /// Formulas found on a circular reference (when iterating). - members: HashSet, - /// Results that read a previous value, directly or through other formulas. - tainted: HashSet, - /// 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> { - fn new(wb: &'a Workbook, exprs: &'a HashMap>, rng: &'a mut u64, now: f64) -> Self { - PassHost { - wb, - exprs, - results: HashMap::with_capacity_and_hasher(exprs.len(), Default::default()), - pending: exprs.keys().copied().collect(), - in_progress: HashSet::default(), - spills: HashMap::default(), - used: HashMap::default(), - rng, - now, - cycle: false, - cycles: vec![], - depth: 0, - suspend: None, - waiting: HashSet::default(), - abandoned: Vec::new(), - also: Vec::new(), - #[cfg(test)] - suspensions: 0, - cycle_seen: HashSet::default(), - iterate: false, - prev: HashMap::default(), - stack: Vec::new(), - members: HashSet::default(), - tainted: HashSet::default(), - taint: false, - stack_top: 0, - out_of_stack: false, - } - } -} - -/// Whether an array result at `k` can't spill: it is in a table, or its area runs off the sheet, -/// overlaps merged cells, holds other cells, or another formula's spill (unless that formula is -/// recalculated in this pass, and so may spill elsewhere now). -fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool { - let Some(sh) = host.wb.sheet(sheet) else { return false }; - let end = - CellRef::new(anchor.row.saturating_add((a.rows as u32).saturating_sub(1)), anchor.col.saturating_add((a.cols as u32).saturating_sub(1))); - let range = RangeRef::new(anchor, end); - let own = sh.spill_ranges.get(&anchor); - let spilled_over = |c: CellRef| { - !own.is_some_and(|o| o.contains(c)) - && sh.spill_ranges.iter().any(|(o, r)| *o != anchor && r.contains(c) && !host.exprs.contains_key(&(sheet, *o))) - }; - end.row >= gridcraft_core::MAX_ROWS - || end.col >= gridcraft_core::MAX_COLS - || sh.merges.iter().any(|m| m.intersects(&range)) - || sh.tables.iter().any(|t| t.range.contains(anchor)) - || sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (!cell.value.is_empty() || cell.formula.is_some())) - || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c) && spilled_over(*c)) -} - -/// Whether `arr`, anchored at `anchor`, would spill exactly what the sheet already holds over -/// `range`: the same area, nothing new in the way, and the same values. -fn spills_as_before(sheet: &gridcraft_model::Sheet, anchor: CellRef, range: RangeRef, arr: &Array) -> bool { - let end = - CellRef::new(anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)), anchor.col.saturating_add((arr.cols as u32).saturating_sub(1))); - RangeRef::new(anchor, end) == range - && !sheet.merges.iter().any(|m| m.intersects(&range)) - && !sheet.tables.iter().any(|t| t.range.contains(anchor)) - && range - .iter() - .enumerate() - .all(|(i, c)| c == anchor || (!sheet.cells.has(c) && sheet.spill.get(&c) == Some(arr.data.get(i).unwrap_or(&Value::Empty)))) -} - -impl PassHost<'_> { - /// Calculates `k` (and the formulas it reads) with bounded stack: when [`Self::compute`] would - /// go deeper than [`MAX_NESTED`], it suspends what is in progress, the formula it needed is - /// calculated from here, then the suspended ones again (reading the results kept so far). - fn calculate(&mut self, k: Key) { - // Formulas to calculate, innermost last, each with the ones abandoned to calculate the - // next. - let mut work: Vec<(Key, Vec)> = vec![(k, Vec::new())]; - while let Some(&(top, _)) = work.last() { - self.compute(top); - match self.suspend.take() { - Some(next) => { - #[cfg(test)] - { - self.suspensions += 1; - } - let held = std::mem::take(&mut self.abandoned); - self.waiting.extend(held.iter().copied()); - if let Some(entry) = work.last_mut() { - entry.1 = held; - } - // The other formulas read at the limit are calculated after `next`, before the - // held evaluations restart. Each has nothing held of its own, so the held ones - // are released only when the last of them is done. - let mut seen: HashSet = HashSet::default(); - seen.insert(next); - for s in std::mem::take(&mut self.also) { - if seen.insert(s) { - work.push((s, Vec::new())); - } - } - work.push((next, Vec::new())); - } - None => { - work.pop(); - if let Some(entry) = work.last_mut() { - for a in entry.1.drain(..) { - self.waiting.remove(&a); - } - } - } - } - } - } - - fn compute(&mut self, k: Key) -> Value { - if let Some(v) = self.results.get(&k) { - let v = v.clone(); - if self.iterate && self.tainted.contains(&k) { - self.taint = true; - } - return v; - } - let Some(expr) = self.exprs.get(&k) else { - return self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default(); - }; - if self.suspend.is_some() { - // Unwinding to `calculate`: the value is thrown away. A formula read at the limit is - // calculated with the suspended one, so the evaluations are not restarted for each. - if self.depth >= MAX_NESTED && !self.in_progress.contains(&k) && !self.waiting.contains(&k) { - self.also.push(k); - } - return Value::Error(CellError::Calc); - } - if self.in_progress.contains(&k) || self.waiting.contains(&k) { - self.cycle = true; - if self.iterate { - return self.circular_read(k); - } - // Once each, though a suspended evaluation meets it again. - if self.cycle_seen.insert(k) { - self.cycles.push(k); - } - return Value::Error(CellError::Circ); - } - if self.depth >= MAX_NESTED { - 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); - if self.iterate { - 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; - self.in_progress.remove(&k); - self.abandoned.push(k); - if self.iterate { - self.stack.pop(); - } - self.taint = outer_taint; - return Value::Error(CellError::Calc); - } - // A legacy array formula fills the range it was entered in. - let legacy = self.wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.as_ref()).and_then(|f| f.array); - let v = match legacy { - Some(range) => fit_to_range(v, range), - None => v, - }; - self.depth -= 1; - self.in_progress.remove(&k); - self.pending.remove(&k); - if self.iterate { - self.stack.pop(); - if self.taint { - self.tainted.insert(k); - } else { - self.tainted.remove(&k); - } - } - self.taint |= outer_taint; - // A blank cell shows as 0 in a formula's result, inside an array as well (`=A1:A3`, - // FILTER or SORT of a range with blanks). GROUPBY and PIVOTBY lay out blank cells - // of their own, which stay blank. - let keep_blanks = matches!(&*expr, Expr::Call(n, _) if matches!(n.as_str(), "GROUPBY" | "PIVOTBY")); - let v = match v { - Value::Array(a) => { - let a = if keep_blanks || !a.data.iter().any(|x| matches!(x, Value::Empty)) { - a - } else { - let data = a.data.iter().map(|x| if matches!(x, Value::Empty) { Value::Number(0.0) } else { x.clone() }).collect(); - Array::new(a.rows, a.cols, data).map(std::sync::Arc::new).unwrap_or(a) - }; - // A dynamic-array formula anchors a spill range even while its result is one - // value -- a FILTER matching one row, a SORT or UNIQUE over one item -- so `C1#` - // is the 1 x 1 range and `ROWS(C1#)` is 1, as in Excel (#1491). A formula that - // returns a scalar never reaches this arm, so it does not become an anchor. - self.spills.insert(k, a.clone()); - a.data.first().cloned().unwrap_or_default() - } - Value::Empty => Value::Number(0.0), - v => v, - }; - // An array that can't spill is #SPILL! for the formulas evaluated after it in this pass too. - let v = match self.spills.get(&k) { - Some(a) if spill_blocked(self, k, a) => { - self.spills.remove(&k); - Value::Error(CellError::Spill) - } - _ => v, - }; - self.results.insert(k, v.clone()); - v - } - - /// A formula's previous value for iterative calculation: from the last iteration, else the - /// value it shows. Blank, and the `#CIRC!` of a loop calculated with iteration off, are 0. - fn previous(&self, k: Key) -> Value { - let v = match self.prev.get(&k) { - Some(v) => v.clone(), - None => self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default(), - }; - if matches!(v, Value::Empty | Value::Error(CellError::Circ)) { Value::Number(0.0) } else { v } - } - - /// Reading `k` while it is being evaluated, with iterative calculation on: its previous value. - /// The formulas around the loop are the ones whose changes decide when iteration stops. - fn circular_read(&mut self, k: Key) -> Value { - self.taint = true; - match self.stack.iter().rposition(|s| *s == k) { - Some(i) => self.members.extend(self.stack.get(i..).unwrap_or_default().iter().copied()), - // Too deep to follow: the formula itself is watched. - None => { - self.members.insert(k); - } - } - self.previous(k) - } - - /// Iterative calculation, after a first pass over `order` that met circular references. The - /// formulas that read a previous value (the loops and what depends on them) are evaluated - /// again in the same order, each pass starting from the last one's values and reading what it - /// has already produced (Gauss-Seidel), until no formula on a loop changes by `max_change` or - /// more, or after `max_iterations` passes in all. Returns the number of passes. - fn iterate_loops(&mut self, order: &[Key], max_iterations: u32, max_change: f64) -> u32 { - let again: Vec = order.iter().copied().filter(|k| self.tainted.contains(k)).collect(); - let set: HashSet = again.iter().copied().collect(); - // Bounds the work whatever Maximum Iterations says. - let max_passes = u32::try_from(MAX_ITERATION_WORK / again.len().max(1)).unwrap_or(u32::MAX).max(1); - let mut passes = 1u32; - while passes < max_iterations.min(max_passes) && !self.converged(max_change) { - self.prev = again.iter().filter_map(|k| self.results.remove(k).map(|v| (*k, v))).collect(); - self.pending = set.clone(); - self.spills.retain(|a, _| !set.contains(a)); - for k in &again { - self.calculate(*k); - } - passes += 1; +/// Pops one finished component off Tarjan's stack, labelling its nodes with `comp_index`. The +/// component is everything up to and including `root`. +fn pop_component(stack: &mut Vec, on_stack: &mut [bool], comp: &mut [usize], comp_index: usize, root: usize) { + while let Some(w) = stack.pop() { + on_stack[w] = false; + comp[w] = comp_index; + if w == root { + break; } - passes - } - - /// Whether every formula on a loop changed by less than `max_change` in the last pass (text, - /// logical and error values: not at all). - fn converged(&self, max_change: f64) -> bool { - self.members.iter().all(|k| { - let Some(new) = self.results.get(k) else { return true }; - match (new, &self.previous(*k)) { - (Value::Number(a), Value::Number(b)) => { - let d = (a - b).abs(); - d < max_change || d == 0.0 - } - (new, old) => new == old, - } - }) } } -/// A legacy (Ctrl+Shift+Enter) array formula's result fitted to the range it was entered in, as -/// Excel does: a single row or column repeats across the range, a single value fills it, cells -/// past the result show `#N/A`, and a result larger than the range is cut off. -fn fit_to_range(v: Value, range: RangeRef) -> Value { - if range.count() > crate::eval::MAX_CELLS { - return Value::Error(CellError::Num); - } - let (h, w) = (range.height() as usize, range.width() as usize); - let Value::Array(a) = v else { - return if h * w == 1 { v } else { Array::new(h, w, vec![v; h * w]).map(Value::from).unwrap_or(Value::Error(CellError::Value)) }; - }; - if h * w == 1 { - return a.data.first().cloned().unwrap_or_default(); - } - let mut data = Vec::with_capacity(h * w); - for r in 0..h { - for c in 0..w { - let (r, c) = (if a.rows == 1 { 0 } else { r }, if a.cols == 1 { 0 } else { c }); - data.push(a.get(r, c).cloned().unwrap_or(Value::Error(CellError::NA))); - } - } - Array::new(h, w, data).map(Value::from).unwrap_or(Value::Error(CellError::Value)) -} - -impl Host for PassHost<'_> { - fn workbook(&self) -> &Workbook { - self.wb - } - fn stack_top(&self) -> Option { - (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 { - let k = (sheet, anchor); - if !(self.pending.contains(&k) || self.results.contains_key(&k)) { - return self.wb.sheet(sheet)?.spill_ranges.get(&anchor).copied(); - } - // Recalculated in this pass: its new array, unless something blocks it (#SPILL!). - self.compute(k); - let arr = self.spills.get(&k)?; - let end = CellRef::new( - anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)), - anchor.col.saturating_add((arr.cols as u32).saturating_sub(1)), - ); - let range = RangeRef::new(anchor, end); - let sh = self.wb.sheet(sheet)?; - let blocked = end.row >= gridcraft_core::MAX_ROWS - || end.col >= gridcraft_core::MAX_COLS - || sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (cell.formula.is_some() || !cell.value.is_empty())) - || sh.merges.iter().any(|m| m.intersects(&range)); - (!blocked).then_some(range) - } - fn cell_value(&mut self, sheet: usize, c: CellRef) -> Value { - let k = (sheet, c); - if self.pending.contains(&k) || self.results.contains_key(&k) { - return self.compute(k); - } - // A cell covered by a spill computed in this pass. - for (anchor, arr) in &self.spills { - if anchor.0 == sheet && c.row >= anchor.1.row && c.col >= anchor.1.col { - let (dr, dc) = ((c.row - anchor.1.row) as usize, (c.col - anchor.1.col) as usize); - if dr < arr.rows && dc < arr.cols { - if self.iterate && self.tainted.contains(anchor) { - self.taint = true; - } - return arr.get(dr, dc).cloned().unwrap_or_default(); - } - } - } - self.wb.sheet(sheet).map(|s| s.value(c)).unwrap_or_default() - } - fn range_values(&mut self, sheet: usize, range: RangeRef) -> Vec { - let (h, w) = (range.height() as usize, range.width() as usize); - let mut out = vec![Value::Empty; h * w]; - let Some(sh) = self.wb.sheet(sheet) else { return out }; - // Sparse walk: only stored cells; formula cells that are (or were) dirty go through - // `compute`, constants are copied straight from the store. - let mut formulas: Vec<(usize, CellRef)> = Vec::new(); - for (c, cell) in sh.cells.iter_range(range) { - let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize; - if cell.formula.is_some() { - formulas.push((i, c)); - } else if let Some(slot) = out.get_mut(i) { - *slot = cell.value.clone(); - } - } - for (c, v) in sh.spill.range(range.start..=range.end) { - if range.contains(*c) { - let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize; - if let Some(slot) = out.get_mut(i) - && slot.is_empty() - { - *slot = v.clone(); +/// Strongly connected components of a graph given as adjacency lists (Tarjan, iterative): each +/// node's component index, and the number of nodes in each component. +fn components(edges: &[Vec]) -> (Vec, Vec) { + let n = edges.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; } - } - for (i, c) in formulas { - let v = self.cell_value(sheet, c); - if let Some(slot) = out.get_mut(i) { - *slot = v; - } - } - if !self.spills.is_empty() { - let new: Vec<(usize, Value)> = self - .spills - .iter() - .filter(|(a, _)| a.0 == sheet) - .flat_map(|(a, arr)| { - let mut v = Vec::new(); - for r in 0..arr.rows { - for cc in 0..arr.cols { - let c = CellRef::new(a.1.row.saturating_add(r as u32), a.1.col.saturating_add(cc as u32)); - if c != a.1 && range.contains(c) { - v.push(( - (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize, - arr.get(r, cc).cloned().unwrap_or_default(), - )); - } - } - } - v - }) - .collect(); - // Iterating: an array spilled by a formula that read a previous value. - if self.iterate - && self.spills.iter().any(|(a, arr)| { - let end = CellRef::new( - a.1.row.saturating_add((arr.rows as u32).saturating_sub(1)), - a.1.col.saturating_add((arr.cols as u32).saturating_sub(1)), - ); - a.0 == sheet && self.tainted.contains(a) && RangeRef::new(a.1, end).intersects(&range) - }) - { - self.taint = true; + call.pop(); + if let Some(&(u, _)) = call.last() { + low[u] = low[u].min(low[v]); } - for (i, v) in new { - if let Some(slot) = out.get_mut(i) { - *slot = v; - } + if low[v] == num[v] { + pop_component(&mut stack, &mut on_stack, &mut comp, comps, v); + comps += 1; } } - out } - fn visit_range(&mut self, sheet: usize, range: RangeRef, f: &mut dyn FnMut(&Value) -> bool) -> bool { - let wb = self.wb; - let Some(sh) = wb.sheet(sheet) else { return false }; - // Formula cells first, in row-major order like `range_values`: reading one calculates it - // and the pass remembers the value, so the walk below gets the same value again without - // this method holding any. - for (c, cell) in sh.cells.iter_range(range) { - if cell.formula.is_some() { - let _ = self.cell_value(sheet, c); - } - } - // Spilled values sit between the stored cells (and the formulas just calculated may have - // produced some): leave such blocks to `range_values`. - if self.spills.keys().any(|a| a.0 == sheet) || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c)) { - return false; + let mut size = vec![0usize; comps]; + for &c in &comp { + if let Some(s) = size.get_mut(c) { + *s += 1; } - for (c, cell) in sh.cells.iter_range(range) { - let go = if cell.formula.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) }; - if !go { - break; - } - } - true - } - fn used_range(&mut self, sheet: usize) -> Option { - let wb = self.wb; - *self.used.entry(sheet).or_insert_with(|| wb.sheet(sheet).and_then(|s| s.used_range())) - } - fn now_serial(&self) -> f64 { - self.now - } - fn random(&mut self) -> f64 { - // xorshift64* - let mut x = *self.rng; - x ^= x >> 12; - x ^= x << 25; - x ^= x >> 27; - *self.rng = x; - (x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64 } + (comp, size) } impl Calc { @@ -967,6 +150,7 @@ impl Calc { if circ.is_empty() { return Vec::new(); } + let n = circ.len(); let index: HashMap = 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(); @@ -981,59 +165,8 @@ impl Calc { 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; - } - } + // Loop members: nodes in a component of more than one, or with an edge to themselves. + let (comp, size) = components(&edges); 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 = diff --git a/crates/calc/src/recalc/graph.rs b/crates/calc/src/recalc/graph.rs new file mode 100644 index 0000000000..aee4b46d55 --- /dev/null +++ b/crates/calc/src/recalc/graph.rs @@ -0,0 +1,340 @@ +use super::*; + +/// A fast, non-cryptographic hasher for cell keys (FxHash-style multiply-rotate). +#[derive(Default, Clone, Copy)] +pub struct FxHasher(u64); + +impl Hasher for FxHasher { + fn finish(&self) -> u64 { + self.0 + } + fn write(&mut self, bytes: &[u8]) { + for b in bytes { + self.write_u64(*b as u64); + } + } + fn write_u64(&mut self, i: u64) { + self.0 = (self.0.rotate_left(5) ^ i).wrapping_mul(0x51_7c_c1_b7_27_22_0a_95); + } + fn write_u32(&mut self, i: u32) { + self.write_u64(i as u64); + } + fn write_usize(&mut self, i: usize) { + self.write_u64(i as u64); + } +} + +pub(super) type HashMap = std::collections::HashMap>; + +pub(super) type HashSet = std::collections::HashSet>; + +pub type Key = (usize, CellRef); + +#[derive(Clone, Debug, Default)] +pub(super) struct Node { + pub(super) areas: Vec, + pub(super) dynamic: bool, +} + +/// Ranges wider than this many columns go to the per-sheet wide index instead of per-column ones. +const WIDE: u32 = 64; + +/// Insertions an [`Intervals`] keeps aside (scanned by every query) before merging them in. +const PENDING: usize = 32; + +/// Row intervals `[r0, r1]`, each with a payload, answering "which intervals overlap rows +/// `a..=b`?" in O(log n + k). +/// +/// The intervals are sorted by start with an implicit segment tree of their largest end, so a query +/// only descends into subtrees that hold a match. Removals mark entries dead (re-inserting one +/// revives it); insertions wait in a small sorted set. [`Intervals::settle`] merges them and drops +/// dead entries once either grows, so mutations cost O(log n) amortised. +#[derive(Clone, Debug)] +pub(super) struct Intervals { + /// Sorted, without duplicates. + items: Vec<(u32, u32, T)>, + dead: Vec, + dead_count: usize, + /// Segment tree over `items`: node `i` has children `2i` and `2i + 1`, leaves start at + /// `max_end.len() / 2`. Each node holds the largest end below it (dead entries included). + max_end: Vec, + /// Inserted since the last merge. + pending: BTreeSet<(u32, u32, T)>, +} + +impl Default for Intervals { + fn default() -> Self { + Intervals { items: Vec::new(), dead: Vec::new(), dead_count: 0, max_end: Vec::new(), pending: BTreeSet::new() } + } +} + +impl Intervals { + pub(super) fn is_empty(&self) -> bool { + self.items.len() == self.dead_count && self.pending.is_empty() + } + + pub(super) fn insert(&mut self, r0: u32, r1: u32, t: T) { + let it = (r0, r1, t); + if let Ok(i) = self.items.binary_search(&it) { + if let Some(d) = self.dead.get_mut(i) + && *d + { + *d = false; + self.dead_count -= 1; + } + return; + } + self.pending.insert(it); + } + + pub(super) fn remove(&mut self, r0: u32, r1: u32, t: T) { + let it = (r0, r1, t); + if let Ok(i) = self.items.binary_search(&it) { + if let Some(d) = self.dead.get_mut(i) + && !*d + { + *d = true; + self.dead_count += 1; + } + return; + } + self.pending.remove(&it); + } + + /// Merges pending insertions and drops dead entries when there are enough of them. + pub(super) fn settle(&mut self) { + if self.pending.len() <= PENDING && self.dead_count <= (self.items.len() / 2).max(PENDING) { + return; + } + let mut all: Vec<(u32, u32, T)> = Vec::with_capacity(self.items.len() - self.dead_count + self.pending.len()); + all.extend(self.items.iter().zip(&self.dead).filter(|(_, d)| !**d).map(|(it, _)| *it)); + all.extend(std::mem::take(&mut self.pending)); + // Two sorted runs: the stable sort merges them in linear time. + all.sort(); + all.dedup(); + let size = all.len().next_power_of_two(); + let mut max_end = vec![0; 2 * size]; + if let Some(leaves) = max_end.get_mut(size..) { + for (slot, it) in leaves.iter_mut().zip(&all) { + *slot = it.1; + } + } + for i in (1..size).rev() { + let m = max_end.get(2 * i).copied().unwrap_or(0).max(max_end.get(2 * i + 1).copied().unwrap_or(0)); + if let Some(slot) = max_end.get_mut(i) { + *slot = m; + } + } + self.dead = vec![false; all.len()]; + self.dead_count = 0; + self.items = all; + self.max_end = max_end; + } + + /// Calls `f` for every interval overlapping rows `a..=b` (`r0 <= b && r1 >= a`). Returns the + /// number of tree nodes visited. + pub(super) fn query(&self, a: u32, b: u32, f: &mut dyn FnMut(&T)) -> usize { + for it in &self.pending { + if it.0 <= b && it.1 >= a { + f(&it.2); + } + } + // Entries before `p` start at or before row `b`. + let p = self.items.partition_point(|it| it.0 <= b); + if p == 0 { + return 0; + } + self.walk(1, 0, self.max_end.len() / 2, p, a, f) + } + + fn walk(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, f: &mut dyn FnMut(&T)) -> usize { + if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) { + return 1; + } + if hi - lo <= 1 { + if let (Some(it), Some(false)) = (self.items.get(lo), self.dead.get(lo)) + && it.1 >= a + { + f(&it.2); + } + return 1; + } + let mid = lo + (hi - lo) / 2; + 1 + self.walk(2 * node, lo, mid, p, a, f) + self.walk(2 * node + 1, mid, hi, p, a, f) + } +} + +#[derive(Clone, Debug, Default)] +pub struct Graph { + pub(super) nodes: HashMap, + /// single cell → dependents + cell_deps: HashMap>, + /// (sheet, col) → row intervals of the ranges (up to `WIDE` columns) that cover the column + col_deps: HashMap<(usize, u32), Intervals>, + /// sheet → row intervals of wider ranges, with their columns: (first, last, dependent) + wide_deps: HashMap>, + pub(super) dynamic: HashSet, + /// Range indexes changed since the last [`Graph::settle`]. + unsettled_cols: HashSet<(usize, u32)>, + unsettled_wide: HashSet, +} + +impl Graph { + pub fn len(&self) -> usize { + self.nodes.len() + } + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() + } + + /// Index entries of a formula's precedents, without duplicates: single cells, column + /// intervals and wide ranges. + fn entries(areas: &[Area]) -> (Vec, Vec<(usize, u32, u32, u32)>, Vec<(usize, RangeRef)>) { + let (mut cells, mut cols, mut wide) = (Vec::new(), Vec::new(), Vec::new()); + for a in areas { + if a.range.is_single() { + cells.push((a.sheet, a.range.start)); + } else if a.range.width() > WIDE { + wide.push((a.sheet, a.range)); + } else { + for c in a.range.start.col..=a.range.end.col { + cols.push((a.sheet, c, a.range.start.row, a.range.end.row)); + } + } + } + cells.sort_unstable(); + cells.dedup(); + cols.sort_unstable(); + cols.dedup(); + wide.sort_unstable_by_key(|(s, r)| (*s, r.start, r.end)); + wide.dedup(); + (cells, cols, wide) + } + + pub(super) fn insert(&mut self, k: Key, node: Node) { + self.remove(k); + let (cells, cols, wide) = Self::entries(&node.areas); + for c in cells { + self.cell_deps.entry(c).or_default().insert(k); + } + for (s, c, r0, r1) in cols { + self.col_deps.entry((s, c)).or_default().insert(r0, r1, k); + self.unsettled_cols.insert((s, c)); + } + for (s, r) in wide { + self.wide_deps.entry(s).or_default().insert(r.start.row, r.end.row, (r.start.col, r.end.col, k)); + self.unsettled_wide.insert(s); + } + if node.dynamic { + self.dynamic.insert(k); + } + self.nodes.insert(k, node); + } + + pub(super) fn remove(&mut self, k: Key) { + let Some(old) = self.nodes.remove(&k) else { return }; + let (cells, cols, wide) = Self::entries(&old.areas); + for c in cells { + if let Some(v) = self.cell_deps.get_mut(&c) { + v.remove(&k); + if v.is_empty() { + self.cell_deps.remove(&c); + } + } + } + for (s, c, r0, r1) in cols { + if let Some(v) = self.col_deps.get_mut(&(s, c)) { + v.remove(r0, r1, k); + self.unsettled_cols.insert((s, c)); + } + } + for (s, r) in wide { + if let Some(v) = self.wide_deps.get_mut(&s) { + v.remove(r.start.row, r.end.row, (r.start.col, r.end.col, k)); + self.unsettled_wide.insert(s); + } + } + self.dynamic.remove(&k); + } + + /// Tidies the range indexes changed by `insert`/`remove` (queries stay correct without it, + /// just slower). + pub(super) fn settle(&mut self) { + for key in std::mem::take(&mut self.unsettled_cols) { + if let Some(v) = self.col_deps.get_mut(&key) { + v.settle(); + if v.is_empty() { + self.col_deps.remove(&key); + } + } + } + for key in std::mem::take(&mut self.unsettled_wide) { + if let Some(v) = self.wide_deps.get_mut(&key) { + v.settle(); + if v.is_empty() { + self.wide_deps.remove(&key); + } + } + } + } + + /// Formulas that read cell `c` of `sheet`. + pub fn dependents(&self, sheet: usize, c: CellRef, out: &mut Vec) { + if let Some(v) = self.cell_deps.get(&(sheet, c)) { + out.extend(v.iter().copied()); + } + if let Some(v) = self.col_deps.get(&(sheet, c.col)) { + v.query(c.row, c.row, &mut |k| out.push(*k)); + } + if let Some(v) = self.wide_deps.get(&sheet) { + v.query(c.row, c.row, &mut |(c0, c1, k)| { + if *c0 <= c.col && c.col <= *c1 { + out.push(*k); + } + }); + } + } + + /// Formulas that read any cell of `range` (a formula can be listed more than once). + pub fn dependents_of_range(&self, sheet: usize, range: RangeRef, out: &mut Vec) { + if range.count() <= 4096 { + for c in range.iter() { + if let Some(v) = self.cell_deps.get(&(sheet, c)) { + out.extend(v.iter().copied()); + } + } + } else { + for ((s, c), v) in &self.cell_deps { + if *s == sheet && range.contains(*c) { + out.extend(v.iter().copied()); + } + } + } + let (r0, r1) = (range.start.row, range.end.row); + if (range.width() as usize) <= self.col_deps.len() { + for col in range.start.col..=range.end.col { + if let Some(v) = self.col_deps.get(&(sheet, col)) { + v.query(r0, r1, &mut |k| out.push(*k)); + } + } + } else { + for ((s, col), v) in &self.col_deps { + if *s == sheet && range.start.col <= *col && *col <= range.end.col { + v.query(r0, r1, &mut |k| out.push(*k)); + } + } + } + if let Some(v) = self.wide_deps.get(&sheet) { + v.query(r0, r1, &mut |(c0, c1, k)| { + if *c0 <= range.end.col && range.start.col <= *c1 { + out.push(*k); + } + }); + } + } + + /// Precedent areas of a formula cell (for Trace Precedents). + pub fn precedents_of(&self, k: Key) -> Vec { + self.nodes.get(&k).map(|n| n.areas.clone()).unwrap_or_default() + } +} diff --git a/crates/calc/src/recalc/pass_host.rs b/crates/calc/src/recalc/pass_host.rs new file mode 100644 index 0000000000..00e92e82b0 --- /dev/null +++ b/crates/calc/src/recalc/pass_host.rs @@ -0,0 +1,554 @@ +use super::graph::{HashMap, HashSet, Key}; +use super::*; + +/// Formulas a formula may calculate on demand one inside the other (each needing one another's +/// value before it is done). Each level takes stack, so a chain deeper than this is suspended and +/// calculated from the top instead (see [`PassHost::calculate`]): long dependency chains calculate +/// whatever order they come in, with bounded stack. +const MAX_NESTED: usize = 64; + +/// Formula evaluations iterative calculation may spend on one recalculation (passes × formulas +/// evaluated again), however high Maximum Iterations is. +const MAX_ITERATION_WORK: usize = 10_000_000; + +pub(super) struct PassHost<'a> { + wb: &'a Workbook, + exprs: &'a HashMap>, + pub(super) results: HashMap, + pending: HashSet, + in_progress: HashSet, + /// New spills found during the pass: anchor → array. + pub(super) spills: HashMap>, + /// Used range per sheet: the workbook doesn't change during a pass, and working it out walks + /// every row, so once per sheet rather than once per range read. + used: HashMap>, + rng: &'a mut u64, + now: f64, + cycle: bool, + pub(super) cycles: Vec, + depth: usize, + /// A formula needed at [`MAX_NESTED`] levels deep: the evaluations in progress are abandoned + /// and it is calculated first. + suspend: Option, + /// Formulas whose evaluation was abandoned for another one, to be calculated again after it. + /// They all need it, so reading one of them while it is calculated is a circular reference, + /// as reading a formula in progress is. + waiting: HashSet, + /// The formulas abandoned since the last suspension. + abandoned: Vec, + /// Other formulas read at [`MAX_NESTED`] levels deep by the evaluations being abandoned. They + /// are calculated along with the suspended one, rather than one per restart (a wide range + /// read at the limit would restart the abandoned evaluations once per cell in it). + also: Vec, + /// Suspensions so far (tests check that a wide read at the limit needs one, not one per cell). + #[cfg(test)] + pub(super) suspensions: usize, + /// The formulas in `cycles`. + cycle_seen: HashSet, + /// Iterative calculation: a circular reference reads the cell's previous value instead of + /// giving `#CIRC!`. + pub(super) iterate: bool, + /// Values from the previous iteration; cells not in it read their stored value. + prev: HashMap, + /// Formulas being evaluated, innermost last (kept only when iterating). + stack: Vec, + /// Formulas found on a circular reference (when iterating). + pub(super) members: HashSet, + /// Results that read a previous value, directly or through other formulas. + pub(super) tainted: HashSet, + /// 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> { + pub(super) fn new(wb: &'a Workbook, exprs: &'a HashMap>, rng: &'a mut u64, now: f64) -> Self { + PassHost { + wb, + exprs, + results: HashMap::with_capacity_and_hasher(exprs.len(), Default::default()), + pending: exprs.keys().copied().collect(), + in_progress: HashSet::default(), + spills: HashMap::default(), + used: HashMap::default(), + rng, + now, + cycle: false, + cycles: vec![], + depth: 0, + suspend: None, + waiting: HashSet::default(), + abandoned: Vec::new(), + also: Vec::new(), + #[cfg(test)] + suspensions: 0, + cycle_seen: HashSet::default(), + iterate: false, + prev: HashMap::default(), + stack: Vec::new(), + members: HashSet::default(), + tainted: HashSet::default(), + taint: false, + stack_top: 0, + out_of_stack: false, + } + } +} + +/// Whether an array result at `k` can't spill: it is in a table, or its area runs off the sheet, +/// overlaps merged cells, holds other cells, or another formula's spill (unless that formula is +/// recalculated in this pass, and so may spill elsewhere now). +fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool { + let Some(sh) = host.wb.sheet(sheet) else { return false }; + let end = + CellRef::new(anchor.row.saturating_add((a.rows as u32).saturating_sub(1)), anchor.col.saturating_add((a.cols as u32).saturating_sub(1))); + let range = RangeRef::new(anchor, end); + let own = sh.spill_ranges.get(&anchor); + let spilled_over = |c: CellRef| { + !own.is_some_and(|o| o.contains(c)) + && sh.spill_ranges.iter().any(|(o, r)| *o != anchor && r.contains(c) && !host.exprs.contains_key(&(sheet, *o))) + }; + end.row >= gridcraft_core::MAX_ROWS + || end.col >= gridcraft_core::MAX_COLS + || sh.merges.iter().any(|m| m.intersects(&range)) + || sh.tables.iter().any(|t| t.range.contains(anchor)) + || sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (!cell.value.is_empty() || cell.formula.is_some())) + || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c) && spilled_over(*c)) +} + +/// Whether `arr`, anchored at `anchor`, would spill exactly what the sheet already holds over +/// `range`: the same area, nothing new in the way, and the same values. +pub(super) fn spills_as_before(sheet: &gridcraft_model::Sheet, anchor: CellRef, range: RangeRef, arr: &Array) -> bool { + let end = + CellRef::new(anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)), anchor.col.saturating_add((arr.cols as u32).saturating_sub(1))); + RangeRef::new(anchor, end) == range + && !sheet.merges.iter().any(|m| m.intersects(&range)) + && !sheet.tables.iter().any(|t| t.range.contains(anchor)) + && range + .iter() + .enumerate() + .all(|(i, c)| c == anchor || (!sheet.cells.has(c) && sheet.spill.get(&c) == Some(arr.data.get(i).unwrap_or(&Value::Empty)))) +} + +impl PassHost<'_> { + /// Calculates `k` (and the formulas it reads) with bounded stack: when [`Self::compute`] would + /// go deeper than [`MAX_NESTED`], it suspends what is in progress, the formula it needed is + /// calculated from here, then the suspended ones again (reading the results kept so far). + pub(super) fn calculate(&mut self, k: Key) { + // Formulas to calculate, innermost last, each with the ones abandoned to calculate the + // next. + let mut work: Vec<(Key, Vec)> = vec![(k, Vec::new())]; + while let Some(&(top, _)) = work.last() { + self.compute(top); + match self.suspend.take() { + Some(next) => { + #[cfg(test)] + { + self.suspensions += 1; + } + let held = std::mem::take(&mut self.abandoned); + self.waiting.extend(held.iter().copied()); + if let Some(entry) = work.last_mut() { + entry.1 = held; + } + // The other formulas read at the limit are calculated after `next`, before the + // held evaluations restart. Each has nothing held of its own, so the held ones + // are released only when the last of them is done. + let mut seen: HashSet = HashSet::default(); + seen.insert(next); + for s in std::mem::take(&mut self.also) { + if seen.insert(s) { + work.push((s, Vec::new())); + } + } + work.push((next, Vec::new())); + } + None => { + work.pop(); + if let Some(entry) = work.last_mut() { + for a in entry.1.drain(..) { + self.waiting.remove(&a); + } + } + } + } + } + } + + fn compute(&mut self, k: Key) -> Value { + if let Some(v) = self.results.get(&k) { + let v = v.clone(); + if self.iterate && self.tainted.contains(&k) { + self.taint = true; + } + return v; + } + let Some(expr) = self.exprs.get(&k) else { + return self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default(); + }; + if self.suspend.is_some() { + // Unwinding to `calculate`: the value is thrown away. A formula read at the limit is + // calculated with the suspended one, so the evaluations are not restarted for each. + if self.depth >= MAX_NESTED && !self.in_progress.contains(&k) && !self.waiting.contains(&k) { + self.also.push(k); + } + return Value::Error(CellError::Calc); + } + if self.in_progress.contains(&k) || self.waiting.contains(&k) { + self.cycle = true; + if self.iterate { + return self.circular_read(k); + } + // Once each, though a suspended evaluation meets it again. + if self.cycle_seen.insert(k) { + self.cycles.push(k); + } + return Value::Error(CellError::Circ); + } + if self.depth >= MAX_NESTED { + 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); + if self.iterate { + 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; + self.in_progress.remove(&k); + self.abandoned.push(k); + if self.iterate { + self.stack.pop(); + } + self.taint = outer_taint; + return Value::Error(CellError::Calc); + } + // A legacy array formula fills the range it was entered in. + let legacy = self.wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.as_ref()).and_then(|f| f.array); + let v = match legacy { + Some(range) => fit_to_range(v, range), + None => v, + }; + self.depth -= 1; + self.in_progress.remove(&k); + self.pending.remove(&k); + if self.iterate { + self.stack.pop(); + if self.taint { + self.tainted.insert(k); + } else { + self.tainted.remove(&k); + } + } + self.taint |= outer_taint; + // A blank cell shows as 0 in a formula's result, inside an array as well (`=A1:A3`, + // FILTER or SORT of a range with blanks). GROUPBY and PIVOTBY lay out blank cells + // of their own, which stay blank. + let keep_blanks = matches!(&*expr, Expr::Call(n, _) if matches!(n.as_str(), "GROUPBY" | "PIVOTBY")); + let v = match v { + Value::Array(a) => { + let a = if keep_blanks || !a.data.iter().any(|x| matches!(x, Value::Empty)) { + a + } else { + let data = a.data.iter().map(|x| if matches!(x, Value::Empty) { Value::Number(0.0) } else { x.clone() }).collect(); + Array::new(a.rows, a.cols, data).map(std::sync::Arc::new).unwrap_or(a) + }; + // A dynamic-array formula anchors a spill range even while its result is one + // value -- a FILTER matching one row, a SORT or UNIQUE over one item -- so `C1#` + // is the 1 x 1 range and `ROWS(C1#)` is 1, as in Excel (#1491). A formula that + // returns a scalar never reaches this arm, so it does not become an anchor. + self.spills.insert(k, a.clone()); + a.data.first().cloned().unwrap_or_default() + } + Value::Empty => Value::Number(0.0), + v => v, + }; + // An array that can't spill is #SPILL! for the formulas evaluated after it in this pass too. + let v = match self.spills.get(&k) { + Some(a) if spill_blocked(self, k, a) => { + self.spills.remove(&k); + Value::Error(CellError::Spill) + } + _ => v, + }; + self.results.insert(k, v.clone()); + v + } + + /// A formula's previous value for iterative calculation: from the last iteration, else the + /// value it shows. Blank, and the `#CIRC!` of a loop calculated with iteration off, are 0. + fn previous(&self, k: Key) -> Value { + let v = match self.prev.get(&k) { + Some(v) => v.clone(), + None => self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default(), + }; + if matches!(v, Value::Empty | Value::Error(CellError::Circ)) { Value::Number(0.0) } else { v } + } + + /// Reading `k` while it is being evaluated, with iterative calculation on: its previous value. + /// The formulas around the loop are the ones whose changes decide when iteration stops. + fn circular_read(&mut self, k: Key) -> Value { + self.taint = true; + match self.stack.iter().rposition(|s| *s == k) { + Some(i) => self.members.extend(self.stack.get(i..).unwrap_or_default().iter().copied()), + // Too deep to follow: the formula itself is watched. + None => { + self.members.insert(k); + } + } + self.previous(k) + } + + /// Iterative calculation, after a first pass over `order` that met circular references. The + /// formulas that read a previous value (the loops and what depends on them) are evaluated + /// again in the same order, each pass starting from the last one's values and reading what it + /// has already produced (Gauss-Seidel), until no formula on a loop changes by `max_change` or + /// more, or after `max_iterations` passes in all. Returns the number of passes. + pub(super) fn iterate_loops(&mut self, order: &[Key], max_iterations: u32, max_change: f64) -> u32 { + let again: Vec = order.iter().copied().filter(|k| self.tainted.contains(k)).collect(); + let set: HashSet = again.iter().copied().collect(); + // Bounds the work whatever Maximum Iterations says. + let max_passes = u32::try_from(MAX_ITERATION_WORK / again.len().max(1)).unwrap_or(u32::MAX).max(1); + let mut passes = 1u32; + while passes < max_iterations.min(max_passes) && !self.converged(max_change) { + self.prev = again.iter().filter_map(|k| self.results.remove(k).map(|v| (*k, v))).collect(); + self.pending = set.clone(); + self.spills.retain(|a, _| !set.contains(a)); + for k in &again { + self.calculate(*k); + } + passes += 1; + } + passes + } + + /// Whether every formula on a loop changed by less than `max_change` in the last pass (text, + /// logical and error values: not at all). + fn converged(&self, max_change: f64) -> bool { + self.members.iter().all(|k| { + let Some(new) = self.results.get(k) else { return true }; + match (new, &self.previous(*k)) { + (Value::Number(a), Value::Number(b)) => { + let d = (a - b).abs(); + d < max_change || d == 0.0 + } + (new, old) => new == old, + } + }) + } +} + +/// A legacy (Ctrl+Shift+Enter) array formula's result fitted to the range it was entered in, as +/// Excel does: a single row or column repeats across the range, a single value fills it, cells +/// past the result show `#N/A`, and a result larger than the range is cut off. +fn fit_to_range(v: Value, range: RangeRef) -> Value { + if range.count() > crate::eval::MAX_CELLS { + return Value::Error(CellError::Num); + } + let (h, w) = (range.height() as usize, range.width() as usize); + let Value::Array(a) = v else { + return if h * w == 1 { v } else { Array::new(h, w, vec![v; h * w]).map(Value::from).unwrap_or(Value::Error(CellError::Value)) }; + }; + if h * w == 1 { + return a.data.first().cloned().unwrap_or_default(); + } + let mut data = Vec::with_capacity(h * w); + for r in 0..h { + for c in 0..w { + let (r, c) = (if a.rows == 1 { 0 } else { r }, if a.cols == 1 { 0 } else { c }); + data.push(a.get(r, c).cloned().unwrap_or(Value::Error(CellError::NA))); + } + } + Array::new(h, w, data).map(Value::from).unwrap_or(Value::Error(CellError::Value)) +} + +impl Host for PassHost<'_> { + fn workbook(&self) -> &Workbook { + self.wb + } + fn stack_top(&self) -> Option { + (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 { + let k = (sheet, anchor); + if !(self.pending.contains(&k) || self.results.contains_key(&k)) { + return self.wb.sheet(sheet)?.spill_ranges.get(&anchor).copied(); + } + // Recalculated in this pass: its new array, unless something blocks it (#SPILL!). + self.compute(k); + let arr = self.spills.get(&k)?; + let end = CellRef::new( + anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)), + anchor.col.saturating_add((arr.cols as u32).saturating_sub(1)), + ); + let range = RangeRef::new(anchor, end); + let sh = self.wb.sheet(sheet)?; + let blocked = end.row >= gridcraft_core::MAX_ROWS + || end.col >= gridcraft_core::MAX_COLS + || sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (cell.formula.is_some() || !cell.value.is_empty())) + || sh.merges.iter().any(|m| m.intersects(&range)); + (!blocked).then_some(range) + } + fn cell_value(&mut self, sheet: usize, c: CellRef) -> Value { + let k = (sheet, c); + if self.pending.contains(&k) || self.results.contains_key(&k) { + return self.compute(k); + } + // A cell covered by a spill computed in this pass. + for (anchor, arr) in &self.spills { + if anchor.0 == sheet && c.row >= anchor.1.row && c.col >= anchor.1.col { + let (dr, dc) = ((c.row - anchor.1.row) as usize, (c.col - anchor.1.col) as usize); + if dr < arr.rows && dc < arr.cols { + if self.iterate && self.tainted.contains(anchor) { + self.taint = true; + } + return arr.get(dr, dc).cloned().unwrap_or_default(); + } + } + } + self.wb.sheet(sheet).map(|s| s.value(c)).unwrap_or_default() + } + fn range_values(&mut self, sheet: usize, range: RangeRef) -> Vec { + let (h, w) = (range.height() as usize, range.width() as usize); + let mut out = vec![Value::Empty; h * w]; + let Some(sh) = self.wb.sheet(sheet) else { return out }; + // Sparse walk: only stored cells; formula cells that are (or were) dirty go through + // `compute`, constants are copied straight from the store. + let mut formulas: Vec<(usize, CellRef)> = Vec::new(); + for (c, cell) in sh.cells.iter_range(range) { + let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize; + if cell.formula.is_some() { + formulas.push((i, c)); + } else if let Some(slot) = out.get_mut(i) { + *slot = cell.value.clone(); + } + } + for (c, v) in sh.spill.range(range.start..=range.end) { + if range.contains(*c) { + let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize; + if let Some(slot) = out.get_mut(i) + && slot.is_empty() + { + *slot = v.clone(); + } + } + } + for (i, c) in formulas { + let v = self.cell_value(sheet, c); + if let Some(slot) = out.get_mut(i) { + *slot = v; + } + } + if !self.spills.is_empty() { + let new: Vec<(usize, Value)> = self + .spills + .iter() + .filter(|(a, _)| a.0 == sheet) + .flat_map(|(a, arr)| { + let mut v = Vec::new(); + for r in 0..arr.rows { + for cc in 0..arr.cols { + let c = CellRef::new(a.1.row.saturating_add(r as u32), a.1.col.saturating_add(cc as u32)); + if c != a.1 && range.contains(c) { + v.push(( + (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize, + arr.get(r, cc).cloned().unwrap_or_default(), + )); + } + } + } + v + }) + .collect(); + // Iterating: an array spilled by a formula that read a previous value. + if self.iterate + && self.spills.iter().any(|(a, arr)| { + let end = CellRef::new( + a.1.row.saturating_add((arr.rows as u32).saturating_sub(1)), + a.1.col.saturating_add((arr.cols as u32).saturating_sub(1)), + ); + a.0 == sheet && self.tainted.contains(a) && RangeRef::new(a.1, end).intersects(&range) + }) + { + self.taint = true; + } + for (i, v) in new { + if let Some(slot) = out.get_mut(i) { + *slot = v; + } + } + } + out + } + fn visit_range(&mut self, sheet: usize, range: RangeRef, f: &mut dyn FnMut(&Value) -> bool) -> bool { + let wb = self.wb; + let Some(sh) = wb.sheet(sheet) else { return false }; + // Formula cells first, in row-major order like `range_values`: reading one calculates it + // and the pass remembers the value, so the walk below gets the same value again without + // this method holding any. + for (c, cell) in sh.cells.iter_range(range) { + if cell.formula.is_some() { + let _ = self.cell_value(sheet, c); + } + } + // Spilled values sit between the stored cells (and the formulas just calculated may have + // produced some): leave such blocks to `range_values`. + if self.spills.keys().any(|a| a.0 == sheet) || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c)) { + return false; + } + for (c, cell) in sh.cells.iter_range(range) { + let go = if cell.formula.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) }; + if !go { + break; + } + } + true + } + fn used_range(&mut self, sheet: usize) -> Option { + let wb = self.wb; + *self.used.entry(sheet).or_insert_with(|| wb.sheet(sheet).and_then(|s| s.used_range())) + } + fn now_serial(&self) -> f64 { + self.now + } + fn random(&mut self) -> f64 { + // xorshift64* + let mut x = *self.rng; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + *self.rng = x; + (x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64 + } +} diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 3257082bb7..76a1619e32 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -10,6 +10,20 @@ use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, series_color}; use crate::trend::{auto_name, fit_trendline, moving_average}; use crate::{ChartData, HAlign, Measure, Prim, Rgba, SeriesData, VAlign}; +mod cartesian; +mod frame; +mod kinds; +mod legend; +mod lines; +mod trend; + +use cartesian::{cartesian, scatter}; +use frame::{Bx, axis_titles, chart_title}; +use kinds::{box_whisker, funnel, histogram, pie, radar, stock, treemap, waterfall}; +use legend::draw_legend; +pub use legend::{Swatch, legend_entries}; +pub use trend::supports_trendline; + pub(crate) const TEXT: Rgba = [0x59, 0x59, 0x59, 0xFF]; const DARK_TEXT: Rgba = [0x26, 0x26, 0x26, 0xFF]; const GRID: Rgba = [0xD9, 0xD9, 0xD9, 0xFF]; @@ -31,32 +45,6 @@ const MAX_MARKERS: usize = 5000; /// Data labels are skipped when a series has more points than this. const MAX_LABELS: usize = 200; -#[derive(Clone, Copy, Debug)] -struct Bx { - x: f32, - y: f32, - w: f32, - h: f32, -} - -impl Bx { - fn right(&self) -> f32 { - self.x + self.w - } - fn bottom(&self) -> f32 { - self.y + self.h - } - fn ok(&self) -> bool { - self.w.is_finite() && self.h.is_finite() && self.w > 4.0 && self.h > 4.0 - } - fn cx(&self) -> f32 { - self.x + self.w / 2.0 - } - fn cy(&self) -> f32 { - self.y + self.h / 2.0 - } -} - struct Ctx<'a> { out: Vec, m: &'a dyn Measure, @@ -105,85 +93,6 @@ impl Ctx<'_> { } } -/// Text colour readable on `fill`. -fn on(fill: Rgba) -> Rgba { - let l = 0.299 * fill[0] as f32 + 0.587 * fill[1] as f32 + 0.114 * fill[2] as f32; - if l < 150.0 { WHITE } else { DARK_TEXT } -} - -/// How a legend entry's swatch is drawn. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Swatch { - Box, - Line, - LineMarker, - Marker, -} - -/// The kind a series is drawn as (per-series kinds only apply to combo charts). -fn plot_kind(chart: &Chart, s: &SeriesData) -> ChartKind { - if chart.kind == ChartKind::Combo { if s.kind == ChartKind::Combo { ChartKind::Line } else { s.kind } } else { chart.kind } -} - -/// Colour of point `j` for charts that colour by point (pie, doughnut, treemap, sunburst). -fn point_color(data: &ChartData, j: usize) -> Rgba { - match (j, data.series.first()) { - (0, Some(s)) => s.color, - _ => j.checked_rem(data.palette.len()).and_then(|k| data.palette.get(k)).copied().unwrap_or_else(|| series_color(&Theme::default(), j)), - } -} - -fn first_len(data: &ChartData) -> usize { - data.series.first().map(|s| s.values.len()).unwrap_or(0) -} - -fn cat_label(data: &ChartData, j: usize) -> String { - data.categories.get(j).cloned().unwrap_or_else(|| (j + 1).to_string()) -} - -/// Legend entries (text, colour, swatch) `render` draws for this chart; empty when hidden. -pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swatch)> { - if chart.legend == LegendPos::None { - return vec![]; - } - match chart.kind { - ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst => { - let n = first_len(data).min(MAX_CATS); - (0..n).map(|j| (cat_label(data, j), point_color(data, j), Swatch::Box)).collect() - } - ChartKind::Histogram | ChartKind::Funnel | ChartKind::Stock => vec![], - ChartKind::Waterfall => { - if data.series.is_empty() { - return vec![]; - } - let total = data.series.first().map(|s| s.color).unwrap_or(TEXT); - vec![("Increase".into(), UP, Swatch::Box), ("Decrease".into(), DOWN, Swatch::Box), ("Total".into(), total, Swatch::Box)] - } - _ => { - let series = data.series.iter().take(MAX_SERIES); - let mut out: Vec<(String, Rgba, Swatch)> = series - .clone() - .map(|s| { - let sw = match plot_kind(chart, s) { - ChartKind::Line | ChartKind::LineStacked | ChartKind::Radar | ChartKind::ScatterLines => Swatch::Line, - ChartKind::LineMarkers => Swatch::LineMarker, - ChartKind::Scatter | ChartKind::Bubble => Swatch::Marker, - _ => Swatch::Box, - }; - (s.name.clone(), s.color, sw) - }) - .collect(); - // Trendlines follow the series, named "Linear (Sales)" unless given a name. - for s in series.filter(|s| supports_trendline(plot_kind(chart, s))) { - for t in s.trendlines.iter().take(MAX_TRENDLINES) { - out.push((t.spec.name.clone().unwrap_or_else(|| auto_name(t.spec.kind, &s.name)), t.color, Swatch::Line)); - } - } - out - } - } -} - fn sanitize_vals(v: &[Option]) -> Vec> { v.iter().take(MAX_POINTS).map(|x| x.filter(|n| n.is_finite()).map(clean)).collect() } @@ -213,14 +122,6 @@ fn sanitize(d: &ChartData) -> ChartData { } } -fn chart_title(chart: &Chart, data: &ChartData) -> Option { - match &chart.title { - Some(t) => Some(t.trim().to_string()).filter(|t| !t.is_empty()), - None if data.series.len() == 1 => data.series.first().map(|s| s.name.clone()).filter(|t| !t.is_empty()), - None => None, - } -} - /// Lays out and draws a chart into a `w`×`h` box at the origin. Empty for a degenerate box. pub fn render(chart: &Chart, data: &ChartData, w: f32, h: f32, m: &dyn Measure) -> Vec { if !(w.is_finite() && h.is_finite()) || w < 1.0 || h < 1.0 || w > 1e7 || h > 1e7 { @@ -276,1483 +177,6 @@ fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { } } -fn axis_titles(ctx: &mut Ctx, chart: &Chart, mut area: Bx) -> Bx { - let lh = AXIS_TITLE_SIZE * 1.4 + 2.0; - if let Some(t) = chart.x_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) { - let t = ctx.fit(t, AXIS_TITLE_SIZE, area.w); - ctx.text(area.cx(), area.bottom(), &t, AXIS_TITLE_SIZE, TEXT, HAlign::Center, VAlign::Bottom); - area.h -= lh; - } - if let Some(t) = chart.y_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) { - let t = ctx.fit(t, AXIS_TITLE_SIZE, area.h); - if !t.is_empty() { - ctx.out.push(Prim::Text { - x: area.x, - y: area.cy(), - text: t, - size: AXIS_TITLE_SIZE, - color: TEXT, - bold: false, - align: HAlign::Center, - valign: VAlign::Top, - rotation: -PI / 2.0, - }); - } - area.x += lh; - area.w -= lh; - } - area -} - -// ---------------------------------------------------------------- legend - -fn swatch_w(sw: Swatch, s: f32) -> f32 { - if sw == Swatch::Box { s } else { s * 2.0 } -} - -fn draw_swatch(ctx: &mut Ctx, sw: Swatch, x: f32, yc: f32, s: f32, color: Rgba) { - match sw { - Swatch::Box => ctx.rect(x, yc - s / 2.0, s, s, color, None), - Swatch::Line => ctx.hline(x, x + 2.0 * s, yc, color, 2.0), - Swatch::LineMarker => { - ctx.hline(x, x + 2.0 * s, yc, color, 2.0); - ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.35, fill: Some(color), stroke: None }); - } - Swatch::Marker => ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.4, fill: Some(color), stroke: None }), - } -} - -fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos: LegendPos, area: Bx) -> Bx { - let size = LABEL_SIZE; - let row_h = size * 1.6; - let s = size * 0.8; - let gap = 4.0; - let spacing = size * 1.3; - let item_w = |ctx: &Ctx, e: &(String, Rgba, Swatch)| swatch_w(e.2, s) + gap + ctx.tw(&e.0, size); - match pos { - LegendPos::None => area, - LegendPos::Top | LegendPos::Bottom => { - let max_rows = ((area.h * 0.3 / row_h).floor() as usize).max(1); - let mut rows: Vec> = vec![vec![]]; - let mut cur = 0.0f32; - for (i, e) in entries.iter().enumerate() { - let iw = item_w(ctx, e).min(area.w); - let need = if cur > 0.0 { spacing + iw } else { iw }; - if cur > 0.0 && cur + need > area.w { - if rows.len() >= max_rows { - break; - } - rows.push(vec![]); - cur = 0.0; - } - cur += if cur > 0.0 { spacing + iw } else { iw }; - if let Some(r) = rows.last_mut() { - r.push((i, iw)); - } - } - let lh = rows.len() as f32 * row_h; - let y0 = if pos == LegendPos::Bottom { area.bottom() - lh } else { area.y }; - for (ri, row) in rows.iter().enumerate() { - let total: f32 = row.iter().map(|(_, w)| w).sum::() + spacing * row.len().saturating_sub(1) as f32; - let mut x = area.x + (area.w - total).max(0.0) / 2.0; - let yc = y0 + ri as f32 * row_h + row_h / 2.0; - for &(i, iw) in row { - if let Some(e) = entries.get(i) { - draw_swatch(ctx, e.2, x, yc, s, e.1); - let sx = swatch_w(e.2, s) + gap; - let t = ctx.fit(&e.0, size, (iw - sx).max(0.0) + 0.5); - ctx.text(x + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle); - } - x += iw + spacing; - } - } - let used = lh + 4.0; - if pos == LegendPos::Bottom { Bx { h: area.h - used, ..area } } else { Bx { y: area.y + used, h: area.h - used, ..area } } - } - LegendPos::Left | LegendPos::Right => { - let colw = entries.iter().map(|e| item_w(ctx, e)).fold(0.0f32, f32::max).min(area.w * 0.35); - let max_rows = ((area.h / row_h).floor() as usize).max(1); - let n = entries.len().min(max_rows); - let lh = n as f32 * row_h; - let y0 = area.y + (area.h - lh).max(0.0) / 2.0; - let x0 = if pos == LegendPos::Right { area.right() - colw } else { area.x }; - for (i, e) in entries.iter().take(n).enumerate() { - let yc = y0 + i as f32 * row_h + row_h / 2.0; - draw_swatch(ctx, e.2, x0, yc, s, e.1); - let sx = swatch_w(e.2, s) + gap; - let t = ctx.fit(&e.0, size, (colw - sx).max(0.0) + 0.5); - ctx.text(x0 + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle); - } - let used = colw + 8.0; - if pos == LegendPos::Right { Bx { w: area.w - used, ..area } } else { Bx { x: area.x + used, w: area.w - used, ..area } } - } - } -} - -// ---------------------------------------------------------------- axes frame - -enum CatAxis<'a> { - /// Category labels and, for a multi-level axis, the outer levels (nearest the labels first; - /// one entry per category, non-empty where a group starts). - Labels(&'a [String], &'a [Vec]), - Numeric(Scale, &'a Fmt), -} - -/// Groups of an outer category level: `(first, end)` index pairs, a group starting at every -/// non-empty entry (and at the first category). -fn level_groups(level: &[String], n: usize) -> Vec<(usize, usize)> { - let mut starts: Vec = (0..n).filter(|&j| j == 0 || level.get(j).is_some_and(|t| !t.is_empty())).collect(); - starts.push(n); - starts.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect() -} - -struct ValAxis<'a> { - scale: Scale, - fmt: &'a Fmt, -} - -impl ValAxis<'_> { - fn labels(&self) -> Vec<(f64, String)> { - self.scale.ticks().into_iter().map(|v| (v, self.fmt.format(v, Some(self.scale.step)))).collect() - } -} - -/// Draws gridlines, axis labels and the category axis line; returns the plot rectangle. -fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis>, horizontal: bool, grid: bool) -> Bx { - let ls = LABEL_SIZE; - let lh = ls * 1.4; - let ylabels = y.labels(); - let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw(t, ls)).fold(0.0f32, f32::max); - if !horizontal { - let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4); - let y2labels = y2.map(ValAxis::labels); - let right = match (&y2labels, &cat) { - (Some(l), _) => (maxw(ctx, l) + 6.0).min(area.w * 0.3), - (None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2), - _ => 4.0, - }; - let levels = match &cat { - CatAxis::Labels(_, o) => o.len() as f32, - CatAxis::Numeric(..) => 0.0, - }; - let plot = Bx { x: area.x + left, y: area.y + lh * 0.5, w: area.w - left - right, h: area.h - lh * 1.5 - 4.0 - lh * levels }; - if !plot.ok() { - return plot; - } - for (v, t) in &ylabels { - let yy = plot.bottom() - y.scale.frac(*v) * plot.h; - if grid { - ctx.hline(plot.x, plot.right(), yy, GRID, 0.75); - } - ctx.text(plot.x - 4.0, yy, t, ls, TEXT, HAlign::Right, VAlign::Middle); - } - if let (Some(a), Some(l)) = (y2, &y2labels) { - for (v, t) in l { - let yy = plot.bottom() - a.scale.frac(*v) * plot.h; - ctx.text(plot.right() + 4.0, yy, t, ls, TEXT, HAlign::Left, VAlign::Middle); - } - } - let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min }; - let y0 = plot.bottom() - y.scale.frac(zero) * plot.h; - ctx.hline(plot.x, plot.right(), y0, AXIS, 0.75); - match cat { - CatAxis::Labels(l, outer) => { - let n = l.len().max(1); - let cw = plot.w / n as f32; - let lw = l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max); - let k = (((lw + 6.0) / cw.max(1e-3)).ceil() as usize).clamp(1, n); - for (j, t) in l.iter().enumerate().step_by(k) { - let t = ctx.fit(t, ls, cw * k as f32 - 2.0); - ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, HAlign::Center, VAlign::Top); - } - // Outer levels, one row each below the labels: a group's name centred under its - // categories, and a separator at each group boundary down through the rows. - for (i, level) in outer.iter().enumerate() { - let top = plot.bottom() + 3.0 + lh * (i as f32 + 1.0); - for (first, end) in level_groups(level, n) { - let (x0, x1) = (plot.x + first as f32 * cw, plot.x + end as f32 * cw); - if let Some(t) = level.get(first).filter(|t| !t.is_empty()) { - let t = ctx.fit(t, ls, x1 - x0 - 2.0); - ctx.text((x0 + x1) / 2.0, top, &t, ls, TEXT, HAlign::Center, VAlign::Top); - } - if first > 0 { - ctx.vline(x0, plot.bottom(), top + lh - 2.0, AXIS, 0.75); - } - } - ctx.vline(plot.right(), plot.bottom(), top + lh - 2.0, AXIS, 0.75); - } - } - CatAxis::Numeric(s, f) => { - let ticks = s.ticks(); - let labels: Vec = ticks.iter().map(|v| f.format(*v, Some(s.step))).collect(); - let lw = labels.iter().map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max); - let slot = plot.w / ticks.len().max(1) as f32; - let k = (((lw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1); - for (v, t) in ticks.iter().zip(&labels).step_by(k) { - let xx = plot.x + s.frac(*v) * plot.w; - ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top); - } - if s.min <= 0.0 && s.max >= 0.0 { - let xx = plot.x + s.frac(0.0) * plot.w; - ctx.vline(xx, plot.y, plot.bottom(), AXIS, 0.75); - } - } - } - plot - } else { - let (labels, outer): (Vec, &[Vec]) = match cat { - CatAxis::Labels(l, o) => (l.to_vec(), o), - CatAxis::Numeric(..) => (vec![], &[]), - }; - let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.35); - // Outer levels stand in columns left of the labels, each as wide as its longest name. - let level_w: Vec = - outer.iter().map(|l| (l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.15)).collect(); - let levels_w: f32 = level_w.iter().sum(); - let right = ylabels.last().map(|(_, t)| ctx.tw(t, ls) / 2.0).unwrap_or(0.0).min(area.w * 0.2) + 2.0; - let plot = Bx { x: area.x + lw + levels_w, y: area.y + 2.0, w: area.w - lw - levels_w - right, h: area.h - lh - 6.0 }; - if !plot.ok() { - return plot; - } - let vw = maxw(ctx, &ylabels); - let slot = plot.w / ylabels.len().max(1) as f32; - let k = (((vw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1); - for (i, (v, t)) in ylabels.iter().enumerate() { - let xx = plot.x + y.scale.frac(*v) * plot.w; - if grid { - ctx.vline(xx, plot.y, plot.bottom(), GRID, 0.75); - } - if i % k == 0 { - ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top); - } - } - let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min }; - let x0 = plot.x + y.scale.frac(zero) * plot.w; - ctx.vline(x0, plot.y, plot.bottom(), AXIS, 0.75); - let n = labels.len().max(1); - let ch = plot.h / n as f32; - let k = ((lh / ch.max(1e-3)).ceil() as usize).clamp(1, n); - for (j, t) in labels.iter().enumerate().step_by(k) { - let t = ctx.fit(t, ls, lw - 6.0); - ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, HAlign::Right, VAlign::Middle); - } - // Outer levels: a group's name centred beside its categories, separators at the - // boundaries running right to the axis. - let mut x1 = plot.x - lw; - for (level, w) in outer.iter().zip(&level_w) { - let x0 = x1 - w; - for (first, end) in level_groups(level, n) { - let (y0, y1) = (plot.bottom() - end as f32 * ch, plot.bottom() - first as f32 * ch); - if let Some(t) = level.get(first).filter(|t| !t.is_empty()) { - let t = ctx.fit(t, ls, w - 4.0); - ctx.text((x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, ls, TEXT, HAlign::Center, VAlign::Middle); - } - if first > 0 { - ctx.hline(x0, plot.x, y1, AXIS, 0.75); - } - } - ctx.hline(x0, plot.x, plot.y, AXIS, 0.75); - x1 = x0; - } - plot - } -} - -// ---------------------------------------------------------------- helpers - -/// Reduces a long polyline to first/min/max/last per pixel column. -fn decimate(pts: Vec<[f32; 2]>, max: usize) -> Vec<[f32; 2]> { - if pts.len() <= max { - return pts; - } - let mut out = Vec::with_capacity(max * 2); - let mut i = 0; - while let Some(p) = pts.get(i) { - let col = p[0].floor(); - let (start, mut mn, mut mx) = (i, i, i); - let mut k = i + 1; - while let Some(q) = pts.get(k) { - if q[0].floor() != col { - break; - } - if pts.get(mn).is_some_and(|m| q[1] < m[1]) { - mn = k; - } - if pts.get(mx).is_some_and(|m| q[1] > m[1]) { - mx = k; - } - k += 1; - } - let mut idx = [start, mn, mx, k - 1]; - idx.sort_unstable(); - let mut last = usize::MAX; - for ix in idx { - if ix != last - && let Some(q) = pts.get(ix) - { - out.push(*q); - last = ix; - } - } - i = k; - } - if out.len() > max * 4 { - let step = out.len() / (max * 2) + 1; - let last = out.last().copied(); - let mut s: Vec<[f32; 2]> = out.into_iter().step_by(step).collect(); - if let Some(l) = last - && s.last() != Some(&l) - { - s.push(l); - } - return s; - } - out -} - -/// Draws a polyline split at gaps (`None`), decimated when long. With `smooth` (the plot box), -/// each run is interpolated with a Catmull-Rom spline so the line curves through the data points. -fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32, smooth: Option) { - let mut run: Vec<[f32; 2]> = Vec::new(); - for p in pts.iter().chain(std::iter::once(&None)) { - match p { - Some(p) => run.push(*p), - None => { - if run.len() >= 2 { - let r = decimate(std::mem::take(&mut run), MAX_LINE_PTS); - let r = match smooth { - Some(plot) => smooth_run(r, plot), - None => r, - }; - ctx.line(r, color, width); - } else { - run.clear(); - } - } - } - } -} - -/// Catmull-Rom spline through `pts`: every segment gets the same number of sub-segments (up to -/// 100), chosen so the whole curve stays within `MAX_LINE_PTS * 2` points. Endpoints are -/// duplicated so the curve passes through the first and last points, and the curve is clamped -/// to the `plot` box (a spline overshoots its data near sharp turns). -fn smooth_run(pts: Vec<[f32; 2]>, plot: Bx) -> Vec<[f32; 2]> { - let n = pts.len(); - if n < 3 { - return pts; - } - let cap = MAX_LINE_PTS * 2; - let steps = (cap / n).clamp(1, 100); - let mut out: Vec<[f32; 2]> = Vec::with_capacity(n.saturating_mul(steps).min(cap) + 1); - let at = |i: isize| -> [f32; 2] { - let j = i.clamp(0, n as isize - 1) as usize; - pts.get(j).copied().unwrap_or([0.0, 0.0]) - }; - let (x0, x1) = (plot.x.min(plot.right()), plot.x.max(plot.right())); - let (y0, y1) = (plot.y.min(plot.bottom()), plot.y.max(plot.bottom())); - let clip = |p: [f32; 2]| [p[0].clamp(x0, x1), p[1].clamp(y0, y1)]; - for i in 0..n.saturating_sub(1) { - let (p0, p1, p2, p3) = (at(i as isize - 1), at(i as isize), at(i as isize + 1), at(i as isize + 2)); - for s in 0..steps { - let t = s as f32 / steps as f32; - out.push(clip(catmull(p0, p1, p2, p3, t))); - } - } - out.push(clip(at(n as isize - 1))); - out -} - -/// One Catmull-Rom point between `p1` and `p2` (uniform parameterisation), tangent-scaled by 0.5. -fn catmull(p0: [f32; 2], p1: [f32; 2], p2: [f32; 2], p3: [f32; 2], t: f32) -> [f32; 2] { - let t2 = t * t; - let t3 = t2 * t; - let mut o = [0.0f32; 2]; - for k in 0..2 { - let (a0, a1, a2, a3) = (p0[k], p1[k], p2[k], p3[k]); - o[k] = 0.5 * ((2.0 * a1) + (-a0 + a2) * t + (2.0 * a0 - 5.0 * a1 + 4.0 * a2 - a3) * t2 + (-a0 + 3.0 * a1 - 3.0 * a2 + a3) * t3); - } - o -} - -fn draw_markers(ctx: &mut Ctx, pts: impl Iterator, r: f32, color: Rgba) { - let mut seen = HashSet::new(); - for p in pts { - if seen.len() >= MAX_MARKERS { - break; - } - if seen.insert(((p[0] * 0.5) as i32, (p[1] * 0.5) as i32)) { - ctx.out.push(Prim::Circle { cx: p[0], cy: p[1], r, fill: Some(color), stroke: Some((WHITE, 0.75)) }); - } - } -} - -fn value_range(it: impl Iterator) -> Option<(f64, f64)> { - it.fold(None, |acc, v| match acc { - None => Some((v, v)), - Some((a, b)) => Some((a.min(v), b.max(v))), - }) -} - -// ---------------------------------------------------------------- trendlines - -/// Samples along a regression trendline. -const TREND_STEPS: usize = 200; - -/// Whether series drawn as `k` show trendlines (Excel offers them on 2-D unstacked column, -/// bar, line, area, scatter and bubble charts). -pub fn supports_trendline(k: ChartKind) -> bool { - use ChartKind::*; - matches!(k, ColumnClustered | BarClustered | Line | LineMarkers | Area | Scatter | ScatterLines | Bubble) -} - -/// A forecast distance as drawn: finite, non-negative and bounded. -fn forecast(v: f64) -> f64 { - if v.is_finite() && v > 0.0 { v.min(1e9) } else { 0.0 } -} - -/// Most empty category slots a forward forecast may add after `ncat` categories: four times the -/// data (at least 32), and never past `MAX_CATS` in total. A larger forecast would shrink every -/// column to nothing. -fn max_forward_slots(ncat: usize) -> f64 { - (ncat.saturating_mul(4).max(32)).min(MAX_CATS.saturating_sub(ncat)) as f64 -} - -/// Longest forward forecast among `s`'s regression trendlines. -fn max_forward(s: &SeriesData) -> f64 { - s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.forward)).fold(0.0, f64::max) -} - -/// Longest backward forecast among `s`'s regression trendlines. -fn max_backward(s: &SeriesData) -> f64 { - s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.backward)).fold(0.0, f64::max) -} - -/// Liang–Barsky: the part of segment `a`–`b` inside the unit square, with whether the start and -/// end were cut. -#[allow(clippy::type_complexity)] -fn clip_segment(a: (f64, f64), b: (f64, f64)) -> Option<((f64, f64), (f64, f64), bool, bool)> { - let (dx, dy) = (b.0 - a.0, b.1 - a.1); - let (mut t0, mut t1) = (0.0f64, 1.0f64); - for (p, q) in [(-dx, a.0), (dx, 1.0 - a.0), (-dy, a.1), (dy, 1.0 - a.1)] { - if p == 0.0 { - if q < 0.0 { - return None; - } - } else { - let r = q / p; - if p < 0.0 { - if r > t1 { - return None; - } - t0 = t0.max(r); - } else { - if r < t0 { - return None; - } - t1 = t1.min(r); - } - } - } - Some(((a.0 + t0 * dx, a.1 + t0 * dy), (a.0 + t1 * dx, a.1 + t1 * dy), t0 > 0.0, t1 < 1.0)) -} - -/// Clips a sampled polyline in plot fractions (`None` = gap) to the plot box (the unit -/// square), returning the visible runs. -fn clip_unit(pts: &[Option<(f64, f64)>]) -> Vec> { - const LIM: f64 = 1e9; - let ok = |p: &(f64, f64)| p.0.is_finite() && p.1.is_finite(); - let cl = |p: (f64, f64)| (p.0.clamp(-LIM, LIM), p.1.clamp(-LIM, LIM)); - let mut runs: Vec> = Vec::new(); - let mut run: Vec<(f64, f64)> = Vec::new(); - for w in pts.windows(2) { - let seg = match w { - [Some(a), Some(b)] if ok(a) && ok(b) => clip_segment(cl(*a), cl(*b)), - _ => None, - }; - match seg { - Some((p, q, cut_start, cut_end)) => { - if cut_start || run.is_empty() { - if run.len() >= 2 { - runs.push(std::mem::take(&mut run)); - } - run.clear(); - run.push(p); - } - run.push(q); - if cut_end { - runs.push(std::mem::take(&mut run)); - } - } - None => { - if run.len() >= 2 { - runs.push(std::mem::take(&mut run)); - } - run.clear(); - } - } - } - if run.len() >= 2 { - runs.push(run); - } - runs -} - -/// Draws `s`'s trendlines fitted to its plotted points `pts` (data x, y). `frac` maps data to -/// fractions of the plot box (0..1 inside, along the x and y axes), `px` fractions to pixels. -/// Equation and R² label lines are appended to `labels`. Degenerate fits draw nothing. -fn draw_trendlines( - ctx: &mut Ctx, - s: &SeriesData, - pts: &[(f64, f64)], - frac: &dyn Fn(f64, f64) -> (f64, f64), - px: &dyn Fn((f64, f64)) -> [f32; 2], - labels: &mut Vec, - window: (f64, f64), -) { - let Some((xlo, xhi)) = value_range(pts.iter().map(|p| p.0)) else { return }; - for t in s.trendlines.iter().take(MAX_TRENDLINES) { - let spec = &t.spec; - let samples: Vec> = match spec.kind { - TrendlineKind::MovingAverage(p) => moving_average(pts, p).into_iter().map(|(x, y)| Some(frac(x, y))).collect(), - _ => { - let Some(fit) = fit_trendline(spec, pts) else { continue }; - // Sample only the part of the forecast that is on the plot. - let (lo, hi) = ((xlo - forecast(spec.backward)).max(window.0), (xhi + forecast(spec.forward)).min(window.1)); - if spec.display_equation { - labels.push(fit.equation()); - } - if spec.display_r_squared { - labels.push(fit.r_squared()); - } - (0..=TREND_STEPS) - .map(|i| { - let x = lo + (hi - lo) * i as f64 / TREND_STEPS as f64; - fit.eval(x).map(|y| frac(x, y)) - }) - .collect() - } - }; - let width = spec.width.filter(|w| w.is_finite()).unwrap_or(1.5).clamp(0.25, 12.0); - let dash = spec.dash != Some(LineDash::Solid); - for run in clip_unit(&samples) { - let line = decimate(run.into_iter().map(px).collect(), MAX_LINE_PTS); - if line.len() >= 2 { - ctx.out.push(Prim::Line { pts: line, color: t.color, width, dash }); - } - } - } -} - -/// Trendline equation / R² labels, stacked in the plot's top-right corner. -fn draw_trend_labels(ctx: &mut Ctx, plot: Bx, labels: &[String]) { - let lh = LABEL_SIZE * 1.3; - for (i, t) in labels.iter().enumerate() { - let y = plot.y + 4.0 + i as f32 * lh; - if y + lh > plot.bottom() { - break; - } - let t = ctx.fit(t, LABEL_SIZE, plot.w - 8.0); - ctx.text(plot.right() - 4.0, y, &t, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Top); - } -} - -// ---------------------------------------------------------------- cartesian (column/bar/line/area/combo) - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Fam { - Col, - ColStack, - Col100, - Line, - LineStack, - Area, - AreaStack, - Area100, -} - -fn family(k: ChartKind) -> Fam { - use ChartKind::*; - match k { - ColumnStacked | BarStacked => Fam::ColStack, - ColumnStacked100 | BarStacked100 => Fam::Col100, - Line | LineMarkers | Scatter | ScatterLines | Radar | Stock => Fam::Line, - 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) -} -fn is_line(f: Fam) -> bool { - matches!(f, Fam::Line | Fam::LineStack) -} - -fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizontal: bool) { - let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS); - if ncat == 0 { - return; - } - let kinds: Vec = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).collect(); - let fams: Vec = kinds.iter().map(|k| family(*k)).collect(); - let any_primary = data.series.iter().any(|s| !s.secondary); - let sec: Vec = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect(); - - // Totals for 100% stacking, per axis and family (100% columns and 100% areas stack apart). - let mut totals: Vec<((bool, Fam), Vec)> = Vec::new(); - for (i, s) in data.series.iter().enumerate() { - 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(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, Vec)> = Vec::new(); - let mut spans: Vec>> = 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 | Fam::Area100); - let key = (a, fam); - let si = if stacked { - match stacks.iter().position(|(k, _, _)| *k == key) { - Some(p) => Some(p), - None => { - stacks.push((key, vec![0.0; ncat], vec![0.0; ncat])); - Some(stacks.len() - 1) - } - } - } else { - None - }; - let mut row = Vec::with_capacity(ncat); - for j in 0..ncat { - 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 { 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; - Some((base, *acc)) - }), - (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); - (base, *acc) - }) - } - (None, _) => v.map(|v| (0.0, v)), - }; - row.push(span); - } - spans.push(row); - } - - // Axis scales. - let mut axes: Vec<(Scale, Fmt)> = Vec::new(); - for axis in [false, true] { - let members: Vec = (0..data.series.len()).filter(|i| sec.get(*i).copied().unwrap_or(false) == axis).collect(); - if members.is_empty() { - 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).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] }) - })); - let (lo, hi) = range.unwrap_or((0.0, 0.0)); - let (scale, fmt) = if all100 { - (fixed_scale(if lo < -1e-9 { -1.0 } else { 0.0 }, if hi > 1e-9 || lo >= -1e-9 { 1.0 } else { 0.0 }, 0.2), Fmt::new("0%")) - } else { - let code = members.first().and_then(|i| data.series.get(*i)).map(|s| s.number_format.as_str()).unwrap_or("General"); - (nice_scale(lo, hi, force_zero), Fmt::new(code)) - }; - axes.push((scale, fmt)); - if !axis && sec.iter().all(|s| !s) { - break; - } - } - let Some((s1, f1)) = axes.first() else { return }; - let y1 = ValAxis { scale: *s1, fmt: f1 }; - let y2 = axes.get(1).map(|(s, f)| ValAxis { scale: *s, fmt: f }); - // A forward forecast on a category axis adds empty category slots after the data. - let fwd = data - .series - .iter() - .enumerate() - .filter(|(i, _)| kinds.get(*i).is_some_and(|k| supports_trendline(*k))) - .map(|(_, s)| max_forward(s)) - .fold(0.0, f64::max); - let ncat_slots = ncat.saturating_add(fwd.min(max_forward_slots(ncat)).ceil() as usize).min(MAX_CATS.max(ncat)); - let labels: Vec = (0..ncat_slots).map(|j| if j < ncat { cat_label(data, j) } else { String::new() }).collect(); - // Outer levels padded to the slots (forecast slots stay in the last group). - let outer: Vec> = - data.category_levels.iter().map(|l| (0..ncat_slots).map(|j| l.get(j).cloned().unwrap_or_default()).collect()).collect(); - let plot = frame(ctx, area, CatAxis::Labels(&labels, &outer), &y1, y2.as_ref(), horizontal, chart.gridlines); - if !plot.ok() { - return; - } - let scale_of = |i: usize| -> Scale { if sec.get(i).copied().unwrap_or(false) { y2.as_ref().map(|a| a.scale).unwrap_or(*s1) } else { *s1 } }; - let cat_len = if horizontal { plot.h } else { plot.w }; - let cw = cat_len / ncat_slots as f32; - let center = |j: usize| if horizontal { plot.bottom() - (j as f32 + 0.5) * cw } else { plot.x + (j as f32 + 0.5) * cw }; - let vpos = |v: f64, sc: &Scale| if horizontal { plot.x + sc.frac(v) * plot.w } else { plot.bottom() - sc.frac(v) * plot.h }; - - // Column slots: each clustered series gets one, each stack group shares one. - let mut slot_keys: Vec<(bool, Fam, usize)> = Vec::new(); - let mut slot_of = vec![0usize; data.series.len()]; - for (i, f) in fams.iter().enumerate() { - if !is_col(*f) { - continue; - } - let a = sec.get(i).copied().unwrap_or(false); - let key = if *f == Fam::Col { (a, *f, i) } else { (a, *f, usize::MAX) }; - let k = match slot_keys.iter().position(|k| *k == key) { - Some(p) => p, - None => { - slot_keys.push(key); - slot_keys.len() - 1 - } - }; - if let Some(s) = slot_of.get_mut(i) { - *s = k; - } - } - let nslots = slot_keys.len().max(1) as f32; - let bw = cw / (nslots + 1.5); - let labels_on = chart.data_labels && ncat <= MAX_LABELS; - // Area labels wait until every area is filled, so a later (stacked) area can't paint over them. - let mut area_labels = Vec::new(); - - // Areas first, then columns, then lines on top. - for pass in 0..3 { - 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 | Fam::Area100), - 1 => is_col(fam), - _ => is_line(fam), - }; - if !want { - continue; - } - let sc = scale_of(i); - let fmt = Fmt::new(&s.number_format); - let row = match spans.get(i) { - Some(r) => r, - None => continue, - }; - match pass { - 0 => { - let mut top = Vec::with_capacity(ncat * 2); - let mut base = Vec::with_capacity(ncat); - for (j, sp) in row.iter().enumerate() { - let (b, t) = sp.unwrap_or((0.0, 0.0)); - let c = center(j); - top.push([c, vpos(t, &sc)]); - base.push([c, vpos(b, &sc)]); - // Source values (not stack totals), just above the top edge like the line labels. - if labels_on - && sp.is_some() - && let Some(v) = s.values.get(j).copied().flatten() - { - area_labels.push((c, vpos(t, &sc) - 5.0, fmt.format(v, None))); - } - } - if top.len() == 1 { - // A single point: give it some width. - if let (Some(t), Some(b)) = (top.first().copied(), base.first().copied()) { - top = vec![[t[0] - cw * 0.3, t[1]], [t[0] + cw * 0.3, t[1]]]; - base = vec![[b[0] - cw * 0.3, b[1]], [b[0] + cw * 0.3, b[1]]]; - } - } - let top = decimate(top, MAX_LINE_PTS * 2); - let base = decimate(base, MAX_LINE_PTS * 2); - let mut poly = top; - poly.extend(base.into_iter().rev()); - if poly.len() >= 3 { - ctx.out.push(Prim::Polygon { pts: poly, fill: s.color, stroke: None }); - } - } - 1 => { - let k = slot_of.get(i).copied().unwrap_or(0) as f32; - for (j, sp) in row.iter().enumerate() { - let Some((b, t)) = sp else { continue }; - let (pb, pt) = (vpos(*b, &sc), vpos(*t, &sc)); - let c = center(j); - let lo = pb.min(pt); - let len = (pb - pt).abs(); - if horizontal { - let y = c + nslots * bw / 2.0 - (k + 1.0) * bw; - ctx.rect(lo, y, len, bw, s.color, None); - } else { - let x = c - nslots * bw / 2.0 + k * bw; - ctx.rect(x, lo, bw, len, s.color, None); - } - if labels_on && let Some(v) = s.values.get(j).copied().flatten() { - let t = fmt.format(v, None); - let stacked = fam != Fam::Col; - let mid = (pb + pt) / 2.0; - if horizontal { - let y = c + nslots * bw / 2.0 - (k + 0.5) * bw; - if stacked { - ctx.text(mid, y, &t, LABEL_SIZE, on(s.color), HAlign::Center, VAlign::Middle); - } else if v >= 0.0 { - ctx.text(pb.max(pt) + 3.0, y, &t, LABEL_SIZE, TEXT, HAlign::Left, VAlign::Middle); - } else { - ctx.text(lo - 3.0, y, &t, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle); - } - } else { - let x = c - nslots * bw / 2.0 + (k + 0.5) * bw; - if stacked { - ctx.text(x, mid, &t, LABEL_SIZE, on(s.color), HAlign::Center, VAlign::Middle); - } else if v >= 0.0 { - ctx.text(x, lo - 2.0, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); - } else { - ctx.text(x, lo + len + 2.0, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Top); - } - } - } - } - } - _ => { - let pts: Vec> = row.iter().enumerate().map(|(j, sp)| sp.map(|(_, t)| [center(j), vpos(t, &sc)])).collect(); - draw_runs(ctx, &pts, s.color, 2.25, s.smooth.then_some(plot)); - let k = kinds.get(i).copied().unwrap_or(ChartKind::Line); - if k == ChartKind::LineMarkers && cw >= 3.0 { - draw_markers(ctx, pts.iter().flatten().copied(), 3.5, s.color); - } - if labels_on { - for (j, p) in pts.iter().enumerate() { - if let (Some(p), Some(v)) = (p, s.values.get(j).copied().flatten()) { - ctx.text(p[0], p[1] - 5.0, &fmt.format(v, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); - } - } - } - } - } - } - for (x, y, t) in area_labels.drain(..) { - ctx.text(x, y, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); - } - } - // Trendlines on top: x is the category number 1..n. - let mut trend_labels = Vec::new(); - for (i, s) in data.series.iter().enumerate() { - if s.trendlines.is_empty() || !kinds.get(i).is_some_and(|k| supports_trendline(*k)) { - continue; - } - let sc = scale_of(i); - let pts: Vec<(f64, f64)> = s.values.iter().take(ncat).enumerate().filter_map(|(j, v)| v.map(|v| ((j + 1) as f64, v))).collect(); - let frac = |x: f64, y: f64| ((x - 0.5) / ncat_slots as f64, (y - sc.min) / (sc.max - sc.min)); - let px = |(fx, fy): (f64, f64)| { - if horizontal { - [plot.x + fy as f32 * plot.w, plot.bottom() - fx as f32 * plot.h] - } else { - [plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h] - } - }; - draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (0.5, ncat_slots as f64 + 0.5)); - } - draw_trend_labels(ctx, plot, &trend_labels); -} - -// ---------------------------------------------------------------- scatter & bubble - -fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { - let pts_of = |s: &SeriesData| -> Vec> { - s.values - .iter() - .enumerate() - .map(|(j, v)| { - let x = match &s.x { - Some(xs) => xs.get(j).copied().flatten(), - None => Some((j + 1) as f64), - }; - match (x, v) { - (Some(x), Some(y)) => Some((x, *y)), - _ => None, - } - }) - .collect() - }; - let all: Vec>> = data.series.iter().map(pts_of).collect(); - let Some((mut xlo, mut xhi)) = value_range(all.iter().flatten().flatten().map(|p| p.0)) else { return }; - // Forecasts extend the x axis to the trendline's ends. - for (s, pts) in data.series.iter().zip(&all) { - if let Some((lo, hi)) = value_range(pts.iter().flatten().map(|p| p.0)).filter(|_| !s.trendlines.is_empty()) { - xlo = xlo.min(lo - max_backward(s)); - xhi = xhi.max(hi + max_forward(s)); - } - } - let Some((ylo, yhi)) = value_range(all.iter().flatten().flatten().map(|p| p.1)) else { return }; - let xs = nice_scale(xlo, xhi, false); - let ys = nice_scale(ylo, yhi, false); - let fx = Fmt::new("General"); - let fy = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); - let plot = frame(ctx, area, CatAxis::Numeric(xs, &fx), &ValAxis { scale: ys, fmt: &fy }, None, false, chart.gridlines); - if !plot.ok() { - return; - } - let map = |(x, y): (f64, f64)| [plot.x + xs.frac(x) * plot.w, plot.bottom() - ys.frac(y) * plot.h]; - let bubble = chart.kind == ChartKind::Bubble; - let rmax = plot.w.min(plot.h) * 0.12; - for (s, pts) in data.series.iter().zip(&all) { - let px: Vec> = pts.iter().map(|p| p.map(map)).collect(); - if bubble { - let sizes = s.sizes.as_deref().unwrap_or(&[]); - let smax = sizes.iter().flatten().fold(0.0f64, |a, b| a.max(*b)); - let mut bubbles: Vec<([f32; 2], f64)> = px - .iter() - .enumerate() - .filter_map(|(j, p)| { - let sz = if sizes.is_empty() { Some(1.0) } else { sizes.get(j).copied().flatten() }; - match (p, sz) { - (Some(p), Some(sz)) if sz > 0.0 => Some((*p, sz)), - _ => None, - } - }) - .take(MAX_MARKERS) - .collect(); - bubbles.sort_by(|a, b| b.1.total_cmp(&a.1)); - let smax = if sizes.is_empty() { 1.0 } else { smax }; - let fill = [s.color[0], s.color[1], s.color[2], 0xC0]; - for (p, sz) in bubbles { - let r = ((sz / smax).sqrt() as f32 * rmax).max(1.0); - ctx.out.push(Prim::Circle { cx: p[0], cy: p[1], r, fill: Some(fill), stroke: Some((WHITE, 0.75)) }); - } - } else { - let lines = chart.kind == ChartKind::ScatterLines || s.kind == ChartKind::ScatterLines; - if lines { - draw_runs(ctx, &px, s.color, 2.0, s.smooth.then_some(plot)); - } - if !lines || px.len() <= 500 { - draw_markers(ctx, px.iter().flatten().copied(), 3.5, s.color); - } - } - if chart.data_labels && pts.len() <= MAX_LABELS { - for (p, raw) in px.iter().zip(pts) { - if let (Some(p), Some((_, y))) = (p, raw) { - ctx.text(p[0], p[1] - 6.0, &fy.format(*y, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); - } - } - } - } - let mut trend_labels = Vec::new(); - for (s, pts) in data.series.iter().zip(&all) { - if s.trendlines.is_empty() { - continue; - } - let pts: Vec<(f64, f64)> = pts.iter().flatten().copied().collect(); - let frac = |x: f64, y: f64| ((x - xs.min) / (xs.max - xs.min), (y - ys.min) / (ys.max - ys.min)); - let px = |(fx, fy): (f64, f64)| [plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h]; - draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (xs.min, xs.max)); - } - draw_trend_labels(ctx, plot, &trend_labels); -} - -// ---------------------------------------------------------------- histogram - -fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { - let Some(s) = data.series.first() else { return }; - let vals: Vec = s.values.iter().flatten().copied().collect(); - let Some((lo, hi)) = value_range(vals.iter().copied()) else { return }; - let n = vals.len(); - let span = hi - lo; - let mut bins = ((n as f64).sqrt().ceil() as usize).clamp(1, 100); - if !(span.is_finite() && span > 0.0) { - bins = 1; - } - let width = if bins == 1 && span.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) { 1.0 } else { span / bins as f64 }; - // Upper edge of each bin, exactly as shown in the labels below. Bins are right-inclusive (the first is closed on both sides), so a value - // equal to an edge belongs to the bin that ends there. - let edges: Vec = (0..bins).map(|b| if b + 1 == bins { hi.max(lo + b as f64 * width) } else { lo + (b + 1) as f64 * width }).collect(); - let mut counts = vec![0usize; bins]; - for v in &vals { - let b = edges.partition_point(|e| e < v).min(bins - 1); - if let Some(c) = counts.get_mut(b) { - *c += 1; - } - } - let f = Fmt::new(&s.number_format); - let labels: Vec = (0..bins) - .map(|b| { - let a = lo + b as f64 * width; - let e = edges.get(b).copied().unwrap_or(a); - let open = if b == 0 { '[' } else { '(' }; - format!("{open}{}, {}]", f.format(a, Some(width / 10.0)), f.format(e, Some(width / 10.0))) - }) - .collect(); - let maxc = counts.iter().copied().max().unwrap_or(0) as f64; - let ys = nice_scale(0.0, maxc, true); - let fc = Fmt::new("General"); - let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &fc }, None, false, chart.gridlines); - if !plot.ok() { - return; - } - let cw = plot.w / bins as f32; - for (b, c) in counts.iter().enumerate() { - let top = plot.bottom() - ys.frac(*c as f64) * plot.h; - let x = plot.x + b as f32 * cw; - ctx.rect(x, top, cw, plot.bottom() - top, s.color, Some((WHITE, 0.75))); - if chart.data_labels { - ctx.text(x + cw / 2.0, top - 2.0, &c.to_string(), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); - } - } -} - -// ---------------------------------------------------------------- waterfall - -fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { - let Some(s) = data.series.first() else { return }; - let mut ncat = data.categories.len().max(s.values.len()).min(MAX_CATS); - if ncat == 0 { - return; - } - let mut labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); - let mut vals: Vec = (0..ncat).map(|j| s.values.get(j).copied().flatten().unwrap_or(0.0)).collect(); - let mut is_total = vec![false; ncat]; - let last_total = labels.last().is_some_and(|l| l.to_lowercase().contains("total")); - if let Some(t) = is_total.last_mut() { - *t = last_total; - } - if !last_total { - labels.push("Total".into()); - vals.push(0.0); - is_total.push(true); - ncat += 1; - } - let mut bars = Vec::with_capacity(ncat); - let mut cum = 0.0f64; - for (j, v) in vals.iter().enumerate() { - if is_total.get(j).copied().unwrap_or(false) { - let total = if last_total && j + 1 == ncat && *v != 0.0 { *v } else { cum }; - bars.push((0.0, total, 0u8)); - cum = total; - } else { - let top = clean(cum + v); - bars.push((cum, top, if *v >= 0.0 { 1 } else { 2 })); - cum = top; - } - } - let (lo, hi) = value_range(bars.iter().flat_map(|(b, t, _)| [*b, *t])).unwrap_or((0.0, 0.0)); - let ys = nice_scale(lo, hi, true); - let f = Fmt::new(&s.number_format); - let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); - if !plot.ok() { - return; - } - let cw = plot.w / ncat as f32; - let bw = cw / 1.5; - let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h; - for (j, (b, t, kind)) in bars.iter().enumerate() { - let color = match kind { - 1 => UP, - 2 => DOWN, - _ => s.color, - }; - let x = plot.x + (j as f32 + 0.5) * cw - bw / 2.0; - let (yb, yt) = (vy(*b), vy(*t)); - ctx.rect(x, yb.min(yt), bw, (yb - yt).abs().max(0.5), color, None); - if j + 1 < bars.len() && ncat <= 500 { - ctx.out.push(Prim::Line { pts: vec![[x + bw, yt], [x + cw, yt]], color: AXIS, width: 0.75, dash: true }); - } - if chart.data_labels && ncat <= MAX_LABELS { - let v = if *kind == 0 { *t } else { t - b }; - ctx.text(x + bw / 2.0, yb.min(yt) - 2.0, &f.format(v, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); - } - } -} - -// ---------------------------------------------------------------- funnel - -fn funnel(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { - let Some(s) = data.series.first() else { return }; - let n = data.categories.len().max(s.values.len()).min(MAX_CATS); - if n == 0 { - return; - } - let vals: Vec = (0..n).map(|j| s.values.get(j).copied().flatten().unwrap_or(0.0).abs()).collect(); - let max = vals.iter().copied().fold(0.0f64, f64::max); - if max.is_nan() || max <= 0.0 { - return; - } - let labels: Vec = (0..n).map(|j| cat_label(data, j)).collect(); - let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, LABEL_SIZE)).fold(0.0f32, f32::max) + 8.0).min(area.w * 0.35); - let plot = Bx { x: area.x + lw, w: area.w - lw, ..area }; - if !plot.ok() { - return; - } - let slot = plot.h / n as f32; - let bh = slot * 0.85; - let f = Fmt::new(&s.number_format); - let show = slot >= LABEL_SIZE * 0.9; - for (j, v) in vals.iter().enumerate() { - let bw = (*v / max) as f32 * plot.w; - let x = plot.x + (plot.w - bw) / 2.0; - let y = plot.y + j as f32 * slot + (slot - bh) / 2.0; - if bw > 0.0 { - ctx.rect(x, y, bw, bh, s.color, None); - } - if show { - let yc = y + bh / 2.0; - if chart.data_labels { - let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None); - let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT }; - ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, HAlign::Center, VAlign::Middle); - } - let l = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw - 8.0); - ctx.text(plot.x - 6.0, yc, &l, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle); - } - } -} - -// ---------------------------------------------------------------- pie, doughnut, sunburst - -fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inner: f32, names: bool) { - let Some(s) = data.series.first() else { return }; - let vals: Vec = s.values.iter().take(MAX_CATS).map(|v| v.map(f64::abs).unwrap_or(0.0)).collect(); - let total: f64 = vals.iter().sum(); - if !(total.is_finite() && total > 0.0) { - return; - } - let (cx, cy) = (area.cx(), area.cy()); - let r = area.w.min(area.h) / 2.0 * 0.92; - if r < 2.0 { - return; - } - let ri = r * inner; - let f = Fmt::new(&s.number_format); - let mut a = 0.0f32; - let mut labels = Vec::new(); - for (j, v) in vals.iter().enumerate() { - let sweep = (*v / total) as f32 * TAU; - if sweep > 1e-4 { - let fill = point_color(data, j); - ctx.out.push(Prim::Wedge { cx, cy, r_outer: r, r_inner: ri, a0: a, a1: (a + sweep).min(TAU), fill, stroke: Some((WHITE, 1.5)) }); - if (chart.data_labels || names) && vals.len() <= MAX_LABELS { - let t = if names { cat_label(data, j) } else { f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None) }; - let mid = a + sweep / 2.0; - let rr = if ri > 0.0 { (r + ri) / 2.0 } else { r * 0.65 }; - let room = sweep * rr; - if ctx.tw(&t, LABEL_SIZE) <= room.max(r - ri) && room >= LABEL_SIZE { - labels.push((cx + rr * mid.sin(), cy - rr * mid.cos(), t, on(fill))); - } - } - } - a += sweep; - } - for (x, y, t, c) in labels { - ctx.text(x, y, &t, LABEL_SIZE, c, HAlign::Center, VAlign::Middle); - } -} - -// ---------------------------------------------------------------- treemap - -fn worst(row: &[f64], side: f64) -> f64 { - let s: f64 = row.iter().sum(); - if s <= 0.0 || side <= 0.0 { - return f64::INFINITY; - } - let (mn, mx) = row.iter().fold((f64::INFINITY, 0.0f64), |(a, b), v| (a.min(*v), b.max(*v))); - let s2 = s * s; - let w2 = side * side; - (w2 * mx / s2).max(s2 / (w2 * mn)) -} - -/// Squarified treemap layout of (index, area) items, sorted descending, into `r`. -fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> { - let total: f64 = items.iter().map(|i| i.1).sum(); - let area = (r.w as f64) * (r.h as f64); - if !(total > 0.0 && area > 0.0) { - return vec![]; - } - let scale = area / total; - let mut out = Vec::with_capacity(items.len()); - let mut rem = r; - let mut row: Vec<(usize, f64)> = Vec::new(); - let mut i = 0; - let layout = |row: &[(usize, f64)], rem: &mut Bx, out: &mut Vec<(usize, Bx)>| { - let s: f64 = row.iter().map(|x| x.1).sum(); - if s <= 0.0 { - return; - } - if rem.w >= rem.h { - let cw = (s / rem.h.max(1e-6) as f64) as f32; - let mut y = rem.y; - for (j, a) in row { - let h = (*a / cw.max(1e-6) as f64) as f32; - out.push((*j, Bx { x: rem.x, y, w: cw, h })); - y += h; - } - rem.x += cw; - rem.w = (rem.w - cw).max(0.0); - } else { - let rh = (s / rem.w.max(1e-6) as f64) as f32; - let mut x = rem.x; - for (j, a) in row { - let w = (*a / rh.max(1e-6) as f64) as f32; - out.push((*j, Bx { x, y: rem.y, w, h: rh })); - x += w; - } - rem.y += rh; - rem.h = (rem.h - rh).max(0.0); - } - }; - while let Some(&(j, v)) = items.get(i) { - let a = v * scale; - let side = rem.w.min(rem.h) as f64; - let cur: Vec = row.iter().map(|x| x.1).collect(); - let mut with = cur.clone(); - with.push(a); - if row.is_empty() || worst(&with, side) <= worst(&cur, side) { - row.push((j, a)); - i += 1; - } else { - layout(&row, &mut rem, &mut out); - row.clear(); - } - } - if !row.is_empty() { - layout(&row, &mut rem, &mut out); - } - out -} - -fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { - let Some(s) = data.series.first() else { return }; - let mut items: Vec<(usize, f64)> = - s.values.iter().take(MAX_MARKERS).enumerate().filter_map(|(j, v)| v.filter(|v| *v > 0.0).map(|v| (j, v))).collect(); - items.sort_by(|a, b| b.1.total_cmp(&a.1)); - let f = Fmt::new(&s.number_format); - for (j, b) in squarify(&items, area) { - let fill = point_color(data, j); - ctx.rect(b.x, b.y, b.w, b.h, fill, Some((WHITE, 1.5))); - if b.w > 24.0 && b.h > LABEL_SIZE * 1.6 { - let name = ctx.fit(&cat_label(data, j), LABEL_SIZE, b.w - 8.0); - ctx.text(b.x + 4.0, b.y + 4.0, &name, LABEL_SIZE, on(fill), HAlign::Left, VAlign::Top); - if chart.data_labels && b.h > LABEL_SIZE * 3.2 { - let v = items.iter().find(|x| x.0 == j).map(|x| x.1).unwrap_or(0.0); - let t = ctx.fit(&f.format(v, None), LABEL_SIZE, b.w - 8.0); - ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), HAlign::Left, VAlign::Top); - } - } - } -} - -// ---------------------------------------------------------------- radar - -fn radar(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { - let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(1000); - if ncat == 0 { - return; - } - let labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); - let lw = labels.iter().map(|t| ctx.tw(t, LABEL_SIZE)).fold(0.0f32, f32::max).min(area.w * 0.2); - let r = ((area.w - 2.0 * (lw + 8.0)).min(area.h - 2.0 * (LABEL_SIZE * 1.4 + 6.0)) / 2.0).max(0.0); - if r < 5.0 { - return; - } - let (cx, cy) = (area.cx(), area.cy()); - let (lo, hi) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())).unwrap_or((0.0, 0.0)); - let sc = nice_scale(lo, hi, true); - let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); - let ang = |j: usize| j as f32 / ncat as f32 * TAU; - let pt = |a: f32, rr: f32| [cx + rr * a.sin(), cy - rr * a.cos()]; - let ticks = sc.ticks(); - let spacing = r / ticks.len().saturating_sub(1).max(1) as f32; - let every = ((LABEL_SIZE * 1.3 / spacing.max(1e-3)).ceil() as usize).max(1); - for (ti, t) in ticks.into_iter().enumerate() { - let rr = sc.frac(t) * r; - if rr > 0.5 && chart.gridlines { - let mut ring: Vec<[f32; 2]> = (0..ncat).map(|j| pt(ang(j), rr)).collect(); - if ncat < 3 { - ring = (0..=48).map(|k| pt(k as f32 / 48.0 * TAU, rr)).collect(); - } else if let Some(p) = ring.first().copied() { - ring.push(p); - } - ctx.line(ring, GRID, 0.75); - } - if ti % every == 0 { - ctx.text(cx - 3.0, cy - rr, &f.format(t, Some(sc.step)), LABEL_SIZE * 0.9, TEXT, HAlign::Right, VAlign::Middle); - } - } - let step = ncat.div_ceil(72).max(1); - for j in (0..ncat).step_by(step) { - let a = ang(j); - ctx.line(vec![[cx, cy], pt(a, r)], GRID, 0.75); - let p = pt(a, r + 6.0); - let (sa, ca) = (a.sin(), a.cos()); - let align = if sa > 0.1 { - HAlign::Left - } else if sa < -0.1 { - HAlign::Right - } else { - HAlign::Center - }; - let valign = if ca > 0.1 { - VAlign::Bottom - } else if ca < -0.1 { - VAlign::Top - } else { - VAlign::Middle - }; - let t = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw.max(20.0)); - ctx.text(p[0], p[1], &t, LABEL_SIZE, TEXT, align, valign); - } - for s in &data.series { - let mut pts: Vec<[f32; 2]> = (0..ncat).map(|j| pt(ang(j), sc.frac(s.values.get(j).copied().flatten().unwrap_or(sc.min)) * r)).collect(); - if let Some(p) = pts.first().copied() { - pts.push(p); - } - ctx.line(pts, s.color, 2.0); - } -} - -// ---------------------------------------------------------------- box & whisker - -fn quantile(sorted: &[f64], q: f64, method: QuartileMethod) -> f64 { - let n = sorted.len(); - if n == 0 { - return 0.0; - } - // Clamp exclusive ranks for short samples so one or two points remain drawable. - let pos = match method { - QuartileMethod::Inclusive => q * (n - 1) as f64, - QuartileMethod::Exclusive => (q * (n as f64 + 1.0) - 1.0).clamp(0.0, (n - 1) as f64), - }; - let i = pos.floor() as usize; - let frac = pos - i as f64; - let a = sorted.get(i).copied().unwrap_or(0.0); - let b = sorted.get(i + 1).copied().unwrap_or(a); - a + (b - a) * frac -} - -fn box_whisker(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { - let labels: Vec = data.series.iter().map(|s| s.name.clone()).collect(); - let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; - let ys = nice_scale(lo, hi, false); - let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); - let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); - if !plot.ok() { - return; - } - let n = data.series.len().max(1); - let cw = plot.w / n as f32; - let bw = (cw * 0.4).min(80.0); - let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h; - for (i, s) in data.series.iter().enumerate() { - let mut v: Vec = s.values.iter().flatten().copied().collect(); - if v.is_empty() { - continue; - } - v.sort_by(f64::total_cmp); - let method = chart.series.get(i).map(|s| s.quartiles).unwrap_or_default(); - let (q1, med, q3) = (quantile(&v, 0.25, method), quantile(&v, 0.5, method), quantile(&v, 0.75, method)); - let iqr = q3 - q1; - let (flo, fhi) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr); - let wlo = v.iter().copied().find(|x| *x >= flo).unwrap_or(q1); - let whi = v.iter().rev().copied().find(|x| *x <= fhi).unwrap_or(q3); - let mean = v.iter().sum::() / v.len() as f64; - let c = plot.x + (i as f32 + 0.5) * cw; - ctx.vline(c, vy(whi), vy(q3), s.color, 1.25); - ctx.vline(c, vy(q1), vy(wlo), s.color, 1.25); - ctx.hline(c - bw / 4.0, c + bw / 4.0, vy(whi), s.color, 1.25); - ctx.hline(c - bw / 4.0, c + bw / 4.0, vy(wlo), s.color, 1.25); - let (yt, yb) = (vy(q3), vy(q1)); - ctx.rect(c - bw / 2.0, yt, bw, (yb - yt).max(0.5), s.color, None); - ctx.hline(c - bw / 2.0, c + bw / 2.0, vy(med), WHITE, 1.5); - let (mx, my, d) = (c, vy(mean), 3.0); - ctx.line(vec![[mx - d, my - d], [mx + d, my + d]], WHITE, 1.25); - ctx.line(vec![[mx - d, my + d], [mx + d, my - d]], WHITE, 1.25); - for o in v.iter().filter(|x| **x < flo || **x > fhi).take(MAX_MARKERS) { - ctx.out.push(Prim::Circle { cx: c, cy: vy(*o), r: 2.5, fill: None, stroke: Some((s.color, 1.0)) }); - } - } -} - -// ---------------------------------------------------------------- stock - -fn stock(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { - let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS); - if ncat == 0 { - return; - } - let labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); - let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; - let ys = nice_scale(lo, hi, false); - let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); - let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); - if !plot.ok() { - return; - } - let ser = |k: usize| data.series.get(k); - let (open, high, low, close) = match data.series.len() { - 0 => return, - 1 => (None, None, None, ser(0)), - 2 => (None, ser(0), ser(1), None), - 3 => (None, ser(0), ser(1), ser(2)), - _ => (ser(0), ser(1), ser(2), ser(3)), - }; - let get = |s: Option<&SeriesData>, j: usize| s.and_then(|s| s.values.get(j).copied().flatten()); - let cw = plot.w / ncat as f32; - let tick = (cw * 0.25).max(1.0); - let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h; - for j in 0..ncat { - let c = plot.x + (j as f32 + 0.5) * cw; - if let (Some(h), Some(l)) = (get(high, j), get(low, j)) { - ctx.vline(c, vy(h), vy(l), STOCK, 1.25); - } - match (get(open, j), get(close, j)) { - (Some(o), Some(cl)) => { - let (yo, yc) = (vy(o), vy(cl)); - let bw = (cw * 0.5).max(1.0); - let fill = if cl >= o { WHITE } else { STOCK }; - ctx.rect(c - bw / 2.0, yo.min(yc), bw, (yo - yc).abs().max(0.75), fill, Some((STOCK, 1.0))); - } - (None, Some(cl)) => { - if high.is_some() { - ctx.hline(c, c + tick, vy(cl), STOCK, 1.25); - } else { - ctx.out.push(Prim::Circle { cx: c, cy: vy(cl), r: 2.5, fill: Some(STOCK), stroke: None }); - } - } - _ => {} - } - } -} - // ---------------------------------------------------------------- final safety pass fn fin(v: f32) -> bool { @@ -1829,60 +253,3 @@ pub(crate) fn finalize(prims: Vec, w: f32, h: f32) -> Vec { }) .collect() } - -#[cfg(test)] -mod smooth_tests { - use super::*; - - #[test] - fn long_runs_are_smoothed_uniformly_within_the_cap() { - let plot = Bx { x: 0.0, y: 0.0, w: 3000.0, h: 100.0 }; - let pts: Vec<[f32; 2]> = (0..1500).map(|i| [i as f32 * 2.0, if i % 2 == 0 { 20.0 } else { 80.0 }]).collect(); - let out = smooth_run(pts, plot); - assert!(out.len() <= MAX_LINE_PTS * 2 + 1, "{}", out.len()); - // Two sub-segments per segment all the way to the end, not 100 each and then a straight tail. - assert_eq!(out.len(), 1499 * 2 + 1); - assert_eq!(out.last().copied(), Some([2998.0, 80.0])); - } - - #[test] - fn spline_overshoot_is_clamped_to_the_plot() { - let plot = Bx { x: 0.0, y: 10.0, w: 300.0, h: 100.0 }; - // A step right at the plot edges: Catmull-Rom overshoots past both. - let pts = vec![[0.0, 110.0], [100.0, 110.0], [110.0, 10.0], [200.0, 10.0], [300.0, 10.0]]; - let out = smooth_run(pts, plot); - assert!(out.len() > 5); - assert!(out.iter().all(|p| (0.0..=300.0).contains(&p[0]) && (10.0..=110.0).contains(&p[1]))); - } -} - -#[cfg(test)] -mod quartile_tests { - use super::*; - - #[test] - fn box_whisker_default_quartiles_are_exclusive() { - let values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; - assert_eq!(quantile(&values, 0.25, QuartileMethod::default()), 1.75); - assert_eq!(quantile(&values, 0.5, QuartileMethod::default()), 3.5); - assert_eq!(quantile(&values, 0.75, QuartileMethod::default()), 5.25); - } - - #[test] - fn quartile_methods_and_short_samples() { - for (values, exclusive, inclusive) in [ - (vec![1., 2., 3.], (1., 3.), (1.5, 2.5)), - (vec![1., 2., 3., 4.], (1.25, 3.75), (1.75, 3.25)), - (vec![1., 2., 3., 4., 5.], (1.5, 4.5), (2., 4.)), - (vec![1., 2., 3., 4., 5., 6., 7.], (2., 6.), (2.5, 5.5)), - (vec![1., 1., 2., 3., 5., 8.], (1., 5.75), (1.25, 4.5)), - (vec![1., 2.], (1., 2.), (1.25, 1.75)), - (vec![1.], (1., 1.), (1., 1.)), - (vec![], (0., 0.), (0., 0.)), - ] { - for (method, expected) in [(QuartileMethod::Exclusive, exclusive), (QuartileMethod::Inclusive, inclusive)] { - assert_eq!((quantile(&values, 0.25, method), quantile(&values, 0.75, method)), expected); - } - } - } -} diff --git a/crates/chart/src/render/cartesian.rs b/crates/chart/src/render/cartesian.rs new file mode 100644 index 0000000000..85a577b085 --- /dev/null +++ b/crates/chart/src/render/cartesian.rs @@ -0,0 +1,431 @@ +use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range}; +use super::legend::{cat_label, plot_kind}; +use super::lines::{decimate, draw_markers, draw_runs}; +use super::trend::{draw_trend_labels, draw_trendlines, max_backward, max_forward, max_forward_slots, supports_trendline}; +use super::*; + +// ---------------------------------------------------------------- cartesian (column/bar/line/area/combo) + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Fam { + Col, + ColStack, + Col100, + Line, + LineStack, + Area, + AreaStack, + Area100, +} + +fn family(k: ChartKind) -> Fam { + use ChartKind::*; + match k { + ColumnStacked | BarStacked => Fam::ColStack, + ColumnStacked100 | BarStacked100 => Fam::Col100, + Line | LineMarkers | Scatter | ScatterLines | Radar | Stock => Fam::Line, + 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) +} + +fn is_line(f: Fam) -> bool { + matches!(f, Fam::Line | Fam::LineStack) +} + +pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizontal: bool) { + let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS); + if ncat == 0 { + return; + } + let kinds: Vec = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).collect(); + let fams: Vec = kinds.iter().map(|k| family(*k)).collect(); + let any_primary = data.series.iter().any(|s| !s.secondary); + let sec: Vec = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect(); + + // Totals for 100% stacking, per axis and family (100% columns and 100% areas stack apart). + let mut totals: Vec<((bool, Fam), Vec)> = Vec::new(); + for (i, s) in data.series.iter().enumerate() { + 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(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, Vec)> = Vec::new(); + let mut spans: Vec>> = 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 | Fam::Area100); + let key = (a, fam); + let si = if stacked { + match stacks.iter().position(|(k, _, _)| *k == key) { + Some(p) => Some(p), + None => { + stacks.push((key, vec![0.0; ncat], vec![0.0; ncat])); + Some(stacks.len() - 1) + } + } + } else { + None + }; + let mut row = Vec::with_capacity(ncat); + for j in 0..ncat { + 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 { 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; + Some((base, *acc)) + }), + (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); + (base, *acc) + }) + } + (None, _) => v.map(|v| (0.0, v)), + }; + row.push(span); + } + spans.push(row); + } + + // Axis scales. + let mut axes: Vec<(Scale, Fmt)> = Vec::new(); + for axis in [false, true] { + let members: Vec = (0..data.series.len()).filter(|i| sec.get(*i).copied().unwrap_or(false) == axis).collect(); + if members.is_empty() { + 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).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] }) + })); + let (lo, hi) = range.unwrap_or((0.0, 0.0)); + let (scale, fmt) = if all100 { + (fixed_scale(if lo < -1e-9 { -1.0 } else { 0.0 }, if hi > 1e-9 || lo >= -1e-9 { 1.0 } else { 0.0 }, 0.2), Fmt::new("0%")) + } else { + let code = members.first().and_then(|i| data.series.get(*i)).map(|s| s.number_format.as_str()).unwrap_or("General"); + (nice_scale(lo, hi, force_zero), Fmt::new(code)) + }; + axes.push((scale, fmt)); + if !axis && sec.iter().all(|s| !s) { + break; + } + } + let Some((s1, f1)) = axes.first() else { return }; + let y1 = ValAxis { scale: *s1, fmt: f1 }; + let y2 = axes.get(1).map(|(s, f)| ValAxis { scale: *s, fmt: f }); + // A forward forecast on a category axis adds empty category slots after the data. + let fwd = data + .series + .iter() + .enumerate() + .filter(|(i, _)| kinds.get(*i).is_some_and(|k| supports_trendline(*k))) + .map(|(_, s)| max_forward(s)) + .fold(0.0, f64::max); + let ncat_slots = ncat.saturating_add(fwd.min(max_forward_slots(ncat)).ceil() as usize).min(MAX_CATS.max(ncat)); + let labels: Vec = (0..ncat_slots).map(|j| if j < ncat { cat_label(data, j) } else { String::new() }).collect(); + // Outer levels padded to the slots (forecast slots stay in the last group). + let outer: Vec> = + data.category_levels.iter().map(|l| (0..ncat_slots).map(|j| l.get(j).cloned().unwrap_or_default()).collect()).collect(); + let plot = frame(ctx, area, CatAxis::Labels(&labels, &outer), &y1, y2.as_ref(), horizontal, chart.gridlines); + if !plot.ok() { + return; + } + let scale_of = |i: usize| -> Scale { if sec.get(i).copied().unwrap_or(false) { y2.as_ref().map(|a| a.scale).unwrap_or(*s1) } else { *s1 } }; + let cat_len = if horizontal { plot.h } else { plot.w }; + let cw = cat_len / ncat_slots as f32; + let center = |j: usize| if horizontal { plot.bottom() - (j as f32 + 0.5) * cw } else { plot.x + (j as f32 + 0.5) * cw }; + let vpos = |v: f64, sc: &Scale| if horizontal { plot.x + sc.frac(v) * plot.w } else { plot.bottom() - sc.frac(v) * plot.h }; + + // Column slots: each clustered series gets one, each stack group shares one. + let mut slot_keys: Vec<(bool, Fam, usize)> = Vec::new(); + let mut slot_of = vec![0usize; data.series.len()]; + for (i, f) in fams.iter().enumerate() { + if !is_col(*f) { + continue; + } + let a = sec.get(i).copied().unwrap_or(false); + let key = if *f == Fam::Col { (a, *f, i) } else { (a, *f, usize::MAX) }; + let k = match slot_keys.iter().position(|k| *k == key) { + Some(p) => p, + None => { + slot_keys.push(key); + slot_keys.len() - 1 + } + }; + if let Some(s) = slot_of.get_mut(i) { + *s = k; + } + } + let nslots = slot_keys.len().max(1) as f32; + let bw = cw / (nslots + 1.5); + let labels_on = chart.data_labels && ncat <= MAX_LABELS; + // Area labels wait until every area is filled, so a later (stacked) area can't paint over them. + let mut area_labels = Vec::new(); + + // Areas first, then columns, then lines on top. + for pass in 0..3 { + 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 | Fam::Area100), + 1 => is_col(fam), + _ => is_line(fam), + }; + if !want { + continue; + } + let sc = scale_of(i); + let fmt = Fmt::new(&s.number_format); + let row = match spans.get(i) { + Some(r) => r, + None => continue, + }; + match pass { + 0 => { + let mut top = Vec::with_capacity(ncat * 2); + let mut base = Vec::with_capacity(ncat); + for (j, sp) in row.iter().enumerate() { + let (b, t) = sp.unwrap_or((0.0, 0.0)); + let c = center(j); + top.push([c, vpos(t, &sc)]); + base.push([c, vpos(b, &sc)]); + // Source values (not stack totals), just above the top edge like the line labels. + if labels_on + && sp.is_some() + && let Some(v) = s.values.get(j).copied().flatten() + { + area_labels.push((c, vpos(t, &sc) - 5.0, fmt.format(v, None))); + } + } + if top.len() == 1 { + // A single point: give it some width. + if let (Some(t), Some(b)) = (top.first().copied(), base.first().copied()) { + top = vec![[t[0] - cw * 0.3, t[1]], [t[0] + cw * 0.3, t[1]]]; + base = vec![[b[0] - cw * 0.3, b[1]], [b[0] + cw * 0.3, b[1]]]; + } + } + let top = decimate(top, MAX_LINE_PTS * 2); + let base = decimate(base, MAX_LINE_PTS * 2); + let mut poly = top; + poly.extend(base.into_iter().rev()); + if poly.len() >= 3 { + ctx.out.push(Prim::Polygon { pts: poly, fill: s.color, stroke: None }); + } + } + 1 => { + let k = slot_of.get(i).copied().unwrap_or(0) as f32; + for (j, sp) in row.iter().enumerate() { + let Some((b, t)) = sp else { continue }; + let (pb, pt) = (vpos(*b, &sc), vpos(*t, &sc)); + let c = center(j); + let lo = pb.min(pt); + let len = (pb - pt).abs(); + if horizontal { + let y = c + nslots * bw / 2.0 - (k + 1.0) * bw; + ctx.rect(lo, y, len, bw, s.color, None); + } else { + let x = c - nslots * bw / 2.0 + k * bw; + ctx.rect(x, lo, bw, len, s.color, None); + } + if labels_on && let Some(v) = s.values.get(j).copied().flatten() { + let t = fmt.format(v, None); + let stacked = fam != Fam::Col; + let mid = (pb + pt) / 2.0; + if horizontal { + let y = c + nslots * bw / 2.0 - (k + 0.5) * bw; + if stacked { + ctx.text(mid, y, &t, LABEL_SIZE, on(s.color), HAlign::Center, VAlign::Middle); + } else if v >= 0.0 { + ctx.text(pb.max(pt) + 3.0, y, &t, LABEL_SIZE, TEXT, HAlign::Left, VAlign::Middle); + } else { + ctx.text(lo - 3.0, y, &t, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle); + } + } else { + let x = c - nslots * bw / 2.0 + (k + 0.5) * bw; + if stacked { + ctx.text(x, mid, &t, LABEL_SIZE, on(s.color), HAlign::Center, VAlign::Middle); + } else if v >= 0.0 { + ctx.text(x, lo - 2.0, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + } else { + ctx.text(x, lo + len + 2.0, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Top); + } + } + } + } + } + _ => { + let pts: Vec> = row.iter().enumerate().map(|(j, sp)| sp.map(|(_, t)| [center(j), vpos(t, &sc)])).collect(); + draw_runs(ctx, &pts, s.color, 2.25, s.smooth.then_some(plot)); + let k = kinds.get(i).copied().unwrap_or(ChartKind::Line); + if k == ChartKind::LineMarkers && cw >= 3.0 { + draw_markers(ctx, pts.iter().flatten().copied(), 3.5, s.color); + } + if labels_on { + for (j, p) in pts.iter().enumerate() { + if let (Some(p), Some(v)) = (p, s.values.get(j).copied().flatten()) { + ctx.text(p[0], p[1] - 5.0, &fmt.format(v, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + } + } + } + } + } + } + for (x, y, t) in area_labels.drain(..) { + ctx.text(x, y, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + } + } + // Trendlines on top: x is the category number 1..n. + let mut trend_labels = Vec::new(); + for (i, s) in data.series.iter().enumerate() { + if s.trendlines.is_empty() || !kinds.get(i).is_some_and(|k| supports_trendline(*k)) { + continue; + } + let sc = scale_of(i); + let pts: Vec<(f64, f64)> = s.values.iter().take(ncat).enumerate().filter_map(|(j, v)| v.map(|v| ((j + 1) as f64, v))).collect(); + let frac = |x: f64, y: f64| ((x - 0.5) / ncat_slots as f64, (y - sc.min) / (sc.max - sc.min)); + let px = |(fx, fy): (f64, f64)| { + if horizontal { + [plot.x + fy as f32 * plot.w, plot.bottom() - fx as f32 * plot.h] + } else { + [plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h] + } + }; + draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (0.5, ncat_slots as f64 + 0.5)); + } + draw_trend_labels(ctx, plot, &trend_labels); +} + +// ---------------------------------------------------------------- scatter & bubble + +pub(super) fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let pts_of = |s: &SeriesData| -> Vec> { + s.values + .iter() + .enumerate() + .map(|(j, v)| { + let x = match &s.x { + Some(xs) => xs.get(j).copied().flatten(), + None => Some((j + 1) as f64), + }; + match (x, v) { + (Some(x), Some(y)) => Some((x, *y)), + _ => None, + } + }) + .collect() + }; + let all: Vec>> = data.series.iter().map(pts_of).collect(); + let Some((mut xlo, mut xhi)) = value_range(all.iter().flatten().flatten().map(|p| p.0)) else { return }; + // Forecasts extend the x axis to the trendline's ends. + for (s, pts) in data.series.iter().zip(&all) { + if let Some((lo, hi)) = value_range(pts.iter().flatten().map(|p| p.0)).filter(|_| !s.trendlines.is_empty()) { + xlo = xlo.min(lo - max_backward(s)); + xhi = xhi.max(hi + max_forward(s)); + } + } + let Some((ylo, yhi)) = value_range(all.iter().flatten().flatten().map(|p| p.1)) else { return }; + let xs = nice_scale(xlo, xhi, false); + let ys = nice_scale(ylo, yhi, false); + let fx = Fmt::new("General"); + let fy = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); + let plot = frame(ctx, area, CatAxis::Numeric(xs, &fx), &ValAxis { scale: ys, fmt: &fy }, None, false, chart.gridlines); + if !plot.ok() { + return; + } + let map = |(x, y): (f64, f64)| [plot.x + xs.frac(x) * plot.w, plot.bottom() - ys.frac(y) * plot.h]; + let bubble = chart.kind == ChartKind::Bubble; + let rmax = plot.w.min(plot.h) * 0.12; + for (s, pts) in data.series.iter().zip(&all) { + let px: Vec> = pts.iter().map(|p| p.map(map)).collect(); + if bubble { + let sizes = s.sizes.as_deref().unwrap_or(&[]); + let smax = sizes.iter().flatten().fold(0.0f64, |a, b| a.max(*b)); + let mut bubbles: Vec<([f32; 2], f64)> = px + .iter() + .enumerate() + .filter_map(|(j, p)| { + let sz = if sizes.is_empty() { Some(1.0) } else { sizes.get(j).copied().flatten() }; + match (p, sz) { + (Some(p), Some(sz)) if sz > 0.0 => Some((*p, sz)), + _ => None, + } + }) + .take(MAX_MARKERS) + .collect(); + bubbles.sort_by(|a, b| b.1.total_cmp(&a.1)); + let smax = if sizes.is_empty() { 1.0 } else { smax }; + let fill = [s.color[0], s.color[1], s.color[2], 0xC0]; + for (p, sz) in bubbles { + let r = ((sz / smax).sqrt() as f32 * rmax).max(1.0); + ctx.out.push(Prim::Circle { cx: p[0], cy: p[1], r, fill: Some(fill), stroke: Some((WHITE, 0.75)) }); + } + } else { + let lines = chart.kind == ChartKind::ScatterLines || s.kind == ChartKind::ScatterLines; + if lines { + draw_runs(ctx, &px, s.color, 2.0, s.smooth.then_some(plot)); + } + if !lines || px.len() <= 500 { + draw_markers(ctx, px.iter().flatten().copied(), 3.5, s.color); + } + } + if chart.data_labels && pts.len() <= MAX_LABELS { + for (p, raw) in px.iter().zip(pts) { + if let (Some(p), Some((_, y))) = (p, raw) { + ctx.text(p[0], p[1] - 6.0, &fy.format(*y, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + } + } + } + } + let mut trend_labels = Vec::new(); + for (s, pts) in data.series.iter().zip(&all) { + if s.trendlines.is_empty() { + continue; + } + let pts: Vec<(f64, f64)> = pts.iter().flatten().copied().collect(); + let frac = |x: f64, y: f64| ((x - xs.min) / (xs.max - xs.min), (y - ys.min) / (ys.max - ys.min)); + let px = |(fx, fy): (f64, f64)| [plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h]; + draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (xs.min, xs.max)); + } + draw_trend_labels(ctx, plot, &trend_labels); +} diff --git a/crates/chart/src/render/frame.rs b/crates/chart/src/render/frame.rs new file mode 100644 index 0000000000..7fa2a54a1e --- /dev/null +++ b/crates/chart/src/render/frame.rs @@ -0,0 +1,245 @@ +use super::*; + +#[derive(Clone, Copy, Debug)] +pub(super) struct Bx { + pub(super) x: f32, + pub(super) y: f32, + pub(super) w: f32, + pub(super) h: f32, +} + +impl Bx { + pub(super) fn right(&self) -> f32 { + self.x + self.w + } + pub(super) fn bottom(&self) -> f32 { + self.y + self.h + } + pub(super) fn ok(&self) -> bool { + self.w.is_finite() && self.h.is_finite() && self.w > 4.0 && self.h > 4.0 + } + pub(super) fn cx(&self) -> f32 { + self.x + self.w / 2.0 + } + pub(super) fn cy(&self) -> f32 { + self.y + self.h / 2.0 + } +} + +/// Text colour readable on `fill`. +pub(super) fn on(fill: Rgba) -> Rgba { + let l = 0.299 * fill[0] as f32 + 0.587 * fill[1] as f32 + 0.114 * fill[2] as f32; + if l < 150.0 { WHITE } else { DARK_TEXT } +} + +pub(super) fn chart_title(chart: &Chart, data: &ChartData) -> Option { + match &chart.title { + Some(t) => Some(t.trim().to_string()).filter(|t| !t.is_empty()), + None if data.series.len() == 1 => data.series.first().map(|s| s.name.clone()).filter(|t| !t.is_empty()), + None => None, + } +} + +pub(super) fn axis_titles(ctx: &mut Ctx, chart: &Chart, mut area: Bx) -> Bx { + let lh = AXIS_TITLE_SIZE * 1.4 + 2.0; + if let Some(t) = chart.x_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) { + let t = ctx.fit(t, AXIS_TITLE_SIZE, area.w); + ctx.text(area.cx(), area.bottom(), &t, AXIS_TITLE_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + area.h -= lh; + } + if let Some(t) = chart.y_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) { + let t = ctx.fit(t, AXIS_TITLE_SIZE, area.h); + if !t.is_empty() { + ctx.out.push(Prim::Text { + x: area.x, + y: area.cy(), + text: t, + size: AXIS_TITLE_SIZE, + color: TEXT, + bold: false, + align: HAlign::Center, + valign: VAlign::Top, + rotation: -PI / 2.0, + }); + } + area.x += lh; + area.w -= lh; + } + area +} + +// ---------------------------------------------------------------- axes frame + +pub(super) enum CatAxis<'a> { + /// Category labels and, for a multi-level axis, the outer levels (nearest the labels first; + /// one entry per category, non-empty where a group starts). + Labels(&'a [String], &'a [Vec]), + Numeric(Scale, &'a Fmt), +} + +/// Groups of an outer category level: `(first, end)` index pairs, a group starting at every +/// non-empty entry (and at the first category). +fn level_groups(level: &[String], n: usize) -> Vec<(usize, usize)> { + let mut starts: Vec = (0..n).filter(|&j| j == 0 || level.get(j).is_some_and(|t| !t.is_empty())).collect(); + starts.push(n); + starts.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect() +} + +pub(super) struct ValAxis<'a> { + pub(super) scale: Scale, + pub(super) fmt: &'a Fmt, +} + +impl ValAxis<'_> { + fn labels(&self) -> Vec<(f64, String)> { + self.scale.ticks().into_iter().map(|v| (v, self.fmt.format(v, Some(self.scale.step)))).collect() + } +} + +/// Draws gridlines, axis labels and the category axis line; returns the plot rectangle. +pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis>, horizontal: bool, grid: bool) -> Bx { + let ls = LABEL_SIZE; + let lh = ls * 1.4; + let ylabels = y.labels(); + let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw(t, ls)).fold(0.0f32, f32::max); + if !horizontal { + let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4); + let y2labels = y2.map(ValAxis::labels); + let right = match (&y2labels, &cat) { + (Some(l), _) => (maxw(ctx, l) + 6.0).min(area.w * 0.3), + (None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2), + _ => 4.0, + }; + let levels = match &cat { + CatAxis::Labels(_, o) => o.len() as f32, + CatAxis::Numeric(..) => 0.0, + }; + let plot = Bx { x: area.x + left, y: area.y + lh * 0.5, w: area.w - left - right, h: area.h - lh * 1.5 - 4.0 - lh * levels }; + if !plot.ok() { + return plot; + } + for (v, t) in &ylabels { + let yy = plot.bottom() - y.scale.frac(*v) * plot.h; + if grid { + ctx.hline(plot.x, plot.right(), yy, GRID, 0.75); + } + ctx.text(plot.x - 4.0, yy, t, ls, TEXT, HAlign::Right, VAlign::Middle); + } + if let (Some(a), Some(l)) = (y2, &y2labels) { + for (v, t) in l { + let yy = plot.bottom() - a.scale.frac(*v) * plot.h; + ctx.text(plot.right() + 4.0, yy, t, ls, TEXT, HAlign::Left, VAlign::Middle); + } + } + let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min }; + let y0 = plot.bottom() - y.scale.frac(zero) * plot.h; + ctx.hline(plot.x, plot.right(), y0, AXIS, 0.75); + match cat { + CatAxis::Labels(l, outer) => { + let n = l.len().max(1); + let cw = plot.w / n as f32; + let lw = l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max); + let k = (((lw + 6.0) / cw.max(1e-3)).ceil() as usize).clamp(1, n); + for (j, t) in l.iter().enumerate().step_by(k) { + let t = ctx.fit(t, ls, cw * k as f32 - 2.0); + ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, HAlign::Center, VAlign::Top); + } + // Outer levels, one row each below the labels: a group's name centred under its + // categories, and a separator at each group boundary down through the rows. + for (i, level) in outer.iter().enumerate() { + let top = plot.bottom() + 3.0 + lh * (i as f32 + 1.0); + for (first, end) in level_groups(level, n) { + let (x0, x1) = (plot.x + first as f32 * cw, plot.x + end as f32 * cw); + if let Some(t) = level.get(first).filter(|t| !t.is_empty()) { + let t = ctx.fit(t, ls, x1 - x0 - 2.0); + ctx.text((x0 + x1) / 2.0, top, &t, ls, TEXT, HAlign::Center, VAlign::Top); + } + if first > 0 { + ctx.vline(x0, plot.bottom(), top + lh - 2.0, AXIS, 0.75); + } + } + ctx.vline(plot.right(), plot.bottom(), top + lh - 2.0, AXIS, 0.75); + } + } + CatAxis::Numeric(s, f) => { + let ticks = s.ticks(); + let labels: Vec = ticks.iter().map(|v| f.format(*v, Some(s.step))).collect(); + let lw = labels.iter().map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max); + let slot = plot.w / ticks.len().max(1) as f32; + let k = (((lw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1); + for (v, t) in ticks.iter().zip(&labels).step_by(k) { + let xx = plot.x + s.frac(*v) * plot.w; + ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top); + } + if s.min <= 0.0 && s.max >= 0.0 { + let xx = plot.x + s.frac(0.0) * plot.w; + ctx.vline(xx, plot.y, plot.bottom(), AXIS, 0.75); + } + } + } + plot + } else { + let (labels, outer): (Vec, &[Vec]) = match cat { + CatAxis::Labels(l, o) => (l.to_vec(), o), + CatAxis::Numeric(..) => (vec![], &[]), + }; + let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.35); + // Outer levels stand in columns left of the labels, each as wide as its longest name. + let level_w: Vec = + outer.iter().map(|l| (l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.15)).collect(); + let levels_w: f32 = level_w.iter().sum(); + let right = ylabels.last().map(|(_, t)| ctx.tw(t, ls) / 2.0).unwrap_or(0.0).min(area.w * 0.2) + 2.0; + let plot = Bx { x: area.x + lw + levels_w, y: area.y + 2.0, w: area.w - lw - levels_w - right, h: area.h - lh - 6.0 }; + if !plot.ok() { + return plot; + } + let vw = maxw(ctx, &ylabels); + let slot = plot.w / ylabels.len().max(1) as f32; + let k = (((vw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1); + for (i, (v, t)) in ylabels.iter().enumerate() { + let xx = plot.x + y.scale.frac(*v) * plot.w; + if grid { + ctx.vline(xx, plot.y, plot.bottom(), GRID, 0.75); + } + if i % k == 0 { + ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top); + } + } + let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min }; + let x0 = plot.x + y.scale.frac(zero) * plot.w; + ctx.vline(x0, plot.y, plot.bottom(), AXIS, 0.75); + let n = labels.len().max(1); + let ch = plot.h / n as f32; + let k = ((lh / ch.max(1e-3)).ceil() as usize).clamp(1, n); + for (j, t) in labels.iter().enumerate().step_by(k) { + let t = ctx.fit(t, ls, lw - 6.0); + ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, HAlign::Right, VAlign::Middle); + } + // Outer levels: a group's name centred beside its categories, separators at the + // boundaries running right to the axis. + let mut x1 = plot.x - lw; + for (level, w) in outer.iter().zip(&level_w) { + let x0 = x1 - w; + for (first, end) in level_groups(level, n) { + let (y0, y1) = (plot.bottom() - end as f32 * ch, plot.bottom() - first as f32 * ch); + if let Some(t) = level.get(first).filter(|t| !t.is_empty()) { + let t = ctx.fit(t, ls, w - 4.0); + ctx.text((x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, ls, TEXT, HAlign::Center, VAlign::Middle); + } + if first > 0 { + ctx.hline(x0, plot.x, y1, AXIS, 0.75); + } + } + ctx.hline(x0, plot.x, plot.y, AXIS, 0.75); + x1 = x0; + } + plot + } +} + +pub(super) fn value_range(it: impl Iterator) -> Option<(f64, f64)> { + it.fold(None, |acc, v| match acc { + None => Some((v, v)), + Some((a, b)) => Some((a.min(v), b.max(v))), + }) +} diff --git a/crates/chart/src/render/kinds.rs b/crates/chart/src/render/kinds.rs new file mode 100644 index 0000000000..2ffbfa3ddc --- /dev/null +++ b/crates/chart/src/render/kinds.rs @@ -0,0 +1,505 @@ +use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range}; +use super::legend::{cat_label, point_color}; +use super::*; + +// ---------------------------------------------------------------- histogram + +pub(super) fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let Some(s) = data.series.first() else { return }; + let vals: Vec = s.values.iter().flatten().copied().collect(); + let Some((lo, hi)) = value_range(vals.iter().copied()) else { return }; + let n = vals.len(); + let span = hi - lo; + let mut bins = ((n as f64).sqrt().ceil() as usize).clamp(1, 100); + if !(span.is_finite() && span > 0.0) { + bins = 1; + } + let width = if bins == 1 && span.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) { 1.0 } else { span / bins as f64 }; + // Upper edge of each bin, exactly as shown in the labels below. Bins are right-inclusive (the first is closed on both sides), so a value + // equal to an edge belongs to the bin that ends there. + let edges: Vec = (0..bins).map(|b| if b + 1 == bins { hi.max(lo + b as f64 * width) } else { lo + (b + 1) as f64 * width }).collect(); + let mut counts = vec![0usize; bins]; + for v in &vals { + let b = edges.partition_point(|e| e < v).min(bins - 1); + if let Some(c) = counts.get_mut(b) { + *c += 1; + } + } + let f = Fmt::new(&s.number_format); + let labels: Vec = (0..bins) + .map(|b| { + let a = lo + b as f64 * width; + let e = edges.get(b).copied().unwrap_or(a); + let open = if b == 0 { '[' } else { '(' }; + format!("{open}{}, {}]", f.format(a, Some(width / 10.0)), f.format(e, Some(width / 10.0))) + }) + .collect(); + let maxc = counts.iter().copied().max().unwrap_or(0) as f64; + let ys = nice_scale(0.0, maxc, true); + let fc = Fmt::new("General"); + let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &fc }, None, false, chart.gridlines); + if !plot.ok() { + return; + } + let cw = plot.w / bins as f32; + for (b, c) in counts.iter().enumerate() { + let top = plot.bottom() - ys.frac(*c as f64) * plot.h; + let x = plot.x + b as f32 * cw; + ctx.rect(x, top, cw, plot.bottom() - top, s.color, Some((WHITE, 0.75))); + if chart.data_labels { + ctx.text(x + cw / 2.0, top - 2.0, &c.to_string(), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + } + } +} + +// ---------------------------------------------------------------- waterfall + +pub(super) fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let Some(s) = data.series.first() else { return }; + let mut ncat = data.categories.len().max(s.values.len()).min(MAX_CATS); + if ncat == 0 { + return; + } + let mut labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); + let mut vals: Vec = (0..ncat).map(|j| s.values.get(j).copied().flatten().unwrap_or(0.0)).collect(); + let mut is_total = vec![false; ncat]; + let last_total = labels.last().is_some_and(|l| l.to_lowercase().contains("total")); + if let Some(t) = is_total.last_mut() { + *t = last_total; + } + if !last_total { + labels.push("Total".into()); + vals.push(0.0); + is_total.push(true); + ncat += 1; + } + let mut bars = Vec::with_capacity(ncat); + let mut cum = 0.0f64; + for (j, v) in vals.iter().enumerate() { + if is_total.get(j).copied().unwrap_or(false) { + let total = if last_total && j + 1 == ncat && *v != 0.0 { *v } else { cum }; + bars.push((0.0, total, 0u8)); + cum = total; + } else { + let top = clean(cum + v); + bars.push((cum, top, if *v >= 0.0 { 1 } else { 2 })); + cum = top; + } + } + let (lo, hi) = value_range(bars.iter().flat_map(|(b, t, _)| [*b, *t])).unwrap_or((0.0, 0.0)); + let ys = nice_scale(lo, hi, true); + let f = Fmt::new(&s.number_format); + let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); + if !plot.ok() { + return; + } + let cw = plot.w / ncat as f32; + let bw = cw / 1.5; + let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h; + for (j, (b, t, kind)) in bars.iter().enumerate() { + let color = match kind { + 1 => UP, + 2 => DOWN, + _ => s.color, + }; + let x = plot.x + (j as f32 + 0.5) * cw - bw / 2.0; + let (yb, yt) = (vy(*b), vy(*t)); + ctx.rect(x, yb.min(yt), bw, (yb - yt).abs().max(0.5), color, None); + if j + 1 < bars.len() && ncat <= 500 { + ctx.out.push(Prim::Line { pts: vec![[x + bw, yt], [x + cw, yt]], color: AXIS, width: 0.75, dash: true }); + } + if chart.data_labels && ncat <= MAX_LABELS { + let v = if *kind == 0 { *t } else { t - b }; + ctx.text(x + bw / 2.0, yb.min(yt) - 2.0, &f.format(v, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + } + } +} + +// ---------------------------------------------------------------- funnel + +pub(super) fn funnel(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let Some(s) = data.series.first() else { return }; + let n = data.categories.len().max(s.values.len()).min(MAX_CATS); + if n == 0 { + return; + } + let vals: Vec = (0..n).map(|j| s.values.get(j).copied().flatten().unwrap_or(0.0).abs()).collect(); + let max = vals.iter().copied().fold(0.0f64, f64::max); + if max.is_nan() || max <= 0.0 { + return; + } + let labels: Vec = (0..n).map(|j| cat_label(data, j)).collect(); + let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, LABEL_SIZE)).fold(0.0f32, f32::max) + 8.0).min(area.w * 0.35); + let plot = Bx { x: area.x + lw, w: area.w - lw, ..area }; + if !plot.ok() { + return; + } + let slot = plot.h / n as f32; + let bh = slot * 0.85; + let f = Fmt::new(&s.number_format); + let show = slot >= LABEL_SIZE * 0.9; + for (j, v) in vals.iter().enumerate() { + let bw = (*v / max) as f32 * plot.w; + let x = plot.x + (plot.w - bw) / 2.0; + let y = plot.y + j as f32 * slot + (slot - bh) / 2.0; + if bw > 0.0 { + ctx.rect(x, y, bw, bh, s.color, None); + } + if show { + let yc = y + bh / 2.0; + if chart.data_labels { + let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None); + let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT }; + ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, HAlign::Center, VAlign::Middle); + } + let l = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw - 8.0); + ctx.text(plot.x - 6.0, yc, &l, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle); + } + } +} + +// ---------------------------------------------------------------- pie, doughnut, sunburst + +pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inner: f32, names: bool) { + let Some(s) = data.series.first() else { return }; + let vals: Vec = s.values.iter().take(MAX_CATS).map(|v| v.map(f64::abs).unwrap_or(0.0)).collect(); + let total: f64 = vals.iter().sum(); + if !(total.is_finite() && total > 0.0) { + return; + } + let (cx, cy) = (area.cx(), area.cy()); + let r = area.w.min(area.h) / 2.0 * 0.92; + if r < 2.0 { + return; + } + let ri = r * inner; + let f = Fmt::new(&s.number_format); + let mut a = 0.0f32; + let mut labels = Vec::new(); + for (j, v) in vals.iter().enumerate() { + let sweep = (*v / total) as f32 * TAU; + if sweep > 1e-4 { + let fill = point_color(data, j); + ctx.out.push(Prim::Wedge { cx, cy, r_outer: r, r_inner: ri, a0: a, a1: (a + sweep).min(TAU), fill, stroke: Some((WHITE, 1.5)) }); + if (chart.data_labels || names) && vals.len() <= MAX_LABELS { + let t = if names { cat_label(data, j) } else { f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None) }; + let mid = a + sweep / 2.0; + let rr = if ri > 0.0 { (r + ri) / 2.0 } else { r * 0.65 }; + let room = sweep * rr; + if ctx.tw(&t, LABEL_SIZE) <= room.max(r - ri) && room >= LABEL_SIZE { + labels.push((cx + rr * mid.sin(), cy - rr * mid.cos(), t, on(fill))); + } + } + } + a += sweep; + } + for (x, y, t, c) in labels { + ctx.text(x, y, &t, LABEL_SIZE, c, HAlign::Center, VAlign::Middle); + } +} + +// ---------------------------------------------------------------- treemap + +fn worst(row: &[f64], side: f64) -> f64 { + let s: f64 = row.iter().sum(); + if s <= 0.0 || side <= 0.0 { + return f64::INFINITY; + } + let (mn, mx) = row.iter().fold((f64::INFINITY, 0.0f64), |(a, b), v| (a.min(*v), b.max(*v))); + let s2 = s * s; + let w2 = side * side; + (w2 * mx / s2).max(s2 / (w2 * mn)) +} + +/// Squarified treemap layout of (index, area) items, sorted descending, into `r`. +fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> { + let total: f64 = items.iter().map(|i| i.1).sum(); + let area = (r.w as f64) * (r.h as f64); + if !(total > 0.0 && area > 0.0) { + return vec![]; + } + let scale = area / total; + let mut out = Vec::with_capacity(items.len()); + let mut rem = r; + let mut row: Vec<(usize, f64)> = Vec::new(); + let mut i = 0; + let layout = |row: &[(usize, f64)], rem: &mut Bx, out: &mut Vec<(usize, Bx)>| { + let s: f64 = row.iter().map(|x| x.1).sum(); + if s <= 0.0 { + return; + } + if rem.w >= rem.h { + let cw = (s / rem.h.max(1e-6) as f64) as f32; + let mut y = rem.y; + for (j, a) in row { + let h = (*a / cw.max(1e-6) as f64) as f32; + out.push((*j, Bx { x: rem.x, y, w: cw, h })); + y += h; + } + rem.x += cw; + rem.w = (rem.w - cw).max(0.0); + } else { + let rh = (s / rem.w.max(1e-6) as f64) as f32; + let mut x = rem.x; + for (j, a) in row { + let w = (*a / rh.max(1e-6) as f64) as f32; + out.push((*j, Bx { x, y: rem.y, w, h: rh })); + x += w; + } + rem.y += rh; + rem.h = (rem.h - rh).max(0.0); + } + }; + while let Some(&(j, v)) = items.get(i) { + let a = v * scale; + let side = rem.w.min(rem.h) as f64; + let cur: Vec = row.iter().map(|x| x.1).collect(); + let mut with = cur.clone(); + with.push(a); + if row.is_empty() || worst(&with, side) <= worst(&cur, side) { + row.push((j, a)); + i += 1; + } else { + layout(&row, &mut rem, &mut out); + row.clear(); + } + } + if !row.is_empty() { + layout(&row, &mut rem, &mut out); + } + out +} + +pub(super) fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let Some(s) = data.series.first() else { return }; + let mut items: Vec<(usize, f64)> = + s.values.iter().take(MAX_MARKERS).enumerate().filter_map(|(j, v)| v.filter(|v| *v > 0.0).map(|v| (j, v))).collect(); + items.sort_by(|a, b| b.1.total_cmp(&a.1)); + let f = Fmt::new(&s.number_format); + for (j, b) in squarify(&items, area) { + let fill = point_color(data, j); + ctx.rect(b.x, b.y, b.w, b.h, fill, Some((WHITE, 1.5))); + if b.w > 24.0 && b.h > LABEL_SIZE * 1.6 { + let name = ctx.fit(&cat_label(data, j), LABEL_SIZE, b.w - 8.0); + ctx.text(b.x + 4.0, b.y + 4.0, &name, LABEL_SIZE, on(fill), HAlign::Left, VAlign::Top); + if chart.data_labels && b.h > LABEL_SIZE * 3.2 { + let v = items.iter().find(|x| x.0 == j).map(|x| x.1).unwrap_or(0.0); + let t = ctx.fit(&f.format(v, None), LABEL_SIZE, b.w - 8.0); + ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), HAlign::Left, VAlign::Top); + } + } + } +} + +// ---------------------------------------------------------------- radar + +pub(super) fn radar(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(1000); + if ncat == 0 { + return; + } + let labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); + let lw = labels.iter().map(|t| ctx.tw(t, LABEL_SIZE)).fold(0.0f32, f32::max).min(area.w * 0.2); + let r = ((area.w - 2.0 * (lw + 8.0)).min(area.h - 2.0 * (LABEL_SIZE * 1.4 + 6.0)) / 2.0).max(0.0); + if r < 5.0 { + return; + } + let (cx, cy) = (area.cx(), area.cy()); + let (lo, hi) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())).unwrap_or((0.0, 0.0)); + let sc = nice_scale(lo, hi, true); + let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); + let ang = |j: usize| j as f32 / ncat as f32 * TAU; + let pt = |a: f32, rr: f32| [cx + rr * a.sin(), cy - rr * a.cos()]; + let ticks = sc.ticks(); + let spacing = r / ticks.len().saturating_sub(1).max(1) as f32; + let every = ((LABEL_SIZE * 1.3 / spacing.max(1e-3)).ceil() as usize).max(1); + for (ti, t) in ticks.into_iter().enumerate() { + let rr = sc.frac(t) * r; + if rr > 0.5 && chart.gridlines { + let mut ring: Vec<[f32; 2]> = (0..ncat).map(|j| pt(ang(j), rr)).collect(); + if ncat < 3 { + ring = (0..=48).map(|k| pt(k as f32 / 48.0 * TAU, rr)).collect(); + } else if let Some(p) = ring.first().copied() { + ring.push(p); + } + ctx.line(ring, GRID, 0.75); + } + if ti % every == 0 { + ctx.text(cx - 3.0, cy - rr, &f.format(t, Some(sc.step)), LABEL_SIZE * 0.9, TEXT, HAlign::Right, VAlign::Middle); + } + } + let step = ncat.div_ceil(72).max(1); + for j in (0..ncat).step_by(step) { + let a = ang(j); + ctx.line(vec![[cx, cy], pt(a, r)], GRID, 0.75); + let p = pt(a, r + 6.0); + let (sa, ca) = (a.sin(), a.cos()); + let align = if sa > 0.1 { + HAlign::Left + } else if sa < -0.1 { + HAlign::Right + } else { + HAlign::Center + }; + let valign = if ca > 0.1 { + VAlign::Bottom + } else if ca < -0.1 { + VAlign::Top + } else { + VAlign::Middle + }; + let t = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw.max(20.0)); + ctx.text(p[0], p[1], &t, LABEL_SIZE, TEXT, align, valign); + } + for s in &data.series { + let mut pts: Vec<[f32; 2]> = (0..ncat).map(|j| pt(ang(j), sc.frac(s.values.get(j).copied().flatten().unwrap_or(sc.min)) * r)).collect(); + if let Some(p) = pts.first().copied() { + pts.push(p); + } + ctx.line(pts, s.color, 2.0); + } +} + +// ---------------------------------------------------------------- box & whisker + +fn quantile(sorted: &[f64], q: f64, method: QuartileMethod) -> f64 { + let n = sorted.len(); + if n == 0 { + return 0.0; + } + // Clamp exclusive ranks for short samples so one or two points remain drawable. + let pos = match method { + QuartileMethod::Inclusive => q * (n - 1) as f64, + QuartileMethod::Exclusive => (q * (n as f64 + 1.0) - 1.0).clamp(0.0, (n - 1) as f64), + }; + let i = pos.floor() as usize; + let frac = pos - i as f64; + let a = sorted.get(i).copied().unwrap_or(0.0); + let b = sorted.get(i + 1).copied().unwrap_or(a); + a + (b - a) * frac +} + +pub(super) fn box_whisker(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let labels: Vec = data.series.iter().map(|s| s.name.clone()).collect(); + let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; + let ys = nice_scale(lo, hi, false); + let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); + let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); + if !plot.ok() { + return; + } + let n = data.series.len().max(1); + let cw = plot.w / n as f32; + let bw = (cw * 0.4).min(80.0); + let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h; + for (i, s) in data.series.iter().enumerate() { + let mut v: Vec = s.values.iter().flatten().copied().collect(); + if v.is_empty() { + continue; + } + v.sort_by(f64::total_cmp); + let method = chart.series.get(i).map(|s| s.quartiles).unwrap_or_default(); + let (q1, med, q3) = (quantile(&v, 0.25, method), quantile(&v, 0.5, method), quantile(&v, 0.75, method)); + let iqr = q3 - q1; + let (flo, fhi) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr); + let wlo = v.iter().copied().find(|x| *x >= flo).unwrap_or(q1); + let whi = v.iter().rev().copied().find(|x| *x <= fhi).unwrap_or(q3); + let mean = v.iter().sum::() / v.len() as f64; + let c = plot.x + (i as f32 + 0.5) * cw; + ctx.vline(c, vy(whi), vy(q3), s.color, 1.25); + ctx.vline(c, vy(q1), vy(wlo), s.color, 1.25); + ctx.hline(c - bw / 4.0, c + bw / 4.0, vy(whi), s.color, 1.25); + ctx.hline(c - bw / 4.0, c + bw / 4.0, vy(wlo), s.color, 1.25); + let (yt, yb) = (vy(q3), vy(q1)); + ctx.rect(c - bw / 2.0, yt, bw, (yb - yt).max(0.5), s.color, None); + ctx.hline(c - bw / 2.0, c + bw / 2.0, vy(med), WHITE, 1.5); + let (mx, my, d) = (c, vy(mean), 3.0); + ctx.line(vec![[mx - d, my - d], [mx + d, my + d]], WHITE, 1.25); + ctx.line(vec![[mx - d, my + d], [mx + d, my - d]], WHITE, 1.25); + for o in v.iter().filter(|x| **x < flo || **x > fhi).take(MAX_MARKERS) { + ctx.out.push(Prim::Circle { cx: c, cy: vy(*o), r: 2.5, fill: None, stroke: Some((s.color, 1.0)) }); + } + } +} + +// ---------------------------------------------------------------- stock + +pub(super) fn stock(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS); + if ncat == 0 { + return; + } + let labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); + let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; + let ys = nice_scale(lo, hi, false); + let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); + let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); + if !plot.ok() { + return; + } + let ser = |k: usize| data.series.get(k); + let (open, high, low, close) = match data.series.len() { + 0 => return, + 1 => (None, None, None, ser(0)), + 2 => (None, ser(0), ser(1), None), + 3 => (None, ser(0), ser(1), ser(2)), + _ => (ser(0), ser(1), ser(2), ser(3)), + }; + let get = |s: Option<&SeriesData>, j: usize| s.and_then(|s| s.values.get(j).copied().flatten()); + let cw = plot.w / ncat as f32; + let tick = (cw * 0.25).max(1.0); + let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h; + for j in 0..ncat { + let c = plot.x + (j as f32 + 0.5) * cw; + if let (Some(h), Some(l)) = (get(high, j), get(low, j)) { + ctx.vline(c, vy(h), vy(l), STOCK, 1.25); + } + match (get(open, j), get(close, j)) { + (Some(o), Some(cl)) => { + let (yo, yc) = (vy(o), vy(cl)); + let bw = (cw * 0.5).max(1.0); + let fill = if cl >= o { WHITE } else { STOCK }; + ctx.rect(c - bw / 2.0, yo.min(yc), bw, (yo - yc).abs().max(0.75), fill, Some((STOCK, 1.0))); + } + (None, Some(cl)) => { + if high.is_some() { + ctx.hline(c, c + tick, vy(cl), STOCK, 1.25); + } else { + ctx.out.push(Prim::Circle { cx: c, cy: vy(cl), r: 2.5, fill: Some(STOCK), stroke: None }); + } + } + _ => {} + } + } +} + +#[cfg(test)] +mod quartile_tests { + use super::*; + + #[test] + fn box_whisker_default_quartiles_are_exclusive() { + let values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; + assert_eq!(quantile(&values, 0.25, QuartileMethod::default()), 1.75); + assert_eq!(quantile(&values, 0.5, QuartileMethod::default()), 3.5); + assert_eq!(quantile(&values, 0.75, QuartileMethod::default()), 5.25); + } + + #[test] + fn quartile_methods_and_short_samples() { + for (values, exclusive, inclusive) in [ + (vec![1., 2., 3.], (1., 3.), (1.5, 2.5)), + (vec![1., 2., 3., 4.], (1.25, 3.75), (1.75, 3.25)), + (vec![1., 2., 3., 4., 5.], (1.5, 4.5), (2., 4.)), + (vec![1., 2., 3., 4., 5., 6., 7.], (2., 6.), (2.5, 5.5)), + (vec![1., 1., 2., 3., 5., 8.], (1., 5.75), (1.25, 4.5)), + (vec![1., 2.], (1., 2.), (1.25, 1.75)), + (vec![1.], (1., 1.), (1., 1.)), + (vec![], (0., 0.), (0., 0.)), + ] { + for (method, expected) in [(QuartileMethod::Exclusive, exclusive), (QuartileMethod::Inclusive, inclusive)] { + assert_eq!((quantile(&values, 0.25, method), quantile(&values, 0.75, method)), expected); + } + } + } +} diff --git a/crates/chart/src/render/legend.rs b/crates/chart/src/render/legend.rs new file mode 100644 index 0000000000..95cb39d480 --- /dev/null +++ b/crates/chart/src/render/legend.rs @@ -0,0 +1,161 @@ +use super::frame::Bx; +use super::trend::supports_trendline; +use super::*; + +/// How a legend entry's swatch is drawn. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Swatch { + Box, + Line, + LineMarker, + Marker, +} + +/// The kind a series is drawn as (per-series kinds only apply to combo charts). +pub(super) fn plot_kind(chart: &Chart, s: &SeriesData) -> ChartKind { + if chart.kind == ChartKind::Combo { if s.kind == ChartKind::Combo { ChartKind::Line } else { s.kind } } else { chart.kind } +} + +/// Colour of point `j` for charts that colour by point (pie, doughnut, treemap, sunburst). +pub(super) fn point_color(data: &ChartData, j: usize) -> Rgba { + match (j, data.series.first()) { + (0, Some(s)) => s.color, + _ => j.checked_rem(data.palette.len()).and_then(|k| data.palette.get(k)).copied().unwrap_or_else(|| series_color(&Theme::default(), j)), + } +} + +fn first_len(data: &ChartData) -> usize { + data.series.first().map(|s| s.values.len()).unwrap_or(0) +} + +pub(super) fn cat_label(data: &ChartData, j: usize) -> String { + data.categories.get(j).cloned().unwrap_or_else(|| (j + 1).to_string()) +} + +/// Legend entries (text, colour, swatch) `render` draws for this chart; empty when hidden. +pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swatch)> { + if chart.legend == LegendPos::None { + return vec![]; + } + match chart.kind { + ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst => { + let n = first_len(data).min(MAX_CATS); + (0..n).map(|j| (cat_label(data, j), point_color(data, j), Swatch::Box)).collect() + } + ChartKind::Histogram | ChartKind::Funnel | ChartKind::Stock => vec![], + ChartKind::Waterfall => { + if data.series.is_empty() { + return vec![]; + } + let total = data.series.first().map(|s| s.color).unwrap_or(TEXT); + vec![("Increase".into(), UP, Swatch::Box), ("Decrease".into(), DOWN, Swatch::Box), ("Total".into(), total, Swatch::Box)] + } + _ => { + let series = data.series.iter().take(MAX_SERIES); + let mut out: Vec<(String, Rgba, Swatch)> = series + .clone() + .map(|s| { + let sw = match plot_kind(chart, s) { + ChartKind::Line | ChartKind::LineStacked | ChartKind::Radar | ChartKind::ScatterLines => Swatch::Line, + ChartKind::LineMarkers => Swatch::LineMarker, + ChartKind::Scatter | ChartKind::Bubble => Swatch::Marker, + _ => Swatch::Box, + }; + (s.name.clone(), s.color, sw) + }) + .collect(); + // Trendlines follow the series, named "Linear (Sales)" unless given a name. + for s in series.filter(|s| supports_trendline(plot_kind(chart, s))) { + for t in s.trendlines.iter().take(MAX_TRENDLINES) { + out.push((t.spec.name.clone().unwrap_or_else(|| auto_name(t.spec.kind, &s.name)), t.color, Swatch::Line)); + } + } + out + } + } +} + +// ---------------------------------------------------------------- legend + +fn swatch_w(sw: Swatch, s: f32) -> f32 { + if sw == Swatch::Box { s } else { s * 2.0 } +} + +fn draw_swatch(ctx: &mut Ctx, sw: Swatch, x: f32, yc: f32, s: f32, color: Rgba) { + match sw { + Swatch::Box => ctx.rect(x, yc - s / 2.0, s, s, color, None), + Swatch::Line => ctx.hline(x, x + 2.0 * s, yc, color, 2.0), + Swatch::LineMarker => { + ctx.hline(x, x + 2.0 * s, yc, color, 2.0); + ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.35, fill: Some(color), stroke: None }); + } + Swatch::Marker => ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.4, fill: Some(color), stroke: None }), + } +} + +pub(super) fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos: LegendPos, area: Bx) -> Bx { + let size = LABEL_SIZE; + let row_h = size * 1.6; + let s = size * 0.8; + let gap = 4.0; + let spacing = size * 1.3; + let item_w = |ctx: &Ctx, e: &(String, Rgba, Swatch)| swatch_w(e.2, s) + gap + ctx.tw(&e.0, size); + match pos { + LegendPos::None => area, + LegendPos::Top | LegendPos::Bottom => { + let max_rows = ((area.h * 0.3 / row_h).floor() as usize).max(1); + let mut rows: Vec> = vec![vec![]]; + let mut cur = 0.0f32; + for (i, e) in entries.iter().enumerate() { + let iw = item_w(ctx, e).min(area.w); + let need = if cur > 0.0 { spacing + iw } else { iw }; + if cur > 0.0 && cur + need > area.w { + if rows.len() >= max_rows { + break; + } + rows.push(vec![]); + cur = 0.0; + } + cur += if cur > 0.0 { spacing + iw } else { iw }; + if let Some(r) = rows.last_mut() { + r.push((i, iw)); + } + } + let lh = rows.len() as f32 * row_h; + let y0 = if pos == LegendPos::Bottom { area.bottom() - lh } else { area.y }; + for (ri, row) in rows.iter().enumerate() { + let total: f32 = row.iter().map(|(_, w)| w).sum::() + spacing * row.len().saturating_sub(1) as f32; + let mut x = area.x + (area.w - total).max(0.0) / 2.0; + let yc = y0 + ri as f32 * row_h + row_h / 2.0; + for &(i, iw) in row { + if let Some(e) = entries.get(i) { + draw_swatch(ctx, e.2, x, yc, s, e.1); + let sx = swatch_w(e.2, s) + gap; + let t = ctx.fit(&e.0, size, (iw - sx).max(0.0) + 0.5); + ctx.text(x + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle); + } + x += iw + spacing; + } + } + let used = lh + 4.0; + if pos == LegendPos::Bottom { Bx { h: area.h - used, ..area } } else { Bx { y: area.y + used, h: area.h - used, ..area } } + } + LegendPos::Left | LegendPos::Right => { + let colw = entries.iter().map(|e| item_w(ctx, e)).fold(0.0f32, f32::max).min(area.w * 0.35); + let max_rows = ((area.h / row_h).floor() as usize).max(1); + let n = entries.len().min(max_rows); + let lh = n as f32 * row_h; + let y0 = area.y + (area.h - lh).max(0.0) / 2.0; + let x0 = if pos == LegendPos::Right { area.right() - colw } else { area.x }; + for (i, e) in entries.iter().take(n).enumerate() { + let yc = y0 + i as f32 * row_h + row_h / 2.0; + draw_swatch(ctx, e.2, x0, yc, s, e.1); + let sx = swatch_w(e.2, s) + gap; + let t = ctx.fit(&e.0, size, (colw - sx).max(0.0) + 0.5); + ctx.text(x0 + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle); + } + let used = colw + 8.0; + if pos == LegendPos::Right { Bx { w: area.w - used, ..area } } else { Bx { x: area.x + used, w: area.w - used, ..area } } + } + } +} diff --git a/crates/chart/src/render/lines.rs b/crates/chart/src/render/lines.rs new file mode 100644 index 0000000000..9cef4608cb --- /dev/null +++ b/crates/chart/src/render/lines.rs @@ -0,0 +1,157 @@ +use super::frame::Bx; +use super::*; + +// ---------------------------------------------------------------- helpers + +/// Reduces a long polyline to first/min/max/last per pixel column. +pub(super) fn decimate(pts: Vec<[f32; 2]>, max: usize) -> Vec<[f32; 2]> { + if pts.len() <= max { + return pts; + } + let mut out = Vec::with_capacity(max * 2); + let mut i = 0; + while let Some(p) = pts.get(i) { + let col = p[0].floor(); + let (start, mut mn, mut mx) = (i, i, i); + let mut k = i + 1; + while let Some(q) = pts.get(k) { + if q[0].floor() != col { + break; + } + if pts.get(mn).is_some_and(|m| q[1] < m[1]) { + mn = k; + } + if pts.get(mx).is_some_and(|m| q[1] > m[1]) { + mx = k; + } + k += 1; + } + let mut idx = [start, mn, mx, k - 1]; + idx.sort_unstable(); + let mut last = usize::MAX; + for ix in idx { + if ix != last + && let Some(q) = pts.get(ix) + { + out.push(*q); + last = ix; + } + } + i = k; + } + if out.len() > max * 4 { + let step = out.len() / (max * 2) + 1; + let last = out.last().copied(); + let mut s: Vec<[f32; 2]> = out.into_iter().step_by(step).collect(); + if let Some(l) = last + && s.last() != Some(&l) + { + s.push(l); + } + return s; + } + out +} + +/// Draws a polyline split at gaps (`None`), decimated when long. With `smooth` (the plot box), +/// each run is interpolated with a Catmull-Rom spline so the line curves through the data points. +pub(super) fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32, smooth: Option) { + let mut run: Vec<[f32; 2]> = Vec::new(); + for p in pts.iter().chain(std::iter::once(&None)) { + match p { + Some(p) => run.push(*p), + None => { + if run.len() >= 2 { + let r = decimate(std::mem::take(&mut run), MAX_LINE_PTS); + let r = match smooth { + Some(plot) => smooth_run(r, plot), + None => r, + }; + ctx.line(r, color, width); + } else { + run.clear(); + } + } + } + } +} + +/// Catmull-Rom spline through `pts`: every segment gets the same number of sub-segments (up to +/// 100), chosen so the whole curve stays within `MAX_LINE_PTS * 2` points. Endpoints are +/// duplicated so the curve passes through the first and last points, and the curve is clamped +/// to the `plot` box (a spline overshoots its data near sharp turns). +fn smooth_run(pts: Vec<[f32; 2]>, plot: Bx) -> Vec<[f32; 2]> { + let n = pts.len(); + if n < 3 { + return pts; + } + let cap = MAX_LINE_PTS * 2; + let steps = (cap / n).clamp(1, 100); + let mut out: Vec<[f32; 2]> = Vec::with_capacity(n.saturating_mul(steps).min(cap) + 1); + let at = |i: isize| -> [f32; 2] { + let j = i.clamp(0, n as isize - 1) as usize; + pts.get(j).copied().unwrap_or([0.0, 0.0]) + }; + let (x0, x1) = (plot.x.min(plot.right()), plot.x.max(plot.right())); + let (y0, y1) = (plot.y.min(plot.bottom()), plot.y.max(plot.bottom())); + let clip = |p: [f32; 2]| [p[0].clamp(x0, x1), p[1].clamp(y0, y1)]; + for i in 0..n.saturating_sub(1) { + let (p0, p1, p2, p3) = (at(i as isize - 1), at(i as isize), at(i as isize + 1), at(i as isize + 2)); + for s in 0..steps { + let t = s as f32 / steps as f32; + out.push(clip(catmull(p0, p1, p2, p3, t))); + } + } + out.push(clip(at(n as isize - 1))); + out +} + +/// One Catmull-Rom point between `p1` and `p2` (uniform parameterisation), tangent-scaled by 0.5. +fn catmull(p0: [f32; 2], p1: [f32; 2], p2: [f32; 2], p3: [f32; 2], t: f32) -> [f32; 2] { + let t2 = t * t; + let t3 = t2 * t; + let mut o = [0.0f32; 2]; + for k in 0..2 { + let (a0, a1, a2, a3) = (p0[k], p1[k], p2[k], p3[k]); + o[k] = 0.5 * ((2.0 * a1) + (-a0 + a2) * t + (2.0 * a0 - 5.0 * a1 + 4.0 * a2 - a3) * t2 + (-a0 + 3.0 * a1 - 3.0 * a2 + a3) * t3); + } + o +} + +pub(super) fn draw_markers(ctx: &mut Ctx, pts: impl Iterator, r: f32, color: Rgba) { + let mut seen = HashSet::new(); + for p in pts { + if seen.len() >= MAX_MARKERS { + break; + } + if seen.insert(((p[0] * 0.5) as i32, (p[1] * 0.5) as i32)) { + ctx.out.push(Prim::Circle { cx: p[0], cy: p[1], r, fill: Some(color), stroke: Some((WHITE, 0.75)) }); + } + } +} + +#[cfg(test)] +mod smooth_tests { + use super::*; + + #[test] + fn long_runs_are_smoothed_uniformly_within_the_cap() { + let plot = Bx { x: 0.0, y: 0.0, w: 3000.0, h: 100.0 }; + let pts: Vec<[f32; 2]> = (0..1500).map(|i| [i as f32 * 2.0, if i % 2 == 0 { 20.0 } else { 80.0 }]).collect(); + let out = smooth_run(pts, plot); + assert!(out.len() <= MAX_LINE_PTS * 2 + 1, "{}", out.len()); + // Two sub-segments per segment all the way to the end, not 100 each and then a straight tail. + assert_eq!(out.len(), 1499 * 2 + 1); + assert_eq!(out.last().copied(), Some([2998.0, 80.0])); + } + + #[test] + fn spline_overshoot_is_clamped_to_the_plot() { + let plot = Bx { x: 0.0, y: 10.0, w: 300.0, h: 100.0 }; + // A step right at the plot edges: Catmull-Rom overshoots past both. + let pts = vec![[0.0, 110.0], [100.0, 110.0], [110.0, 10.0], [200.0, 10.0], [300.0, 10.0]]; + let out = smooth_run(pts, plot); + assert!(out.len() > 5); + assert!(out.iter().all(|p| (0.0..=300.0).contains(&p[0]) && (10.0..=110.0).contains(&p[1]))); + } +} diff --git a/crates/chart/src/render/trend.rs b/crates/chart/src/render/trend.rs new file mode 100644 index 0000000000..38e435a476 --- /dev/null +++ b/crates/chart/src/render/trend.rs @@ -0,0 +1,166 @@ +use super::frame::{Bx, value_range}; +use super::lines::decimate; +use super::*; + +// ---------------------------------------------------------------- trendlines + +/// Samples along a regression trendline. +const TREND_STEPS: usize = 200; + +/// Whether series drawn as `k` show trendlines (Excel offers them on 2-D unstacked column, +/// bar, line, area, scatter and bubble charts). +pub fn supports_trendline(k: ChartKind) -> bool { + use ChartKind::*; + matches!(k, ColumnClustered | BarClustered | Line | LineMarkers | Area | Scatter | ScatterLines | Bubble) +} + +/// A forecast distance as drawn: finite, non-negative and bounded. +fn forecast(v: f64) -> f64 { + if v.is_finite() && v > 0.0 { v.min(1e9) } else { 0.0 } +} + +/// Most empty category slots a forward forecast may add after `ncat` categories: four times the +/// data (at least 32), and never past `MAX_CATS` in total. A larger forecast would shrink every +/// column to nothing. +pub(super) fn max_forward_slots(ncat: usize) -> f64 { + (ncat.saturating_mul(4).max(32)).min(MAX_CATS.saturating_sub(ncat)) as f64 +} + +/// Longest forward forecast among `s`'s regression trendlines. +pub(super) fn max_forward(s: &SeriesData) -> f64 { + s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.forward)).fold(0.0, f64::max) +} + +/// Longest backward forecast among `s`'s regression trendlines. +pub(super) fn max_backward(s: &SeriesData) -> f64 { + s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.backward)).fold(0.0, f64::max) +} + +/// Liang–Barsky: the part of segment `a`–`b` inside the unit square, with whether the start and +/// end were cut. +#[allow(clippy::type_complexity)] +fn clip_segment(a: (f64, f64), b: (f64, f64)) -> Option<((f64, f64), (f64, f64), bool, bool)> { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let (mut t0, mut t1) = (0.0f64, 1.0f64); + for (p, q) in [(-dx, a.0), (dx, 1.0 - a.0), (-dy, a.1), (dy, 1.0 - a.1)] { + if p == 0.0 { + if q < 0.0 { + return None; + } + } else { + let r = q / p; + if p < 0.0 { + if r > t1 { + return None; + } + t0 = t0.max(r); + } else { + if r < t0 { + return None; + } + t1 = t1.min(r); + } + } + } + Some(((a.0 + t0 * dx, a.1 + t0 * dy), (a.0 + t1 * dx, a.1 + t1 * dy), t0 > 0.0, t1 < 1.0)) +} + +/// Clips a sampled polyline in plot fractions (`None` = gap) to the plot box (the unit +/// square), returning the visible runs. +fn clip_unit(pts: &[Option<(f64, f64)>]) -> Vec> { + const LIM: f64 = 1e9; + let ok = |p: &(f64, f64)| p.0.is_finite() && p.1.is_finite(); + let cl = |p: (f64, f64)| (p.0.clamp(-LIM, LIM), p.1.clamp(-LIM, LIM)); + let mut runs: Vec> = Vec::new(); + let mut run: Vec<(f64, f64)> = Vec::new(); + for w in pts.windows(2) { + let seg = match w { + [Some(a), Some(b)] if ok(a) && ok(b) => clip_segment(cl(*a), cl(*b)), + _ => None, + }; + match seg { + Some((p, q, cut_start, cut_end)) => { + if cut_start || run.is_empty() { + if run.len() >= 2 { + runs.push(std::mem::take(&mut run)); + } + run.clear(); + run.push(p); + } + run.push(q); + if cut_end { + runs.push(std::mem::take(&mut run)); + } + } + None => { + if run.len() >= 2 { + runs.push(std::mem::take(&mut run)); + } + run.clear(); + } + } + } + if run.len() >= 2 { + runs.push(run); + } + runs +} + +/// Draws `s`'s trendlines fitted to its plotted points `pts` (data x, y). `frac` maps data to +/// fractions of the plot box (0..1 inside, along the x and y axes), `px` fractions to pixels. +/// Equation and R² label lines are appended to `labels`. Degenerate fits draw nothing. +pub(super) fn draw_trendlines( + ctx: &mut Ctx, + s: &SeriesData, + pts: &[(f64, f64)], + frac: &dyn Fn(f64, f64) -> (f64, f64), + px: &dyn Fn((f64, f64)) -> [f32; 2], + labels: &mut Vec, + window: (f64, f64), +) { + let Some((xlo, xhi)) = value_range(pts.iter().map(|p| p.0)) else { return }; + for t in s.trendlines.iter().take(MAX_TRENDLINES) { + let spec = &t.spec; + let samples: Vec> = match spec.kind { + TrendlineKind::MovingAverage(p) => moving_average(pts, p).into_iter().map(|(x, y)| Some(frac(x, y))).collect(), + _ => { + let Some(fit) = fit_trendline(spec, pts) else { continue }; + // Sample only the part of the forecast that is on the plot. + let (lo, hi) = ((xlo - forecast(spec.backward)).max(window.0), (xhi + forecast(spec.forward)).min(window.1)); + if spec.display_equation { + labels.push(fit.equation()); + } + if spec.display_r_squared { + labels.push(fit.r_squared()); + } + (0..=TREND_STEPS) + .map(|i| { + let x = lo + (hi - lo) * i as f64 / TREND_STEPS as f64; + fit.eval(x).map(|y| frac(x, y)) + }) + .collect() + } + }; + let width = spec.width.filter(|w| w.is_finite()).unwrap_or(1.5).clamp(0.25, 12.0); + let dash = spec.dash != Some(LineDash::Solid); + for run in clip_unit(&samples) { + let line = decimate(run.into_iter().map(px).collect(), MAX_LINE_PTS); + if line.len() >= 2 { + ctx.out.push(Prim::Line { pts: line, color: t.color, width, dash }); + } + } + } +} + +/// Trendline equation / R² labels, stacked in the plot's top-right corner. +pub(super) fn draw_trend_labels(ctx: &mut Ctx, plot: Bx, labels: &[String]) { + let lh = LABEL_SIZE * 1.3; + for (i, t) in labels.iter().enumerate() { + let y = plot.y + 4.0 + i as f32 * lh; + if y + lh > plot.bottom() { + break; + } + let t = ctx.fit(t, LABEL_SIZE, plot.w - 8.0); + ctx.text(plot.right() - 4.0, y, &t, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Top); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd.rs similarity index 100% rename from crates/engine/src/cmd/mod.rs rename to crates/engine/src/cmd.rs diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index fcab9db713..5fa076e648 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -11,6 +11,24 @@ use super::*; use crate::outline; use crate::selection::current_region; +mod filter; +mod grouping; +mod sort; +mod tools; +mod validation; + +pub use filter::filter_values; +pub(crate) use filter::{apply_filter, grow_autofilter}; +use filter::{clear_filter, filter_by, reapply, toggle_filter}; +use grouping::{detail, group, outline_settings, show_levels, subtotal}; +use sort::{quick_sort, sort}; +#[cfg(test)] +use tools::split_delimited; +use tools::{remove_duplicates, text_to_columns}; +pub use validation::{check_validation, list_items}; +use validation::{circle_invalid, validate_cmd, validation}; +pub(super) use validation::{move_rule_formula, remove_validation_cells}; + pub fn specs() -> Vec { vec![ cmd!("data.sortAscending", "Sort A to Z", ["Data", "Sort & Filter"], None, "{range?, column?: \"B\"}", has_doc, |s, p| quick_sort( @@ -200,1893 +218,5 @@ fn guess_header(wb: &Workbook, sh: &Sheet, r: RangeRef) -> bool { votes > 0 } -#[derive(Clone)] -struct SortKey { - col: u32, - desc: bool, - by: String, - list: Option>, -} - -/// The sheet and range a sort works on: the sheet `sheet` or a `Sheet!` prefix on `range` names, -/// else the active one. The selection belongs to the active sheet, so another sheet needs `range`. -fn sort_target(s: &Session, p: &Json) -> Result<(usize, RangeRef)> { - let sheet = target_sheet(s, p)?; - let d = s.doc()?; - if let Some(t) = str_param(p, "range") - && let (Some(name), _) = split_sheet(t) - && d.wb.sheet_index(&name) != Some(sheet) - { - return Err(bad("data.sort", format!("`{t}` names a sheet other than `sheet`"))); - } - if sheet != d.wb.active_sheet() && p.get("range").is_none() { - return Err(bad("data.sort", "missing `range`: the selection is on the active sheet")); - } - Ok((sheet, data_range(s, p)?)) -} - -fn quick_sort(s: &mut Session, p: &Json, asc: bool) -> Result { - let (sheet, r) = sort_target(s, p)?; - let col = col_param(p.get("column"), r.start.col).unwrap_or(s.doc()?.selection.active.col).clamp(r.start.col, r.end.col); - let d = s.doc()?; - let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; - let header = guess_header(&d.wb, sh, r); - do_sort(s, sheet, r, header, vec![SortKey { col, desc: !asc, by: "values".into(), list: None }], false, false) -} - -fn sort(s: &mut Session, p: &Json) -> Result { - let (sheet, r) = sort_target(s, p)?; - let d = s.doc()?; - let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; - let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r)); - let by_cols = str_param(p, "orientation") == Some("columns"); - let case = bool_param(p, "matchCase").unwrap_or(false); - let mut keys: Vec = Vec::new(); - for k in p.get("keys").and_then(Json::as_array).map(Vec::as_slice).unwrap_or_default() { - let col = if by_cols { - let row = match k.get("row").and_then(Json::as_u64) { - // Worksheet rows are 1-based; 0 (or a value past u32) has no coordinate. - Some(n) => { - let row = u32::try_from(n).ok().and_then(|n| n.checked_sub(1)); - Some(row.ok_or_else(|| bad("data.sort", "sort key row must be 1 or greater"))?) - } - None => None, - }; - row.or_else(|| col_param(k.get("column"), r.start.row)) - } else { - col_param(k.get("column"), r.start.col) - }; - let Some(col) = col else { continue }; - keys.push(SortKey { - col, - desc: k.get("order").and_then(Json::as_str) == Some("desc"), - by: k.get("by").and_then(Json::as_str).unwrap_or("values").to_string(), - list: k.get("customList").and_then(Json::as_array).map(|l| l.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()), - }); - } - if keys.is_empty() { - s.ui_requests.push(crate::UiRequest::Dialog("sort".into(), json!({"range": r.a1(), "header": header}))); - return ok(); - } - if keys.len() > 64 { - return Err(bad("data.sort", "too many sort levels (64 max)")); - } - do_sort(s, sheet, r, header, keys, by_cols, case) -} - -fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec, by_cols: bool, case: bool) -> Result { - // Protect Sheet ▸ Sort. Both Sort… and the A-to-Z buttons come through here. - super::refuse_unless_allowed(s, sheet, "Sorting", |p| p.sort)?; - let body = if by_cols { - if header && r.width() > 1 { RangeRef::new(CellRef::new(r.start.row, r.start.col + 1), r.end) } else { r } - } else if header && r.height() > 1 { - RangeRef::new(CellRef::new(r.start.row + 1, r.start.col), r.end) - } else { - r - }; - if body.count() > 20_000_000 { - return Err(bad("data.sort", "range too large")); - } - edit(s, |cx| { - let Some(sh) = cx.wb.sheet(sheet) else { return Ok(Json::Null) }; - if sh.merges.iter().any(|m| m.intersects(&body) && !m.is_single()) { - return Err(EngineError::Other("To do this, all the merged cells need to be the same size.".into())); - } - let n = if by_cols { body.width() } else { body.height() }; - let line = |i: u32, k: u32| if by_cols { CellRef::new(k, body.start.col + i) } else { CellRef::new(body.start.row + i, k) }; - let theme = cx.wb.theme.clone(); - let styles = &cx.wb.styles; - let key_val = |i: u32, k: &SortKey| -> (Value, u32) { - let c = line(i, k.col); - let v = sh.value(c); - let color = match k.by.as_str() { - "cellColor" => { - styles.get(sh.style_id(c)).fill.fg.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX) - } - "fontColor" => { - styles.get(sh.style_id(c)).font.color.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX) - } - _ => 0, - }; - (v, color) - }; - let mut order: Vec = (0..n).collect(); - let keyed: Vec> = (0..n).map(|i| keys.iter().map(|k| key_val(i, k)).collect()).collect(); - order.sort_by(|&a, &b| { - for (ki, k) in keys.iter().enumerate() { - let (va, ca) = &keyed[a as usize][ki]; - let (vb, cb) = &keyed[b as usize][ki]; - let o = if k.by != "values" { - ca.cmp(cb) - } else if let Some(list) = &k.list { - let pos = |v: &Value| v.as_text().and_then(|t| list.iter().position(|x| x.eq_ignore_ascii_case(t))).unwrap_or(usize::MAX); - pos(va).cmp(&pos(vb)) - } else if case && va.is_text() && vb.is_text() { - sort_compare(va, vb).then_with(|| case_order(va.as_text().unwrap_or(""), vb.as_text().unwrap_or(""))) - } else { - sort_compare(va, vb) - }; - // Blanks always sort last, whatever the order. - let o = if k.desc && !(va.is_empty() || vb.is_empty()) { o.reverse() } else { o }; - if o != Ordering::Equal { - return o; - } - } - Ordering::Equal - }); - if order.iter().enumerate().all(|(i, &o)| i as u32 == o) { - return Ok(Json::Null); - } - // Move whole lines (cells with formulas adjusted for relative references). - let lanes: Vec = if by_cols { (body.start.row..=body.end.row).collect() } else { (body.start.col..=body.end.col).collect() }; - // Notes and hyperlinks hang off a cell, not a row, so they travel with the row they - // describe — the way cell pictures and row heights do. Collect them before inserting, - // so a swap can't resurrect one from a destination that is also a source. - let side: Vec<(CellRef, Comment)> = order - .iter() - .enumerate() - .flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k))) - .filter_map(|(src_i, dst_i, k)| sh.comments.get(&line(src_i, k)).map(|cm| (line(dst_i, k), cm.clone()))) - .collect(); - let links: Vec<(CellRef, Hyperlink)> = order - .iter() - .enumerate() - .flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k))) - .filter_map(|(src_i, dst_i, k)| sh.hyperlinks.get(&line(src_i, k)).map(|h| (line(dst_i, k), h.clone()))) - .collect(); - let mut moved: Vec<(CellRef, Option)> = Vec::with_capacity(body.count() as usize); - let mut pictures = Vec::new(); - for (dst_i, &src_i) in order.iter().enumerate() { - for &k in &lanes { - let src = line(src_i, k); - let dst = line(dst_i as u32, k); - let mut cell = sh.cell(src).cloned(); - if let Some(c) = cell.as_mut() - && let Some(f) = &c.formula - && let Some(e) = f.expr() - { - let shifted = gridcraft_formula::adjust::shift_relative(e, dst.row as i64 - src.row as i64, dst.col as i64 - src.col as i64); - c.formula = Some(Arc::new(Formula::from_expr(shifted))); - } - if let Some(picture) = sh.cell_pictures.get(&src) { - pictures.push((dst, picture.clone())); - } - moved.push((dst, cell)); - } - } - // Row heights travel with rows. - let heights: Vec> = - if by_cols { vec![] } else { order.iter().map(|&i| sh.rows.get(&(body.start.row + i)).copied()).collect() }; - // Floating objects anchored inside the sorted body travel with their lines, unless they - // are pinned absolutely ("Don't move or size with cells", `editAs="absolute"`). Reordering - // is the inverse of `order`: `dst_of[src] = dst`. - let dst_of: Vec = { - let mut v = vec![0u32; order.len()]; - for (dst_i, &src_i) in order.iter().enumerate() { - if let Some(slot) = v.get_mut(src_i as usize) { - *slot = dst_i as u32; - } - } - v - }; - // The object's own geometry (its top-left anchor cell, offset and size) is read from the - // pre-sort `sh`; a two-cell object's bottom-right cell is derived from its size here so it - // can be re-fitted to the lines it spans after the sort. - let objs: Vec = - sh.images.iter().map(|o| o.anchor).chain(sh.charts.iter().map(|o| o.anchor)).chain(sh.shapes.iter().map(|o| o.anchor)).collect(); - let descs: Vec<(u32, u32, f32, f32)> = objs - .iter() - .map(|a| { - let x2 = sh.col_left(a.cell.col) + a.dx as f64 + a.width.max(0.0) as f64; - let y2 = sh.row_top(a.cell.row) + a.dy as f64 + a.height.max(0.0) as f64; - let tc = sh.col_at(x2); - let tr = sh.row_at(y2); - (tc, tr, (x2 - sh.col_left(tc)) as f32, (y2 - sh.row_top(tr)) as f32) - }) - .collect(); - let shm = cx.sheet_mut(sheet)?; - for (c, cell) in &moved { - match cell { - Some(x) => shm.set_cell(*c, x.clone()), - None => { - shm.remove_cell(*c); - } - } - } - // Every cell of the body is the destination of exactly one source cell, so clearing the - // body and inserting the permuted entries is the same permutation the cells went through. - for c in body.iter() { - shm.comments.remove(&c); - shm.hyperlinks.remove(&c); - } - for (at, cm) in side { - shm.comments.insert(at, cm); - } - for (at, h) in links { - shm.hyperlinks.insert(at, h); - } - shm.cell_pictures.extend(pictures); - for (i, h) in heights.into_iter().enumerate() { - let row = body.start.row + i as u32; - match h { - Some(info) => { - shm.rows.insert(row, LineInfo { hidden: shm.rows.get(&row).is_some_and(|x| x.hidden), ..info }); - } - None => { - if let Some(e) = shm.rows.get_mut(&row) { - e.size = None; - } - } - } - } - // Re-anchor floating objects now that the row heights are final. `objs` was collected as - // images ++ charts ++ shapes, so the same index maps straight onto each vec. - // Only objects anchored inside the sorted block move: within its lines on the sort axis - // and within its span on the other axis (a picture beside the block stays put). - let (b0, b1) = if by_cols { (body.start.col, body.end.col) } else { (body.start.row, body.end.row) }; - let (o0, o1) = if by_cols { (body.start.row, body.end.row) } else { (body.start.col, body.end.col) }; - let ni = shm.images.len(); - let nch = shm.charts.len(); - for (i, (a, desc)) in objs.into_iter().zip(descs).enumerate() { - let other = if by_cols { a.cell.row } else { a.cell.col }; - if other < o0 || other > o1 { - continue; - } - let na = sorted_anchor(shm, a, desc, by_cols, b0, b1, &dst_of); - if na != a { - if i < ni { - if let Some(o) = shm.images.get_mut(i) { - o.anchor = na; - } - } else if i < ni + nch { - if let Some(o) = shm.charts.get_mut(i - ni) { - o.anchor = na; - } - } else if let Some(o) = shm.shapes.get_mut(i - ni - nch) { - o.anchor = na; - } - } - } - for (c, _) in moved { - cx.changed.push((sheet, c)); - } - cx.structural = true; - Ok(Json::Null) - })?; - Ok(json!({"sorted": body.a1()})) -} - -/// Re-anchor one floating object for a sort. `bottom` is the object's bottom-right line and the -/// offset of that corner inside it, measured before the sort. `map` (via `dst_of`) translates a -/// line index on the moving axis and is the identity outside `b0..=b1`. `sh` is the post-sort -/// grid (row heights already reordered), so re-fitting uses the heights the object lands on. -/// Objects pinned absolutely ("Don't move or size with cells") never move; "move but don't size" -/// keeps its size; "move and size" is re-fitted when its whole extent stays inside the body. -fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: bool, b0: u32, b1: u32, dst_of: &[u32]) -> Anchor { - if a.mode == AnchorMode::Absolute { - return a; - } - let (tc, tr, tdx, tdy) = bottom; - let map = |v: u32| -> u32 { if v >= b0 && v <= b1 { dst_of.get((v - b0) as usize).map_or(v, |&d| b0 + d) } else { v } }; - let (axis_start, axis_end) = if by_cols { (a.cell.col, tc) } else { (a.cell.row, tr) }; - if axis_start < b0 || axis_start > b1 { - return a; - } - let (nr, nc) = if by_cols { (a.cell.row, map(a.cell.col)) } else { (map(a.cell.row), a.cell.col) }; - if a.mode == AnchorMode::MoveAndSize && axis_end >= b0 && axis_end <= b1 { - let (nbr, nbc) = if by_cols { (tr, map(tc)) } else { (map(tr), tc) }; - let x1 = sh.col_left(nc) + a.dx as f64; - let y1 = sh.row_top(nr) + a.dy as f64; - let x2 = sh.col_left(nbc) + tdx as f64; - let y2 = sh.row_top(nbr) + tdy as f64; - Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: (x2 - x1).max(0.0) as f32, height: (y2 - y1).max(0.0) as f32, mode: a.mode } - } else { - Anchor { cell: CellRef::new(nr, nc), ..a } - } -} - -// ---------------------------------------------------------------- filter - -fn toggle_filter(s: &mut Session, p: &Json) -> Result { - let (sheet, r) = data_target(s, p, "data.filter")?; - super::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?; - // The active cell belongs to the active sheet: on another sheet the table is the one at `range`. - let at = if sheet == s.doc()?.wb.active_sheet() { s.doc()?.selection.active } else { r.start }; - let has = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.autofilter.is_some()); - let in_table = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(at).map(|t| t.name.clone())); - if let Some(name) = in_table { - return edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - if let Some(t) = sh.tables.iter_mut().find(|t| t.name == name) { - t.filter_button = !t.filter_button; - if !t.filter_button { - t.filter.clear(); - } - } - Ok(Json::Null) - }); - } - if has { - return edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - // Reveal only rows the filter hid; rows hidden by hand or by a collapsed group stay hidden. - let filtered: Vec = sh.rows.keys().copied().filter(|r| sh.is_row_filtered(*r)).collect(); - sh.autofilter = None; - for r in filtered { - if let Some(info) = sh.rows.get_mut(&r) { - info.hidden = false; - } - } - cx.structural = true; - Ok(json!({"filter": false})) - }); - } - install_filter(s, p) -} - -/// Turn a sheet AutoFilter on over `p.range`, or over the data range of the selection. -fn install_filter(s: &mut Session, p: &Json) -> Result { - let (sheet, mut r) = data_target(s, p, "data.filter")?; - // A selected header row filters the data below it (#201). - if p.get("range").is_none() && r.height() == 1 { - let d = s.doc()?; - let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - let below = current_region(sh, r.start).end.row; - if below > r.end.row { - r = RangeRef::new(r.start, CellRef::new(below, r.end.col)); - } - } - edit(s, |cx| { - cx.sheet_mut(sheet)?.autofilter = Some(AutoFilter { range: r, criteria: vec![] }); - Ok(json!({"filter": true, "range": r.a1()})) - }) -} - -fn filter_by(s: &mut Session, p: &Json) -> Result { - let sheet = s.doc()?.wb.active_sheet(); - super::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?; - let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; - let af = match &sh.autofilter { - Some(a) => a.clone(), - None => { - install_filter(s, &json!({}))?; - s.doc()?.wb.sheet(sheet).and_then(|sh| sh.autofilter.clone()).ok_or_else(|| EngineError::Other("no filter".into()))? - } - }; - let col = match col_param(p.get("column"), af.range.start.col) { - Some(c) => c, - // A number too large to be a column is outside the filter range, not a missing parameter. - None if p.get("column").is_some_and(|v| v.as_u64().is_some()) => return Err(bad("data.filterBy", "column outside the filter range")), - None => return Err(bad("data.filterBy", "missing `column`")), - }; - if col < af.range.start.col || col > af.range.end.col { - return Err(bad("data.filterBy", "column outside the filter range")); - } - let off = col - af.range.start.col; - let crit = if bool_param(p, "clear").unwrap_or(false) { - None - } else if let Some(vals) = p.get("values").and_then(Json::as_array) { - Some(FilterCriterion::Values { - values: vals.iter().map(super::edit::json_to_input).collect(), - blanks: bool_param(p, "blanks").unwrap_or(false), - }) - } else if let Some(c) = p.get("custom") { - let a = (c.get("op").and_then(Json::as_str).unwrap_or("=").to_string(), c.get("value").map(super::edit::json_to_input).unwrap_or_default()); - let b = c.get("op2").and_then(Json::as_str).map(|o| (o.to_string(), c.get("value2").map(super::edit::json_to_input).unwrap_or_default())); - Some(FilterCriterion::Custom { a, b, and: c.get("and").and_then(Json::as_bool).unwrap_or(true) }) - } else if let Some(t) = p.get("top") { - Some(FilterCriterion::Top10 { - bottom: t.get("bottom").and_then(Json::as_bool).unwrap_or(false), - percent: t.get("percent").and_then(Json::as_bool).unwrap_or(false), - count: t.get("count").and_then(Json::as_u64).unwrap_or(10) as u32, - }) - } else { - bool_param(p, "aboveAverage").map(FilterCriterion::AboveAverage) - }; - edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - if let Some(a) = sh.autofilter.as_mut() { - a.criteria.retain(|(o, _)| *o != off); - if let Some(c) = crit.clone() { - a.criteria.push((off, c)); - } - } - apply_filter(&mut cx.wb, sheet); - cx.structural = true; - Ok(Json::Null) - })?; - let hidden = s.doc()?.wb.sheet(sheet).map(|sh| sh.rows.values().filter(|r| r.hidden).count()).unwrap_or(0); - Ok(json!({"hiddenRows": hidden})) -} - -/// Does a value pass a custom comparison (`>`, `=*abc*`, `<>x`…)? -fn custom_ok(v: &Value, text: &str, op: &str, crit: &str) -> bool { - let num = gridcraft_core::parse::parse_number_text(crit); - let cmp = match (v.as_f64(), num) { - (Some(a), Some(b)) => a.partial_cmp(&b), - // A numeric ordering comparison only matches numbers; blanks and text never do. - (None, Some(_)) if matches!(op, "<" | "<=" | ">" | ">=") => None, - _ => Some(gridcraft_core::compare_text(text, crit)), - }; - let wild = || super::edit::wildcard(&text.to_lowercase(), &crit.to_lowercase()); - match op { - "=" => { - if crit.contains(['*', '?']) { - wild() - } else { - cmp == Some(Ordering::Equal) - } - } - "<>" => { - if crit.contains(['*', '?']) { - !wild() - } else { - cmp != Some(Ordering::Equal) - } - } - ">" => cmp == Some(Ordering::Greater), - "<" => cmp == Some(Ordering::Less), - ">=" => matches!(cmp, Some(Ordering::Greater | Ordering::Equal)), - "<=" => matches!(cmp, Some(Ordering::Less | Ordering::Equal)), - "beginsWith" => text.to_lowercase().starts_with(&crit.to_lowercase()), - "endsWith" => text.to_lowercase().ends_with(&crit.to_lowercase()), - "contains" => text.to_lowercase().contains(&crit.to_lowercase()), - "notContains" => !text.to_lowercase().contains(&crit.to_lowercase()), - _ => true, - } -} - -/// The filter's "(Blanks)" group: truly empty cells and zero-length text such as a `=""` result. -fn filter_blank(v: &Value) -> bool { - v.is_empty() || matches!(v, Value::Text(t) if t.is_empty()) -} - -/// Excel grows an AutoFilter range when a value is entered in the row right below it, in one of its columns (#2342). -/// A table's own filter is left to the table, and a row typed after a blank row does not count. -/// A formula counts before it is calculated: this runs inside the edit, ahead of the recalc. -pub(crate) fn grow_autofilter(sh: &mut Sheet, at: CellRef) { - if !sh.cells.get(at).is_some_and(|c| !filter_blank(&c.value) || c.formula.is_some()) { - return; - } - let Some(af) = sh.autofilter.as_ref() else { return }; - let r = af.range; - if r.end.row.checked_add(1) != Some(at.row) || at.col < r.start.col || at.col > r.end.col || sh.tables.iter().any(|t| t.range == r) { - return; - } - let grown = RangeRef::new(r.start, CellRef::new(at.row, r.end.col)); - if let Some(af) = sh.autofilter.as_mut() { - af.range = grown; - } -} - -/// Hides rows of the autofilter range that fail any criterion. -pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { - let Some(sh) = wb.sheet(sheet) else { return }; - let Some(af) = sh.autofilter.clone() else { return }; - let r = af.range; - let mut hide = Vec::new(); - // Precompute per-criterion top10 threshold and average once, not per row. - let mut col_stats: std::collections::HashMap, f64)> = std::collections::HashMap::new(); - for (i, (off, c)) in af.criteria.iter().enumerate() { - if matches!(c, FilterCriterion::Top10 { .. } | FilterCriterion::AboveAverage(_)) { - let col = r.start.col + off; - let mut nums: Vec = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect(); - let avg = if nums.is_empty() { 0.0 } else { nums.iter().sum::() / nums.len() as f64 }; - let thr = match c { - FilterCriterion::Top10 { bottom, percent, count } => { - nums.sort_by(|a, b| if *bottom { a.partial_cmp(b) } else { b.partial_cmp(a) }.unwrap_or(Ordering::Equal)); - let k = if *percent { ((nums.len() as f64) * (*count as f64) / 100.0).ceil() as usize } else { *count as usize }; - let k = k.clamp(1, nums.len().max(1)); - nums.get(k - 1).copied() - } - _ => None, - }; - col_stats.insert(i, (thr, avg)); - } - } - for row in r.start.row + 1..=r.end.row { - let mut visible = true; - for (i, (off, crit)) in af.criteria.iter().enumerate() { - let c = CellRef::new(row, r.start.col + off); - let v = sh.value(c); - let text = crate::display::cell_text(wb, sh, c); - let ok = match crit { - FilterCriterion::Values { values, blanks } => { - if filter_blank(&v) { - *blanks - } else { - values.iter().any(|x| x.eq_ignore_ascii_case(&text) || x.eq_ignore_ascii_case(&v.display())) - } - } - FilterCriterion::Custom { a, b, and } => { - let x = custom_ok(&v, &text, &a.0, &a.1); - match b { - Some(b) => { - let y = custom_ok(&v, &text, &b.0, &b.1); - if *and { x && y } else { x || y } - } - None => x, - } - } - FilterCriterion::Top10 { bottom, .. } => match (v.as_f64(), col_stats.get(&i)) { - (Some(n), Some((thr, _))) => { - let thr = thr.unwrap_or(n); - if *bottom { n <= thr } else { n >= thr } - } - _ => false, - }, - FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(&i)) { - (Some(n), Some((_, avg))) => { - if *above { - n > *avg - } else { - n < *avg - } - } - _ => false, - }, - FilterCriterion::FillColor(col) => wb.styles.get(sh.style_id(c)).fill.fg == *col, - FilterCriterion::FontColor(col) => wb.styles.get(sh.style_id(c)).font.color == *col, - // Icons are not evaluated here: the row keeps its current visibility. - FilterCriterion::Icon { .. } => !sh.is_row_hidden(row), - }; - if !ok { - visible = false; - break; - } - } - hide.push((row, !visible)); - } - let Some(shm) = wb.sheet_mut(sheet) else { return }; - for (row, h) in hide { - if h { - shm.rows.entry(row).or_default().hidden = true; - } else if let Some(e) = shm.rows.get_mut(&row) { - e.hidden = false; - } - } -} - -fn clear_filter(s: &mut Session, _: &Json) -> Result { - let sheet = s.doc()?.wb.active_sheet(); - edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - if let Some(a) = sh.autofilter.as_mut() { - a.criteria.clear(); - } - apply_filter(&mut cx.wb, sheet); - cx.structural = true; - Ok(Json::Null) - }) -} - -fn reapply(s: &mut Session, _: &Json) -> Result { - let sheet = s.doc()?.wb.active_sheet(); - edit(s, |cx| { - apply_filter(&mut cx.wb, sheet); - cx.structural = true; - Ok(Json::Null) - }) -} - -/// Distinct display values of a filter column (for the dropdown list). -pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool)> { - let Some(sh) = wb.sheet(sheet) else { return vec![] }; - let Some(af) = &sh.autofilter else { return vec![] }; - let off = col.saturating_sub(af.range.start.col); - let selected: Option<&FilterCriterion> = af.criteria.iter().find(|(o, _)| *o == off).map(|(_, c)| c); - let mut seen = std::collections::BTreeMap::new(); - for row in af.range.start.row + 1..=af.range.end.row.min(af.range.start.row + 1_000_000) { - let c = CellRef::new(row, col); - let v = sh.value(c); - let blank = filter_blank(&v); - let t = if blank { "(Blanks)".to_string() } else { crate::display::cell_text(wb, sh, c) }; - let on = match selected { - Some(FilterCriterion::Values { values, blanks }) => { - if blank { - *blanks - } else { - values.iter().any(|x| x.eq_ignore_ascii_case(&t)) - } - } - _ => true, - }; - let key = if blank { sort_key(&Value::Empty) } else { sort_key(&v) }; - seen.entry((key, t)).or_insert(on); - } - seen.into_iter().map(|((_, t), on)| (t, on)).collect() -} - -/// Case-sensitive tie-break for text that is equal ignoring case: as in Excel, lowercase sorts -/// before uppercase at the first letter that differs in case. -fn case_order(a: &str, b: &str) -> Ordering { - a.chars().zip(b.chars()).find(|(x, y)| x != y).map_or(Ordering::Equal, |(x, y)| y.is_lowercase().cmp(&x.is_lowercase())) -} - -fn sort_key(v: &Value) -> (u8, i64, String) { - match v { - Value::Number(n) => (0, (n * 1e6) as i64, String::new()), - Value::Text(t) => (1, 0, t.to_lowercase()), - Value::Bool(b) => (2, *b as i64, String::new()), - Value::Empty => (9, 0, String::new()), - _ => (3, 0, v.display()), - } -} - -// ---------------------------------------------------------------- tools - -fn remove_duplicates(s: &mut Session, p: &Json) -> Result { - let (sheet, r) = data_target(s, p, "data.removeDuplicates")?; - let d = s.doc()?; - let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; - // Rows and columns past the data are blank: only the part with data is compared (whole - // columns compared a million blank rows and reported them as removed duplicates). - let r = sh.used_range().and_then(|u| r.intersection(&u)).unwrap_or(RangeRef::cell(r.start)); - let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r)); - let cols: Vec = p - .get("columns") - .and_then(Json::as_array) - .map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect()) - .unwrap_or_else(|| (r.start.col..=r.end.col).collect()); - // With no column to compare, every row key is empty: the first row would survive and every - // other row would be deleted as a duplicate. A column outside the range compares data the - // user didn't select. Excel refuses both. - if cols.is_empty() || cols.iter().any(|c| !(r.start.col..=r.end.col).contains(c)) { - return Err(bad("data.removeDuplicates", "`columns` must name at least one column of the range")); - } - let start = r.start.row + header as u32; - let mut removed = 0; - edit(s, |cx| { - let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) }; - let mut seen = std::collections::HashSet::new(); - let mut keep: Vec = Vec::new(); - // Cells are compared by the text they show, as in Excel: the same date in two formats - // isn't a duplicate, while 1.01 and 1.02 shown as 1.0 are. - let shown = |c: CellRef| crate::display::format(&sh.value(c), cx.wb.styles.get(sh.style_id(c)).num_fmt.as_str(), &cx.wb).text; - for row in start..=r.end.row { - let key: Vec = cols.iter().map(|c| shown(CellRef::new(row, *c)).to_lowercase()).collect(); - if seen.insert(key) { - keep.push(row); - } else { - removed += 1; - } - } - let pictures: Vec<_> = keep - .iter() - .enumerate() - .flat_map(|(i, row)| { - (r.start.col..=r.end.col) - .filter_map(move |col| sh.cell_pictures.get(&CellRef::new(*row, col)).map(|p| (CellRef::new(start + i as u32, col), p.clone()))) - }) - .collect(); - // Rows a filter keeps are compacted to `start + i`, so the notes and hyperlinks that - // describe them move with them; the ones on removed rows are dropped with the row. - let notes: Vec<_> = keep - .iter() - .enumerate() - .flat_map(|(i, row)| { - (r.start.col..=r.end.col) - .filter_map(move |col| sh.comments.get(&CellRef::new(*row, col)).map(|cm| (CellRef::new(start + i as u32, col), cm.clone()))) - }) - .collect(); - let links: Vec<_> = keep - .iter() - .enumerate() - .flat_map(|(i, row)| { - (r.start.col..=r.end.col) - .filter_map(move |col| sh.hyperlinks.get(&CellRef::new(*row, col)).map(|h| (CellRef::new(start + i as u32, col), h.clone()))) - }) - .collect(); - let rows: Vec>> = - keep.iter().map(|row| (r.start.col..=r.end.col).map(|c| sh.cell(CellRef::new(*row, c)).cloned()).collect()).collect(); - let shm = cx.sheet_mut(sheet)?; - for row in start..=r.end.row { - for c in r.start.col..=r.end.col { - shm.remove_cell(CellRef::new(row, c)); - } - } - for row in start..=r.end.row { - for c in r.start.col..=r.end.col { - let at = CellRef::new(row, c); - shm.comments.remove(&at); - shm.hyperlinks.remove(&at); - } - } - for (i, cells) in rows.into_iter().enumerate() { - for (j, cell) in cells.into_iter().enumerate() { - if let Some(cell) = cell { - shm.set_cell(CellRef::new(start + i as u32, r.start.col + j as u32), cell); - } - } - } - shm.cell_pictures.extend(pictures); - for (at, cm) in notes { - shm.comments.insert(at, cm); - } - for (at, h) in links { - shm.hyperlinks.insert(at, h); - } - for c in RangeRef::new(CellRef::new(start, r.start.col), r.end).iter() { - cx.changed.push((sheet, c)); - } - Ok(()) - })?; - let remaining = (r.end.row + 1 - start) as usize - removed; - Ok(json!({"removed": removed, "remaining": remaining})) -} - -fn text_to_columns(s: &mut Session, p: &Json) -> Result { - let r = target_range(s, p)?; - let sheet = target_sheet(s, p)?; - let mut delims: Vec = p - .get("delimiters") - .and_then(Json::as_array) - .map(|a| a.iter().filter_map(|d| d.as_str().and_then(|x| x.chars().next())).collect()) - .unwrap_or_else(|| vec!['\t']); - if let Some(o) = str_param(p, "other").and_then(|o| o.chars().next()) { - delims.push(o); - } - let fixed: Option> = - p.get("fixedWidths").and_then(Json::as_array).map(|a| a.iter().filter_map(|x| x.as_u64().map(|v| v as usize)).collect()); - let consecutive = bool_param(p, "treatConsecutive").unwrap_or(false); - let quote = str_param(p, "textQualifier").and_then(|q| q.chars().next()).unwrap_or('"'); - let dest = cell_param_on(s, p, "destination", sheet)?.unwrap_or(r.start); - edit(s, |cx| { - let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) }; - let mut out: Vec<(CellRef, String)> = Vec::new(); - for (i, row) in (r.start.row..=r.end.row).enumerate() { - let text = sh.value(CellRef::new(row, r.start.col)).display(); - let parts: Vec = match &fixed { - Some(w) => { - let chars: Vec = text.chars().collect(); - let mut cuts = vec![0]; - let mut breaks: Vec = w.iter().copied().filter(|x| *x > 0 && *x < chars.len()).collect(); - breaks.sort_unstable(); - breaks.dedup(); - cuts.extend(breaks); - cuts.push(chars.len()); - cuts.windows(2) - .map(|p| chars.get(p[0]..p[1]).map(|s| s.iter().collect::().trim().to_string()).unwrap_or_default()) - .collect() - } - None => split_delimited(&text, &delims, consecutive, quote), - }; - for (j, part) in parts.into_iter().enumerate() { - out.push((CellRef::new(dest.row + i as u32, dest.col + j as u32), part)); - } - } - for (c, t) in out { - let cell = super::edit::input_to_cell(&t, sheet, c, &mut cx.wb).unwrap_or(None); - let shm = cx.sheet_mut(sheet)?; - match cell { - Some(x) => shm.set_cell(c, x), - None => { - shm.remove_cell(c); - } - } - cx.changed.push((sheet, c)); - } - Ok(()) - })?; - ok() -} - -fn split_delimited(text: &str, delims: &[char], consecutive: bool, quote: char) -> Vec { - let mut out = Vec::new(); - let mut cur = String::new(); - let mut in_q = false; - let mut last_delim = false; - let mut chars = text.chars().peekable(); - while let Some(c) = chars.next() { - if c == quote { - if in_q && chars.peek() == Some("e) { - cur.push(quote); - chars.next(); - } else { - in_q = !in_q; - } - continue; - } - if !in_q && delims.contains(&c) { - if consecutive && last_delim { - continue; - } - out.push(std::mem::take(&mut cur)); - last_delim = true; - continue; - } - last_delim = false; - cur.push(c); - } - out.push(cur); - out -} - -// ---------------------------------------------------------------- validation - -fn op_param(s: Option<&str>) -> CfOperator { - match s.unwrap_or("between") { - "notBetween" => CfOperator::NotBetween, - "equal" => CfOperator::Equal, - "notEqual" => CfOperator::NotEqual, - "greater" | "greaterThan" => CfOperator::Greater, - "less" | "lessThan" => CfOperator::Less, - "greaterOrEqual" | "greaterThanOrEqual" => CfOperator::GreaterOrEqual, - "lessOrEqual" | "lessThanOrEqual" => CfOperator::LessOrEqual, - _ => CfOperator::Between, - } -} - -/// `r` minus `cut`, as up to four non-overlapping rectangles. -fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec { - let Some(i) = r.intersection(cut) else { return vec![r] }; - let mut out = Vec::new(); - if i.start.row > r.start.row { - out.push(RangeRef::new(r.start, CellRef::new(i.start.row - 1, r.end.col))); - } - if i.end.row < r.end.row { - out.push(RangeRef::new(CellRef::new(i.end.row + 1, r.start.col), r.end)); - } - if i.start.col > r.start.col { - out.push(RangeRef::new(CellRef::new(i.start.row, r.start.col), CellRef::new(i.end.row, i.start.col - 1))); - } - if i.end.col < r.end.col { - out.push(RangeRef::new(CellRef::new(i.start.row, i.end.col + 1), CellRef::new(i.end.row, r.end.col))); - } - out -} - -/// Removes the cells of `cuts` from every validation, keeping the rest of each rule's ranges. -pub(super) fn remove_validation_cells(validations: &mut Vec, cuts: &[RangeRef]) { - for dv in validations.iter_mut() { - for cut in cuts { - dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect(); - } - } - validations.retain(|d| !d.ranges.is_empty()); -} - -fn validation(s: &mut Session, p: &Json) -> Result { - let ranges = target_ranges(s, p)?; - let sheet = target_sheet(s, p)?; - if bool_param(p, "clear").unwrap_or(false) { - return edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - remove_validation_cells(&mut sh.validations, &ranges); - Ok(Json::Null) - }); - } - let Some(kind) = str_param(p, "type") else { - s.ui_requests.push(crate::UiRequest::Dialog("dataValidation".into(), json!({}))); - return ok(); - }; - let kind = match kind { - "whole" => ValidationKind::Whole, - "decimal" => ValidationKind::Decimal, - "list" => ValidationKind::List, - "date" => ValidationKind::Date, - "time" => ValidationKind::Time, - "textLength" => ValidationKind::TextLength, - "custom" => ValidationKind::Custom, - "any" => ValidationKind::Any, - // An unknown type used to give an "any value" rule, saved as `type="none"`: no validation. - other => return Err(bad("data.validation", format!("unknown type `{other}` (any, whole, decimal, list, date, time, textLength, custom)"))), - }; - if kind == ValidationKind::List && str_param(p, "formula1").is_none_or(|f| f.trim().trim_start_matches('=').is_empty()) { - return Err(bad("data.validation", "a list validation needs `formula1`")); - } - const OPERATORS: [&str; 12] = [ - "between", - "notBetween", - "equal", - "notEqual", - "greater", - "greaterThan", - "less", - "lessThan", - "greaterOrEqual", - "greaterThanOrEqual", - "lessOrEqual", - "lessThanOrEqual", - ]; - if let Some(op) = str_param(p, "operator").filter(|op| !OPERATORS.contains(op)) { - return Err(bad("data.validation", format!("unknown operator `{op}` ({})", OPERATORS.join(", ")))); - } - let error_style = match str_param(p, "errorStyle") { - None | Some("stop") => ErrorStyle::Stop, - Some("warning") => ErrorStyle::Warning, - Some("information") => ErrorStyle::Information, - Some(other) => return Err(bad("data.validation", format!("unknown errorStyle `{other}` (stop, warning, information)"))), - }; - let f1 = str_param(p, "formula1").unwrap_or(""); - let f1 = if kind == ValidationKind::List { list_source(f1) } else { f1.trim_start_matches('=').to_string() }; - let dv = Validation { - ranges: ranges.clone(), - kind, - op: op_param(str_param(p, "operator")), - f1, - f2: str_param(p, "formula2").map(|f| f.trim_start_matches('=').to_string()), - allow_blank: bool_param(p, "ignoreBlank").unwrap_or(true), - in_cell_dropdown: bool_param(p, "dropdown").unwrap_or(true), - input_title: str_param(p, "inputTitle").unwrap_or("").into(), - input_message: str_param(p, "inputMessage").unwrap_or("").into(), - show_input: true, - error_title: str_param(p, "errorTitle").unwrap_or("").into(), - error_message: str_param(p, "errorMessage").unwrap_or("").into(), - error_style, - show_error: true, - }; - edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - remove_validation_cells(&mut sh.validations, &ranges); - sh.validations.push(dv.clone()); - Ok(Json::Null) - }) -} - -/// Whether a list rule's source is a literal list (`"a,b"`, or `a,b` typed without quotes) rather -/// than a reference or a name to evaluate. -fn is_literal_list(f: &str) -> bool { - f.starts_with('"') - || (!f.contains('!') - && gridcraft_formula::parse(f).map(|e| !matches!(e, gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_))).unwrap_or(true)) -} - -/// A list rule's source as a file stores it. A literal list typed without quotes, as in Excel's -/// Source box (`Open,In progress,Closed`), becomes the quoted string Excel saves -/// (`"Open,In progress,Closed"`, inner quotes doubled); unquoted, the file would read it as a -/// formula. A formula (`=…`), a reference, a name or an already quoted list is kept as before. -fn list_source(f: &str) -> String { - let t = f.trim(); - if t.is_empty() || t.starts_with('=') || t.starts_with('"') || !is_literal_list(t) { - return f.trim_start_matches('=').to_string(); - } - format!("\"{}\"", t.replace('"', "\"\"")) -} - -/// List entries of a list validation (literal or from a range). -pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec { - let f = dv.f1.trim(); - if is_literal_list(f) { - // A quoted list is a string literal: `""` inside stands for one `"`. - let items = match f.strip_prefix('"').and_then(|x| x.strip_suffix('"')) { - Some(body) => body.replace("\"\"", "\""), - None => f.trim_matches('"').to_string(), - }; - return items.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(); - } - let at = dv.ranges.first().map(|r| r.start).unwrap_or_default(); - match gridcraft_calc::evaluate(wb, sheet, at, f) { - Value::Array(a) => a.data.iter().filter(|v| !v.is_empty()).map(Value::display).collect(), - v if !v.is_empty() && !v.is_error() => vec![v.display()], - _ => vec![], - } -} - -/// Checks typed input against the validation of a cell. `Ok(None)` = valid. -pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) -> Option<(Validation, String)> { - let sh = wb.sheet(sheet)?; - let dv = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(at)))?; - if dv.kind == ValidationKind::Any || !dv.show_error { - return None; - } - if input.is_empty() && dv.allow_blank { - return None; - } - let v = gridcraft_core::parse::parse_input(input, wb.date_system).value; - // The formulas are written for the first cell of the range and see the value being entered, - // as in Excel (a custom `=A1>10` on A1:A9 checks A5's new value at A5). - let first = dv.ranges.first().map(|r| r.start).unwrap_or(at); - let entered; - let wb = if sh.value(at) == v { - wb - } else { - let mut w = wb.clone(); - if let Some(sh) = w.sheet_mut(sheet) { - let style = sh.style_id(at); - sh.cells.set(at, Cell { value: v.clone(), style, ..Default::default() }); - } - entered = w; - &entered - }; - let eval = |f: &str| gridcraft_calc::evaluate(wb, sheet, at, &formula_at(f, first, at)); - let num = |f: &str| eval(f).to_number().ok(); - let ok = match dv.kind { - ValidationKind::List => list_items(wb, sheet, dv).iter().any(|x| x.eq_ignore_ascii_case(input)), - ValidationKind::Custom => eval(&dv.f1).to_bool().unwrap_or(false), - ValidationKind::TextLength => { - let n = input.chars().count() as f64; - compare_op(dv.op, n, num(&dv.f1), dv.f2.as_deref().and_then(num)) - } - _ => match v.as_f64() { - Some(n) => { - if dv.kind == ValidationKind::Whole && n.fract() != 0.0 { - false - } else { - compare_op(dv.op, n, num(&dv.f1), dv.f2.as_deref().and_then(num)) - } - } - None => false, - }, - }; - if ok { - return None; - } - let msg = if dv.error_message.is_empty() { - "This value doesn't match the data validation restrictions defined for this cell.".to_string() - } else { - dv.error_message.clone() - }; - Some((dv.clone(), msg)) -} - -/// A formula written for `first`, as seen from `at` (relative references shifted). -fn formula_at(f: &str, first: CellRef, at: CellRef) -> String { - if first == at { - return f.to_string(); - } - match gridcraft_formula::parse(f.trim_start_matches('=')) { - Ok(e) => gridcraft_formula::print(&gridcraft_formula::adjust::shift_relative( - e, - at.row as i64 - first.row as i64, - at.col as i64 - first.col as i64, - )), - Err(_) => f.to_string(), - } -} - -/// Moves the relative references of a rule formula written for `first` so it reads the same cells from `to` -/// (pasting a rule); text without relative references (constants, literal lists) stays as typed. -pub(super) fn move_rule_formula(f: &str, first: CellRef, to: CellRef) -> String { - if first == to { - return f.to_string(); - } - let moved = formula_at(f, first, to); - let has_relative = gridcraft_formula::parse(f.trim_start_matches('=')).is_ok_and(|e| gridcraft_formula::print(&e) != moved); - let eq = if f.starts_with('=') { "=" } else { "" }; - if has_relative { format!("{eq}{moved}") } else { f.to_string() } -} - -fn compare_op(op: CfOperator, n: f64, a: Option, b: Option) -> bool { - let Some(a) = a else { return false }; - match op { - CfOperator::Between => b.is_some_and(|b| n >= a.min(b) && n <= a.max(b)), - CfOperator::NotBetween => b.is_some_and(|b| n < a.min(b) || n > a.max(b)), - CfOperator::Equal => n == a, - CfOperator::NotEqual => n != a, - CfOperator::Greater => n > a, - CfOperator::Less => n < a, - CfOperator::GreaterOrEqual => n >= a, - CfOperator::LessOrEqual => n <= a, - } -} - -fn validate_cmd(s: &mut Session, p: &Json) -> Result { - let d = s.doc()?; - let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); - let input = str_param(p, "input").unwrap_or(""); - match check_validation(&d.wb, d.wb.active_sheet(), at, input) { - None => Ok(json!({"ok": true})), - Some((dv, msg)) => Ok(json!({"ok": false, "message": msg, "title": dv.error_title, "style": format!("{:?}", dv.error_style)})), - } -} - -fn circle_invalid(s: &mut Session, _: &Json) -> Result { - let d = s.doc()?; - let sheet = d.wb.active_sheet(); - let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; - let mut bad_cells = Vec::new(); - for dv in &sh.validations { - for r in &dv.ranges { - let r = sh.used_range().and_then(|u| r.intersection(&u)); - let Some(r) = r else { continue }; - for c in r.iter().take(100_000) { - let text = sh.input_text(c); - if !text.is_empty() && check_validation(&d.wb, sheet, c, &text).is_some() { - bad_cells.push(c.a1()); - } - } - } - } - Ok(json!({"invalid": bad_cells})) -} - -// ---------------------------------------------------------------- outline - -fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> { - if let Some(r) = str_param(p, "rows").and_then(parse_lines) { - return Ok((true, r.start.row, r.end.row)); - } - if let Some(r) = str_param(p, "cols").and_then(parse_lines) { - return Ok((false, r.start.col, r.end.col)); - } - let r = s.doc()?.selection.current(); - match str_param(p, "by") { - Some("rows") => Ok((true, r.start.row, r.end.row)), - Some("cols" | "columns") => Ok((false, r.start.col, r.end.col)), - _ if r.is_full_cols() && !r.is_full_rows() => Ok((false, r.start.col, r.end.col)), - _ => Ok((true, r.start.row, r.end.row)), - } -} - -fn group(s: &mut Session, p: &Json, on: bool) -> Result { - let si = target_sheet(s, p)?; - // The selection belongs to the active sheet, so another sheet needs its lines. - if si != s.doc()?.wb.active_sheet() && p.get("rows").is_none() && p.get("cols").is_none() { - return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet")); - } - let (rows, a, b) = outline_lines(s, p)?; - 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 { - let e = map.entry(i).or_default(); - e.outline = if on { (e.outline + 1).min(outline::MAX_LEVEL) } else { e.outline.saturating_sub(1) }; - if !on && e.outline == 0 { - e.hidden = false; - } - } - // 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_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; - } - cx.structural = true; // ungrouping reveals hidden rows: SUBTOTAL(10x) depends on it - Ok(Json::Null) - }) -} - -/// Hide Detail / Show Detail: the innermost group (or the one at `level`) that holds the lines, or -/// failing that the one they are the summary of. Hiding marks the summary line collapsed (the -/// + button, saved to XLSX); showing keeps inner groups that were collapsed on their own collapsed. -fn detail(s: &mut Session, p: &Json, hide: bool) -> Result { - let (rows, a, b) = outline_lines(s, p)?; - let want = p.get("level").and_then(Json::as_u64).map(|l| l.min(outline::MAX_LEVEL as u64) as u8); - edit(s, |cx| { - let si = cx.sheet_index(); - 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_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 { - let hits: Vec<_> = all.iter().filter(|g| level_ok(g) && hit(g)).copied().collect(); - let deepest = hits.iter().map(|g| g.level).max().unwrap_or(0); - hits.into_iter().filter(|g| g.level == deepest).collect() - }; - let mut chosen = pick(&|g| g.start <= b && g.end >= a); - if chosen.is_empty() { - 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 = all - .iter() - .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(); - for i in g.start..=g.end { - if let Some(e) = map.get_mut(&i) { - 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()) { - e.collapsed = false; - } - } - cx.structural = true; - Ok(Json::Null) - }) -} - -/// Hides or shows an outline line. A line shown with a zero size (files that hide columns as -/// `width="0" hidden="1"`) gets the default size back, as Unhide does; otherwise + would mark the -/// group expanded and leave it invisible (#1714). -fn set_hidden(e: &mut LineInfo, hidden: bool) { - e.hidden = hidden; - if !hidden && e.size == Some(0.0) { - e.size = None; - } -} - -/// The outline's level buttons: show rows / columns down to outline level `n` (1 = only the -/// top-level summaries); deeper detail is hidden and its summary lines marked collapsed. -fn show_levels(s: &mut Session, p: &Json) -> Result { - let level = |k: &str| p.get(k).and_then(Json::as_u64).map(|n| n.clamp(1, outline::MAX_LEVEL as u64 + 1) as u8); - let (rows, cols) = (level("rows"), level("cols")); - if rows.is_none() && cols.is_none() { - return Err(EngineError::BadParams { - cmd: "data.showLevels".into(), - msg: "give `rows` or `cols`: the outline level to show (1 = top-level summaries only)".into(), - }); - } - edit(s, |cx| { - let si = cx.sheet_index(); - let sh = cx.sheet_mut(si)?; - 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_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.into_iter().filter(|g| g.has_summary()) { - map.entry(g.summary()).or_default().collapsed = g.level >= n; - } - } - cx.structural = true; - Ok(Json::Null) - }) -} - -/// 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. -/// Moves the collapsed marks of a line axis to the summary line on its new side (`on` is the new -/// setting, `was_before` the old one). A line that nested groups share keeps its mark on the outermost -/// group whose detail is all hidden, or the outermost when none is. -fn move_marks(map: &mut std::collections::BTreeMap, was_before: bool, on: bool) { - let mut marked: Vec = - 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 = 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; - } - } -} - -fn outline_settings(s: &mut Session, p: &Json) -> Result { - 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; - } - move_marks(if rows { &mut sh.rows } else { &mut sh.cols }, was_before, on); - } - Ok(json!({"summaryBelow": sh.summary_below, "summaryRight": sh.summary_right})) - }) -} - -fn subtotal(s: &mut Session, p: &Json) -> Result { - let (sheet, r) = data_target(s, p, "data.subtotal")?; - let header = bool_param(p, "header").unwrap_or(true); - let group_col = col_param(p.get("groupBy"), r.start.col).unwrap_or(r.start.col); - let func = str_param(p, "function").unwrap_or("sum"); - let code = match func { - "count" => 3, - "average" => 1, - "max" => 4, - "min" => 5, - "product" => 6, - _ => 9, - }; - let cols: Vec = p - .get("columns") - .and_then(Json::as_array) - .map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect()) - .unwrap_or_else(|| vec![r.end.col]); - let label = match func { - "count" => "Count", - "average" => "Average", - "max" => "Max", - "min" => "Min", - "product" => "Product", - _ => "Total", - }; - // Group boundaries (header in the first row). - let d = s.doc()?; - let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; - let mut groups: Vec<(u32, u32, String)> = Vec::new(); - let first = r.start.row + u32::from(header); - let mut start = first; - for row in first..=r.end.row + 1 { - let cur = sh.value(CellRef::new(row, group_col)).display(); - let prev = sh.value(CellRef::new(start, group_col)).display(); - if row > r.end.row || cur != prev { - groups.push((start, row - 1, prev)); - 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() { - s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", g1 + 2, g1 + 2)}))?; - let row = g1 + 1; - let lbl = CellRef::new(row, group_col).a1(); - s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("{name} {label}")}))?; - for c in &cols { - let range = RangeRef::new(CellRef::new(*g0, *c), CellRef::new(*g1, *c)).a1(); - s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(row, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?; - } - s.execute( - "home.bold", - json!({"sheet": sheet, "range": RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)).a1(), "on": true}), - )?; - s.execute("data.group", json!({"sheet": sheet, "rows": format!("{}:{}", g0 + 1, g1 + 1)}))?; - inserted += 1; - } - let last = r.end.row + inserted + 1; - let lbl = CellRef::new(last, group_col).a1(); - s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", last + 1, last + 1)}))?; - s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("Grand {label}")}))?; - for c in &cols { - let range = RangeRef::new(CellRef::new(first, *c), CellRef::new(last - 1, *c)).a1(); - s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(last, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?; - } - Ok(json!({"groups": inserted})) -} - #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn filter_blanks_include_empty_text_and_formula_results() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [["id", "f"], ["e", null], ["ef", "=\"\""], ["sp", " "], ["z", 0]]})).unwrap(); - s.execute("data.filter", json!({"range": "A1:B5"})).unwrap(); - let r = s.execute("data.filterBy", json!({"column": "B", "values": [], "blanks": true})).unwrap(); - assert_eq!(r["hiddenRows"], json!(2)); - let vals = filter_values(&s.doc().unwrap().wb, 0, 1); - // The `=""` result joins (Blanks) instead of showing as its own empty entry. - assert!(!vals.iter().any(|(t, _)| t.is_empty()), "{vals:?}"); - let blanks: Vec<_> = vals.into_iter().filter(|(t, _)| t == "(Blanks)").collect(); - assert_eq!(blanks, vec![("(Blanks)".to_string(), true)]); - } - - #[test] - fn subtotal_honours_header_false() { - // #131: with header:false the first data row was treated as a header and left out of its - // group's subtotal and the grand total. - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [["East", 10], ["East", 20], ["West", 30], ["West", 40]]})).unwrap(); - s.execute("data.subtotal", json!({"range": "A1:B4", "groupBy": "A", "function": "sum", "columns": ["B"], "header": false})).unwrap(); - let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); - assert_eq!(get(&mut s, "A1")["value"], json!("East")); - assert_eq!(get(&mut s, "A3")["text"], json!("East Total")); - assert_eq!(get(&mut s, "B3")["value"], json!(30.0), "East's subtotal counts both East rows"); - assert_eq!(get(&mut s, "A7")["text"], json!("Grand Total")); - 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(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "B1", "values": [["Name", "Qty"], ["a", 1], ["b", 2]]})).unwrap(); - s.execute("data.filter", json!({"range": "B1:C3"})).unwrap(); - for col in [json!(2), json!(u64::from(u32::MAX)), json!(u64::MAX)] { - let err = s.execute("data.filterBy", json!({"column": col, "values": ["a"]})).unwrap_err(); - assert!(err.to_string().contains("column outside the filter range"), "{col}: {err}"); - } - let ok = s.execute("data.filterBy", json!({"column": 0, "values": ["a"]})).unwrap(); - assert_eq!(ok["hiddenRows"], json!(1)); - } - - #[test] - fn filter_by_inside_a_table_installs_the_autofilter_and_keeps_the_button() { - for sel in [json!({"cell": "A1"}), json!({"cell": "A2"}), json!({"range": "A1:A3"})] { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"]]})).unwrap(); - s.execute("insert.table", json!({"range": "A1:A3", "header": true})).unwrap(); - s.execute("selection.set", sel.clone()).unwrap(); - s.execute("data.filterBy", json!({"column": "A", "values": ["a"]})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - assert_eq!(sh.autofilter.as_ref().map(|a| a.range.a1()), Some("A1:A3".to_string()), "{sel}"); - assert!(sh.tables[0].filter_button, "{sel}"); - assert!(!sh.is_row_hidden(1), "{sel}"); - assert!(sh.is_row_hidden(2), "{sel}"); - assert_eq!(sh.value(CellRef::new(2, 0)).display(), "b", "{sel}"); - } - } - - #[test] - fn autofilter_range_grows_when_a_value_is_entered_in_the_row_below() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["d", 4], ["b", 2]]})).unwrap(); - s.execute("data.filter", json!({"range": "A1:B3"})).unwrap(); - let range = |s: &Session| s.doc().unwrap().wb.active().unwrap().autofilter.as_ref().map(|a| a.range.a1()); - s.execute("cell.set", json!({"cell": "A4", "input": "e"})).unwrap(); - assert_eq!(range(&s), Some("A1:B4".to_string())); - s.execute("range.setValues", json!({"range": "A5", "values": [["f", 6]]})).unwrap(); - assert_eq!(range(&s), Some("A1:B5".to_string())); - // A row typed after a blank row does not grow it. - s.execute("cell.set", json!({"cell": "A7", "input": "x"})).unwrap(); - assert_eq!(range(&s), Some("A1:B5".to_string())); - // A cell outside the filter's columns does not either. - s.execute("cell.set", json!({"cell": "C6", "input": "x"})).unwrap(); - assert_eq!(range(&s), Some("A1:B5".to_string())); - // Empty text is a blank entry, not a value. - s.execute("range.setValues", json!({"range": "A6", "parse": false, "values": [[""]]})).unwrap(); - assert_eq!(range(&s), Some("A1:B5".to_string())); - // A formula and Ctrl+Enter (range.fill) are entries too. - s.execute("cell.set", json!({"cell": "B6", "input": "=1+1"})).unwrap(); - assert_eq!(range(&s), Some("A1:B6".to_string())); - s.execute("range.fill", json!({"range": "A7:B7", "input": "g"})).unwrap(); - assert_eq!(range(&s), Some("A1:B7".to_string())); - } - - #[test] - fn list_validation_without_formula1_is_an_error() { - let mut s = Session::new(); - s.new_workbook(); - assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "source": "\"a,b,c\""})).is_err()); - assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list"})).is_err()); - assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty()); - s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "formula1": "\"a,b,c\""})).unwrap(); - assert_eq!(s.execute("data.validate", json!({"cell": "A1", "input": "a"})).unwrap()["ok"], json!(true)); - } - - #[test] - fn partial_validation_edits_keep_the_rest_of_the_old_rule() { - let mut s = Session::new(); - s.new_workbook(); - let rule = |range: &str, lo: &str, hi: &str| json!({"range": range, "type": "whole", "operator": "between", "formula1": lo, "formula2": hi}); - let ok = |s: &mut Session, cell: &str, input: &str| { - s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == json!(true) - }; - s.execute("data.validation", rule("B1:B3", "1", "10")).unwrap(); - s.execute("data.validation", json!({"range": "B2", "clear": true})).unwrap(); - assert!(ok(&mut s, "B2", "999")); - assert!(!ok(&mut s, "B1", "999")); - assert!(!ok(&mut s, "B3", "999")); - s.execute("data.validation", rule("B2:B4", "100", "200")).unwrap(); - for cell in ["B2", "B3", "B4"] { - assert!(ok(&mut s, cell, "150"), "{cell}"); - assert!(!ok(&mut s, cell, "5"), "{cell}"); - } - assert!(ok(&mut s, "B1", "5")); - assert!(!ok(&mut s, "B1", "150")); - } - - #[test] - fn showing_detail_gives_zero_width_columns_their_width_back() { - // #1714: templates hide grouped columns as `width="0" hidden="1"`; + must show them. - let mut s = Session::new(); - s.new_workbook(); - let hidden = LineInfo { size: Some(0.0), hidden: true, outline: 1, ..LineInfo::default() }; - let sh = Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap(); - for c in 2..=4 { - sh.cols.insert(c, hidden); - } - sh.cols.insert(5, LineInfo { collapsed: true, ..LineInfo::default() }); - s.execute("data.showDetail", json!({"cols": "C:E", "level": 1})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - for c in 2..=4 { - assert_eq!(sh.col_width(c), sh.default_col_width, "column {c}"); - } - // Hiding again hides them; the level buttons show zero-width columns too. - s.execute("data.hideDetail", json!({"cols": "C:E", "level": 1})).unwrap(); - assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(2), 0.0); - Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().cols.insert(3, hidden); - s.execute("data.showLevels", json!({"cols": 2})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - assert_eq!((sh.col_width(2), sh.col_width(3)), (sh.default_col_width, sh.default_col_width)); - } - - #[test] - fn removing_an_autofilter_keeps_manually_hidden_and_collapsed_rows() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap(); - s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap(); - s.execute("data.group", json!({"rows": "2:4"})).unwrap(); - s.execute("data.hideDetail", json!({"rows": "2:4"})).unwrap(); - s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap(); - let hidden = |s: &Session| -> Vec { - let sh = s.doc().unwrap().wb.active().unwrap(); - sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect() - }; - let before = hidden(&s); - assert_eq!(before, [1, 2, 3, 9]); - s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); - s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); - assert!(s.doc().unwrap().wb.active().unwrap().autofilter.is_none()); - assert_eq!(hidden(&s), before); - assert_eq!(row_state(&s, 4..=4), [(false, true)]); - } - - #[test] - fn table_dropdown_setup_outside_the_table_keeps_manually_hidden_rows() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap(); - s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap(); - s.execute("insert.table", json!({"range": "A1:A4", "header": true})).unwrap(); - s.execute("home.hideRows", json!({"rows": "3:3"})).unwrap(); - s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap(); - s.execute("selection.set", json!({"range": "E1"})).unwrap(); - // The table filter dropdown installs an AutoFilter on demand by toggling `data.filter` twice. - s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); - s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - assert_eq!(sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect::>(), [2, 9]); - } - - /// Rows 5:8 grouped, with 6:7 grouped again inside (0-based 4..=7 and 5..=6). - fn nested_rows() -> Session { - let mut s = Session::new(); - s.new_workbook(); - s.execute("data.group", json!({"rows": "5:8"})).unwrap(); - s.execute("data.group", json!({"rows": "6:7"})).unwrap(); - s - } - - /// (hidden, collapsed) of 0-based rows. - fn row_state(s: &Session, rows: std::ops::RangeInclusive) -> Vec<(bool, bool)> { - let sh = s.doc().unwrap().wb.active().unwrap(); - rows.map(|r| sh.rows.get(&r).map(|l| (l.hidden, l.collapsed)).unwrap_or_default()).collect() - } - - #[test] - fn hide_detail_marks_the_summary_collapsed_and_show_detail_keeps_inner_groups_collapsed() { - let mut s = nested_rows(); - s.execute("data.hideDetail", json!({"rows": "6:7", "level": 2})).unwrap(); - assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]); - s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); - assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, true), (false, true)]); - s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap(); - assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]); - s.execute("data.showDetail", json!({"rows": "6:7", "level": 2})).unwrap(); - assert_eq!(row_state(&s, 4..=8), [(false, false); 5]); - // Each step is one undoable command. - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(row_state(&s, 5..=7), [(true, false), (true, false), (false, true)]); - } - - #[test] - fn hide_detail_on_the_summary_row_collapses_the_group_above_it() { - let mut s = nested_rows(); - s.execute("data.hideDetail", json!({"rows": "9:9"})).unwrap(); - assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, false), (false, true)]); - } - - #[test] - fn show_levels_collapses_and_expands_to_a_level() { - let mut s = nested_rows(); - s.execute("data.showLevels", json!({"rows": 1})).unwrap(); - assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, true), (false, true)]); - s.execute("data.showLevels", json!({"rows": 2})).unwrap(); - assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]); - s.execute("data.showLevels", json!({"rows": 3})).unwrap(); - assert_eq!(row_state(&s, 4..=8), [(false, false); 5]); - // Columns are separate; no level at all is an error rather than a guess. - s.execute("data.group", json!({"cols": "C:E"})).unwrap(); - s.execute("data.showLevels", json!({"cols": 1})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - assert!((2..=4).all(|c| sh.cols.get(&c).is_some_and(|l| l.hidden))); - assert!(sh.cols.get(&5).is_some_and(|l| l.collapsed)); - assert_eq!(row_state(&s, 4..=4), [(false, false)]); - assert!(s.execute("data.showLevels", json!({})).is_err()); - } - - #[test] - fn group_by_columns_for_a_block_of_cells() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("selection.set", json!({"range": "B2:C5"})).unwrap(); - s.execute("data.group", json!({"by": "cols"})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - assert!((1..=2).all(|c| sh.cols.get(&c).is_some_and(|l| l.outline == 1))); - assert!(sh.rows.values().all(|l| l.outline == 0)); - } - - #[test] - fn group_shortcuts_are_the_windows_ones() { - let sc = |id: &str| specs().into_iter().find(|c| c.id == id).and_then(|c| c.shortcut); - assert_eq!(sc("data.group"), Some("Alt+Shift+Right")); - 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::>(), [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::>(), [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::>(), [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(); - s.new_workbook(); - s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap(); - for row in [0u64, 1 << 32] { - let params = json!({"range": "A1:B2", "header": false, "orientation": "columns", "keys": [{"row": row}]}); - let err = s.execute("data.sort", params).unwrap_err(); - assert!(matches!(err, EngineError::BadParams { .. }), "row {row}: {err:?}"); - } - } - - #[test] - fn single_row_and_column_parameters() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap(); - s.execute("home.insertRows", json!({"rows": "2"})).unwrap(); - s.execute("home.insertColumns", json!({"cols": "B"})).unwrap(); - let v = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone(); - assert_eq!(v(&mut s, "C3"), json!(4.0)); - s.execute("home.hideRows", json!({"rows": "1"})).unwrap(); - s.execute("data.group", json!({"cols": "C"})).unwrap(); - assert!(s.execute("home.deleteRows", json!({"rows": "0"})).is_err()); - s.execute("home.deleteRows", json!({"rows": "2"})).unwrap(); - s.execute("home.deleteColumns", json!({"cols": "B"})).unwrap(); - assert_eq!(v(&mut s, "B2"), json!(4.0)); - } - - #[test] - fn t2c_fixed_widths_are_positions_in_any_order_without_duplicates() { - let cases: [(Json, [&str; 4]); 5] = [ - (json!([6, 3]), ["abc", "def", "ghij", ""]), - (json!([3, 3]), ["abc", "defghij", "", ""]), - (json!([0]), ["abcdefghij", "", "", ""]), - (json!([20]), ["abcdefghij", "", "", ""]), - (json!([7, 0, 3, 7, 10]), ["abc", "defg", "hij", ""]), - ]; - for (widths, want) in cases { - let mut s = Session::new(); - s.new_workbook(); - s.execute("cell.set", json!({"cell": "A1", "input": "abcdefghij"})).unwrap(); - s.execute("data.textToColumns", json!({"range": "A1:A1", "fixedWidths": widths})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - let got: Vec = (0..4).map(|c| sh.value(CellRef::new(0, c)).display()).collect(); - assert_eq!(got, want, "{widths}"); - } - } - - #[test] - fn split() { - assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]); - assert_eq!(split_delimited("a,b,,c", &[','], true, '"'), ["a", "b", "c"]); - assert_eq!(split_delimited("\"x,y\",z", &[','], false, '"'), ["x,y", "z"]); - } - - #[test] - fn remove_duplicates_without_a_valid_column_is_refused() { - // `columns: []` (or only unusable names) used to compare nothing and delete every row - // but the first. - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [["a"], ["b"], ["c"]]})).unwrap(); - for columns in [json!([]), json!(["ZZZ"])] { - let err = s.execute("data.removeDuplicates", json!({"range": "A1:A3", "columns": columns})).unwrap_err(); - assert!(err.to_string().contains("columns"), "{err}"); - } - let sh = s.doc().unwrap().wb.active().unwrap(); - assert_eq!(sh.value(CellRef::new(0, 0)), Value::text("a")); - assert_eq!(sh.value(CellRef::new(2, 0)), Value::text("c")); - } - - #[test] - fn a_list_typed_without_quotes_is_saved_as_a_quoted_list() { - // In a file an inline list is a string literal (`"a,b"`); unquoted it reads as a formula. - // References, names and formulas stay unquoted, and every rule survives a save and reopen. - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "K1:K3", "values": [["Red"], ["Green"], ["Blue"]]})).unwrap(); - s.execute("formulas.defineName", json!({"name": "Colors", "refersTo": "=Sheet1!$K$1:$K$3"})).unwrap(); - let rules = [ - ("A1", "Open,In progress,Closed", r#""Open,In progress,Closed""#), - ("B1", r#""Open,In progress,Closed""#, r#""Open,In progress,Closed""#), - ("C1", " 1,2,3 ", r#""1,2,3""#), - ("D1", r#"Say "hi",Bye"#, r#""Say ""hi"",Bye""#), - ("E1", "=$K$1:$K$3", "$K$1:$K$3"), - ("F1", "$K$1:$K$3", "$K$1:$K$3"), - ("G1", "=Colors", "Colors"), - ("H1", "Sheet1!$K$1:$K$3", "Sheet1!$K$1:$K$3"), - ("I1", "=OFFSET($K$1,0,0,3,1)", "OFFSET($K$1,0,0,3,1)"), - ]; - for (cell, source, _) in rules { - s.execute("data.validation", json!({"range": cell, "type": "list", "formula1": source})).unwrap(); - } - let accepted = [ - ("A1", "In progress"), - ("B1", "Closed"), - ("C1", "2"), - ("D1", r#"Say "hi""#), - ("E1", "Green"), - ("F1", "Blue"), - ("G1", "Red"), - ("H1", "Green"), - ]; - let check = |wb: &Workbook| { - for (cell, input) in accepted { - let at = CellRef::parse(cell).unwrap(); - assert!(check_validation(wb, 0, at, input).is_none(), "{cell} refuses {input}"); - assert!(check_validation(wb, 0, at, "Pending").is_some(), "{cell} accepts Pending"); - } - }; - let wb = s.doc().unwrap().wb.clone(); - check(&wb); - - let sheet_xml = |wb: &Workbook| { - let bytes = gridcraft_xlsx::write_xlsx(wb).unwrap(); - let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); - let mut xml = String::new(); - std::io::Read::read_to_string(&mut zip.by_name("xl/worksheets/sheet1.xml").unwrap(), &mut xml).unwrap(); - xml - }; - let xml = sheet_xml(&wb); - for (cell, _, saved) in rules { - assert!(xml.contains(&format!(r#"sqref="{cell}">{saved}"#)), "{cell}: {saved} missing from {xml}"); - } - let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap(); - check(&reopened); - assert_eq!(sheet_xml(&reopened), xml, "a second save writes the same rules"); - } - - #[test] - fn a_rule_with_an_unknown_type_operator_or_error_style_is_refused() { - // A typo used to give a rule that validates nothing (`type="none"`) or the wrong check, - // with no error. - let mut s = Session::new(); - s.new_workbook(); - for params in [ - json!({"range": "A1", "type": "lst", "formula1": "\"a,b\""}), - json!({"range": "A1", "type": "whole", "operator": "greaterthan", "formula1": "1"}), - json!({"range": "A1", "type": "whole", "operator": ">", "formula1": "1"}), - json!({"range": "A1", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warn"}), - ] { - let e = s.execute("data.validation", params.clone()).unwrap_err().to_string(); - assert!(e.contains("unknown"), "{params}: {e}"); - } - assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty()); - for params in [ - json!({"range": "A1", "type": "any"}), - json!({"range": "A2", "type": "whole", "operator": "greaterThan", "formula1": "1"}), - json!({"range": "A3", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warning"}), - ] { - s.execute("data.validation", params).unwrap(); - } - let sh = s.doc().unwrap().wb.active().unwrap().clone(); - assert_eq!(sh.validations.len(), 3); - assert!(check_validation(&s.doc().unwrap().wb, 0, CellRef::parse("A2").unwrap(), "0").is_some()); - } -} +mod tests; diff --git a/crates/engine/src/cmd/data/filter.rs b/crates/engine/src/cmd/data/filter.rs new file mode 100644 index 0000000000..71aa698f4a --- /dev/null +++ b/crates/engine/src/cmd/data/filter.rs @@ -0,0 +1,316 @@ +use super::sort::sort_key; +use super::*; + +// ---------------------------------------------------------------- filter + +pub(super) fn toggle_filter(s: &mut Session, p: &Json) -> Result { + let (sheet, r) = data_target(s, p, "data.filter")?; + crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?; + // The active cell belongs to the active sheet: on another sheet the table is the one at `range`. + let at = if sheet == s.doc()?.wb.active_sheet() { s.doc()?.selection.active } else { r.start }; + let has = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.autofilter.is_some()); + let in_table = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(at).map(|t| t.name.clone())); + if let Some(name) = in_table { + return edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + if let Some(t) = sh.tables.iter_mut().find(|t| t.name == name) { + t.filter_button = !t.filter_button; + if !t.filter_button { + t.filter.clear(); + } + } + Ok(Json::Null) + }); + } + if has { + return edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + // Reveal only rows the filter hid; rows hidden by hand or by a collapsed group stay hidden. + let filtered: Vec = sh.rows.keys().copied().filter(|r| sh.is_row_filtered(*r)).collect(); + sh.autofilter = None; + for r in filtered { + if let Some(info) = sh.rows.get_mut(&r) { + info.hidden = false; + } + } + cx.structural = true; + Ok(json!({"filter": false})) + }); + } + install_filter(s, p) +} + +/// Turn a sheet AutoFilter on over `p.range`, or over the data range of the selection. +fn install_filter(s: &mut Session, p: &Json) -> Result { + let (sheet, mut r) = data_target(s, p, "data.filter")?; + // A selected header row filters the data below it (#201). + if p.get("range").is_none() && r.height() == 1 { + let d = s.doc()?; + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let below = current_region(sh, r.start).end.row; + if below > r.end.row { + r = RangeRef::new(r.start, CellRef::new(below, r.end.col)); + } + } + edit(s, |cx| { + cx.sheet_mut(sheet)?.autofilter = Some(AutoFilter { range: r, criteria: vec![] }); + Ok(json!({"filter": true, "range": r.a1()})) + }) +} + +pub(super) fn filter_by(s: &mut Session, p: &Json) -> Result { + let sheet = s.doc()?.wb.active_sheet(); + crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?; + let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; + let af = match &sh.autofilter { + Some(a) => a.clone(), + None => { + install_filter(s, &json!({}))?; + s.doc()?.wb.sheet(sheet).and_then(|sh| sh.autofilter.clone()).ok_or_else(|| EngineError::Other("no filter".into()))? + } + }; + let col = match col_param(p.get("column"), af.range.start.col) { + Some(c) => c, + // A number too large to be a column is outside the filter range, not a missing parameter. + None if p.get("column").is_some_and(|v| v.as_u64().is_some()) => return Err(bad("data.filterBy", "column outside the filter range")), + None => return Err(bad("data.filterBy", "missing `column`")), + }; + if col < af.range.start.col || col > af.range.end.col { + return Err(bad("data.filterBy", "column outside the filter range")); + } + let off = col - af.range.start.col; + let crit = if bool_param(p, "clear").unwrap_or(false) { + None + } else if let Some(vals) = p.get("values").and_then(Json::as_array) { + Some(FilterCriterion::Values { + values: vals.iter().map(crate::cmd::edit::json_to_input).collect(), + blanks: bool_param(p, "blanks").unwrap_or(false), + }) + } else if let Some(c) = p.get("custom") { + let a = + (c.get("op").and_then(Json::as_str).unwrap_or("=").to_string(), c.get("value").map(crate::cmd::edit::json_to_input).unwrap_or_default()); + let b = + c.get("op2").and_then(Json::as_str).map(|o| (o.to_string(), c.get("value2").map(crate::cmd::edit::json_to_input).unwrap_or_default())); + Some(FilterCriterion::Custom { a, b, and: c.get("and").and_then(Json::as_bool).unwrap_or(true) }) + } else if let Some(t) = p.get("top") { + Some(FilterCriterion::Top10 { + bottom: t.get("bottom").and_then(Json::as_bool).unwrap_or(false), + percent: t.get("percent").and_then(Json::as_bool).unwrap_or(false), + count: t.get("count").and_then(Json::as_u64).unwrap_or(10) as u32, + }) + } else { + bool_param(p, "aboveAverage").map(FilterCriterion::AboveAverage) + }; + edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + if let Some(a) = sh.autofilter.as_mut() { + a.criteria.retain(|(o, _)| *o != off); + if let Some(c) = crit.clone() { + a.criteria.push((off, c)); + } + } + apply_filter(&mut cx.wb, sheet); + cx.structural = true; + Ok(Json::Null) + })?; + let hidden = s.doc()?.wb.sheet(sheet).map(|sh| sh.rows.values().filter(|r| r.hidden).count()).unwrap_or(0); + Ok(json!({"hiddenRows": hidden})) +} + +/// Does a value pass a custom comparison (`>`, `=*abc*`, `<>x`…)? +fn custom_ok(v: &Value, text: &str, op: &str, crit: &str) -> bool { + let num = gridcraft_core::parse::parse_number_text(crit); + let cmp = match (v.as_f64(), num) { + (Some(a), Some(b)) => a.partial_cmp(&b), + // A numeric ordering comparison only matches numbers; blanks and text never do. + (None, Some(_)) if matches!(op, "<" | "<=" | ">" | ">=") => None, + _ => Some(gridcraft_core::compare_text(text, crit)), + }; + let wild = || crate::cmd::edit::wildcard(&text.to_lowercase(), &crit.to_lowercase()); + match op { + "=" => { + if crit.contains(['*', '?']) { + wild() + } else { + cmp == Some(Ordering::Equal) + } + } + "<>" => { + if crit.contains(['*', '?']) { + !wild() + } else { + cmp != Some(Ordering::Equal) + } + } + ">" => cmp == Some(Ordering::Greater), + "<" => cmp == Some(Ordering::Less), + ">=" => matches!(cmp, Some(Ordering::Greater | Ordering::Equal)), + "<=" => matches!(cmp, Some(Ordering::Less | Ordering::Equal)), + "beginsWith" => text.to_lowercase().starts_with(&crit.to_lowercase()), + "endsWith" => text.to_lowercase().ends_with(&crit.to_lowercase()), + "contains" => text.to_lowercase().contains(&crit.to_lowercase()), + "notContains" => !text.to_lowercase().contains(&crit.to_lowercase()), + _ => true, + } +} + +/// The filter's "(Blanks)" group: truly empty cells and zero-length text such as a `=""` result. +fn filter_blank(v: &Value) -> bool { + v.is_empty() || matches!(v, Value::Text(t) if t.is_empty()) +} + +/// Excel grows an AutoFilter range when a value is entered in the row right below it, in one of its columns (#2342). +/// A table's own filter is left to the table, and a row typed after a blank row does not count. +/// A formula counts before it is calculated: this runs inside the edit, ahead of the recalc. +pub(crate) fn grow_autofilter(sh: &mut Sheet, at: CellRef) { + if !sh.cells.get(at).is_some_and(|c| !filter_blank(&c.value) || c.formula.is_some()) { + return; + } + let Some(af) = sh.autofilter.as_ref() else { return }; + let r = af.range; + if r.end.row.checked_add(1) != Some(at.row) || at.col < r.start.col || at.col > r.end.col || sh.tables.iter().any(|t| t.range == r) { + return; + } + let grown = RangeRef::new(r.start, CellRef::new(at.row, r.end.col)); + if let Some(af) = sh.autofilter.as_mut() { + af.range = grown; + } +} + +/// Hides rows of the autofilter range that fail any criterion. +pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { + let Some(sh) = wb.sheet(sheet) else { return }; + let Some(af) = sh.autofilter.clone() else { return }; + let r = af.range; + let mut hide = Vec::new(); + // Precompute per-criterion top10 threshold and average once, not per row. + let mut col_stats: std::collections::HashMap, f64)> = std::collections::HashMap::new(); + for (i, (off, c)) in af.criteria.iter().enumerate() { + if matches!(c, FilterCriterion::Top10 { .. } | FilterCriterion::AboveAverage(_)) { + let col = r.start.col + off; + let mut nums: Vec = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect(); + let avg = if nums.is_empty() { 0.0 } else { nums.iter().sum::() / nums.len() as f64 }; + let thr = match c { + FilterCriterion::Top10 { bottom, percent, count } => { + nums.sort_by(|a, b| if *bottom { a.partial_cmp(b) } else { b.partial_cmp(a) }.unwrap_or(Ordering::Equal)); + let k = if *percent { ((nums.len() as f64) * (*count as f64) / 100.0).ceil() as usize } else { *count as usize }; + let k = k.clamp(1, nums.len().max(1)); + nums.get(k - 1).copied() + } + _ => None, + }; + col_stats.insert(i, (thr, avg)); + } + } + for row in r.start.row + 1..=r.end.row { + let mut visible = true; + for (i, (off, crit)) in af.criteria.iter().enumerate() { + let c = CellRef::new(row, r.start.col + off); + let v = sh.value(c); + let text = crate::display::cell_text(wb, sh, c); + let ok = match crit { + FilterCriterion::Values { values, blanks } => { + if filter_blank(&v) { + *blanks + } else { + values.iter().any(|x| x.eq_ignore_ascii_case(&text) || x.eq_ignore_ascii_case(&v.display())) + } + } + FilterCriterion::Custom { a, b, and } => { + let x = custom_ok(&v, &text, &a.0, &a.1); + match b { + Some(b) => { + let y = custom_ok(&v, &text, &b.0, &b.1); + if *and { x && y } else { x || y } + } + None => x, + } + } + FilterCriterion::Top10 { bottom, .. } => match (v.as_f64(), col_stats.get(&i)) { + (Some(n), Some((thr, _))) => { + let thr = thr.unwrap_or(n); + if *bottom { n <= thr } else { n >= thr } + } + _ => false, + }, + FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(&i)) { + (Some(n), Some((_, avg))) => { + if *above { + n > *avg + } else { + n < *avg + } + } + _ => false, + }, + FilterCriterion::FillColor(col) => wb.styles.get(sh.style_id(c)).fill.fg == *col, + FilterCriterion::FontColor(col) => wb.styles.get(sh.style_id(c)).font.color == *col, + // Icons are not evaluated here: the row keeps its current visibility. + FilterCriterion::Icon { .. } => !sh.is_row_hidden(row), + }; + if !ok { + visible = false; + break; + } + } + hide.push((row, !visible)); + } + let Some(shm) = wb.sheet_mut(sheet) else { return }; + for (row, h) in hide { + if h { + shm.rows.entry(row).or_default().hidden = true; + } else if let Some(e) = shm.rows.get_mut(&row) { + e.hidden = false; + } + } +} + +pub(super) fn clear_filter(s: &mut Session, _: &Json) -> Result { + let sheet = s.doc()?.wb.active_sheet(); + edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + if let Some(a) = sh.autofilter.as_mut() { + a.criteria.clear(); + } + apply_filter(&mut cx.wb, sheet); + cx.structural = true; + Ok(Json::Null) + }) +} + +pub(super) fn reapply(s: &mut Session, _: &Json) -> Result { + let sheet = s.doc()?.wb.active_sheet(); + edit(s, |cx| { + apply_filter(&mut cx.wb, sheet); + cx.structural = true; + Ok(Json::Null) + }) +} + +/// Distinct display values of a filter column (for the dropdown list). +pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool)> { + let Some(sh) = wb.sheet(sheet) else { return vec![] }; + let Some(af) = &sh.autofilter else { return vec![] }; + let off = col.saturating_sub(af.range.start.col); + let selected: Option<&FilterCriterion> = af.criteria.iter().find(|(o, _)| *o == off).map(|(_, c)| c); + let mut seen = std::collections::BTreeMap::new(); + for row in af.range.start.row + 1..=af.range.end.row.min(af.range.start.row + 1_000_000) { + let c = CellRef::new(row, col); + let v = sh.value(c); + let blank = filter_blank(&v); + let t = if blank { "(Blanks)".to_string() } else { crate::display::cell_text(wb, sh, c) }; + let on = match selected { + Some(FilterCriterion::Values { values, blanks }) => { + if blank { + *blanks + } else { + values.iter().any(|x| x.eq_ignore_ascii_case(&t)) + } + } + _ => true, + }; + let key = if blank { sort_key(&Value::Empty) } else { sort_key(&v) }; + seen.entry((key, t)).or_insert(on); + } + seen.into_iter().map(|((_, t), on)| (t, on)).collect() +} diff --git a/crates/engine/src/cmd/data/grouping.rs b/crates/engine/src/cmd/data/grouping.rs new file mode 100644 index 0000000000..7b5a2254c9 --- /dev/null +++ b/crates/engine/src/cmd/data/grouping.rs @@ -0,0 +1,267 @@ +use super::*; + +// ---------------------------------------------------------------- outline + +fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> { + if let Some(r) = str_param(p, "rows").and_then(parse_lines) { + return Ok((true, r.start.row, r.end.row)); + } + if let Some(r) = str_param(p, "cols").and_then(parse_lines) { + return Ok((false, r.start.col, r.end.col)); + } + let r = s.doc()?.selection.current(); + match str_param(p, "by") { + Some("rows") => Ok((true, r.start.row, r.end.row)), + Some("cols" | "columns") => Ok((false, r.start.col, r.end.col)), + _ if r.is_full_cols() && !r.is_full_rows() => Ok((false, r.start.col, r.end.col)), + _ => Ok((true, r.start.row, r.end.row)), + } +} + +pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result { + let si = target_sheet(s, p)?; + // The selection belongs to the active sheet, so another sheet needs its lines. + if si != s.doc()?.wb.active_sheet() && p.get("rows").is_none() && p.get("cols").is_none() { + return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet")); + } + let (rows, a, b) = outline_lines(s, p)?; + 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 { + let e = map.entry(i).or_default(); + e.outline = if on { (e.outline + 1).min(outline::MAX_LEVEL) } else { e.outline.saturating_sub(1) }; + if !on && e.outline == 0 { + e.hidden = false; + } + } + // 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_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; + } + cx.structural = true; // ungrouping reveals hidden rows: SUBTOTAL(10x) depends on it + Ok(Json::Null) + }) +} + +/// Hide Detail / Show Detail: the innermost group (or the one at `level`) that holds the lines, or +/// failing that the one they are the summary of. Hiding marks the summary line collapsed (the +/// + button, saved to XLSX); showing keeps inner groups that were collapsed on their own collapsed. +pub(super) fn detail(s: &mut Session, p: &Json, hide: bool) -> Result { + let (rows, a, b) = outline_lines(s, p)?; + let want = p.get("level").and_then(Json::as_u64).map(|l| l.min(outline::MAX_LEVEL as u64) as u8); + edit(s, |cx| { + let si = cx.sheet_index(); + 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_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 { + let hits: Vec<_> = all.iter().filter(|g| level_ok(g) && hit(g)).copied().collect(); + let deepest = hits.iter().map(|g| g.level).max().unwrap_or(0); + hits.into_iter().filter(|g| g.level == deepest).collect() + }; + let mut chosen = pick(&|g| g.start <= b && g.end >= a); + if chosen.is_empty() { + 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 = all + .iter() + .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(); + for i in g.start..=g.end { + if let Some(e) = map.get_mut(&i) { + 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()) { + e.collapsed = false; + } + } + cx.structural = true; + Ok(Json::Null) + }) +} + +/// Hides or shows an outline line. A line shown with a zero size (files that hide columns as +/// `width="0" hidden="1"`) gets the default size back, as Unhide does; otherwise + would mark the +/// group expanded and leave it invisible (#1714). +fn set_hidden(e: &mut LineInfo, hidden: bool) { + e.hidden = hidden; + if !hidden && e.size == Some(0.0) { + e.size = None; + } +} + +/// The outline's level buttons: show rows / columns down to outline level `n` (1 = only the +/// top-level summaries); deeper detail is hidden and its summary lines marked collapsed. +pub(super) fn show_levels(s: &mut Session, p: &Json) -> Result { + let level = |k: &str| p.get(k).and_then(Json::as_u64).map(|n| n.clamp(1, outline::MAX_LEVEL as u64 + 1) as u8); + let (rows, cols) = (level("rows"), level("cols")); + if rows.is_none() && cols.is_none() { + return Err(EngineError::BadParams { + cmd: "data.showLevels".into(), + msg: "give `rows` or `cols`: the outline level to show (1 = top-level summaries only)".into(), + }); + } + edit(s, |cx| { + let si = cx.sheet_index(); + let sh = cx.sheet_mut(si)?; + 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_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.into_iter().filter(|g| g.has_summary()) { + map.entry(g.summary()).or_default().collapsed = g.level >= n; + } + } + cx.structural = true; + Ok(Json::Null) + }) +} + +/// 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. +/// Moves the collapsed marks of a line axis to the summary line on its new side (`on` is the new +/// setting, `was_before` the old one). A line that nested groups share keeps its mark on the outermost +/// group whose detail is all hidden, or the outermost when none is. +fn move_marks(map: &mut std::collections::BTreeMap, was_before: bool, on: bool) { + let mut marked: Vec = + 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 = 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; + } + } +} + +pub(super) fn outline_settings(s: &mut Session, p: &Json) -> Result { + 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; + } + move_marks(if rows { &mut sh.rows } else { &mut sh.cols }, was_before, on); + } + Ok(json!({"summaryBelow": sh.summary_below, "summaryRight": sh.summary_right})) + }) +} + +pub(super) fn subtotal(s: &mut Session, p: &Json) -> Result { + let (sheet, r) = data_target(s, p, "data.subtotal")?; + let header = bool_param(p, "header").unwrap_or(true); + let group_col = col_param(p.get("groupBy"), r.start.col).unwrap_or(r.start.col); + let func = str_param(p, "function").unwrap_or("sum"); + let code = match func { + "count" => 3, + "average" => 1, + "max" => 4, + "min" => 5, + "product" => 6, + _ => 9, + }; + let cols: Vec = p + .get("columns") + .and_then(Json::as_array) + .map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect()) + .unwrap_or_else(|| vec![r.end.col]); + let label = match func { + "count" => "Count", + "average" => "Average", + "max" => "Max", + "min" => "Min", + "product" => "Product", + _ => "Total", + }; + // Group boundaries (header in the first row). + let d = s.doc()?; + let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + let mut groups: Vec<(u32, u32, String)> = Vec::new(); + let first = r.start.row + u32::from(header); + let mut start = first; + for row in first..=r.end.row + 1 { + let cur = sh.value(CellRef::new(row, group_col)).display(); + let prev = sh.value(CellRef::new(start, group_col)).display(); + if row > r.end.row || cur != prev { + groups.push((start, row - 1, prev)); + 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() { + s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", g1 + 2, g1 + 2)}))?; + let row = g1 + 1; + let lbl = CellRef::new(row, group_col).a1(); + s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("{name} {label}")}))?; + for c in &cols { + let range = RangeRef::new(CellRef::new(*g0, *c), CellRef::new(*g1, *c)).a1(); + s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(row, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?; + } + s.execute( + "home.bold", + json!({"sheet": sheet, "range": RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)).a1(), "on": true}), + )?; + s.execute("data.group", json!({"sheet": sheet, "rows": format!("{}:{}", g0 + 1, g1 + 1)}))?; + inserted += 1; + } + let last = r.end.row + inserted + 1; + let lbl = CellRef::new(last, group_col).a1(); + s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", last + 1, last + 1)}))?; + s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("Grand {label}")}))?; + for c in &cols { + let range = RangeRef::new(CellRef::new(first, *c), CellRef::new(last - 1, *c)).a1(); + s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(last, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?; + } + Ok(json!({"groups": inserted})) +} diff --git a/crates/engine/src/cmd/data/sort.rs b/crates/engine/src/cmd/data/sort.rs new file mode 100644 index 0000000000..919de1734a --- /dev/null +++ b/crates/engine/src/cmd/data/sort.rs @@ -0,0 +1,322 @@ +use super::*; + +#[derive(Clone)] +struct SortKey { + col: u32, + desc: bool, + by: String, + list: Option>, +} + +/// The sheet and range a sort works on: the sheet `sheet` or a `Sheet!` prefix on `range` names, +/// else the active one. The selection belongs to the active sheet, so another sheet needs `range`. +fn sort_target(s: &Session, p: &Json) -> Result<(usize, RangeRef)> { + let sheet = target_sheet(s, p)?; + let d = s.doc()?; + if let Some(t) = str_param(p, "range") + && let (Some(name), _) = split_sheet(t) + && d.wb.sheet_index(&name) != Some(sheet) + { + return Err(bad("data.sort", format!("`{t}` names a sheet other than `sheet`"))); + } + if sheet != d.wb.active_sheet() && p.get("range").is_none() { + return Err(bad("data.sort", "missing `range`: the selection is on the active sheet")); + } + Ok((sheet, data_range(s, p)?)) +} + +pub(super) fn quick_sort(s: &mut Session, p: &Json, asc: bool) -> Result { + let (sheet, r) = sort_target(s, p)?; + let col = col_param(p.get("column"), r.start.col).unwrap_or(s.doc()?.selection.active.col).clamp(r.start.col, r.end.col); + let d = s.doc()?; + let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + let header = guess_header(&d.wb, sh, r); + do_sort(s, sheet, r, header, vec![SortKey { col, desc: !asc, by: "values".into(), list: None }], false, false) +} + +pub(super) fn sort(s: &mut Session, p: &Json) -> Result { + let (sheet, r) = sort_target(s, p)?; + let d = s.doc()?; + let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r)); + let by_cols = str_param(p, "orientation") == Some("columns"); + let case = bool_param(p, "matchCase").unwrap_or(false); + let mut keys: Vec = Vec::new(); + for k in p.get("keys").and_then(Json::as_array).map(Vec::as_slice).unwrap_or_default() { + let col = if by_cols { + let row = match k.get("row").and_then(Json::as_u64) { + // Worksheet rows are 1-based; 0 (or a value past u32) has no coordinate. + Some(n) => { + let row = u32::try_from(n).ok().and_then(|n| n.checked_sub(1)); + Some(row.ok_or_else(|| bad("data.sort", "sort key row must be 1 or greater"))?) + } + None => None, + }; + row.or_else(|| col_param(k.get("column"), r.start.row)) + } else { + col_param(k.get("column"), r.start.col) + }; + let Some(col) = col else { continue }; + keys.push(SortKey { + col, + desc: k.get("order").and_then(Json::as_str) == Some("desc"), + by: k.get("by").and_then(Json::as_str).unwrap_or("values").to_string(), + list: k.get("customList").and_then(Json::as_array).map(|l| l.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()), + }); + } + if keys.is_empty() { + s.ui_requests.push(crate::UiRequest::Dialog("sort".into(), json!({"range": r.a1(), "header": header}))); + return ok(); + } + if keys.len() > 64 { + return Err(bad("data.sort", "too many sort levels (64 max)")); + } + do_sort(s, sheet, r, header, keys, by_cols, case) +} + +fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec, by_cols: bool, case: bool) -> Result { + // Protect Sheet ▸ Sort. Both Sort… and the A-to-Z buttons come through here. + crate::cmd::refuse_unless_allowed(s, sheet, "Sorting", |p| p.sort)?; + let body = if by_cols { + if header && r.width() > 1 { RangeRef::new(CellRef::new(r.start.row, r.start.col + 1), r.end) } else { r } + } else if header && r.height() > 1 { + RangeRef::new(CellRef::new(r.start.row + 1, r.start.col), r.end) + } else { + r + }; + if body.count() > 20_000_000 { + return Err(bad("data.sort", "range too large")); + } + edit(s, |cx| { + let Some(sh) = cx.wb.sheet(sheet) else { return Ok(Json::Null) }; + if sh.merges.iter().any(|m| m.intersects(&body) && !m.is_single()) { + return Err(EngineError::Other("To do this, all the merged cells need to be the same size.".into())); + } + let n = if by_cols { body.width() } else { body.height() }; + let line = |i: u32, k: u32| if by_cols { CellRef::new(k, body.start.col + i) } else { CellRef::new(body.start.row + i, k) }; + let theme = cx.wb.theme.clone(); + let styles = &cx.wb.styles; + let key_val = |i: u32, k: &SortKey| -> (Value, u32) { + let c = line(i, k.col); + let v = sh.value(c); + let color = match k.by.as_str() { + "cellColor" => { + styles.get(sh.style_id(c)).fill.fg.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX) + } + "fontColor" => { + styles.get(sh.style_id(c)).font.color.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX) + } + _ => 0, + }; + (v, color) + }; + let mut order: Vec = (0..n).collect(); + let keyed: Vec> = (0..n).map(|i| keys.iter().map(|k| key_val(i, k)).collect()).collect(); + order.sort_by(|&a, &b| { + for (ki, k) in keys.iter().enumerate() { + let (va, ca) = &keyed[a as usize][ki]; + let (vb, cb) = &keyed[b as usize][ki]; + let o = if k.by != "values" { + ca.cmp(cb) + } else if let Some(list) = &k.list { + let pos = |v: &Value| v.as_text().and_then(|t| list.iter().position(|x| x.eq_ignore_ascii_case(t))).unwrap_or(usize::MAX); + pos(va).cmp(&pos(vb)) + } else if case && va.is_text() && vb.is_text() { + sort_compare(va, vb).then_with(|| case_order(va.as_text().unwrap_or(""), vb.as_text().unwrap_or(""))) + } else { + sort_compare(va, vb) + }; + // Blanks always sort last, whatever the order. + let o = if k.desc && !(va.is_empty() || vb.is_empty()) { o.reverse() } else { o }; + if o != Ordering::Equal { + return o; + } + } + Ordering::Equal + }); + if order.iter().enumerate().all(|(i, &o)| i as u32 == o) { + return Ok(Json::Null); + } + // Move whole lines (cells with formulas adjusted for relative references). + let lanes: Vec = if by_cols { (body.start.row..=body.end.row).collect() } else { (body.start.col..=body.end.col).collect() }; + // Notes and hyperlinks hang off a cell, not a row, so they travel with the row they + // describe — the way cell pictures and row heights do. Collect them before inserting, + // so a swap can't resurrect one from a destination that is also a source. + let side: Vec<(CellRef, Comment)> = order + .iter() + .enumerate() + .flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k))) + .filter_map(|(src_i, dst_i, k)| sh.comments.get(&line(src_i, k)).map(|cm| (line(dst_i, k), cm.clone()))) + .collect(); + let links: Vec<(CellRef, Hyperlink)> = order + .iter() + .enumerate() + .flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k))) + .filter_map(|(src_i, dst_i, k)| sh.hyperlinks.get(&line(src_i, k)).map(|h| (line(dst_i, k), h.clone()))) + .collect(); + let mut moved: Vec<(CellRef, Option)> = Vec::with_capacity(body.count() as usize); + let mut pictures = Vec::new(); + for (dst_i, &src_i) in order.iter().enumerate() { + for &k in &lanes { + let src = line(src_i, k); + let dst = line(dst_i as u32, k); + let mut cell = sh.cell(src).cloned(); + if let Some(c) = cell.as_mut() + && let Some(f) = &c.formula + && let Some(e) = f.expr() + { + let shifted = gridcraft_formula::adjust::shift_relative(e, dst.row as i64 - src.row as i64, dst.col as i64 - src.col as i64); + c.formula = Some(Arc::new(Formula::from_expr(shifted))); + } + if let Some(picture) = sh.cell_pictures.get(&src) { + pictures.push((dst, picture.clone())); + } + moved.push((dst, cell)); + } + } + // Row heights travel with rows. + let heights: Vec> = + if by_cols { vec![] } else { order.iter().map(|&i| sh.rows.get(&(body.start.row + i)).copied()).collect() }; + // Floating objects anchored inside the sorted body travel with their lines, unless they + // are pinned absolutely ("Don't move or size with cells", `editAs="absolute"`). Reordering + // is the inverse of `order`: `dst_of[src] = dst`. + let dst_of: Vec = { + let mut v = vec![0u32; order.len()]; + for (dst_i, &src_i) in order.iter().enumerate() { + if let Some(slot) = v.get_mut(src_i as usize) { + *slot = dst_i as u32; + } + } + v + }; + // The object's own geometry (its top-left anchor cell, offset and size) is read from the + // pre-sort `sh`; a two-cell object's bottom-right cell is derived from its size here so it + // can be re-fitted to the lines it spans after the sort. + let objs: Vec = + sh.images.iter().map(|o| o.anchor).chain(sh.charts.iter().map(|o| o.anchor)).chain(sh.shapes.iter().map(|o| o.anchor)).collect(); + let descs: Vec<(u32, u32, f32, f32)> = objs + .iter() + .map(|a| { + let x2 = sh.col_left(a.cell.col) + a.dx as f64 + a.width.max(0.0) as f64; + let y2 = sh.row_top(a.cell.row) + a.dy as f64 + a.height.max(0.0) as f64; + let tc = sh.col_at(x2); + let tr = sh.row_at(y2); + (tc, tr, (x2 - sh.col_left(tc)) as f32, (y2 - sh.row_top(tr)) as f32) + }) + .collect(); + let shm = cx.sheet_mut(sheet)?; + for (c, cell) in &moved { + match cell { + Some(x) => shm.set_cell(*c, x.clone()), + None => { + shm.remove_cell(*c); + } + } + } + // Every cell of the body is the destination of exactly one source cell, so clearing the + // body and inserting the permuted entries is the same permutation the cells went through. + for c in body.iter() { + shm.comments.remove(&c); + shm.hyperlinks.remove(&c); + } + for (at, cm) in side { + shm.comments.insert(at, cm); + } + for (at, h) in links { + shm.hyperlinks.insert(at, h); + } + shm.cell_pictures.extend(pictures); + for (i, h) in heights.into_iter().enumerate() { + let row = body.start.row + i as u32; + match h { + Some(info) => { + shm.rows.insert(row, LineInfo { hidden: shm.rows.get(&row).is_some_and(|x| x.hidden), ..info }); + } + None => { + if let Some(e) = shm.rows.get_mut(&row) { + e.size = None; + } + } + } + } + // Re-anchor floating objects now that the row heights are final. `objs` was collected as + // images ++ charts ++ shapes, so the same index maps straight onto each vec. + // Only objects anchored inside the sorted block move: within its lines on the sort axis + // and within its span on the other axis (a picture beside the block stays put). + let (b0, b1) = if by_cols { (body.start.col, body.end.col) } else { (body.start.row, body.end.row) }; + let (o0, o1) = if by_cols { (body.start.row, body.end.row) } else { (body.start.col, body.end.col) }; + let ni = shm.images.len(); + let nch = shm.charts.len(); + for (i, (a, desc)) in objs.into_iter().zip(descs).enumerate() { + let other = if by_cols { a.cell.row } else { a.cell.col }; + if other < o0 || other > o1 { + continue; + } + let na = sorted_anchor(shm, a, desc, by_cols, b0, b1, &dst_of); + if na != a { + if i < ni { + if let Some(o) = shm.images.get_mut(i) { + o.anchor = na; + } + } else if i < ni + nch { + if let Some(o) = shm.charts.get_mut(i - ni) { + o.anchor = na; + } + } else if let Some(o) = shm.shapes.get_mut(i - ni - nch) { + o.anchor = na; + } + } + } + for (c, _) in moved { + cx.changed.push((sheet, c)); + } + cx.structural = true; + Ok(Json::Null) + })?; + Ok(json!({"sorted": body.a1()})) +} + +/// Re-anchor one floating object for a sort. `bottom` is the object's bottom-right line and the +/// offset of that corner inside it, measured before the sort. `map` (via `dst_of`) translates a +/// line index on the moving axis and is the identity outside `b0..=b1`. `sh` is the post-sort +/// grid (row heights already reordered), so re-fitting uses the heights the object lands on. +/// Objects pinned absolutely ("Don't move or size with cells") never move; "move but don't size" +/// keeps its size; "move and size" is re-fitted when its whole extent stays inside the body. +fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: bool, b0: u32, b1: u32, dst_of: &[u32]) -> Anchor { + if a.mode == AnchorMode::Absolute { + return a; + } + let (tc, tr, tdx, tdy) = bottom; + let map = |v: u32| -> u32 { if v >= b0 && v <= b1 { dst_of.get((v - b0) as usize).map_or(v, |&d| b0 + d) } else { v } }; + let (axis_start, axis_end) = if by_cols { (a.cell.col, tc) } else { (a.cell.row, tr) }; + if axis_start < b0 || axis_start > b1 { + return a; + } + let (nr, nc) = if by_cols { (a.cell.row, map(a.cell.col)) } else { (map(a.cell.row), a.cell.col) }; + if a.mode == AnchorMode::MoveAndSize && axis_end >= b0 && axis_end <= b1 { + let (nbr, nbc) = if by_cols { (tr, map(tc)) } else { (map(tr), tc) }; + let x1 = sh.col_left(nc) + a.dx as f64; + let y1 = sh.row_top(nr) + a.dy as f64; + let x2 = sh.col_left(nbc) + tdx as f64; + let y2 = sh.row_top(nbr) + tdy as f64; + Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: (x2 - x1).max(0.0) as f32, height: (y2 - y1).max(0.0) as f32, mode: a.mode } + } else { + Anchor { cell: CellRef::new(nr, nc), ..a } + } +} + +/// Case-sensitive tie-break for text that is equal ignoring case: as in Excel, lowercase sorts +/// before uppercase at the first letter that differs in case. +fn case_order(a: &str, b: &str) -> Ordering { + a.chars().zip(b.chars()).find(|(x, y)| x != y).map_or(Ordering::Equal, |(x, y)| y.is_lowercase().cmp(&x.is_lowercase())) +} + +pub(super) fn sort_key(v: &Value) -> (u8, i64, String) { + match v { + Value::Number(n) => (0, (n * 1e6) as i64, String::new()), + Value::Text(t) => (1, 0, t.to_lowercase()), + Value::Bool(b) => (2, *b as i64, String::new()), + Value::Empty => (9, 0, String::new()), + _ => (3, 0, v.display()), + } +} diff --git a/crates/engine/src/cmd/data/tests.rs b/crates/engine/src/cmd/data/tests.rs new file mode 100644 index 0000000000..5b848e5c09 --- /dev/null +++ b/crates/engine/src/cmd/data/tests.rs @@ -0,0 +1,509 @@ +use super::*; + +#[test] +fn filter_blanks_include_empty_text_and_formula_results() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["id", "f"], ["e", null], ["ef", "=\"\""], ["sp", " "], ["z", 0]]})).unwrap(); + s.execute("data.filter", json!({"range": "A1:B5"})).unwrap(); + let r = s.execute("data.filterBy", json!({"column": "B", "values": [], "blanks": true})).unwrap(); + assert_eq!(r["hiddenRows"], json!(2)); + let vals = filter_values(&s.doc().unwrap().wb, 0, 1); + // The `=""` result joins (Blanks) instead of showing as its own empty entry. + assert!(!vals.iter().any(|(t, _)| t.is_empty()), "{vals:?}"); + let blanks: Vec<_> = vals.into_iter().filter(|(t, _)| t == "(Blanks)").collect(); + assert_eq!(blanks, vec![("(Blanks)".to_string(), true)]); +} + +#[test] +fn subtotal_honours_header_false() { + // #131: with header:false the first data row was treated as a header and left out of its + // group's subtotal and the grand total. + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["East", 10], ["East", 20], ["West", 30], ["West", 40]]})).unwrap(); + s.execute("data.subtotal", json!({"range": "A1:B4", "groupBy": "A", "function": "sum", "columns": ["B"], "header": false})).unwrap(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + assert_eq!(get(&mut s, "A1")["value"], json!("East")); + assert_eq!(get(&mut s, "A3")["text"], json!("East Total")); + assert_eq!(get(&mut s, "B3")["value"], json!(30.0), "East's subtotal counts both East rows"); + assert_eq!(get(&mut s, "A7")["text"], json!("Grand Total")); + 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(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "B1", "values": [["Name", "Qty"], ["a", 1], ["b", 2]]})).unwrap(); + s.execute("data.filter", json!({"range": "B1:C3"})).unwrap(); + for col in [json!(2), json!(u64::from(u32::MAX)), json!(u64::MAX)] { + let err = s.execute("data.filterBy", json!({"column": col, "values": ["a"]})).unwrap_err(); + assert!(err.to_string().contains("column outside the filter range"), "{col}: {err}"); + } + let ok = s.execute("data.filterBy", json!({"column": 0, "values": ["a"]})).unwrap(); + assert_eq!(ok["hiddenRows"], json!(1)); +} + +#[test] +fn filter_by_inside_a_table_installs_the_autofilter_and_keeps_the_button() { + for sel in [json!({"cell": "A1"}), json!({"cell": "A2"}), json!({"range": "A1:A3"})] { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"]]})).unwrap(); + s.execute("insert.table", json!({"range": "A1:A3", "header": true})).unwrap(); + s.execute("selection.set", sel.clone()).unwrap(); + s.execute("data.filterBy", json!({"column": "A", "values": ["a"]})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.autofilter.as_ref().map(|a| a.range.a1()), Some("A1:A3".to_string()), "{sel}"); + assert!(sh.tables[0].filter_button, "{sel}"); + assert!(!sh.is_row_hidden(1), "{sel}"); + assert!(sh.is_row_hidden(2), "{sel}"); + assert_eq!(sh.value(CellRef::new(2, 0)).display(), "b", "{sel}"); + } +} + +#[test] +fn autofilter_range_grows_when_a_value_is_entered_in_the_row_below() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["d", 4], ["b", 2]]})).unwrap(); + s.execute("data.filter", json!({"range": "A1:B3"})).unwrap(); + let range = |s: &Session| s.doc().unwrap().wb.active().unwrap().autofilter.as_ref().map(|a| a.range.a1()); + s.execute("cell.set", json!({"cell": "A4", "input": "e"})).unwrap(); + assert_eq!(range(&s), Some("A1:B4".to_string())); + s.execute("range.setValues", json!({"range": "A5", "values": [["f", 6]]})).unwrap(); + assert_eq!(range(&s), Some("A1:B5".to_string())); + // A row typed after a blank row does not grow it. + s.execute("cell.set", json!({"cell": "A7", "input": "x"})).unwrap(); + assert_eq!(range(&s), Some("A1:B5".to_string())); + // A cell outside the filter's columns does not either. + s.execute("cell.set", json!({"cell": "C6", "input": "x"})).unwrap(); + assert_eq!(range(&s), Some("A1:B5".to_string())); + // Empty text is a blank entry, not a value. + s.execute("range.setValues", json!({"range": "A6", "parse": false, "values": [[""]]})).unwrap(); + assert_eq!(range(&s), Some("A1:B5".to_string())); + // A formula and Ctrl+Enter (range.fill) are entries too. + s.execute("cell.set", json!({"cell": "B6", "input": "=1+1"})).unwrap(); + assert_eq!(range(&s), Some("A1:B6".to_string())); + s.execute("range.fill", json!({"range": "A7:B7", "input": "g"})).unwrap(); + assert_eq!(range(&s), Some("A1:B7".to_string())); +} + +#[test] +fn list_validation_without_formula1_is_an_error() { + let mut s = Session::new(); + s.new_workbook(); + assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "source": "\"a,b,c\""})).is_err()); + assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list"})).is_err()); + assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty()); + s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "formula1": "\"a,b,c\""})).unwrap(); + assert_eq!(s.execute("data.validate", json!({"cell": "A1", "input": "a"})).unwrap()["ok"], json!(true)); +} + +#[test] +fn partial_validation_edits_keep_the_rest_of_the_old_rule() { + let mut s = Session::new(); + s.new_workbook(); + let rule = |range: &str, lo: &str, hi: &str| json!({"range": range, "type": "whole", "operator": "between", "formula1": lo, "formula2": hi}); + let ok = + |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == json!(true); + s.execute("data.validation", rule("B1:B3", "1", "10")).unwrap(); + s.execute("data.validation", json!({"range": "B2", "clear": true})).unwrap(); + assert!(ok(&mut s, "B2", "999")); + assert!(!ok(&mut s, "B1", "999")); + assert!(!ok(&mut s, "B3", "999")); + s.execute("data.validation", rule("B2:B4", "100", "200")).unwrap(); + for cell in ["B2", "B3", "B4"] { + assert!(ok(&mut s, cell, "150"), "{cell}"); + assert!(!ok(&mut s, cell, "5"), "{cell}"); + } + assert!(ok(&mut s, "B1", "5")); + assert!(!ok(&mut s, "B1", "150")); +} + +#[test] +fn showing_detail_gives_zero_width_columns_their_width_back() { + // #1714: templates hide grouped columns as `width="0" hidden="1"`; + must show them. + let mut s = Session::new(); + s.new_workbook(); + let hidden = LineInfo { size: Some(0.0), hidden: true, outline: 1, ..LineInfo::default() }; + let sh = Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap(); + for c in 2..=4 { + sh.cols.insert(c, hidden); + } + sh.cols.insert(5, LineInfo { collapsed: true, ..LineInfo::default() }); + s.execute("data.showDetail", json!({"cols": "C:E", "level": 1})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + for c in 2..=4 { + assert_eq!(sh.col_width(c), sh.default_col_width, "column {c}"); + } + // Hiding again hides them; the level buttons show zero-width columns too. + s.execute("data.hideDetail", json!({"cols": "C:E", "level": 1})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(2), 0.0); + Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().cols.insert(3, hidden); + s.execute("data.showLevels", json!({"cols": 2})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!((sh.col_width(2), sh.col_width(3)), (sh.default_col_width, sh.default_col_width)); +} + +#[test] +fn removing_an_autofilter_keeps_manually_hidden_and_collapsed_rows() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap(); + s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap(); + s.execute("data.group", json!({"rows": "2:4"})).unwrap(); + s.execute("data.hideDetail", json!({"rows": "2:4"})).unwrap(); + s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap(); + let hidden = |s: &Session| -> Vec { + let sh = s.doc().unwrap().wb.active().unwrap(); + sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect() + }; + let before = hidden(&s); + assert_eq!(before, [1, 2, 3, 9]); + s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + assert!(s.doc().unwrap().wb.active().unwrap().autofilter.is_none()); + assert_eq!(hidden(&s), before); + assert_eq!(row_state(&s, 4..=4), [(false, true)]); +} + +#[test] +fn table_dropdown_setup_outside_the_table_keeps_manually_hidden_rows() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap(); + s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap(); + s.execute("insert.table", json!({"range": "A1:A4", "header": true})).unwrap(); + s.execute("home.hideRows", json!({"rows": "3:3"})).unwrap(); + s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap(); + s.execute("selection.set", json!({"range": "E1"})).unwrap(); + // The table filter dropdown installs an AutoFilter on demand by toggling `data.filter` twice. + s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect::>(), [2, 9]); +} + +/// Rows 5:8 grouped, with 6:7 grouped again inside (0-based 4..=7 and 5..=6). +fn nested_rows() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("data.group", json!({"rows": "5:8"})).unwrap(); + s.execute("data.group", json!({"rows": "6:7"})).unwrap(); + s +} + +/// (hidden, collapsed) of 0-based rows. +fn row_state(s: &Session, rows: std::ops::RangeInclusive) -> Vec<(bool, bool)> { + let sh = s.doc().unwrap().wb.active().unwrap(); + rows.map(|r| sh.rows.get(&r).map(|l| (l.hidden, l.collapsed)).unwrap_or_default()).collect() +} + +#[test] +fn hide_detail_marks_the_summary_collapsed_and_show_detail_keeps_inner_groups_collapsed() { + let mut s = nested_rows(); + s.execute("data.hideDetail", json!({"rows": "6:7", "level": 2})).unwrap(); + assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]); + s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, true), (false, true)]); + s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]); + s.execute("data.showDetail", json!({"rows": "6:7", "level": 2})).unwrap(); + assert_eq!(row_state(&s, 4..=8), [(false, false); 5]); + // Each step is one undoable command. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(row_state(&s, 5..=7), [(true, false), (true, false), (false, true)]); +} + +#[test] +fn hide_detail_on_the_summary_row_collapses_the_group_above_it() { + let mut s = nested_rows(); + s.execute("data.hideDetail", json!({"rows": "9:9"})).unwrap(); + assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, false), (false, true)]); +} + +#[test] +fn show_levels_collapses_and_expands_to_a_level() { + let mut s = nested_rows(); + s.execute("data.showLevels", json!({"rows": 1})).unwrap(); + assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, true), (false, true)]); + s.execute("data.showLevels", json!({"rows": 2})).unwrap(); + assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]); + s.execute("data.showLevels", json!({"rows": 3})).unwrap(); + assert_eq!(row_state(&s, 4..=8), [(false, false); 5]); + // Columns are separate; no level at all is an error rather than a guess. + s.execute("data.group", json!({"cols": "C:E"})).unwrap(); + s.execute("data.showLevels", json!({"cols": 1})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert!((2..=4).all(|c| sh.cols.get(&c).is_some_and(|l| l.hidden))); + assert!(sh.cols.get(&5).is_some_and(|l| l.collapsed)); + assert_eq!(row_state(&s, 4..=4), [(false, false)]); + assert!(s.execute("data.showLevels", json!({})).is_err()); +} + +#[test] +fn group_by_columns_for_a_block_of_cells() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("selection.set", json!({"range": "B2:C5"})).unwrap(); + s.execute("data.group", json!({"by": "cols"})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert!((1..=2).all(|c| sh.cols.get(&c).is_some_and(|l| l.outline == 1))); + assert!(sh.rows.values().all(|l| l.outline == 0)); +} + +#[test] +fn group_shortcuts_are_the_windows_ones() { + let sc = |id: &str| specs().into_iter().find(|c| c.id == id).and_then(|c| c.shortcut); + assert_eq!(sc("data.group"), Some("Alt+Shift+Right")); + 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::>(), [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::>(), [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::>(), [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(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap(); + for row in [0u64, 1 << 32] { + let params = json!({"range": "A1:B2", "header": false, "orientation": "columns", "keys": [{"row": row}]}); + let err = s.execute("data.sort", params).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "row {row}: {err:?}"); + } +} + +#[test] +fn single_row_and_column_parameters() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap(); + s.execute("home.insertRows", json!({"rows": "2"})).unwrap(); + s.execute("home.insertColumns", json!({"cols": "B"})).unwrap(); + let v = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone(); + assert_eq!(v(&mut s, "C3"), json!(4.0)); + s.execute("home.hideRows", json!({"rows": "1"})).unwrap(); + s.execute("data.group", json!({"cols": "C"})).unwrap(); + assert!(s.execute("home.deleteRows", json!({"rows": "0"})).is_err()); + s.execute("home.deleteRows", json!({"rows": "2"})).unwrap(); + s.execute("home.deleteColumns", json!({"cols": "B"})).unwrap(); + assert_eq!(v(&mut s, "B2"), json!(4.0)); +} + +#[test] +fn t2c_fixed_widths_are_positions_in_any_order_without_duplicates() { + let cases: [(Json, [&str; 4]); 5] = [ + (json!([6, 3]), ["abc", "def", "ghij", ""]), + (json!([3, 3]), ["abc", "defghij", "", ""]), + (json!([0]), ["abcdefghij", "", "", ""]), + (json!([20]), ["abcdefghij", "", "", ""]), + (json!([7, 0, 3, 7, 10]), ["abc", "defg", "hij", ""]), + ]; + for (widths, want) in cases { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "abcdefghij"})).unwrap(); + s.execute("data.textToColumns", json!({"range": "A1:A1", "fixedWidths": widths})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + let got: Vec = (0..4).map(|c| sh.value(CellRef::new(0, c)).display()).collect(); + assert_eq!(got, want, "{widths}"); + } +} + +#[test] +fn split() { + assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]); + assert_eq!(split_delimited("a,b,,c", &[','], true, '"'), ["a", "b", "c"]); + assert_eq!(split_delimited("\"x,y\",z", &[','], false, '"'), ["x,y", "z"]); +} + +#[test] +fn remove_duplicates_without_a_valid_column_is_refused() { + // `columns: []` (or only unusable names) used to compare nothing and delete every row + // but the first. + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["a"], ["b"], ["c"]]})).unwrap(); + for columns in [json!([]), json!(["ZZZ"])] { + let err = s.execute("data.removeDuplicates", json!({"range": "A1:A3", "columns": columns})).unwrap_err(); + assert!(err.to_string().contains("columns"), "{err}"); + } + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.value(CellRef::new(0, 0)), Value::text("a")); + assert_eq!(sh.value(CellRef::new(2, 0)), Value::text("c")); +} + +#[test] +fn a_list_typed_without_quotes_is_saved_as_a_quoted_list() { + // In a file an inline list is a string literal (`"a,b"`); unquoted it reads as a formula. + // References, names and formulas stay unquoted, and every rule survives a save and reopen. + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "K1:K3", "values": [["Red"], ["Green"], ["Blue"]]})).unwrap(); + s.execute("formulas.defineName", json!({"name": "Colors", "refersTo": "=Sheet1!$K$1:$K$3"})).unwrap(); + let rules = [ + ("A1", "Open,In progress,Closed", r#""Open,In progress,Closed""#), + ("B1", r#""Open,In progress,Closed""#, r#""Open,In progress,Closed""#), + ("C1", " 1,2,3 ", r#""1,2,3""#), + ("D1", r#"Say "hi",Bye"#, r#""Say ""hi"",Bye""#), + ("E1", "=$K$1:$K$3", "$K$1:$K$3"), + ("F1", "$K$1:$K$3", "$K$1:$K$3"), + ("G1", "=Colors", "Colors"), + ("H1", "Sheet1!$K$1:$K$3", "Sheet1!$K$1:$K$3"), + ("I1", "=OFFSET($K$1,0,0,3,1)", "OFFSET($K$1,0,0,3,1)"), + ]; + for (cell, source, _) in rules { + s.execute("data.validation", json!({"range": cell, "type": "list", "formula1": source})).unwrap(); + } + let accepted = [ + ("A1", "In progress"), + ("B1", "Closed"), + ("C1", "2"), + ("D1", r#"Say "hi""#), + ("E1", "Green"), + ("F1", "Blue"), + ("G1", "Red"), + ("H1", "Green"), + ]; + let check = |wb: &Workbook| { + for (cell, input) in accepted { + let at = CellRef::parse(cell).unwrap(); + assert!(check_validation(wb, 0, at, input).is_none(), "{cell} refuses {input}"); + assert!(check_validation(wb, 0, at, "Pending").is_some(), "{cell} accepts Pending"); + } + }; + let wb = s.doc().unwrap().wb.clone(); + check(&wb); + + let sheet_xml = |wb: &Workbook| { + let bytes = gridcraft_xlsx::write_xlsx(wb).unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("xl/worksheets/sheet1.xml").unwrap(), &mut xml).unwrap(); + xml + }; + let xml = sheet_xml(&wb); + for (cell, _, saved) in rules { + assert!(xml.contains(&format!(r#"sqref="{cell}">{saved}"#)), "{cell}: {saved} missing from {xml}"); + } + let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap(); + check(&reopened); + assert_eq!(sheet_xml(&reopened), xml, "a second save writes the same rules"); +} + +#[test] +fn a_rule_with_an_unknown_type_operator_or_error_style_is_refused() { + // A typo used to give a rule that validates nothing (`type="none"`) or the wrong check, + // with no error. + let mut s = Session::new(); + s.new_workbook(); + for params in [ + json!({"range": "A1", "type": "lst", "formula1": "\"a,b\""}), + json!({"range": "A1", "type": "whole", "operator": "greaterthan", "formula1": "1"}), + json!({"range": "A1", "type": "whole", "operator": ">", "formula1": "1"}), + json!({"range": "A1", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warn"}), + ] { + let e = s.execute("data.validation", params.clone()).unwrap_err().to_string(); + assert!(e.contains("unknown"), "{params}: {e}"); + } + assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty()); + for params in [ + json!({"range": "A1", "type": "any"}), + json!({"range": "A2", "type": "whole", "operator": "greaterThan", "formula1": "1"}), + json!({"range": "A3", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warning"}), + ] { + s.execute("data.validation", params).unwrap(); + } + let sh = s.doc().unwrap().wb.active().unwrap().clone(); + assert_eq!(sh.validations.len(), 3); + assert!(check_validation(&s.doc().unwrap().wb, 0, CellRef::parse("A2").unwrap(), "0").is_some()); +} diff --git a/crates/engine/src/cmd/data/tools.rs b/crates/engine/src/cmd/data/tools.rs new file mode 100644 index 0000000000..1603b2cb4d --- /dev/null +++ b/crates/engine/src/cmd/data/tools.rs @@ -0,0 +1,190 @@ +use super::*; + +// ---------------------------------------------------------------- tools + +pub(super) fn remove_duplicates(s: &mut Session, p: &Json) -> Result { + let (sheet, r) = data_target(s, p, "data.removeDuplicates")?; + let d = s.doc()?; + let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + // Rows and columns past the data are blank: only the part with data is compared (whole + // columns compared a million blank rows and reported them as removed duplicates). + let r = sh.used_range().and_then(|u| r.intersection(&u)).unwrap_or(RangeRef::cell(r.start)); + let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r)); + let cols: Vec = p + .get("columns") + .and_then(Json::as_array) + .map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect()) + .unwrap_or_else(|| (r.start.col..=r.end.col).collect()); + // With no column to compare, every row key is empty: the first row would survive and every + // other row would be deleted as a duplicate. A column outside the range compares data the + // user didn't select. Excel refuses both. + if cols.is_empty() || cols.iter().any(|c| !(r.start.col..=r.end.col).contains(c)) { + return Err(bad("data.removeDuplicates", "`columns` must name at least one column of the range")); + } + let start = r.start.row + header as u32; + let mut removed = 0; + edit(s, |cx| { + let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) }; + let mut seen = std::collections::HashSet::new(); + let mut keep: Vec = Vec::new(); + // Cells are compared by the text they show, as in Excel: the same date in two formats + // isn't a duplicate, while 1.01 and 1.02 shown as 1.0 are. + let shown = |c: CellRef| crate::display::format(&sh.value(c), cx.wb.styles.get(sh.style_id(c)).num_fmt.as_str(), &cx.wb).text; + for row in start..=r.end.row { + let key: Vec = cols.iter().map(|c| shown(CellRef::new(row, *c)).to_lowercase()).collect(); + if seen.insert(key) { + keep.push(row); + } else { + removed += 1; + } + } + let pictures: Vec<_> = keep + .iter() + .enumerate() + .flat_map(|(i, row)| { + (r.start.col..=r.end.col) + .filter_map(move |col| sh.cell_pictures.get(&CellRef::new(*row, col)).map(|p| (CellRef::new(start + i as u32, col), p.clone()))) + }) + .collect(); + // Rows a filter keeps are compacted to `start + i`, so the notes and hyperlinks that + // describe them move with them; the ones on removed rows are dropped with the row. + let notes: Vec<_> = keep + .iter() + .enumerate() + .flat_map(|(i, row)| { + (r.start.col..=r.end.col) + .filter_map(move |col| sh.comments.get(&CellRef::new(*row, col)).map(|cm| (CellRef::new(start + i as u32, col), cm.clone()))) + }) + .collect(); + let links: Vec<_> = keep + .iter() + .enumerate() + .flat_map(|(i, row)| { + (r.start.col..=r.end.col) + .filter_map(move |col| sh.hyperlinks.get(&CellRef::new(*row, col)).map(|h| (CellRef::new(start + i as u32, col), h.clone()))) + }) + .collect(); + let rows: Vec>> = + keep.iter().map(|row| (r.start.col..=r.end.col).map(|c| sh.cell(CellRef::new(*row, c)).cloned()).collect()).collect(); + let shm = cx.sheet_mut(sheet)?; + for row in start..=r.end.row { + for c in r.start.col..=r.end.col { + shm.remove_cell(CellRef::new(row, c)); + } + } + for row in start..=r.end.row { + for c in r.start.col..=r.end.col { + let at = CellRef::new(row, c); + shm.comments.remove(&at); + shm.hyperlinks.remove(&at); + } + } + for (i, cells) in rows.into_iter().enumerate() { + for (j, cell) in cells.into_iter().enumerate() { + if let Some(cell) = cell { + shm.set_cell(CellRef::new(start + i as u32, r.start.col + j as u32), cell); + } + } + } + shm.cell_pictures.extend(pictures); + for (at, cm) in notes { + shm.comments.insert(at, cm); + } + for (at, h) in links { + shm.hyperlinks.insert(at, h); + } + for c in RangeRef::new(CellRef::new(start, r.start.col), r.end).iter() { + cx.changed.push((sheet, c)); + } + Ok(()) + })?; + let remaining = (r.end.row + 1 - start) as usize - removed; + Ok(json!({"removed": removed, "remaining": remaining})) +} + +pub(super) fn text_to_columns(s: &mut Session, p: &Json) -> Result { + let r = target_range(s, p)?; + let sheet = target_sheet(s, p)?; + let mut delims: Vec = p + .get("delimiters") + .and_then(Json::as_array) + .map(|a| a.iter().filter_map(|d| d.as_str().and_then(|x| x.chars().next())).collect()) + .unwrap_or_else(|| vec!['\t']); + if let Some(o) = str_param(p, "other").and_then(|o| o.chars().next()) { + delims.push(o); + } + let fixed: Option> = + p.get("fixedWidths").and_then(Json::as_array).map(|a| a.iter().filter_map(|x| x.as_u64().map(|v| v as usize)).collect()); + let consecutive = bool_param(p, "treatConsecutive").unwrap_or(false); + let quote = str_param(p, "textQualifier").and_then(|q| q.chars().next()).unwrap_or('"'); + let dest = cell_param_on(s, p, "destination", sheet)?.unwrap_or(r.start); + edit(s, |cx| { + let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) }; + let mut out: Vec<(CellRef, String)> = Vec::new(); + for (i, row) in (r.start.row..=r.end.row).enumerate() { + let text = sh.value(CellRef::new(row, r.start.col)).display(); + let parts: Vec = match &fixed { + Some(w) => { + let chars: Vec = text.chars().collect(); + let mut cuts = vec![0]; + let mut breaks: Vec = w.iter().copied().filter(|x| *x > 0 && *x < chars.len()).collect(); + breaks.sort_unstable(); + breaks.dedup(); + cuts.extend(breaks); + cuts.push(chars.len()); + cuts.windows(2) + .map(|p| chars.get(p[0]..p[1]).map(|s| s.iter().collect::().trim().to_string()).unwrap_or_default()) + .collect() + } + None => split_delimited(&text, &delims, consecutive, quote), + }; + for (j, part) in parts.into_iter().enumerate() { + out.push((CellRef::new(dest.row + i as u32, dest.col + j as u32), part)); + } + } + for (c, t) in out { + let cell = crate::cmd::edit::input_to_cell(&t, sheet, c, &mut cx.wb).unwrap_or(None); + let shm = cx.sheet_mut(sheet)?; + match cell { + Some(x) => shm.set_cell(c, x), + None => { + shm.remove_cell(c); + } + } + cx.changed.push((sheet, c)); + } + Ok(()) + })?; + ok() +} + +pub(super) fn split_delimited(text: &str, delims: &[char], consecutive: bool, quote: char) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut in_q = false; + let mut last_delim = false; + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + if c == quote { + if in_q && chars.peek() == Some("e) { + cur.push(quote); + chars.next(); + } else { + in_q = !in_q; + } + continue; + } + if !in_q && delims.contains(&c) { + if consecutive && last_delim { + continue; + } + out.push(std::mem::take(&mut cur)); + last_delim = true; + continue; + } + last_delim = false; + cur.push(c); + } + out.push(cur); + out +} diff --git a/crates/engine/src/cmd/data/validation.rs b/crates/engine/src/cmd/data/validation.rs new file mode 100644 index 0000000000..6d4ae73cfd --- /dev/null +++ b/crates/engine/src/cmd/data/validation.rs @@ -0,0 +1,290 @@ +use super::*; + +// ---------------------------------------------------------------- validation + +fn op_param(s: Option<&str>) -> CfOperator { + match s.unwrap_or("between") { + "notBetween" => CfOperator::NotBetween, + "equal" => CfOperator::Equal, + "notEqual" => CfOperator::NotEqual, + "greater" | "greaterThan" => CfOperator::Greater, + "less" | "lessThan" => CfOperator::Less, + "greaterOrEqual" | "greaterThanOrEqual" => CfOperator::GreaterOrEqual, + "lessOrEqual" | "lessThanOrEqual" => CfOperator::LessOrEqual, + _ => CfOperator::Between, + } +} + +/// `r` minus `cut`, as up to four non-overlapping rectangles. +fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec { + let Some(i) = r.intersection(cut) else { return vec![r] }; + let mut out = Vec::new(); + if i.start.row > r.start.row { + out.push(RangeRef::new(r.start, CellRef::new(i.start.row - 1, r.end.col))); + } + if i.end.row < r.end.row { + out.push(RangeRef::new(CellRef::new(i.end.row + 1, r.start.col), r.end)); + } + if i.start.col > r.start.col { + out.push(RangeRef::new(CellRef::new(i.start.row, r.start.col), CellRef::new(i.end.row, i.start.col - 1))); + } + if i.end.col < r.end.col { + out.push(RangeRef::new(CellRef::new(i.start.row, i.end.col + 1), CellRef::new(i.end.row, r.end.col))); + } + out +} + +/// Removes the cells of `cuts` from every validation, keeping the rest of each rule's ranges. +pub(in super::super) fn remove_validation_cells(validations: &mut Vec, cuts: &[RangeRef]) { + for dv in validations.iter_mut() { + for cut in cuts { + dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect(); + } + } + validations.retain(|d| !d.ranges.is_empty()); +} + +pub(super) fn validation(s: &mut Session, p: &Json) -> Result { + let ranges = target_ranges(s, p)?; + let sheet = target_sheet(s, p)?; + if bool_param(p, "clear").unwrap_or(false) { + return edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + remove_validation_cells(&mut sh.validations, &ranges); + Ok(Json::Null) + }); + } + let Some(kind) = str_param(p, "type") else { + s.ui_requests.push(crate::UiRequest::Dialog("dataValidation".into(), json!({}))); + return ok(); + }; + let kind = match kind { + "whole" => ValidationKind::Whole, + "decimal" => ValidationKind::Decimal, + "list" => ValidationKind::List, + "date" => ValidationKind::Date, + "time" => ValidationKind::Time, + "textLength" => ValidationKind::TextLength, + "custom" => ValidationKind::Custom, + "any" => ValidationKind::Any, + // An unknown type used to give an "any value" rule, saved as `type="none"`: no validation. + other => return Err(bad("data.validation", format!("unknown type `{other}` (any, whole, decimal, list, date, time, textLength, custom)"))), + }; + if kind == ValidationKind::List && str_param(p, "formula1").is_none_or(|f| f.trim().trim_start_matches('=').is_empty()) { + return Err(bad("data.validation", "a list validation needs `formula1`")); + } + const OPERATORS: [&str; 12] = [ + "between", + "notBetween", + "equal", + "notEqual", + "greater", + "greaterThan", + "less", + "lessThan", + "greaterOrEqual", + "greaterThanOrEqual", + "lessOrEqual", + "lessThanOrEqual", + ]; + if let Some(op) = str_param(p, "operator").filter(|op| !OPERATORS.contains(op)) { + return Err(bad("data.validation", format!("unknown operator `{op}` ({})", OPERATORS.join(", ")))); + } + let error_style = match str_param(p, "errorStyle") { + None | Some("stop") => ErrorStyle::Stop, + Some("warning") => ErrorStyle::Warning, + Some("information") => ErrorStyle::Information, + Some(other) => return Err(bad("data.validation", format!("unknown errorStyle `{other}` (stop, warning, information)"))), + }; + let f1 = str_param(p, "formula1").unwrap_or(""); + let f1 = if kind == ValidationKind::List { list_source(f1) } else { f1.trim_start_matches('=').to_string() }; + let dv = Validation { + ranges: ranges.clone(), + kind, + op: op_param(str_param(p, "operator")), + f1, + f2: str_param(p, "formula2").map(|f| f.trim_start_matches('=').to_string()), + allow_blank: bool_param(p, "ignoreBlank").unwrap_or(true), + in_cell_dropdown: bool_param(p, "dropdown").unwrap_or(true), + input_title: str_param(p, "inputTitle").unwrap_or("").into(), + input_message: str_param(p, "inputMessage").unwrap_or("").into(), + show_input: true, + error_title: str_param(p, "errorTitle").unwrap_or("").into(), + error_message: str_param(p, "errorMessage").unwrap_or("").into(), + error_style, + show_error: true, + }; + edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + remove_validation_cells(&mut sh.validations, &ranges); + sh.validations.push(dv.clone()); + Ok(Json::Null) + }) +} + +/// Whether a list rule's source is a literal list (`"a,b"`, or `a,b` typed without quotes) rather +/// than a reference or a name to evaluate. +fn is_literal_list(f: &str) -> bool { + f.starts_with('"') + || (!f.contains('!') + && gridcraft_formula::parse(f).map(|e| !matches!(e, gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_))).unwrap_or(true)) +} + +/// A list rule's source as a file stores it. A literal list typed without quotes, as in Excel's +/// Source box (`Open,In progress,Closed`), becomes the quoted string Excel saves +/// (`"Open,In progress,Closed"`, inner quotes doubled); unquoted, the file would read it as a +/// formula. A formula (`=…`), a reference, a name or an already quoted list is kept as before. +fn list_source(f: &str) -> String { + let t = f.trim(); + if t.is_empty() || t.starts_with('=') || t.starts_with('"') || !is_literal_list(t) { + return f.trim_start_matches('=').to_string(); + } + format!("\"{}\"", t.replace('"', "\"\"")) +} + +/// List entries of a list validation (literal or from a range). +pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec { + let f = dv.f1.trim(); + if is_literal_list(f) { + // A quoted list is a string literal: `""` inside stands for one `"`. + let items = match f.strip_prefix('"').and_then(|x| x.strip_suffix('"')) { + Some(body) => body.replace("\"\"", "\""), + None => f.trim_matches('"').to_string(), + }; + return items.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(); + } + let at = dv.ranges.first().map(|r| r.start).unwrap_or_default(); + match gridcraft_calc::evaluate(wb, sheet, at, f) { + Value::Array(a) => a.data.iter().filter(|v| !v.is_empty()).map(Value::display).collect(), + v if !v.is_empty() && !v.is_error() => vec![v.display()], + _ => vec![], + } +} + +/// Checks typed input against the validation of a cell. `Ok(None)` = valid. +pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) -> Option<(Validation, String)> { + let sh = wb.sheet(sheet)?; + let dv = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(at)))?; + if dv.kind == ValidationKind::Any || !dv.show_error { + return None; + } + if input.is_empty() && dv.allow_blank { + return None; + } + let v = gridcraft_core::parse::parse_input(input, wb.date_system).value; + // The formulas are written for the first cell of the range and see the value being entered, + // as in Excel (a custom `=A1>10` on A1:A9 checks A5's new value at A5). + let first = dv.ranges.first().map(|r| r.start).unwrap_or(at); + let entered; + let wb = if sh.value(at) == v { + wb + } else { + let mut w = wb.clone(); + if let Some(sh) = w.sheet_mut(sheet) { + let style = sh.style_id(at); + sh.cells.set(at, Cell { value: v.clone(), style, ..Default::default() }); + } + entered = w; + &entered + }; + let eval = |f: &str| gridcraft_calc::evaluate(wb, sheet, at, &formula_at(f, first, at)); + let num = |f: &str| eval(f).to_number().ok(); + let ok = match dv.kind { + ValidationKind::List => list_items(wb, sheet, dv).iter().any(|x| x.eq_ignore_ascii_case(input)), + ValidationKind::Custom => eval(&dv.f1).to_bool().unwrap_or(false), + ValidationKind::TextLength => { + let n = input.chars().count() as f64; + compare_op(dv.op, n, num(&dv.f1), dv.f2.as_deref().and_then(num)) + } + _ => match v.as_f64() { + Some(n) => { + if dv.kind == ValidationKind::Whole && n.fract() != 0.0 { + false + } else { + compare_op(dv.op, n, num(&dv.f1), dv.f2.as_deref().and_then(num)) + } + } + None => false, + }, + }; + if ok { + return None; + } + let msg = if dv.error_message.is_empty() { + "This value doesn't match the data validation restrictions defined for this cell.".to_string() + } else { + dv.error_message.clone() + }; + Some((dv.clone(), msg)) +} + +/// A formula written for `first`, as seen from `at` (relative references shifted). +fn formula_at(f: &str, first: CellRef, at: CellRef) -> String { + if first == at { + return f.to_string(); + } + match gridcraft_formula::parse(f.trim_start_matches('=')) { + Ok(e) => gridcraft_formula::print(&gridcraft_formula::adjust::shift_relative( + e, + at.row as i64 - first.row as i64, + at.col as i64 - first.col as i64, + )), + Err(_) => f.to_string(), + } +} + +/// Moves the relative references of a rule formula written for `first` so it reads the same cells from `to` +/// (pasting a rule); text without relative references (constants, literal lists) stays as typed. +pub(in super::super) fn move_rule_formula(f: &str, first: CellRef, to: CellRef) -> String { + if first == to { + return f.to_string(); + } + let moved = formula_at(f, first, to); + let has_relative = gridcraft_formula::parse(f.trim_start_matches('=')).is_ok_and(|e| gridcraft_formula::print(&e) != moved); + let eq = if f.starts_with('=') { "=" } else { "" }; + if has_relative { format!("{eq}{moved}") } else { f.to_string() } +} + +fn compare_op(op: CfOperator, n: f64, a: Option, b: Option) -> bool { + let Some(a) = a else { return false }; + match op { + CfOperator::Between => b.is_some_and(|b| n >= a.min(b) && n <= a.max(b)), + CfOperator::NotBetween => b.is_some_and(|b| n < a.min(b) || n > a.max(b)), + CfOperator::Equal => n == a, + CfOperator::NotEqual => n != a, + CfOperator::Greater => n > a, + CfOperator::Less => n < a, + CfOperator::GreaterOrEqual => n >= a, + CfOperator::LessOrEqual => n <= a, + } +} + +pub(super) fn validate_cmd(s: &mut Session, p: &Json) -> Result { + let d = s.doc()?; + let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); + let input = str_param(p, "input").unwrap_or(""); + match check_validation(&d.wb, d.wb.active_sheet(), at, input) { + None => Ok(json!({"ok": true})), + Some((dv, msg)) => Ok(json!({"ok": false, "message": msg, "title": dv.error_title, "style": format!("{:?}", dv.error_style)})), + } +} + +pub(super) fn circle_invalid(s: &mut Session, _: &Json) -> Result { + let d = s.doc()?; + let sheet = d.wb.active_sheet(); + let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; + let mut bad_cells = Vec::new(); + for dv in &sh.validations { + for r in &dv.ranges { + let r = sh.used_range().and_then(|u| r.intersection(&u)); + let Some(r) = r else { continue }; + for c in r.iter().take(100_000) { + let text = sh.input_text(c); + if !text.is_empty() && check_validation(&d.wb, sheet, c, &text).is_some() { + bad_cells.push(c.a1()); + } + } + } + } + Ok(json!({"invalid": bad_cells})) +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 28ce338d6c..e48fe88381 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -8,6 +8,33 @@ use super::*; use crate::selection::{current_region, jump}; use crate::{Clipboard, Session}; +mod cell; +mod clipboard; +mod fill; +mod find; +mod go_to; +mod paste_special; +mod selection; + +use cell::{cell_set, range_fill, range_set_values}; +pub(crate) use cell::{check_arg_counts, input_to_cell, json_to_input, parse_typed_formula}; +#[cfg(test)] +pub(crate) use clipboard::parse_tsv; +use clipboard::{clear_clipboard, copy, cut, paste}; +use fill::{auto_fill, fill_dir, fill_series, flash_fill}; +#[cfg(test)] +use find::replace_ci; +pub(crate) use find::wildcard; +use find::{find, replace}; +pub(super) use go_to::go_to; +pub use go_to::go_to_names; +use go_to::go_to_special; +#[cfg(test)] +use go_to::{GO_TO_MAX_AREAS, GO_TO_RECENT, reference_parts}; +use paste_special::paste_special; +pub(crate) use paste_special::rewrite_all_formulas; +use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_set}; + pub fn specs() -> Vec { vec![ cmd!("cell.set", "Enter Cell", [], None, "{cell?: \"B2\", input: \"text, number or =formula\", sheet?, array?: bool}", has_doc, cell_set), @@ -116,657 +143,6 @@ pub fn specs() -> Vec { ] } -// ---------------------------------------------------------------- entry - -/// What typing `input` into a cell produces (constant or formula, plus an automatic number -/// format). Inherits the destination's cell, row or column style, including its number -/// format, before parsing. Errors when the formula can't be parsed. -pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gridcraft_model::Workbook) -> Result> { - let style = wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); - if input.is_empty() { - let c = Cell { value: Value::Empty, formula: None, style }; - return Ok(if c.is_blank() { None } else { Some(c) }); - } - let fmt_is_text = wb.styles.get(style).num_fmt.as_str() == "@"; - if fmt_is_text { - return Ok(Some(Cell { value: Value::text(input), formula: None, style })); - } - let is_formula = input.starts_with('=') - || (input.len() > 1 - && (input.starts_with('+') || input.starts_with('-')) - && gridcraft_core::parse::parse_number_text(input).is_none() - && input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '(')); - if is_formula { - let body = input.strip_prefix('=').unwrap_or(input); - let expr = wb.name_call_case(parse_typed_formula(body)?); - check_arg_counts(&expr)?; - let mut cell = Cell::formula(Formula::from_expr(expr)); - cell.style = style; - // Formulas whose result is a date/time get a format from functions like TODAY(). - if wb.styles.get(style).num_fmt.as_str() == "General" { - let f = cell.formula.as_ref().map(|f| f.text.to_ascii_uppercase()).unwrap_or_default(); - let auto = if f.starts_with("TODAY(") - || f.starts_with("DATE(") - || f.starts_with("EDATE(") - || f.starts_with("EOMONTH(") - || f.starts_with("WORKDAY(") - { - Some("m/d/yyyy") - } else if f.starts_with("NOW(") { - Some("m/d/yyyy h:mm") - } else if f.starts_with("TIME(") { - Some("h:mm AM/PM") - } else { - None - }; - if let Some(code) = auto { - cell.style = wb.styles.derive(style, |s| s.num_fmt = gridcraft_model::NumFmt::new(code)); - } - } - return Ok(Some(cell)); - } - let mut parsed = gridcraft_core::parse::parse_input(input, wb.date_system); - // Like Excel's automatic percent entry: a bare number of 1 or more typed into a cell already - // formatted as a percentage is a percent (3 -> 3%); smaller numbers are taken as fractions. - if parsed.format.is_none() - && !input.contains('%') - && wb.styles.get(style).num_fmt.as_str().ends_with('%') - && let Value::Number(n) = parsed.value - && n.abs() >= 1.0 - { - parsed.value = Value::Number(n / 100.0); - } - let mut style = style; - if let Some(code) = parsed.format { - let cur = wb.styles.get(style).num_fmt.as_str().to_string(); - if cur == "General" { - style = wb.styles.derive(style, |s| s.num_fmt = gridcraft_model::NumFmt::new(code)); - } - } - // Wrap text automatically when the input has line breaks (Alt+Enter). - if input.contains('\n') && !wb.styles.get(style).align.wrap { - style = wb.styles.derive(style, |s| s.align.wrap = true); - } - Ok(Some(Cell { value: parsed.value, formula: None, style })) -} - -/// Parses a formula as it is typed (without the `=`). Excel closes missing parentheses for you, -/// and refuses a formula that still doesn't parse. -pub(crate) fn parse_typed_formula(body: &str) -> Result { - // Excel closes missing parentheses for you. - let parsed = gridcraft_formula::parse_closing_parens(body, gridcraft_formula::parse); - parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}"))) -} - -/// Like Excel, a formula that calls a built-in function with too few or too many arguments is -/// refused when it's entered, rather than stored to show `#VALUE!`. Functions the registry doesn't -/// hold (special forms like LET, LAMBDA, OFFSET, INDIRECT, SUBTOTAL), defined LAMBDA names and -/// unknown functions are skipped. -pub(crate) fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> { - let mut bad = None; - expr.walk(&mut |e| { - if bad.is_none() - && let gridcraft_formula::Expr::Call(name, args) = e - && let Some(spec) = gridcraft_functions::lookup(name) - && !spec.accepts(args.len()) - { - let few = args.len() < spec.min_args; - bad = Some(format!("{} has too {} arguments. Use {}", spec.name, if few { "few" } else { "many" }, spec.signature)); - } - }); - match bad { - Some(msg) => Err(EngineError::Other(msg)), - None => Ok(()), - } -} - -fn cell_set(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; - let at = match cell_param_on(s, p, "cell", sheet)? { - Some(c) => c, - None => s.doc()?.selection.active, - }; - // Both spellings take a string, number or boolean, as typed (#697). - let input = p.get("input").or_else(|| p.get("value")).map(json_to_input).ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?; - let array = bool_param(p, "array").unwrap_or(false); - let d = s.doc()?; - if let Some(sh) = d.wb.sheet(sheet) - && sh.is_protected() - && d.wb.styles.get(sh.style_id(at)).protection.locked - && let Some(m) = locked_cell_error(sh, at, &d.unlocked_ranges) - { - return Err(EngineError::Other(m)); - } - let active_sheet = s.doc()?.wb.active_sheet(); - edit(s, |cx| { - if array { - // Legacy Ctrl+Shift+Enter array formula over the selection, when the selection holds - // the cell; a cell elsewhere (or on another sheet) gets a one-cell array. - let selected = cx.sel.current(); - let range = if sheet == active_sheet && selected.contains(at) { selected } else { RangeRef::cell(at) }; - // A table header can't hold an array's value, and a table resizes under it. - if !range.is_single() && cx.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.range.intersects(&range))) { - return Err(EngineError::Other("Multi-cell array formulas aren't allowed in tables.".into())); - } - let mut cell = input_to_cell(&input, sheet, range.start, &mut cx.wb)?.unwrap_or_default(); - if let Some(f) = cell.formula.as_mut() { - std::sync::Arc::make_mut(f).array = Some(range); - } - let sh = cx.sheet_mut(sheet)?; - // The array takes the whole range: other cells in it lose their contents. - for c in range.iter().skip(1).take(1_000_000) { - if let Some(old) = sh.cells.get(c).filter(|x| !x.value.is_empty() || x.formula.is_some()) { - let style = old.style; - sh.set_cell(c, Cell { style, ..Cell::default() }); - } - } - sh.set_cell(range.start, cell); - cx.touch(sheet, range.start); - return Ok(Json::Null); - } - let cell = input_to_cell(&input, sheet, at, &mut cx.wb)?; - let sh = cx.sheet_mut(sheet)?; - match cell { - Some(c) => sh.set_cell(at, c), - None => { - sh.remove_cell(at); - } - } - super::data::grow_autofilter(sh, at); - cx.touch(sheet, at); - Ok(Json::Null) - })?; - let v = s.doc()?.wb.sheet(sheet).map(|sh| sh.value(at)).unwrap_or_default(); - Ok(json!({"cell": at.a1(), "value": v.display()})) -} - -pub(crate) fn json_to_input(v: &Json) -> String { - match v { - Json::Null => String::new(), - Json::Bool(b) => { - if *b { - "TRUE".into() - } else { - "FALSE".into() - } - } - Json::Number(n) => n.to_string(), - Json::String(s) => s.clone(), - other => other.to_string(), - } -} - -fn range_set_values(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; - let start = target_range(s, p)?.start; - let Some(rows) = p.get("values").and_then(Json::as_array) else { return Err(bad("range.setValues", "missing `values` (array of rows)")) }; - // A string is run through typed input by default, which reads it with en-US rules: a caller - // sending "05.04.2026" from a dd.mm.yyyy export got 4 May, and "2.500" became 2.5, in silence - // (#2255). `parse: false` stores a string as the text it is; numbers, booleans and formulas - // are unaffected either way. - let parse = match p.get("parse") { - None | Some(Json::Null) => true, - Some(Json::Bool(b)) => *b, - Some(other) => return Err(bad("range.setValues", format!("parse {other}: true or false"))), - }; - if rows.len() > MAX_ROWS as usize { - return Err(bad("range.setValues", "too many rows")); - } - let mut count = 0; - edit(s, |cx| { - for (ri, row) in rows.iter().enumerate() { - let cols: Vec<&Json> = match row.as_array() { - Some(a) => a.iter().collect(), - None => vec![row], - }; - for (ci, v) in cols.iter().enumerate() { - let Some(at) = start.offset(ri as i64, ci as i64) else { continue }; - let input = json_to_input(v); - let cell = match v { - Json::Number(n) => Some(Cell { - value: Value::number(n.as_f64().unwrap_or(0.0)), - formula: None, - style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), - }), - Json::Bool(b) => { - Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() }) - } - // Text as given: no date or number reading, and a leading `'` is part of the - // text rather than the marker that makes it text. A formula is still a - // formula, which is what the ticket asks for and what `=` means in a cell. - Json::String(text) if !parse && !text.starts_with('=') => { - let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); - // Line breaks still turn on Wrap Text, as typed input does. - if text.contains('\n') && !cx.wb.styles.get(style).align.wrap { - style = cx.wb.styles.derive(style, |s| s.align.wrap = true); - } - Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style }) - } - _ => input_to_cell(&input, sheet, at, &mut cx.wb)?, - }; - let sh = cx.sheet_mut(sheet)?; - match cell { - Some(c) => sh.set_cell(at, c), - None => { - sh.remove_cell(at); - } - } - super::data::grow_autofilter(sh, at); - cx.touch(sheet, at); - count += 1; - } - } - Ok(()) - })?; - Ok(json!({"cells": count})) -} - -fn range_fill(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; - let ranges = target_ranges(s, p)?; - let input = str_param(p, "input").unwrap_or("").to_string(); - let origin = s.doc()?.selection.active; - edit(s, |cx| { - // Each cell gets what typing the input there gives: the automatic number format where - // it's General, its own format otherwise, text where it's Text. That depends only on - // the cell's style, so the input is parsed once per style. - let mut by_style: std::collections::HashMap> = std::collections::HashMap::new(); - for r in &ranges { - if r.count() > 2_000_000 { - return Err(bad("range.fill", "range too large")); - } - for at in r.iter() { - let style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); - let base = match by_style.get(&style) { - Some(c) => c.clone(), - None => { - let c = input_to_cell(&input, sheet, at, &mut cx.wb)?; - by_style.insert(style, c.clone()); - c - } - }; - let mut cell = base.unwrap_or_default(); - if let Some(f) = &cell.formula - && let Some(e) = f.expr() - { - let shifted = gridcraft_formula::adjust::shift_relative(e, at.row as i64 - origin.row as i64, at.col as i64 - origin.col as i64); - cell.formula = Some(std::sync::Arc::new(Formula::from_expr(shifted))); - } - let sh = cx.sheet_mut(sheet)?; - sh.set_cell(at, cell); - super::data::grow_autofilter(sh, at); - cx.touch(sheet, at); - } - } - Ok(Json::Null) - }) -} - -// ---------------------------------------------------------------- selection - -/// Whether the cell may be selected: a protected sheet can forbid selecting locked and/or unlocked cells. -fn cell_selectable(wb: &gridcraft_model::Workbook, sheet: &gridcraft_model::Sheet, c: CellRef) -> bool { - let Some(p) = sheet.protection.as_ref() else { return true }; - if wb.styles.get(sheet.style_id(c)).protection.locked { p.select_locked } else { p.select_unlocked } -} - -fn selection_set(s: &mut Session, p: &Json) -> Result { - if p.get("sheet").is_some() { - let i = target_sheet(s, p)?; - s.doc_mut()?.wb_switch_sheet(i); - } else if let Some(r) = str_param(p, "range").or_else(|| str_param(p, "cell")) - && let (Some(_), _) = split_sheet(r) - { - let i = target_sheet(s, p)?; - s.doc_mut()?.wb_switch_sheet(i); - } - let ranges = target_ranges(s, p)?; - let active = cell_param(s, p, "active")?; - let d = s.doc_mut()?; - let active = active.or_else(|| ranges.last().map(|r| r.start)).unwrap_or_default(); - let mut sel = crate::Selection { active, anchor: ranges.last().map(|r| r.start).unwrap_or(active), ranges }; - if let Some(sh) = d.wb.active() { - sel.expand_merges(sh); - if !cell_selectable(&d.wb, sh, sel.active) { - return Err(EngineError::Other("The cell you are trying to change is on a protected sheet that does not allow selecting it.".into())); - } - } - d.selection = sel; - Ok(json!({"selection": d.selection.a1(), "active": d.selection.active.a1()})) -} - -impl crate::DocState { - /// Switches the active sheet, remembering each sheet's selection. - pub fn wb_switch_sheet(&mut self, i: usize) { - if i >= self.wb.sheets.len() || i == self.wb.active_sheet() { - return; - } - let cur = self.wb.active_sheet(); - if self.sheet_selections.len() < self.wb.sheets.len() { - self.sheet_selections.resize(self.wb.sheets.len(), crate::Selection::default()); - } - if let Some(slot) = self.sheet_selections.get_mut(cur) { - *slot = self.selection.clone(); - } - // View state: set in place, so the workbook is neither copied nor made dirty. - self.wb.set_active_sheet(i); - self.selection = self.sheet_selections.get(i).cloned().unwrap_or_default(); - } -} - -fn selection_move(s: &mut Session, p: &Json) -> Result { - let dr = p.get("dr").and_then(Json::as_i64).unwrap_or(0); - let dc = p.get("dc").and_then(Json::as_i64).unwrap_or(0); - let extend = bool_param(p, "extend").unwrap_or(false); - let jmp = bool_param(p, "jump").unwrap_or(false); - let d = s.doc_mut()?; - let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; - let sel = &mut d.selection; - // Extension moves the far corner, not the active cell. - let from = if extend { sel.moving_end() } else { sel.active }; - // Skip hidden rows/columns and step over merged areas. - let mut to = if jmp { - jump(sheet, from, dr.signum(), dc.signum()) - } else { - let mut t = from.offset_clamped(dr, dc); - let mut guard = 0; - while (sheet.is_row_hidden(t.row) || sheet.is_col_hidden(t.col)) && guard < 100_000 { - let n = t.offset_clamped(dr.signum(), dc.signum()); - if n == t { - break; - } - t = n; - guard += 1; - } - if !extend && let Some(m) = sheet.merge_at(from) { - // Leaving a merged cell moves from its edge. - if dr > 0 { - t = CellRef::new(m.end.offset_clamped(dr, 0).row, from.col); - } else if dc > 0 { - t = CellRef::new(from.row, m.end.offset_clamped(0, dc).col); - } - } - t - }; - if let Some(m) = sheet.merge_at(to) - && !extend - { - to = m.start; - } - if !extend && !cell_selectable(&d.wb, sheet, to) { - // Step on to the next selectable cell in the same direction; stay put if there is none. - let (sr, sc) = (dr.signum(), dc.signum()); - let mut t = to; - let mut found = false; - if !jmp && (sr, sc) != (0, 0) { - for _ in 0..10_000 { - let n = t.offset_clamped(sr, sc); - if n == t { - break; - } - t = n; - if !sheet.is_row_hidden(t.row) && !sheet.is_col_hidden(t.col) && cell_selectable(&d.wb, sheet, t) { - found = true; - break; - } - } - } - if !found { - return Ok(json!({"selection": sel.a1(), "active": sel.active.a1()})); - } - to = t; - } - if extend { - sel.extend_to(to); - sel.expand_merges(sheet); - } else { - *sel = crate::Selection::at(to); - if let Some(m) = sheet.merge_at(to) { - sel.ranges = vec![m]; - } - } - Ok(json!({"selection": sel.a1(), "active": sel.active.a1()})) -} - -fn selection_next(s: &mut Session, p: &Json) -> Result { - let fwd = bool_param(p, "forward").unwrap_or(true); - let by_row = bool_param(p, "byRow").unwrap_or(false); - let d = s.doc_mut()?; - d.selection.cycle(fwd, by_row); - Ok(json!({"active": d.selection.active.a1()})) -} - -fn select_all(s: &mut Session, _: &Json) -> Result { - let d = s.doc_mut()?; - let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; - let region = current_region(sheet, d.selection.active); - let all = RangeRef::all(); - let r = if region.is_single() || d.selection.current() == region { all } else { region }; - let active = d.selection.active; - d.selection = crate::Selection { active, anchor: r.start, ranges: vec![r] }; - Ok(json!({"selection": d.selection.a1()})) -} - -fn select_region(s: &mut Session, _: &Json) -> Result { - let d = s.doc_mut()?; - let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; - let r = current_region(sheet, d.selection.active); - let active = d.selection.active; - d.selection = crate::Selection { active, anchor: r.start, ranges: vec![r] }; - Ok(json!({"selection": d.selection.a1()})) -} - -fn select_row(s: &mut Session, _: &Json) -> Result { - let d = s.doc_mut()?; - let r = d.selection.current(); - let active = d.selection.active; - d.selection = crate::Selection { active, anchor: CellRef::new(r.start.row, 0), ranges: vec![RangeRef::rows(r.start.row, r.end.row)] }; - Ok(json!({"selection": d.selection.a1()})) -} - -fn select_col(s: &mut Session, _: &Json) -> Result { - let d = s.doc_mut()?; - let r = d.selection.current(); - let active = d.selection.active; - d.selection = crate::Selection { active, anchor: CellRef::new(0, r.start.col), ranges: vec![RangeRef::cols(r.start.col, r.end.col)] }; - 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 { - 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`")) }; - let d = s.doc()?; - let mut target: Option = None; - let mut areas: Vec = 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())); - } - 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::>().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::>().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 { - 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, 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, - }; - 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)> { - let from = n.scope.unwrap_or(sheet); - let formula = n.formula.trim().trim_start_matches('='); - let mut target: Option = 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 { - let own: std::collections::HashSet = - wb.names.iter().filter(|n| n.scope == Some(sheet)).map(|n| gridcraft_formula::fold_name(&n.name)).collect(); - let mut out: Vec = 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 { - let kind = str_param(p, "kind").unwrap_or("blanks"); - let d = s.doc()?; - let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; - let scope = if d.selection.is_single_cell() { sheet.used_range().unwrap_or(RangeRef::cell(d.selection.active)) } else { d.selection.bounds() }; - if kind == "lastCell" { - let c = sheet.used_range().map(|r| r.end).unwrap_or_default(); - return selection_set(s, &json!({"cell": c.a1()})); - } - if kind == "currentRegion" { - return select_region(s, &json!({})); - } - let mut hits: Vec = Vec::new(); - if scope.count() > 4_000_000 { - return Err(bad("edit.goToSpecial", "range too large")); - } - for c in scope.iter() { - let cell = sheet.cell(c); - let v = sheet.value(c); - let ok = match kind { - "blanks" => v.is_empty(), - "constants" => cell.is_some_and(|x| x.formula.is_none() && !x.value.is_empty()), - "formulas" => cell.is_some_and(|x| x.formula.is_some()), - "comments" | "notes" => sheet.comments.contains_key(&c), - "errors" => v.is_error(), - "numbers" => v.is_number(), - "text" => v.is_text(), - "visible" => !sheet.is_row_hidden(c.row) && !sheet.is_col_hidden(c.col), - "conditionalFormats" => sheet.cond_formats.iter().any(|cf| cf.ranges.iter().any(|r| r.contains(c))), - "dataValidation" => sheet.validations.iter().any(|dv| dv.ranges.iter().any(|r| r.contains(c))), - _ => return Err(bad("edit.goToSpecial", format!("unknown kind `{kind}`"))), - }; - if ok { - hits.push(c); - } - } - if hits.is_empty() { - return Err(EngineError::Other("No cells were found.".into())); - } - let ranges = coalesce(&hits); - let d = s.doc_mut()?; - d.selection = crate::Selection { active: hits[0], anchor: hits[0], ranges }; - Ok(json!({"selection": d.selection.a1(), "count": hits.len()})) -} - /// Merges cells into vertical runs per column (a compact multi-area selection). fn coalesce(cells: &[CellRef]) -> Vec { let mut sorted = cells.to_vec(); @@ -832,619 +208,6 @@ fn redo(s: &mut Session, p: &Json) -> Result { Ok(json!({"redone": label})) } -// ---------------------------------------------------------------- clipboard - -fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { - let multiple = || EngineError::Other("This action won't work on multiple selections.".into()); - let sheet = target_sheet(s, p)?; - let ranges = target_ranges(s, p)?; - if ranges.len() > 1 && cut { - return Err(multiple()); - } - // The selection as made (untrimmed), for Insert Copied Cells. - let selected = ranges.first().copied().unwrap_or_default(); - let d = s.doc()?; - let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; - let trim = |range: RangeRef| { - if range.is_full_cols() || range.is_full_rows() { - let used = sh.used_range().unwrap_or(RangeRef::cell(range.start)); - range.intersection(&used.union(&RangeRef::cell(range.start))).unwrap_or(range) - } else { - range - } - }; - let (wb, range, areas) = if ranges.len() > 1 { - let areas: Vec = ranges.iter().map(|r| trim(*r)).collect(); - let (wb, block) = slide_together(&d.wb, sheet, &areas).ok_or_else(multiple)?; - (std::sync::Arc::new(wb), block, areas) - } else { - (d.wb.clone(), trim(ranges.first().copied().unwrap_or_default()), Vec::new()) - }; - let text = crate::display::range_text(&wb, sheet, range); - // The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and - // scripts get it only when they ask for it. - let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&wb, sheet, range, &text) } else { None }; - let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected, trust: d.trust }; - s.clipboard = Some(clip); - let mut result = json!({"range": range.a1(), "text": text}); - if let Some(html) = html { - result["html"] = Json::String(html); - } - Ok(result) -} - -/// Excel copies a multi-area selection only when the areas line up: all cover the same rows (they -/// slide together side by side, left to right) or all cover the same columns (stacked, top to -/// bottom), without overlapping. Returns a copy of the workbook whose sheet holds the areas packed -/// into one block at the first area's place, and that block. Formulas travel as their values, as -/// in Excel. `None` when the areas don't line up. -fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRef]) -> Option<(gridcraft_model::Workbook, RangeRef)> { - let first = *areas.first()?; - let across = areas.iter().all(|a| a.start.row == first.start.row && a.end.row == first.end.row); - let down = areas.iter().all(|a| a.start.col == first.start.col && a.end.col == first.end.col); - let mut sorted = areas.to_vec(); - if across { - sorted.sort_by_key(|a| a.start.col); - } else if down { - sorted.sort_by_key(|a| a.start.row); - } else { - return None; - } - if sorted.windows(2).any(|w| matches!(w, [a, b] if a.intersects(b))) { - return None; - } - let anchor = sorted.first()?.start; - let (h, w) = if across { - (first.height(), sorted.iter().map(RangeRef::width).fold(0u32, u32::saturating_add)) - } else { - (sorted.iter().map(RangeRef::height).fold(0u32, u32::saturating_add), first.width()) - }; - let block = RangeRef::new(anchor, anchor.offset(i64::from(h) - 1, i64::from(w) - 1)?); - let src = wb.sheet(sheet)?; - let mut out = wb.clone(); - let dst = out.sheet_mut(sheet)?; - dst.take_cells(block); - dst.spill.retain(|c, _| !block.contains(*c)); - dst.spill_ranges.retain(|c, _| !block.contains(*c)); - dst.comments.retain(|c, _| !block.contains(*c)); - dst.merges.retain(|m| !m.intersects(&block)); - if across { - dst.cols.retain(|c, _| !(block.start.col..=block.end.col).contains(c)); - } else { - dst.rows.retain(|r, _| !(block.start.row..=block.end.row).contains(r)); - } - let mut next = if across { anchor.col } else { anchor.row }; - for a in &sorted { - let (dr, dc) = if across { (0, i64::from(next) - i64::from(a.start.col)) } else { (i64::from(next) - i64::from(a.start.row), 0) }; - let cells = src.cells.iter_range(*a).map(|(c, _)| c).chain(src.spill.keys().copied().filter(|c| a.contains(*c))); - for c in cells { - let Some(to) = c.offset(dr, dc) else { continue }; - let style = src.cell(c).map(|x| x.style).unwrap_or_default(); - dst.set_cell(to, Cell { value: src.value(c), formula: None, style }); - if let Some(picture) = src.cell_pictures.get(&c) { - dst.cell_pictures.insert(to, picture.clone()); - } - } - for (c, comment) in src.comments.iter().filter(|(c, _)| a.contains(**c)) { - if let Some(to) = c.offset(dr, dc) { - dst.comments.insert(to, comment.clone()); - } - } - for m in src.merges.iter().filter(|m| a.contains_range(m)) { - if let (Some(s), Some(e)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) { - dst.merges.push(RangeRef::new(s, e)); - } - } - // Column widths (side by side) or row heights and hidden rows (stacked) follow their cells. - if across { - for (c, info) in src.cols.range(a.start.col..=a.end.col) { - dst.cols.insert(next.saturating_add(c.saturating_sub(a.start.col)), *info); - } - } else { - for (r, info) in src.rows.range(a.start.row..=a.end.row) { - dst.rows.insert(next.saturating_add(r.saturating_sub(a.start.row)), *info); - } - } - next = next.saturating_add(if across { a.width() } else { a.height() }); - } - Some((out, block)) -} - -fn copy(s: &mut Session, p: &Json) -> Result { - copy_impl(s, p, false) -} -fn cut(s: &mut Session, p: &Json) -> Result { - copy_impl(s, p, true) -} -fn clear_clipboard(s: &mut Session, _: &Json) -> Result { - s.clipboard = None; - s.format_painter = None; - ok() -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum What { - All, - Formulas, - Values, - Formats, - Comments, - Validation, - AllExceptBorders, - ColumnWidths, - FormulasAndNumberFormats, - ValuesAndNumberFormats, -} - -fn paste(s: &mut Session, p: &Json) -> Result { - // External text (from the system clipboard) when it differs from ours. - if let Some(text) = str_param(p, "text") - && s.clipboard.as_ref().is_none_or(|c| c.text.trim_end_matches(['\r', '\n']) != text.trim_end_matches(['\r', '\n'])) - { - return paste_text(s, p, text); - } - if s.clipboard.is_none() { - return Err(EngineError::Other("Nothing to paste.".into())); - } - paste_special(s, &json!({"what": "all", "at": p.get("at").cloned().unwrap_or(Json::Null)})) -} - -fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result { - let sheet = target_sheet(s, p)?; - let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start); - let rows: Vec> = parse_tsv(text); - let h = rows.len(); - let w = rows.iter().map(Vec::len).max().unwrap_or(0); - // The HTML flavour, when the host passed it: the number behind each cell's shown text. Read - // only as far as the text's own shape, so spans in the markup can't expand without bound. - let html = str_param(p, "html").map(|html| crate::clip_html::table_cells(html, h, w)).unwrap_or_default(); - edit(s, |cx| { - for (ri, row) in rows.iter().enumerate() { - for (ci, v) in row.iter().enumerate() { - let Some(c) = at.offset(ri as i64, ci as i64) else { continue }; - let mut cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str())))); - if let Some(cl) = cell.as_mut() { - numeric_from_html(cl, v, html.get(ri).and_then(|r| r.get(ci)), &mut cx.wb); - } - let sh = cx.sheet_mut(sheet)?; - match cell { - Some(cell) => sh.set_cell(c, cell), - None => { - sh.remove_cell(c); - } - } - cx.touch(sheet, c); - } - } - if h > 0 && w > 0 { - let end = at.offset_clamped(h as i64 - 1, w as i64 - 1); - *cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] }; - } - Ok(()) - })?; - Ok(json!({"rows": h, "cols": w})) -} - -/// A pasted cell that came out as text ("Rp10,000.00" isn't en-US input) becomes the number the -/// source recorded for it in its HTML, with a format that shows the same text when one can be -/// inferred and the cell has none. The HTML cell must show the same text as the tab-separated -/// field, so HTML that belongs to another copy (or a table laid out differently) changes nothing. -fn numeric_from_html(cell: &mut Cell, field: &str, html: Option<&crate::clip_html::HtmlCell>, wb: &mut gridcraft_model::Workbook) { - let Some(hc) = html else { return }; - let Some(n) = hc.num else { return }; - if cell.formula.is_some() || !matches!(cell.value, Value::Text(_)) || wb.styles.get(cell.style).num_fmt.as_str() == "@" { - return; - } - if crate::clip_html::normalized(&hc.text) != crate::clip_html::normalized(field) { - return; - } - cell.value = Value::number(n); - if wb.styles.get(cell.style).num_fmt.as_str() == "General" - && let Some(code) = crate::clip_html::format_for(field, n) - { - cell.style = wb.styles.derive(cell.style, |s| s.num_fmt = gridcraft_model::NumFmt::new(&code)); - } -} - -/// Tab-separated text with Excel-style quoting. -pub(crate) fn parse_tsv(text: &str) -> Vec> { - let mut rows = Vec::new(); - let mut row = Vec::new(); - let mut field = String::new(); - let mut chars = text.chars().peekable(); - let mut quoted = false; - let mut at_start = true; - while let Some(c) = chars.next() { - if quoted { - if c == '"' { - if chars.peek() == Some(&'"') { - field.push('"'); - chars.next(); - } else { - quoted = false; - } - } else { - field.push(c); - } - continue; - } - match c { - '"' if at_start => { - quoted = true; - at_start = false; - } - '\t' => { - row.push(std::mem::take(&mut field)); - at_start = true; - } - '\r' => {} - '\n' => { - row.push(std::mem::take(&mut field)); - rows.push(std::mem::take(&mut row)); - at_start = true; - } - _ => { - field.push(c); - at_start = false; - } - } - } - if !field.is_empty() || !row.is_empty() { - row.push(field); - rows.push(row); - } - rows -} - -fn paste_special(s: &mut Session, p: &Json) -> Result { - let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) }; - let what = match str_param(p, "what").unwrap_or("all") { - "all" => What::All, - "formulas" => What::Formulas, - "values" => What::Values, - "formats" => What::Formats, - "comments" | "notes" => What::Comments, - "validation" => What::Validation, - "allExceptBorders" => What::AllExceptBorders, - "columnWidths" => What::ColumnWidths, - "formulasAndNumberFormats" => What::FormulasAndNumberFormats, - "valuesAndNumberFormats" => What::ValuesAndNumberFormats, - other => return Err(bad("edit.pasteSpecial", format!("unknown `what`: {other}"))), - }; - let op = str_param(p, "operation").unwrap_or("none").to_string(); - let skip_blanks = bool_param(p, "skipBlanks").unwrap_or(false); - let transpose = bool_param(p, "transpose").unwrap_or(false); - let link = bool_param(p, "link").unwrap_or(false); - if link && !clip.areas.is_empty() { - return Err(EngineError::Other("This action won't work on multiple selections.".into())); - } - let dest_sheet = target_sheet(s, p)?; - let sel = s.doc()?.selection.current(); - let at = cell_param_on(s, p, "at", dest_sheet)?.unwrap_or(sel.start); - let src = clip.range; - let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) }; - // Tile the copy over a larger destination that is a multiple of its size. - let tile = sel.height() % h == 0 - && sel.width() % w == 0 - && !sel.is_single() - && cell_param_on(s, p, "at", dest_sheet)?.is_none() - && !sel.is_full_cols() - && !sel.is_full_rows() - && sel.count() <= 4_000_000; - let (tiles_r, tiles_c) = if tile { (sel.height() / h, sel.width() / w) } else { (1, 1) }; - let same_doc = s.doc()?.uid == clip.doc_uid; - let cut_move = clip.cut && same_doc && what == What::All; - let src_wb = clip.wb.clone(); - let Some(src_sheet) = src_wb.sheet(clip.sheet) else { return Err(EngineError::Other("The copied sheet no longer exists.".into())) }; - let src_sheet_name = src_sheet.name.clone(); - if cut_move && at.offset((tiles_r * h).saturating_sub(1) as i64, (tiles_c * w).saturating_sub(1) as i64).is_none() { - // Check the whole footprint before the source is emptied, so a move that overflows the grid loses nothing. - return Err(EngineError::Other("The cut area doesn't fit at this position; nothing was moved.".into())); - } - edit(s, |cx| { - // Styles from another workbook must be re-interned. - let mut style_map = std::collections::HashMap::new(); - let mut map_style = |id: StyleId, wb: &mut gridcraft_model::Workbook| -> StyleId { - if same_doc { - return id; - } - *style_map.entry(id).or_insert_with(|| wb.styles.intern(src_wb.styles.get(id).clone())) - }; - let mut moved_formulas = Vec::new(); - let mut moved_pictures = std::collections::BTreeMap::new(); - if cut_move { - // Remove the source first (contents and formats), and fix references to it. - let source = cx.sheet_mut(clip.sheet)?; - moved_pictures = source.cell_pictures.iter().filter(|(c, _)| src.contains(**c)).map(|(c, p)| (*c, p.clone())).collect(); - let taken = source.take_cells(src); - // Notes, hyperlinks and merged areas sit beside the cells, and a cut takes them - // with the block: the destination is written from the clipboard snapshot taken - // before the cut, so removing them here loses nothing and leaves the old place - // clean instead of leaving one merged block and one note behind. - source.comments.retain(|c, _| !src.contains(*c)); - source.hyperlinks.retain(|c, _| !src.contains(*c)); - source.merges.retain(|m| !src.contains_range(m)); - for (c, _) in &taken { - cx.touch(clip.sheet, *c); - } - moved_formulas = taken; - } - for tr in 0..tiles_r { - for tc in 0..tiles_c { - for r in 0..src.height() { - for c in 0..src.width() { - let sc = CellRef::new(src.start.row + r, src.start.col + c); - let (dr, dc) = if transpose { (c, r) } else { (r, c) }; - let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue }; - let src_cell = if cut_move { - moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone()) - } else { - src_sheet.cell(sc).cloned() - }; - let src_val = src_sheet.value(sc); - let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned(); - if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) { - continue; - } - let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default(); - let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned(); - let mut new_picture = old_picture.clone(); - let mut new = old.clone(); - let shift = |cell: &Cell| -> Option> { - let f = cell.formula.as_ref()?; - let e = f.expr()?; - if cut_move { - // A moved formula still reads cells on its original sheet. Qualify - // those references before the workbook-wide move adjustment below. - let e = if dest_sheet != clip.sheet { - e.map(&mut |x| match x { - gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => { - r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone()); - gridcraft_formula::Expr::Ref(r) - } - other => other, - }) - } else { - e - }; - let mut moved = Formula::from_expr(e); - // The array range moves with its formula. - let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); - moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?))); - moved.dynamic = f.dynamic; - return Some(std::sync::Arc::new(moved)); - } - let moved = - gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); - Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) })) - }; - if link { - let sheet_prefix = if dest_sheet == clip.sheet && same_doc { - String::new() - } else { - format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name)) - }; - new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1())))); - new.value = Value::Empty; - new_picture = None; - } else { - let sc_cell = src_cell.clone().unwrap_or_default(); - match what { - What::All | What::AllExceptBorders => { - new_picture = src_picture.clone(); - new.formula = shift(&sc_cell); - new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; - let st = map_style(sc_cell.style, &mut cx.wb); - new.style = if what == What::AllExceptBorders { - let borders = cx.wb.styles.get(old.style).borders; - cx.wb.styles.derive(st, |s| s.borders = borders) - } else { - st - }; - } - What::Formulas | What::FormulasAndNumberFormats => { - new_picture = src_picture.clone(); - new.formula = shift(&sc_cell); - new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; - if what == What::FormulasAndNumberFormats { - let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); - new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); - } - } - What::Values | What::ValuesAndNumberFormats => { - new_picture = src_picture.clone(); - new.formula = None; - new.value = src_val.clone(); - if what == What::ValuesAndNumberFormats { - let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); - new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); - } - } - What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb), - What::Comments | What::Validation | What::ColumnWidths => {} - } - // Operations combine numbers with what is already there. - if op != "none" - && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) - && new.formula.is_none() - { - if old_picture.is_some() || new_picture.is_some() { - new_picture = None; - new.value = Value::Error(gridcraft_core::CellError::Value); - } - let a = old.value.clone(); - let b = new.value.clone(); - // Numbers stored as text take part (multiplying by 1 turns them into - // numbers); other text and errors stay as they are. - if let (Ok(x), Ok(y)) = (a.to_number(), b.to_number()) - && (a.is_number() || a.is_empty() || a.is_text()) - && (b.is_number() || b.is_empty()) - { - new.value = match op.as_str() { - "add" => Value::number(x + y), - "subtract" => Value::number(x - y), - "multiply" => Value::number(x * y), - "divide" => { - if y == 0.0 { - Value::Error(gridcraft_core::CellError::Div0) - } else { - Value::number(x / y) - } - } - _ => b.clone(), - }; - } else if (a.is_text() || a.is_error()) && (b.is_number() || b.is_empty()) { - new.value = a; - } - // A formula already there is kept and combined with the pasted number. - if let Some(f) = &old.formula - && let Ok(y) = b.to_number() - && (b.is_number() || b.is_empty()) - && let Some(sym) = match op.as_str() { - "add" => Some('+'), - "subtract" => Some('-'), - "multiply" => Some('*'), - "divide" => Some('/'), - _ => None, - } - { - let text = format!("=({}){sym}{}", f.text, gridcraft_core::number_to_text(y)); - new.formula = Some(std::sync::Arc::new(Formula::new(&text))); - new.value = Value::Empty; - } - } - } - // The copied cell's validation rule replaces whatever rule the destination had. - if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) { - let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned(); - let sh = cx.sheet_mut(dest_sheet)?; - super::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]); - if let Some(mut dv) = rule { - // Relative references move with the rule, as pasted formulas do. - let first = dv.ranges.first().map_or(sc, |r| r.start); - dv.f1 = super::data::move_rule_formula(&dv.f1, first, dest); - dv.f2 = dv.f2.map(|f| super::data::move_rule_formula(&f, first, dest)); - dv.ranges = vec![RangeRef::new(dest, dest)]; - sh.validations.push(dv); - } - } - match what { - What::Comments => { - if let Some(cm) = src_sheet.comments.get(&sc).cloned() { - cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); - } - } - What::ColumnWidths => { - if let Some(info) = src_sheet.cols.get(&sc.col).copied() { - cx.sheet_mut(dest_sheet)?.cols.insert(dest.col, info); - } - } - What::All => { - if let Some(cm) = src_sheet.comments.get(&sc).cloned() { - cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); - } - if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() { - cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h); - } - cx.sheet_mut(dest_sheet)?.set_cell(dest, new); - } - _ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new), - } - if let Some(picture) = new_picture { - cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture); - } - cx.touch(dest_sheet, dest); - } - } - } - } - // Merged areas inside the copied block travel with it. - if matches!(what, What::All | What::Formats) && !transpose { - let merges: Vec = src_sheet.merges.iter().filter(|m| src.contains_range(m)).copied().collect(); - for m in merges { - let dr = at.row as i64 - src.start.row as i64; - let dc = at.col as i64 - src.start.col as i64; - if let (Some(a), Some(b)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) { - let nm = RangeRef::new(a, b); - let sh = cx.sheet_mut(dest_sheet)?; - sh.merges.retain(|x| !x.intersects(&nm)); - sh.merges.push(nm); - } - } - } - if cut_move { - // References elsewhere to the moved block now point to its new place. - let dest_name = cx.wb.sheet(dest_sheet).map(|s| s.name.clone()).unwrap_or_default(); - let e = if dest_sheet == clip.sheet { - gridcraft_formula::adjust::Edit::Move { from: src, to_row: at.row, to_col: at.col } - } else { - gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name, to_row: at.row, to_col: at.col } - }; - rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e); - cx.structural = true; - } - let end = at.offset_clamped((tiles_r * h) as i64 - 1, (tiles_c * w) as i64 - 1); - *cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] }; - Ok(()) - })?; - // Formulas and validation from an untrusted file leave the destination untrusted; plain values, - // formats (cell styles and merged areas, no conditional formats), column widths and notes - // carry nothing active. A linked paste writes a formula into every cell whatever `what` says, - // except for column widths and notes, which write no cells at all. - let passive = - matches!(what, What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Formats | What::Values | What::ValuesAndNumberFormats)); - if !same_doc && !passive { - let d = s.doc_mut()?; - d.trust = d.trust.receive(clip.trust); - } - if cut_move { - s.clipboard = None; - } - Ok(json!({"pasted": s.doc()?.selection.a1()})) -} - -/// Applies a reference adjustment to every cell formula and defined name in the workbook. -pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) { - use gridcraft_formula::adjust::Edit; - // Rows and columns inserted or deleted on the target sheet move its legacy array ranges too - // (a cut moves its arrays itself). - let moves_arrays = matches!(e, Edit::Insert { .. } | Edit::Delete { .. } | Edit::InsertCells { .. } | Edit::DeleteCells { .. }); - for si in 0..wb.sheets.len() { - let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default(); - let keys: Vec<(CellRef, std::sync::Arc)> = - wb.sheets.get(si).map(|s| s.cells.iter().filter_map(|(c, cell)| cell.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default(); - if keys.is_empty() { - continue; - } - let on_target = moves_arrays && host.eq_ignore_ascii_case(target); - let Some(sheet) = wb.sheet_mut(si) else { continue }; - for (c, f) in keys { - let Some(expr) = f.expr() else { continue }; - let new = gridcraft_formula::adjust::adjust(expr, &host, target, e); - let text = gridcraft_formula::print(&new); - let array = match f.array { - Some(a) if on_target => super::sheet::map_range(a, e), - a => a, - }; - if (text != f.text || array != f.array) - && let Some(cell) = sheet.cells.get_mut(c) - { - let mut nf = Formula::from_expr(new); - nf.array = array; - nf.dynamic = f.dynamic; - cell.formula = Some(std::sync::Arc::new(nf)); - } - } - } - for n in wb.names.iter_mut() { - if let Ok(expr) = gridcraft_formula::parse_name(&n.formula) { - // Names are workbook-level: unqualified refs don't occur; qualified ones adjust. - let new = gridcraft_formula::adjust::adjust(expr, "\u{0}", target, e); - n.formula = gridcraft_formula::print(&new); - } - } -} - // ---------------------------------------------------------------- clear fn clear(s: &mut Session, p: &Json, what: &str) -> Result { @@ -1538,283 +301,6 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { }) } -// ---------------------------------------------------------------- fill - -fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result { - let sheet = target_sheet(s, p)?; - let mut r = target_range(s, p)?; - // Whole rows or columns fill only where the sheet has content (nothing on an empty sheet). - if r.is_full_cols() || r.is_full_rows() { - match s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| r.intersection(&u)) { - Some(u) => r = u, - None => return Ok(Json::Null), - } - } - if r.count() > crate::fill::MAX_FILL_CELLS { - return Err(bad("edit.fill", "range too large")); - } - // With one row selected, Fill Down copies from the row above (Excel behaviour). - if dr == 1 && r.height() == 1 { - r.start.row = r.start.row.saturating_sub(1); - } - if dc == 1 && r.width() == 1 { - r.start.col = r.start.col.saturating_sub(1); - } - let src = if dr == 1 { - RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) - } else if dr == -1 { - RangeRef::new(CellRef::new(r.end.row, r.start.col), r.end) - } else if dc == 1 { - RangeRef::new(r.start, CellRef::new(r.end.row, r.start.col)) - } else { - RangeRef::new(CellRef::new(r.start.row, r.end.col), r.end) - }; - edit(s, |cx| { - crate::fill::fill(cx, sheet, src, r, crate::fill::FillMode::Copy)?; - Ok(Json::Null) - }) -} - -fn auto_fill(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; - let src = str_param(p, "source").and_then(RangeRef::parse).map_or_else(|| target_range(s, p), Ok)?; - let Some(target) = str_param(p, "target").and_then(RangeRef::parse) else { return Err(bad("edit.autoFill", "missing `target`")) }; - let mode = match str_param(p, "mode").unwrap_or("series") { - "copy" => crate::fill::FillMode::Copy, - "alternate" => crate::fill::FillMode::Alternate, - "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 { - return Err(bad("edit.autoFill", "range too large")); - } - // Excel refuses a target that doesn't hold the source in a corner. Such targets used to fill - // every source lane across the whole target extent: billions of cells for whole rows (`2:3`) - // into whole columns (`A:A`). - if !crate::fill::holds_source_in_corner(src, target) { - return Err(bad("edit.autoFill", "the target must contain the source in one of its corners")); - } - edit(s, |cx| { - crate::fill::fill(cx, sheet, src, target, mode)?; - *cx.sel = crate::Selection { active: src.start, anchor: src.start, ranges: vec![target.union(&src)] }; - Ok(Json::Null) - }) -} - -fn fill_series(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; - let r = target_range(s, p)?; - let rows = str_param(p, "direction").map(|d| d == "rows").unwrap_or(r.width() > r.height()); - let kind = str_param(p, "type").unwrap_or("linear").to_string(); - let step = f64_param(p, "step").unwrap_or(1.0); - let stop = f64_param(p, "stop"); - let unit = str_param(p, "dateUnit").unwrap_or("day").to_string(); - edit(s, |cx| { - crate::fill::series(cx, sheet, r, rows, &kind, step, stop, &unit)?; - Ok(Json::Null) - }) -} - -fn flash_fill(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; - let r = target_range(s, p)?; - let n = edit(s, |cx| crate::fill::flash_fill(cx, sheet, r.start))?; - if n == 0 { - return Err(EngineError::Other( - "We looked at all the data next to your selection and didn't see a pattern for filling in values for you.".into(), - )); - } - Ok(json!({"filled": n})) -} - -// ---------------------------------------------------------------- find & replace - -fn matches(hay: &str, what: &str, case: bool, whole: bool) -> bool { - let (h, w) = if case { (hay.to_string(), what.to_string()) } else { (hay.to_lowercase(), what.to_lowercase()) }; - let has_wild = w.contains(['*', '?']); - if has_wild { - let pat = if whole { w } else { format!("*{w}*") }; - return wildcard(&h, &pat); - } - if whole { h == w } else { h.contains(&w) } -} - -/// `*`/`?` wildcard match with `~` escapes. -pub(crate) fn wildcard(text: &str, pat: &str) -> bool { - let t: Vec = text.chars().collect(); - let mut p: Vec<(char, bool)> = Vec::new(); - let mut it = pat.chars(); - while let Some(c) = it.next() { - if c == '~' { - if let Some(n) = it.next() { - p.push((n, true)); - } - } else { - p.push((c, false)); - } - } - let (mut ti, mut pi) = (0usize, 0usize); - let (mut star, mut mark) = (None, 0usize); - while ti < t.len() { - if pi < p.len() && (p[pi] == ('?', false) || (p[pi].0 == t[ti] && p[pi] != ('*', false))) { - ti += 1; - pi += 1; - } else if pi < p.len() && p[pi] == ('*', false) { - star = Some(pi); - mark = ti; - pi += 1; - } else if let Some(sp) = star { - pi = sp + 1; - mark += 1; - ti = mark; - } else { - return false; - } - } - while pi < p.len() && p[pi] == ('*', false) { - pi += 1; - } - pi == p.len() -} - -fn find(s: &mut Session, p: &Json) -> Result { - let what = str_param(p, "what").unwrap_or("").to_string(); - if what.is_empty() { - return Err(bad("edit.find", "missing `what`")); - } - let case = bool_param(p, "matchCase").unwrap_or(false); - let whole = bool_param(p, "wholeCell").unwrap_or(false); - let values = str_param(p, "lookIn") == Some("values"); - let by_cols = bool_param(p, "byColumns").unwrap_or(false); - let all = bool_param(p, "all").unwrap_or(false); - let workbook = str_param(p, "within") == Some("workbook"); - s.last_find = Some(p.clone()); - let d = s.doc()?; - let sheets: Vec = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet()] }; - let mut hits: Vec<(usize, CellRef, String)> = Vec::new(); - for si in sheets { - let Some(sh) = d.wb.sheet(si) else { continue }; - let mut cells: Vec<(CellRef, String)> = sh - .cells - .iter() - .filter_map(|(c, cell)| { - let text = if values || cell.formula.is_none() || crate::display::formula_hidden(&d.wb, sh, c) { - crate::display::cell_text(&d.wb, sh, c) - } else { - cell.input_text() - }; - matches(&text, &what, case, whole).then_some((c, text)) - }) - .collect(); - if by_cols { - cells.sort_by_key(|(c, _)| (c.col, c.row)); - } - hits.extend(cells.into_iter().map(|(c, t)| (si, c, t))); - } - if hits.is_empty() { - return Err(EngineError::Other(format!("We couldn't find what you were looking for: {what}"))); - } - if all { - let list: Vec = - hits.iter().map(|(si, c, t)| json!({"sheet": d.wb.sheet(*si).map(|s| s.name.clone()), "cell": c.a1(), "value": t})).collect(); - return Ok(json!({"count": list.len(), "results": list})); - } - // Next hit after the active cell. - let cur = (d.wb.active_sheet(), d.selection.active); - let key = |si: usize, c: CellRef| if by_cols { (si, c.col, c.row) } else { (si, c.row, c.col) }; - let next = hits.iter().find(|(si, c, _)| key(*si, *c) > key(cur.0, cur.1)).or_else(|| hits.first()).cloned(); - let Some((si, c, t)) = next else { return Err(EngineError::Other("not found".into())) }; - let dd = s.doc_mut()?; - dd.wb_switch_sheet(si); - dd.selection = crate::Selection::at(c); - Ok(json!({"cell": c.a1(), "sheet": si, "value": t, "count": hits.len()})) -} - -fn replace(s: &mut Session, p: &Json) -> Result { - let what = str_param(p, "what").unwrap_or("").to_string(); - let with = str_param(p, "with").unwrap_or("").to_string(); - if what.is_empty() { - return Err(bad("edit.replace", "missing `what`")); - } - let case = bool_param(p, "matchCase").unwrap_or(false); - let whole = bool_param(p, "wholeCell").unwrap_or(false); - let all = bool_param(p, "all").unwrap_or(true); - let workbook = str_param(p, "within") == Some("workbook"); - let active = s.doc()?.selection.active; - let n = edit(s, |cx| { - let sheets: Vec = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet()] }; - let mut n = 0; - for si in sheets { - let cells: Vec<(CellRef, String)> = - cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, _)| (c, sh.input_text(c))).collect()).unwrap_or_default(); - for (c, text) in cells { - if !all && c != active { - continue; - } - if !matches(&text, &what, case, whole) { - continue; - } - let new = if whole { - with.clone() - } else if case { - text.replace(&what, &with) - } else { - replace_ci(&text, &what, &with) - }; - let cell = input_to_cell(&new, si, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str())))); - let sh = cx.sheet_mut(si)?; - match cell { - Some(cell) => sh.set_cell(c, cell), - None => { - sh.remove_cell(c); - } - } - cx.touch(si, c); - n += 1; - } - } - Ok(n) - })?; - Ok(json!({"replaced": n})) -} - -/// Whether case-folding `s` maps every char to exactly one char of the same UTF-8 length, so -/// byte positions in the folded text still line up with the original's character boundaries. -fn folds_in_place(s: &str) -> bool { - s.chars().all(|c| { - let mut folded = c.to_lowercase(); - let first = folded.next(); - folded.next().is_none() && first.is_some_and(|l| l.len_utf8() == c.len_utf8()) - }) -} - -fn replace_ci(text: &str, what: &str, with: &str) -> String { - let lower = text.to_lowercase(); - let w = what.to_lowercase(); - // Slicing `text` at positions found in `lower` is only sound when folding kept every byte - // boundary: a length check alone misses foldings that keep the total length while shifting - // boundaries (U+212A KELVIN SIGN shrinks 3→1 bytes, U+0130 grows 2→3), where the tail used - // to be dropped. Those fall back to the case-sensitive replace, as length changes already do. - if lower.len() != text.len() || w.is_empty() || !folds_in_place(text) || !folds_in_place(what) { - return text.replace(what, with); - } - let mut out = String::new(); - let mut i = 0; - while let Some(pos) = lower.get(i..).and_then(|s| s.find(&w)) { - out.push_str(text.get(i..i + pos).unwrap_or("")); - out.push_str(with); - i += pos + w.len(); - } - out.push_str(text.get(i..).unwrap_or("")); - out -} - // ---------------------------------------------------------------- format painter fn format_painter(s: &mut Session, p: &Json) -> Result { @@ -1910,310 +396,4 @@ fn begin_edit(s: &mut Session, _: &Json) -> Result { } #[cfg(test)] -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![""]); - 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. - let mut s = Session::new(); - s.new_workbook(); - s.execute("cell.set", json!({"cell": "A1", "input": 123.5})).unwrap(); - s.execute("cell.set", json!({"cell": "A2", "value": 77})).unwrap(); - s.execute("cell.set", json!({"cell": "A3", "input": true})).unwrap(); - s.execute("cell.set", json!({"cell": "A4", "input": "=A1+A2"})).unwrap(); - let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); - assert_eq!(get(&mut s, "A1")["value"], json!(123.5)); - assert_eq!(get(&mut s, "A2")["value"], json!(77.0)); - assert_eq!(get(&mut s, "A3")["value"], json!(true)); - assert_eq!(get(&mut s, "A4")["value"], json!(200.5)); - assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err()); - } - - /// Every string in `values` went through typed input, read with en-US rules, in silence: a - /// caller sending "05.04.2026" from a dd.mm.yyyy export stored 4 May, "2.500" became 2.5 and - /// "4.25 %" became a percentage (#2255). `parse: false` stores a string as the text it is. - #[test] - fn set_values_can_store_strings_as_the_text_they_are() { - let mut s = Session::new(); - s.new_workbook(); - let strings = ["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"]; - let row: Vec = strings.iter().map(|v| json!(v)).collect(); - let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); - - // The default is unchanged: the dates swap, the thousands separator is a decimal point. - s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap(); - assert_eq!(get(&mut s, "A1")["text"], "5/4/2026", "dd.mm.yyyy read as mm.dd.yyyy"); - assert_eq!(get(&mut s, "C1")["value"], json!(2.5), "\"2.500\" read as a decimal"); - assert_eq!(get(&mut s, "D1")["value"], json!(0.0425), "\"4.25 %\" read as a percentage"); - - // With `parse: false` each string is the text it was given, and a formula is still a - // formula. - s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row.clone()]})).unwrap(); - for (i, want) in strings.iter().enumerate() { - let cell = format!("{}3", (b'A' + i as u8) as char); - let got = get(&mut s, &cell); - if *want == "=1+1" { - assert_eq!(got["value"], json!(2.0), "{cell} is still a formula: {got}"); - assert_eq!(got["formula"], "=1+1", "{got}"); - } else { - assert_eq!(got["value"], json!(*want), "{cell} is the text it was given: {got}"); - assert!(got["formula"].is_null(), "{cell} is not a formula: {got}"); - } - } - // Numbers and booleans are untouched either way. - s.execute("range.setValues", json!({"range": "A5", "parse": false, "values": [[12.5, true, null]]})).unwrap(); - assert_eq!(get(&mut s, "A5")["value"], json!(12.5)); - assert_eq!(get(&mut s, "B5")["value"], json!(true)); - assert_eq!(get(&mut s, "C5")["text"], ""); - // `parse` is a flag, and anything else is a usage error rather than a quiet default. - s.execute("range.setValues", json!({"range": "A7", "parse": true, "values": [["2.500"]]})).unwrap(); - assert_eq!(get(&mut s, "A7")["value"], json!(2.5)); - assert!(s.execute("range.setValues", json!({"range": "A8", "parse": "no", "values": [["x"]]})).is_err()); - assert!(s.execute("range.setValues", json!({"range": "A8", "parse": 0, "values": [["x"]]})).is_err()); - // A line break still turns on Wrap Text. - s.execute("range.setValues", json!({"range": "A9", "parse": false, "values": [["one\ntwo"]]})).unwrap(); - assert_eq!(get(&mut s, "A9")["value"], json!("one\ntwo")); - let d = s.doc().unwrap(); - let sh = d.wb.active().unwrap(); - assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap); - } - - #[test] - fn legacy_array_entry() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "B2", "values": [[1], [2]]})).unwrap(); - s.execute("cell.set", json!({"cell": "C4", "input": "old"})).unwrap(); - s.execute("selection.set", json!({"range": "C2:C4"})).unwrap(); - s.execute("cell.set", json!({"cell": "C2", "input": "=B2:B3*2", "array": true})).unwrap(); - let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); - assert_eq!(get(&mut s, "C3")["value"], json!(4.0)); - // Past the result Excel shows #N/A; the old content of the range is replaced. - assert_eq!(get(&mut s, "C4")["text"], "#N/A"); - let bar = s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("C2").unwrap()).map(|c| c.bar_text()); - assert_eq!(bar.as_deref(), Some("{=B2:B3*2}")); - } - - #[test] - fn an_array_entered_outside_the_selection_covers_only_its_cell() { - // Found by the command-sequence fuzzer: the array took the selection's range even with - // `cell` elsewhere (or on another sheet), so its formula sat outside its own range and the - // selected cells were wiped. - let mut s = Session::new(); - s.new_workbook(); - s.execute("cell.set", json!({"cell": "K20", "input": "keep"})).unwrap(); - s.execute("selection.set", json!({"range": "K20:K22"})).unwrap(); - s.execute("cell.set", json!({"cell": "H5", "input": "=1+1", "array": true})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - let f = sh.cell(CellRef::parse("H5").unwrap()).and_then(|c| c.formula.clone()).unwrap(); - assert_eq!(f.array, Some(RangeRef::parse("H5").unwrap())); - assert_eq!(sh.value(CellRef::parse("K20").unwrap()), Value::text("keep")); - } - - #[test] - fn wildcards() { - assert!(wildcard("hello", "h*o")); - assert!(wildcard("hello", "h?llo")); - assert!(!wildcard("hello", "h?lo")); - assert!(wildcard("a*b", "a~*b")); - assert!(!wildcard("axb", "a~*b")); - assert!(wildcard("", "*")); - } - - #[test] - fn tsv() { - assert_eq!(parse_tsv("a\tb\n1\t2\n"), vec![vec!["a", "b"], vec!["1", "2"]]); - assert_eq!(parse_tsv("\"x\ty\"\tz"), vec![vec!["x\ty", "z"]]); - assert_eq!(parse_tsv("\"he said \"\"hi\"\"\""), vec![vec!["he said \"hi\""]]); - } - - #[test] - fn paste_special_operations_keep_text_errors_and_formulas() { - let mut s = Session::new(); - s.new_workbook(); - s.execute( - "range.setValues", - json!({"range": "A1", "values": [["'123", "Total", "=NA()", "=B3*2", 5], [1, null, null, null, null], [null, 4, null, null, null]]}), - ) - .unwrap(); - let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); - assert_eq!(get(&mut s, "A1")["value"], "123"); - s.execute("edit.copy", json!({"range": "A2"})).unwrap(); - s.execute("selection.set", json!({"range": "A1:E1"})).unwrap(); - s.execute("edit.pasteSpecial", json!({"what": "values", "operation": "multiply"})).unwrap(); - assert_eq!(get(&mut s, "A1")["value"], json!(123.0)); - assert_eq!(get(&mut s, "B1")["value"], "Total"); - assert_eq!(get(&mut s, "C1")["value"]["error"], "#N/A"); - assert_eq!(get(&mut s, "D1")["formula"], "=(B3*2)*1"); - assert_eq!(get(&mut s, "D1")["value"], json!(8.0)); - assert_eq!(get(&mut s, "E1")["value"], json!(5.0)); - } - - #[test] - fn cut_paste_that_overflows_the_grid_moves_nothing() { - // #997: the source was emptied first and the cells that fell off the grid were skipped. - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2]]})).unwrap(); - let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); - s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap(); - // Transposed, the 2 rows become 2 columns and the second one is past XFD. - assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "transpose": true, "at": "XFD1"})).is_err()); - assert_eq!(get(&mut s, "A1")["value"], json!(1.0)); - assert_eq!(get(&mut s, "A2")["value"], json!(2.0)); - // Not transposed, the second row would be past the last row. - assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "at": "A1048576"})).is_err()); - assert_eq!(get(&mut s, "A1")["value"], json!(1.0)); - assert_eq!(get(&mut s, "A2")["value"], json!(2.0)); - // Not transposed it still fits in column XFD. - s.execute("edit.pasteSpecial", json!({"what": "all", "at": "XFD1"})).unwrap(); - assert_eq!(get(&mut s, "XFD1")["value"], json!(1.0)); - assert_eq!(get(&mut s, "XFD2")["value"], json!(2.0)); - } - - #[test] - fn replace_case_insensitive() { - assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear"); - } - - #[test] - fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() { - // U+212A KELVIN SIGN folds to `k` (3→1 bytes) while U+0130 folds to `i` plus a - // combining dot (2→3 bytes): the folded text is the same length but its byte positions - // no longer line up with the original, and the old slicing dropped the tail. - let mut s = Session::new(); - s.new_workbook(); - let text = "\u{212A}\u{0130}\u{0130}"; - s.execute("cell.set", json!({"cell": "A1", "input": text})).unwrap(); - s.execute("edit.replace", json!({"what": "k", "with": "x"})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text)); - } -} +mod tests; diff --git a/crates/engine/src/cmd/edit/cell.rs b/crates/engine/src/cmd/edit/cell.rs new file mode 100644 index 0000000000..62a6b38bd0 --- /dev/null +++ b/crates/engine/src/cmd/edit/cell.rs @@ -0,0 +1,287 @@ +use super::*; + +// ---------------------------------------------------------------- entry + +/// What typing `input` into a cell produces (constant or formula, plus an automatic number +/// format). Inherits the destination's cell, row or column style, including its number +/// format, before parsing. Errors when the formula can't be parsed. +pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gridcraft_model::Workbook) -> Result> { + let style = wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); + if input.is_empty() { + let c = Cell { value: Value::Empty, formula: None, style }; + return Ok(if c.is_blank() { None } else { Some(c) }); + } + let fmt_is_text = wb.styles.get(style).num_fmt.as_str() == "@"; + if fmt_is_text { + return Ok(Some(Cell { value: Value::text(input), formula: None, style })); + } + let is_formula = input.starts_with('=') + || (input.len() > 1 + && (input.starts_with('+') || input.starts_with('-')) + && gridcraft_core::parse::parse_number_text(input).is_none() + && input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '(')); + if is_formula { + let body = input.strip_prefix('=').unwrap_or(input); + let expr = wb.name_call_case(parse_typed_formula(body)?); + check_arg_counts(&expr)?; + let mut cell = Cell::formula(Formula::from_expr(expr)); + cell.style = style; + // Formulas whose result is a date/time get a format from functions like TODAY(). + if wb.styles.get(style).num_fmt.as_str() == "General" { + let f = cell.formula.as_ref().map(|f| f.text.to_ascii_uppercase()).unwrap_or_default(); + let auto = if f.starts_with("TODAY(") + || f.starts_with("DATE(") + || f.starts_with("EDATE(") + || f.starts_with("EOMONTH(") + || f.starts_with("WORKDAY(") + { + Some("m/d/yyyy") + } else if f.starts_with("NOW(") { + Some("m/d/yyyy h:mm") + } else if f.starts_with("TIME(") { + Some("h:mm AM/PM") + } else { + None + }; + if let Some(code) = auto { + cell.style = wb.styles.derive(style, |s| s.num_fmt = gridcraft_model::NumFmt::new(code)); + } + } + return Ok(Some(cell)); + } + let mut parsed = gridcraft_core::parse::parse_input(input, wb.date_system); + // Like Excel's automatic percent entry: a bare number of 1 or more typed into a cell already + // formatted as a percentage is a percent (3 -> 3%); smaller numbers are taken as fractions. + if parsed.format.is_none() + && !input.contains('%') + && wb.styles.get(style).num_fmt.as_str().ends_with('%') + && let Value::Number(n) = parsed.value + && n.abs() >= 1.0 + { + parsed.value = Value::Number(n / 100.0); + } + let mut style = style; + if let Some(code) = parsed.format { + let cur = wb.styles.get(style).num_fmt.as_str().to_string(); + if cur == "General" { + style = wb.styles.derive(style, |s| s.num_fmt = gridcraft_model::NumFmt::new(code)); + } + } + // Wrap text automatically when the input has line breaks (Alt+Enter). + if input.contains('\n') && !wb.styles.get(style).align.wrap { + style = wb.styles.derive(style, |s| s.align.wrap = true); + } + Ok(Some(Cell { value: parsed.value, formula: None, style })) +} + +/// Parses a formula as it is typed (without the `=`). Excel closes missing parentheses for you, +/// and refuses a formula that still doesn't parse. +pub(crate) fn parse_typed_formula(body: &str) -> Result { + // Excel closes missing parentheses for you. + let parsed = gridcraft_formula::parse_closing_parens(body, gridcraft_formula::parse); + parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}"))) +} + +/// Like Excel, a formula that calls a built-in function with too few or too many arguments is +/// refused when it's entered, rather than stored to show `#VALUE!`. Functions the registry doesn't +/// hold (special forms like LET, LAMBDA, OFFSET, INDIRECT, SUBTOTAL), defined LAMBDA names and +/// unknown functions are skipped. +pub(crate) fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> { + let mut bad = None; + expr.walk(&mut |e| { + if bad.is_none() + && let gridcraft_formula::Expr::Call(name, args) = e + && let Some(spec) = gridcraft_functions::lookup(name) + && !spec.accepts(args.len()) + { + let few = args.len() < spec.min_args; + bad = Some(format!("{} has too {} arguments. Use {}", spec.name, if few { "few" } else { "many" }, spec.signature)); + } + }); + match bad { + Some(msg) => Err(EngineError::Other(msg)), + None => Ok(()), + } +} + +pub(super) fn cell_set(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let at = match cell_param_on(s, p, "cell", sheet)? { + Some(c) => c, + None => s.doc()?.selection.active, + }; + // Both spellings take a string, number or boolean, as typed (#697). + let input = p.get("input").or_else(|| p.get("value")).map(json_to_input).ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?; + let array = bool_param(p, "array").unwrap_or(false); + let d = s.doc()?; + if let Some(sh) = d.wb.sheet(sheet) + && sh.is_protected() + && d.wb.styles.get(sh.style_id(at)).protection.locked + && let Some(m) = locked_cell_error(sh, at, &d.unlocked_ranges) + { + return Err(EngineError::Other(m)); + } + let active_sheet = s.doc()?.wb.active_sheet(); + edit(s, |cx| { + if array { + // Legacy Ctrl+Shift+Enter array formula over the selection, when the selection holds + // the cell; a cell elsewhere (or on another sheet) gets a one-cell array. + let selected = cx.sel.current(); + let range = if sheet == active_sheet && selected.contains(at) { selected } else { RangeRef::cell(at) }; + // A table header can't hold an array's value, and a table resizes under it. + if !range.is_single() && cx.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.range.intersects(&range))) { + return Err(EngineError::Other("Multi-cell array formulas aren't allowed in tables.".into())); + } + let mut cell = input_to_cell(&input, sheet, range.start, &mut cx.wb)?.unwrap_or_default(); + if let Some(f) = cell.formula.as_mut() { + std::sync::Arc::make_mut(f).array = Some(range); + } + let sh = cx.sheet_mut(sheet)?; + // The array takes the whole range: other cells in it lose their contents. + for c in range.iter().skip(1).take(1_000_000) { + if let Some(old) = sh.cells.get(c).filter(|x| !x.value.is_empty() || x.formula.is_some()) { + let style = old.style; + sh.set_cell(c, Cell { style, ..Cell::default() }); + } + } + sh.set_cell(range.start, cell); + cx.touch(sheet, range.start); + return Ok(Json::Null); + } + let cell = input_to_cell(&input, sheet, at, &mut cx.wb)?; + let sh = cx.sheet_mut(sheet)?; + match cell { + Some(c) => sh.set_cell(at, c), + None => { + sh.remove_cell(at); + } + } + crate::cmd::data::grow_autofilter(sh, at); + cx.touch(sheet, at); + Ok(Json::Null) + })?; + let v = s.doc()?.wb.sheet(sheet).map(|sh| sh.value(at)).unwrap_or_default(); + Ok(json!({"cell": at.a1(), "value": v.display()})) +} + +pub(crate) fn json_to_input(v: &Json) -> String { + match v { + Json::Null => String::new(), + Json::Bool(b) => { + if *b { + "TRUE".into() + } else { + "FALSE".into() + } + } + Json::Number(n) => n.to_string(), + Json::String(s) => s.clone(), + other => other.to_string(), + } +} + +pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let start = target_range(s, p)?.start; + let Some(rows) = p.get("values").and_then(Json::as_array) else { return Err(bad("range.setValues", "missing `values` (array of rows)")) }; + // A string is run through typed input by default, which reads it with en-US rules: a caller + // sending "05.04.2026" from a dd.mm.yyyy export got 4 May, and "2.500" became 2.5, in silence + // (#2255). `parse: false` stores a string as the text it is; numbers, booleans and formulas + // are unaffected either way. + let parse = match p.get("parse") { + None | Some(Json::Null) => true, + Some(Json::Bool(b)) => *b, + Some(other) => return Err(bad("range.setValues", format!("parse {other}: true or false"))), + }; + if rows.len() > MAX_ROWS as usize { + return Err(bad("range.setValues", "too many rows")); + } + let mut count = 0; + edit(s, |cx| { + for (ri, row) in rows.iter().enumerate() { + let cols: Vec<&Json> = match row.as_array() { + Some(a) => a.iter().collect(), + None => vec![row], + }; + for (ci, v) in cols.iter().enumerate() { + let Some(at) = start.offset(ri as i64, ci as i64) else { continue }; + let input = json_to_input(v); + let cell = match v { + Json::Number(n) => Some(Cell { + value: Value::number(n.as_f64().unwrap_or(0.0)), + formula: None, + style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), + }), + Json::Bool(b) => { + Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() }) + } + // Text as given: no date or number reading, and a leading `'` is part of the + // text rather than the marker that makes it text. A formula is still a + // formula, which is what the ticket asks for and what `=` means in a cell. + Json::String(text) if !parse && !text.starts_with('=') => { + let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); + // Line breaks still turn on Wrap Text, as typed input does. + if text.contains('\n') && !cx.wb.styles.get(style).align.wrap { + style = cx.wb.styles.derive(style, |s| s.align.wrap = true); + } + Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style }) + } + _ => input_to_cell(&input, sheet, at, &mut cx.wb)?, + }; + let sh = cx.sheet_mut(sheet)?; + match cell { + Some(c) => sh.set_cell(at, c), + None => { + sh.remove_cell(at); + } + } + crate::cmd::data::grow_autofilter(sh, at); + cx.touch(sheet, at); + count += 1; + } + } + Ok(()) + })?; + Ok(json!({"cells": count})) +} + +pub(super) fn range_fill(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let ranges = target_ranges(s, p)?; + let input = str_param(p, "input").unwrap_or("").to_string(); + let origin = s.doc()?.selection.active; + edit(s, |cx| { + // Each cell gets what typing the input there gives: the automatic number format where + // it's General, its own format otherwise, text where it's Text. That depends only on + // the cell's style, so the input is parsed once per style. + let mut by_style: std::collections::HashMap> = std::collections::HashMap::new(); + for r in &ranges { + if r.count() > 2_000_000 { + return Err(bad("range.fill", "range too large")); + } + for at in r.iter() { + let style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); + let base = match by_style.get(&style) { + Some(c) => c.clone(), + None => { + let c = input_to_cell(&input, sheet, at, &mut cx.wb)?; + by_style.insert(style, c.clone()); + c + } + }; + let mut cell = base.unwrap_or_default(); + if let Some(f) = &cell.formula + && let Some(e) = f.expr() + { + let shifted = gridcraft_formula::adjust::shift_relative(e, at.row as i64 - origin.row as i64, at.col as i64 - origin.col as i64); + cell.formula = Some(std::sync::Arc::new(Formula::from_expr(shifted))); + } + let sh = cx.sheet_mut(sheet)?; + sh.set_cell(at, cell); + crate::cmd::data::grow_autofilter(sh, at); + cx.touch(sheet, at); + } + } + Ok(Json::Null) + }) +} diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs new file mode 100644 index 0000000000..60c05c6879 --- /dev/null +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -0,0 +1,269 @@ +use super::cell::input_to_cell; +use super::paste_special::paste_special; +use super::*; + +// ---------------------------------------------------------------- clipboard + +fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { + let multiple = || EngineError::Other("This action won't work on multiple selections.".into()); + let sheet = target_sheet(s, p)?; + let ranges = target_ranges(s, p)?; + if ranges.len() > 1 && cut { + return Err(multiple()); + } + // The selection as made (untrimmed), for Insert Copied Cells. + let selected = ranges.first().copied().unwrap_or_default(); + let d = s.doc()?; + let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; + let trim = |range: RangeRef| { + if range.is_full_cols() || range.is_full_rows() { + let used = sh.used_range().unwrap_or(RangeRef::cell(range.start)); + range.intersection(&used.union(&RangeRef::cell(range.start))).unwrap_or(range) + } else { + range + } + }; + let (wb, range, areas) = if ranges.len() > 1 { + let areas: Vec = ranges.iter().map(|r| trim(*r)).collect(); + let (wb, block) = slide_together(&d.wb, sheet, &areas).ok_or_else(multiple)?; + (std::sync::Arc::new(wb), block, areas) + } else { + (d.wb.clone(), trim(ranges.first().copied().unwrap_or_default()), Vec::new()) + }; + let text = crate::display::range_text(&wb, sheet, range); + // The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and + // scripts get it only when they ask for it. + let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&wb, sheet, range, &text) } else { None }; + let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected, trust: d.trust }; + s.clipboard = Some(clip); + let mut result = json!({"range": range.a1(), "text": text}); + if let Some(html) = html { + result["html"] = Json::String(html); + } + Ok(result) +} + +/// Excel copies a multi-area selection only when the areas line up: all cover the same rows (they +/// slide together side by side, left to right) or all cover the same columns (stacked, top to +/// bottom), without overlapping. Returns a copy of the workbook whose sheet holds the areas packed +/// into one block at the first area's place, and that block. Formulas travel as their values, as +/// in Excel. `None` when the areas don't line up. +fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRef]) -> Option<(gridcraft_model::Workbook, RangeRef)> { + let first = *areas.first()?; + let across = areas.iter().all(|a| a.start.row == first.start.row && a.end.row == first.end.row); + let down = areas.iter().all(|a| a.start.col == first.start.col && a.end.col == first.end.col); + let mut sorted = areas.to_vec(); + if across { + sorted.sort_by_key(|a| a.start.col); + } else if down { + sorted.sort_by_key(|a| a.start.row); + } else { + return None; + } + if sorted.windows(2).any(|w| matches!(w, [a, b] if a.intersects(b))) { + return None; + } + let anchor = sorted.first()?.start; + let (h, w) = if across { + (first.height(), sorted.iter().map(RangeRef::width).fold(0u32, u32::saturating_add)) + } else { + (sorted.iter().map(RangeRef::height).fold(0u32, u32::saturating_add), first.width()) + }; + let block = RangeRef::new(anchor, anchor.offset(i64::from(h) - 1, i64::from(w) - 1)?); + let src = wb.sheet(sheet)?; + let mut out = wb.clone(); + let dst = out.sheet_mut(sheet)?; + dst.take_cells(block); + dst.spill.retain(|c, _| !block.contains(*c)); + dst.spill_ranges.retain(|c, _| !block.contains(*c)); + dst.comments.retain(|c, _| !block.contains(*c)); + dst.merges.retain(|m| !m.intersects(&block)); + if across { + dst.cols.retain(|c, _| !(block.start.col..=block.end.col).contains(c)); + } else { + dst.rows.retain(|r, _| !(block.start.row..=block.end.row).contains(r)); + } + let mut next = if across { anchor.col } else { anchor.row }; + for a in &sorted { + let (dr, dc) = if across { (0, i64::from(next) - i64::from(a.start.col)) } else { (i64::from(next) - i64::from(a.start.row), 0) }; + let cells = src.cells.iter_range(*a).map(|(c, _)| c).chain(src.spill.keys().copied().filter(|c| a.contains(*c))); + for c in cells { + let Some(to) = c.offset(dr, dc) else { continue }; + let style = src.cell(c).map(|x| x.style).unwrap_or_default(); + dst.set_cell(to, Cell { value: src.value(c), formula: None, style }); + if let Some(picture) = src.cell_pictures.get(&c) { + dst.cell_pictures.insert(to, picture.clone()); + } + } + for (c, comment) in src.comments.iter().filter(|(c, _)| a.contains(**c)) { + if let Some(to) = c.offset(dr, dc) { + dst.comments.insert(to, comment.clone()); + } + } + for m in src.merges.iter().filter(|m| a.contains_range(m)) { + if let (Some(s), Some(e)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) { + dst.merges.push(RangeRef::new(s, e)); + } + } + // Column widths (side by side) or row heights and hidden rows (stacked) follow their cells. + if across { + for (c, info) in src.cols.range(a.start.col..=a.end.col) { + dst.cols.insert(next.saturating_add(c.saturating_sub(a.start.col)), *info); + } + } else { + for (r, info) in src.rows.range(a.start.row..=a.end.row) { + dst.rows.insert(next.saturating_add(r.saturating_sub(a.start.row)), *info); + } + } + next = next.saturating_add(if across { a.width() } else { a.height() }); + } + Some((out, block)) +} + +pub(super) fn copy(s: &mut Session, p: &Json) -> Result { + copy_impl(s, p, false) +} + +pub(super) fn cut(s: &mut Session, p: &Json) -> Result { + copy_impl(s, p, true) +} + +pub(super) fn clear_clipboard(s: &mut Session, _: &Json) -> Result { + s.clipboard = None; + s.format_painter = None; + ok() +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum What { + All, + Formulas, + Values, + Formats, + Comments, + Validation, + AllExceptBorders, + ColumnWidths, + FormulasAndNumberFormats, + ValuesAndNumberFormats, +} + +pub(super) fn paste(s: &mut Session, p: &Json) -> Result { + // External text (from the system clipboard) when it differs from ours. + if let Some(text) = str_param(p, "text") + && s.clipboard.as_ref().is_none_or(|c| c.text.trim_end_matches(['\r', '\n']) != text.trim_end_matches(['\r', '\n'])) + { + return paste_text(s, p, text); + } + if s.clipboard.is_none() { + return Err(EngineError::Other("Nothing to paste.".into())); + } + paste_special(s, &json!({"what": "all", "at": p.get("at").cloned().unwrap_or(Json::Null)})) +} + +fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result { + let sheet = target_sheet(s, p)?; + let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start); + let rows: Vec> = parse_tsv(text); + let h = rows.len(); + let w = rows.iter().map(Vec::len).max().unwrap_or(0); + // The HTML flavour, when the host passed it: the number behind each cell's shown text. Read + // only as far as the text's own shape, so spans in the markup can't expand without bound. + let html = str_param(p, "html").map(|html| crate::clip_html::table_cells(html, h, w)).unwrap_or_default(); + edit(s, |cx| { + for (ri, row) in rows.iter().enumerate() { + for (ci, v) in row.iter().enumerate() { + let Some(c) = at.offset(ri as i64, ci as i64) else { continue }; + let mut cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str())))); + if let Some(cl) = cell.as_mut() { + numeric_from_html(cl, v, html.get(ri).and_then(|r| r.get(ci)), &mut cx.wb); + } + let sh = cx.sheet_mut(sheet)?; + match cell { + Some(cell) => sh.set_cell(c, cell), + None => { + sh.remove_cell(c); + } + } + cx.touch(sheet, c); + } + } + if h > 0 && w > 0 { + let end = at.offset_clamped(h as i64 - 1, w as i64 - 1); + *cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] }; + } + Ok(()) + })?; + Ok(json!({"rows": h, "cols": w})) +} + +/// A pasted cell that came out as text ("Rp10,000.00" isn't en-US input) becomes the number the +/// source recorded for it in its HTML, with a format that shows the same text when one can be +/// inferred and the cell has none. The HTML cell must show the same text as the tab-separated +/// field, so HTML that belongs to another copy (or a table laid out differently) changes nothing. +fn numeric_from_html(cell: &mut Cell, field: &str, html: Option<&crate::clip_html::HtmlCell>, wb: &mut gridcraft_model::Workbook) { + let Some(hc) = html else { return }; + let Some(n) = hc.num else { return }; + if cell.formula.is_some() || !matches!(cell.value, Value::Text(_)) || wb.styles.get(cell.style).num_fmt.as_str() == "@" { + return; + } + if crate::clip_html::normalized(&hc.text) != crate::clip_html::normalized(field) { + return; + } + cell.value = Value::number(n); + if wb.styles.get(cell.style).num_fmt.as_str() == "General" + && let Some(code) = crate::clip_html::format_for(field, n) + { + cell.style = wb.styles.derive(cell.style, |s| s.num_fmt = gridcraft_model::NumFmt::new(&code)); + } +} + +/// Tab-separated text with Excel-style quoting. +pub(crate) fn parse_tsv(text: &str) -> Vec> { + let mut rows = Vec::new(); + let mut row = Vec::new(); + let mut field = String::new(); + let mut chars = text.chars().peekable(); + let mut quoted = false; + let mut at_start = true; + while let Some(c) = chars.next() { + if quoted { + if c == '"' { + if chars.peek() == Some(&'"') { + field.push('"'); + chars.next(); + } else { + quoted = false; + } + } else { + field.push(c); + } + continue; + } + match c { + '"' if at_start => { + quoted = true; + at_start = false; + } + '\t' => { + row.push(std::mem::take(&mut field)); + at_start = true; + } + '\r' => {} + '\n' => { + row.push(std::mem::take(&mut field)); + rows.push(std::mem::take(&mut row)); + at_start = true; + } + _ => { + field.push(c); + at_start = false; + } + } + } + if !field.is_empty() || !row.is_empty() { + row.push(field); + rows.push(row); + } + rows +} diff --git a/crates/engine/src/cmd/edit/fill.rs b/crates/engine/src/cmd/edit/fill.rs new file mode 100644 index 0000000000..84f4cd6d37 --- /dev/null +++ b/crates/engine/src/cmd/edit/fill.rs @@ -0,0 +1,96 @@ +use super::*; + +// ---------------------------------------------------------------- fill + +pub(super) fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result { + let sheet = target_sheet(s, p)?; + let mut r = target_range(s, p)?; + // Whole rows or columns fill only where the sheet has content (nothing on an empty sheet). + if r.is_full_cols() || r.is_full_rows() { + match s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| r.intersection(&u)) { + Some(u) => r = u, + None => return Ok(Json::Null), + } + } + if r.count() > crate::fill::MAX_FILL_CELLS { + return Err(bad("edit.fill", "range too large")); + } + // With one row selected, Fill Down copies from the row above (Excel behaviour). + if dr == 1 && r.height() == 1 { + r.start.row = r.start.row.saturating_sub(1); + } + if dc == 1 && r.width() == 1 { + r.start.col = r.start.col.saturating_sub(1); + } + let src = if dr == 1 { + RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) + } else if dr == -1 { + RangeRef::new(CellRef::new(r.end.row, r.start.col), r.end) + } else if dc == 1 { + RangeRef::new(r.start, CellRef::new(r.end.row, r.start.col)) + } else { + RangeRef::new(CellRef::new(r.start.row, r.end.col), r.end) + }; + edit(s, |cx| { + crate::fill::fill(cx, sheet, src, r, crate::fill::FillMode::Copy)?; + Ok(Json::Null) + }) +} + +pub(super) fn auto_fill(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let src = str_param(p, "source").and_then(RangeRef::parse).map_or_else(|| target_range(s, p), Ok)?; + let Some(target) = str_param(p, "target").and_then(RangeRef::parse) else { return Err(bad("edit.autoFill", "missing `target`")) }; + let mode = match str_param(p, "mode").unwrap_or("series") { + "copy" => crate::fill::FillMode::Copy, + "alternate" => crate::fill::FillMode::Alternate, + "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 { + return Err(bad("edit.autoFill", "range too large")); + } + // Excel refuses a target that doesn't hold the source in a corner. Such targets used to fill + // every source lane across the whole target extent: billions of cells for whole rows (`2:3`) + // into whole columns (`A:A`). + if !crate::fill::holds_source_in_corner(src, target) { + return Err(bad("edit.autoFill", "the target must contain the source in one of its corners")); + } + edit(s, |cx| { + crate::fill::fill(cx, sheet, src, target, mode)?; + *cx.sel = crate::Selection { active: src.start, anchor: src.start, ranges: vec![target.union(&src)] }; + Ok(Json::Null) + }) +} + +pub(super) fn fill_series(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let r = target_range(s, p)?; + let rows = str_param(p, "direction").map(|d| d == "rows").unwrap_or(r.width() > r.height()); + let kind = str_param(p, "type").unwrap_or("linear").to_string(); + let step = f64_param(p, "step").unwrap_or(1.0); + let stop = f64_param(p, "stop"); + let unit = str_param(p, "dateUnit").unwrap_or("day").to_string(); + edit(s, |cx| { + crate::fill::series(cx, sheet, r, rows, &kind, step, stop, &unit)?; + Ok(Json::Null) + }) +} + +pub(super) fn flash_fill(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let r = target_range(s, p)?; + let n = edit(s, |cx| crate::fill::flash_fill(cx, sheet, r.start))?; + if n == 0 { + return Err(EngineError::Other( + "We looked at all the data next to your selection and didn't see a pattern for filling in values for you.".into(), + )); + } + Ok(json!({"filled": n})) +} diff --git a/crates/engine/src/cmd/edit/find.rs b/crates/engine/src/cmd/edit/find.rs new file mode 100644 index 0000000000..2dcbfa8868 --- /dev/null +++ b/crates/engine/src/cmd/edit/find.rs @@ -0,0 +1,184 @@ +use super::cell::input_to_cell; +use super::*; + +// ---------------------------------------------------------------- find & replace + +fn matches(hay: &str, what: &str, case: bool, whole: bool) -> bool { + let (h, w) = if case { (hay.to_string(), what.to_string()) } else { (hay.to_lowercase(), what.to_lowercase()) }; + let has_wild = w.contains(['*', '?']); + if has_wild { + let pat = if whole { w } else { format!("*{w}*") }; + return wildcard(&h, &pat); + } + if whole { h == w } else { h.contains(&w) } +} + +/// `*`/`?` wildcard match with `~` escapes. +pub(crate) fn wildcard(text: &str, pat: &str) -> bool { + let t: Vec = text.chars().collect(); + let mut p: Vec<(char, bool)> = Vec::new(); + let mut it = pat.chars(); + while let Some(c) = it.next() { + if c == '~' { + if let Some(n) = it.next() { + p.push((n, true)); + } + } else { + p.push((c, false)); + } + } + let (mut ti, mut pi) = (0usize, 0usize); + let (mut star, mut mark) = (None, 0usize); + while ti < t.len() { + if pi < p.len() && (p[pi] == ('?', false) || (p[pi].0 == t[ti] && p[pi] != ('*', false))) { + ti += 1; + pi += 1; + } else if pi < p.len() && p[pi] == ('*', false) { + star = Some(pi); + mark = ti; + pi += 1; + } else if let Some(sp) = star { + pi = sp + 1; + mark += 1; + ti = mark; + } else { + return false; + } + } + while pi < p.len() && p[pi] == ('*', false) { + pi += 1; + } + pi == p.len() +} + +pub(super) fn find(s: &mut Session, p: &Json) -> Result { + let what = str_param(p, "what").unwrap_or("").to_string(); + if what.is_empty() { + return Err(bad("edit.find", "missing `what`")); + } + let case = bool_param(p, "matchCase").unwrap_or(false); + let whole = bool_param(p, "wholeCell").unwrap_or(false); + let values = str_param(p, "lookIn") == Some("values"); + let by_cols = bool_param(p, "byColumns").unwrap_or(false); + let all = bool_param(p, "all").unwrap_or(false); + let workbook = str_param(p, "within") == Some("workbook"); + s.last_find = Some(p.clone()); + let d = s.doc()?; + let sheets: Vec = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet()] }; + let mut hits: Vec<(usize, CellRef, String)> = Vec::new(); + for si in sheets { + let Some(sh) = d.wb.sheet(si) else { continue }; + let mut cells: Vec<(CellRef, String)> = sh + .cells + .iter() + .filter_map(|(c, cell)| { + let text = if values || cell.formula.is_none() || crate::display::formula_hidden(&d.wb, sh, c) { + crate::display::cell_text(&d.wb, sh, c) + } else { + cell.input_text() + }; + matches(&text, &what, case, whole).then_some((c, text)) + }) + .collect(); + if by_cols { + cells.sort_by_key(|(c, _)| (c.col, c.row)); + } + hits.extend(cells.into_iter().map(|(c, t)| (si, c, t))); + } + if hits.is_empty() { + return Err(EngineError::Other(format!("We couldn't find what you were looking for: {what}"))); + } + if all { + let list: Vec = + hits.iter().map(|(si, c, t)| json!({"sheet": d.wb.sheet(*si).map(|s| s.name.clone()), "cell": c.a1(), "value": t})).collect(); + return Ok(json!({"count": list.len(), "results": list})); + } + // Next hit after the active cell. + let cur = (d.wb.active_sheet(), d.selection.active); + let key = |si: usize, c: CellRef| if by_cols { (si, c.col, c.row) } else { (si, c.row, c.col) }; + let next = hits.iter().find(|(si, c, _)| key(*si, *c) > key(cur.0, cur.1)).or_else(|| hits.first()).cloned(); + let Some((si, c, t)) = next else { return Err(EngineError::Other("not found".into())) }; + let dd = s.doc_mut()?; + dd.wb_switch_sheet(si); + dd.selection = crate::Selection::at(c); + Ok(json!({"cell": c.a1(), "sheet": si, "value": t, "count": hits.len()})) +} + +pub(super) fn replace(s: &mut Session, p: &Json) -> Result { + let what = str_param(p, "what").unwrap_or("").to_string(); + let with = str_param(p, "with").unwrap_or("").to_string(); + if what.is_empty() { + return Err(bad("edit.replace", "missing `what`")); + } + let case = bool_param(p, "matchCase").unwrap_or(false); + let whole = bool_param(p, "wholeCell").unwrap_or(false); + let all = bool_param(p, "all").unwrap_or(true); + let workbook = str_param(p, "within") == Some("workbook"); + let active = s.doc()?.selection.active; + let n = edit(s, |cx| { + let sheets: Vec = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet()] }; + let mut n = 0; + for si in sheets { + let cells: Vec<(CellRef, String)> = + cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, _)| (c, sh.input_text(c))).collect()).unwrap_or_default(); + for (c, text) in cells { + if !all && c != active { + continue; + } + if !matches(&text, &what, case, whole) { + continue; + } + let new = if whole { + with.clone() + } else if case { + text.replace(&what, &with) + } else { + replace_ci(&text, &what, &with) + }; + let cell = input_to_cell(&new, si, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str())))); + let sh = cx.sheet_mut(si)?; + match cell { + Some(cell) => sh.set_cell(c, cell), + None => { + sh.remove_cell(c); + } + } + cx.touch(si, c); + n += 1; + } + } + Ok(n) + })?; + Ok(json!({"replaced": n})) +} + +/// Whether case-folding `s` maps every char to exactly one char of the same UTF-8 length, so +/// byte positions in the folded text still line up with the original's character boundaries. +fn folds_in_place(s: &str) -> bool { + s.chars().all(|c| { + let mut folded = c.to_lowercase(); + let first = folded.next(); + folded.next().is_none() && first.is_some_and(|l| l.len_utf8() == c.len_utf8()) + }) +} + +pub(super) fn replace_ci(text: &str, what: &str, with: &str) -> String { + let lower = text.to_lowercase(); + let w = what.to_lowercase(); + // Slicing `text` at positions found in `lower` is only sound when folding kept every byte + // boundary: a length check alone misses foldings that keep the total length while shifting + // boundaries (U+212A KELVIN SIGN shrinks 3→1 bytes, U+0130 grows 2→3), where the tail used + // to be dropped. Those fall back to the case-sensitive replace, as length changes already do. + if lower.len() != text.len() || w.is_empty() || !folds_in_place(text) || !folds_in_place(what) { + return text.replace(what, with); + } + let mut out = String::new(); + let mut i = 0; + while let Some(pos) = lower.get(i..).and_then(|s| s.find(&w)) { + out.push_str(text.get(i..i + pos).unwrap_or("")); + out.push_str(with); + i += pos + w.len(); + } + out.push_str(text.get(i..).unwrap_or("")); + out +} diff --git a/crates/engine/src/cmd/edit/go_to.rs b/crates/engine/src/cmd/edit/go_to.rs new file mode 100644 index 0000000000..b94bcd2211 --- /dev/null +++ b/crates/engine/src/cmd/edit/go_to.rs @@ -0,0 +1,199 @@ +use super::selection::{select_region, selection_set}; +use super::*; + +/// How many references the Go To dialog keeps for going back to. +pub(super) const GO_TO_RECENT: usize = 10; + +/// The most areas one Go To selects. +pub(super) 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(in super::super) fn go_to(s: &mut Session, p: &Json) -> Result { + 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`")) }; + let d = s.doc()?; + let mut target: Option = None; + let mut areas: Vec = 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())); + } + 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::>().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::>().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. +pub(super) fn reference_parts(text: &str) -> impl Iterator { + 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, 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, + }; + 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)> { + let from = n.scope.unwrap_or(sheet); + let formula = n.formula.trim().trim_start_matches('='); + let mut target: Option = 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 { + let own: std::collections::HashSet = + wb.names.iter().filter(|n| n.scope == Some(sheet)).map(|n| gridcraft_formula::fold_name(&n.name)).collect(); + let mut out: Vec = 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 +} + +pub(super) fn go_to_special(s: &mut Session, p: &Json) -> Result { + let kind = str_param(p, "kind").unwrap_or("blanks"); + let d = s.doc()?; + let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; + let scope = if d.selection.is_single_cell() { sheet.used_range().unwrap_or(RangeRef::cell(d.selection.active)) } else { d.selection.bounds() }; + if kind == "lastCell" { + let c = sheet.used_range().map(|r| r.end).unwrap_or_default(); + return selection_set(s, &json!({"cell": c.a1()})); + } + if kind == "currentRegion" { + return select_region(s, &json!({})); + } + let mut hits: Vec = Vec::new(); + if scope.count() > 4_000_000 { + return Err(bad("edit.goToSpecial", "range too large")); + } + for c in scope.iter() { + let cell = sheet.cell(c); + let v = sheet.value(c); + let ok = match kind { + "blanks" => v.is_empty(), + "constants" => cell.is_some_and(|x| x.formula.is_none() && !x.value.is_empty()), + "formulas" => cell.is_some_and(|x| x.formula.is_some()), + "comments" | "notes" => sheet.comments.contains_key(&c), + "errors" => v.is_error(), + "numbers" => v.is_number(), + "text" => v.is_text(), + "visible" => !sheet.is_row_hidden(c.row) && !sheet.is_col_hidden(c.col), + "conditionalFormats" => sheet.cond_formats.iter().any(|cf| cf.ranges.iter().any(|r| r.contains(c))), + "dataValidation" => sheet.validations.iter().any(|dv| dv.ranges.iter().any(|r| r.contains(c))), + _ => return Err(bad("edit.goToSpecial", format!("unknown kind `{kind}`"))), + }; + if ok { + hits.push(c); + } + } + if hits.is_empty() { + return Err(EngineError::Other("No cells were found.".into())); + } + let ranges = coalesce(&hits); + let d = s.doc_mut()?; + d.selection = crate::Selection { active: hits[0], anchor: hits[0], ranges }; + Ok(json!({"selection": d.selection.a1(), "count": hits.len()})) +} diff --git a/crates/engine/src/cmd/edit/paste_special.rs b/crates/engine/src/cmd/edit/paste_special.rs new file mode 100644 index 0000000000..eba1b429e3 --- /dev/null +++ b/crates/engine/src/cmd/edit/paste_special.rs @@ -0,0 +1,351 @@ +use super::clipboard::What; +use super::*; + +pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result { + let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) }; + let what = match str_param(p, "what").unwrap_or("all") { + "all" => What::All, + "formulas" => What::Formulas, + "values" => What::Values, + "formats" => What::Formats, + "comments" | "notes" => What::Comments, + "validation" => What::Validation, + "allExceptBorders" => What::AllExceptBorders, + "columnWidths" => What::ColumnWidths, + "formulasAndNumberFormats" => What::FormulasAndNumberFormats, + "valuesAndNumberFormats" => What::ValuesAndNumberFormats, + other => return Err(bad("edit.pasteSpecial", format!("unknown `what`: {other}"))), + }; + let op = str_param(p, "operation").unwrap_or("none").to_string(); + let skip_blanks = bool_param(p, "skipBlanks").unwrap_or(false); + let transpose = bool_param(p, "transpose").unwrap_or(false); + let link = bool_param(p, "link").unwrap_or(false); + if link && !clip.areas.is_empty() { + return Err(EngineError::Other("This action won't work on multiple selections.".into())); + } + let dest_sheet = target_sheet(s, p)?; + let sel = s.doc()?.selection.current(); + let at = cell_param_on(s, p, "at", dest_sheet)?.unwrap_or(sel.start); + let src = clip.range; + let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) }; + // Tile the copy over a larger destination that is a multiple of its size. + let tile = sel.height() % h == 0 + && sel.width() % w == 0 + && !sel.is_single() + && cell_param_on(s, p, "at", dest_sheet)?.is_none() + && !sel.is_full_cols() + && !sel.is_full_rows() + && sel.count() <= 4_000_000; + let (tiles_r, tiles_c) = if tile { (sel.height() / h, sel.width() / w) } else { (1, 1) }; + let same_doc = s.doc()?.uid == clip.doc_uid; + let cut_move = clip.cut && same_doc && what == What::All; + let src_wb = clip.wb.clone(); + let Some(src_sheet) = src_wb.sheet(clip.sheet) else { return Err(EngineError::Other("The copied sheet no longer exists.".into())) }; + let src_sheet_name = src_sheet.name.clone(); + if cut_move && at.offset((tiles_r * h).saturating_sub(1) as i64, (tiles_c * w).saturating_sub(1) as i64).is_none() { + // Check the whole footprint before the source is emptied, so a move that overflows the grid loses nothing. + return Err(EngineError::Other("The cut area doesn't fit at this position; nothing was moved.".into())); + } + edit(s, |cx| { + // Styles from another workbook must be re-interned. + let mut style_map = std::collections::HashMap::new(); + let mut map_style = |id: StyleId, wb: &mut gridcraft_model::Workbook| -> StyleId { + if same_doc { + return id; + } + *style_map.entry(id).or_insert_with(|| wb.styles.intern(src_wb.styles.get(id).clone())) + }; + let mut moved_formulas = Vec::new(); + let mut moved_pictures = std::collections::BTreeMap::new(); + if cut_move { + // Remove the source first (contents and formats), and fix references to it. + let source = cx.sheet_mut(clip.sheet)?; + moved_pictures = source.cell_pictures.iter().filter(|(c, _)| src.contains(**c)).map(|(c, p)| (*c, p.clone())).collect(); + let taken = source.take_cells(src); + // Notes, hyperlinks and merged areas sit beside the cells, and a cut takes them + // with the block: the destination is written from the clipboard snapshot taken + // before the cut, so removing them here loses nothing and leaves the old place + // clean instead of leaving one merged block and one note behind. + source.comments.retain(|c, _| !src.contains(*c)); + source.hyperlinks.retain(|c, _| !src.contains(*c)); + source.merges.retain(|m| !src.contains_range(m)); + for (c, _) in &taken { + cx.touch(clip.sheet, *c); + } + moved_formulas = taken; + } + for tr in 0..tiles_r { + for tc in 0..tiles_c { + for r in 0..src.height() { + for c in 0..src.width() { + let sc = CellRef::new(src.start.row + r, src.start.col + c); + let (dr, dc) = if transpose { (c, r) } else { (r, c) }; + let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue }; + let src_cell = if cut_move { + moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone()) + } else { + src_sheet.cell(sc).cloned() + }; + let src_val = src_sheet.value(sc); + let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned(); + if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) { + continue; + } + let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default(); + let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned(); + let mut new_picture = old_picture.clone(); + let mut new = old.clone(); + let shift = |cell: &Cell| -> Option> { + let f = cell.formula.as_ref()?; + let e = f.expr()?; + if cut_move { + // A moved formula still reads cells on its original sheet. Qualify + // those references before the workbook-wide move adjustment below. + let e = if dest_sheet != clip.sheet { + e.map(&mut |x| match x { + gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => { + r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone()); + gridcraft_formula::Expr::Ref(r) + } + other => other, + }) + } else { + e + }; + let mut moved = Formula::from_expr(e); + // The array range moves with its formula. + let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); + moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?))); + moved.dynamic = f.dynamic; + return Some(std::sync::Arc::new(moved)); + } + let moved = + gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); + Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) })) + }; + if link { + let sheet_prefix = if dest_sheet == clip.sheet && same_doc { + String::new() + } else { + format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name)) + }; + new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1())))); + new.value = Value::Empty; + new_picture = None; + } else { + let sc_cell = src_cell.clone().unwrap_or_default(); + match what { + What::All | What::AllExceptBorders => { + new_picture = src_picture.clone(); + new.formula = shift(&sc_cell); + new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; + let st = map_style(sc_cell.style, &mut cx.wb); + new.style = if what == What::AllExceptBorders { + let borders = cx.wb.styles.get(old.style).borders; + cx.wb.styles.derive(st, |s| s.borders = borders) + } else { + st + }; + } + What::Formulas | What::FormulasAndNumberFormats => { + new_picture = src_picture.clone(); + new.formula = shift(&sc_cell); + new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; + if what == What::FormulasAndNumberFormats { + let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); + new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); + } + } + What::Values | What::ValuesAndNumberFormats => { + new_picture = src_picture.clone(); + new.formula = None; + new.value = src_val.clone(); + if what == What::ValuesAndNumberFormats { + let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); + new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); + } + } + What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb), + What::Comments | What::Validation | What::ColumnWidths => {} + } + // Operations combine numbers with what is already there. + if op != "none" + && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) + && new.formula.is_none() + { + if old_picture.is_some() || new_picture.is_some() { + new_picture = None; + new.value = Value::Error(gridcraft_core::CellError::Value); + } + let a = old.value.clone(); + let b = new.value.clone(); + // Numbers stored as text take part (multiplying by 1 turns them into + // numbers); other text and errors stay as they are. + if let (Ok(x), Ok(y)) = (a.to_number(), b.to_number()) + && (a.is_number() || a.is_empty() || a.is_text()) + && (b.is_number() || b.is_empty()) + { + new.value = match op.as_str() { + "add" => Value::number(x + y), + "subtract" => Value::number(x - y), + "multiply" => Value::number(x * y), + "divide" => { + if y == 0.0 { + Value::Error(gridcraft_core::CellError::Div0) + } else { + Value::number(x / y) + } + } + _ => b.clone(), + }; + } else if (a.is_text() || a.is_error()) && (b.is_number() || b.is_empty()) { + new.value = a; + } + // A formula already there is kept and combined with the pasted number. + if let Some(f) = &old.formula + && let Ok(y) = b.to_number() + && (b.is_number() || b.is_empty()) + && let Some(sym) = match op.as_str() { + "add" => Some('+'), + "subtract" => Some('-'), + "multiply" => Some('*'), + "divide" => Some('/'), + _ => None, + } + { + let text = format!("=({}){sym}{}", f.text, gridcraft_core::number_to_text(y)); + new.formula = Some(std::sync::Arc::new(Formula::new(&text))); + new.value = Value::Empty; + } + } + } + // The copied cell's validation rule replaces whatever rule the destination had. + if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) { + let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned(); + let sh = cx.sheet_mut(dest_sheet)?; + crate::cmd::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]); + if let Some(mut dv) = rule { + // Relative references move with the rule, as pasted formulas do. + let first = dv.ranges.first().map_or(sc, |r| r.start); + dv.f1 = crate::cmd::data::move_rule_formula(&dv.f1, first, dest); + dv.f2 = dv.f2.map(|f| crate::cmd::data::move_rule_formula(&f, first, dest)); + dv.ranges = vec![RangeRef::new(dest, dest)]; + sh.validations.push(dv); + } + } + match what { + What::Comments => { + if let Some(cm) = src_sheet.comments.get(&sc).cloned() { + cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); + } + } + What::ColumnWidths => { + if let Some(info) = src_sheet.cols.get(&sc.col).copied() { + cx.sheet_mut(dest_sheet)?.cols.insert(dest.col, info); + } + } + What::All => { + if let Some(cm) = src_sheet.comments.get(&sc).cloned() { + cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); + } + if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() { + cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h); + } + cx.sheet_mut(dest_sheet)?.set_cell(dest, new); + } + _ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new), + } + if let Some(picture) = new_picture { + cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture); + } + cx.touch(dest_sheet, dest); + } + } + } + } + // Merged areas inside the copied block travel with it. + if matches!(what, What::All | What::Formats) && !transpose { + let merges: Vec = src_sheet.merges.iter().filter(|m| src.contains_range(m)).copied().collect(); + for m in merges { + let dr = at.row as i64 - src.start.row as i64; + let dc = at.col as i64 - src.start.col as i64; + if let (Some(a), Some(b)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) { + let nm = RangeRef::new(a, b); + let sh = cx.sheet_mut(dest_sheet)?; + sh.merges.retain(|x| !x.intersects(&nm)); + sh.merges.push(nm); + } + } + } + if cut_move { + // References elsewhere to the moved block now point to its new place. + let dest_name = cx.wb.sheet(dest_sheet).map(|s| s.name.clone()).unwrap_or_default(); + let e = if dest_sheet == clip.sheet { + gridcraft_formula::adjust::Edit::Move { from: src, to_row: at.row, to_col: at.col } + } else { + gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name, to_row: at.row, to_col: at.col } + }; + rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e); + cx.structural = true; + } + let end = at.offset_clamped((tiles_r * h) as i64 - 1, (tiles_c * w) as i64 - 1); + *cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] }; + Ok(()) + })?; + // Formulas and validation from an untrusted file leave the destination untrusted; plain values, + // formats (cell styles and merged areas, no conditional formats), column widths and notes + // carry nothing active. A linked paste writes a formula into every cell whatever `what` says, + // except for column widths and notes, which write no cells at all. + let passive = + matches!(what, What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Formats | What::Values | What::ValuesAndNumberFormats)); + if !same_doc && !passive { + let d = s.doc_mut()?; + d.trust = d.trust.receive(clip.trust); + } + if cut_move { + s.clipboard = None; + } + Ok(json!({"pasted": s.doc()?.selection.a1()})) +} + +/// Applies a reference adjustment to every cell formula and defined name in the workbook. +pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) { + use gridcraft_formula::adjust::Edit; + // Rows and columns inserted or deleted on the target sheet move its legacy array ranges too + // (a cut moves its arrays itself). + let moves_arrays = matches!(e, Edit::Insert { .. } | Edit::Delete { .. } | Edit::InsertCells { .. } | Edit::DeleteCells { .. }); + for si in 0..wb.sheets.len() { + let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default(); + let keys: Vec<(CellRef, std::sync::Arc)> = + wb.sheets.get(si).map(|s| s.cells.iter().filter_map(|(c, cell)| cell.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default(); + if keys.is_empty() { + continue; + } + let on_target = moves_arrays && host.eq_ignore_ascii_case(target); + let Some(sheet) = wb.sheet_mut(si) else { continue }; + for (c, f) in keys { + let Some(expr) = f.expr() else { continue }; + let new = gridcraft_formula::adjust::adjust(expr, &host, target, e); + let text = gridcraft_formula::print(&new); + let array = match f.array { + Some(a) if on_target => crate::cmd::sheet::map_range(a, e), + a => a, + }; + if (text != f.text || array != f.array) + && let Some(cell) = sheet.cells.get_mut(c) + { + let mut nf = Formula::from_expr(new); + nf.array = array; + nf.dynamic = f.dynamic; + cell.formula = Some(std::sync::Arc::new(nf)); + } + } + } + for n in wb.names.iter_mut() { + if let Ok(expr) = gridcraft_formula::parse_name(&n.formula) { + // Names are workbook-level: unqualified refs don't occur; qualified ones adjust. + let new = gridcraft_formula::adjust::adjust(expr, "\u{0}", target, e); + n.formula = gridcraft_formula::print(&new); + } + } +} diff --git a/crates/engine/src/cmd/edit/selection.rs b/crates/engine/src/cmd/edit/selection.rs new file mode 100644 index 0000000000..95793f338c --- /dev/null +++ b/crates/engine/src/cmd/edit/selection.rs @@ -0,0 +1,171 @@ +use super::*; + +// ---------------------------------------------------------------- selection + +/// Whether the cell may be selected: a protected sheet can forbid selecting locked and/or unlocked cells. +fn cell_selectable(wb: &gridcraft_model::Workbook, sheet: &gridcraft_model::Sheet, c: CellRef) -> bool { + let Some(p) = sheet.protection.as_ref() else { return true }; + if wb.styles.get(sheet.style_id(c)).protection.locked { p.select_locked } else { p.select_unlocked } +} + +pub(super) fn selection_set(s: &mut Session, p: &Json) -> Result { + if p.get("sheet").is_some() { + let i = target_sheet(s, p)?; + s.doc_mut()?.wb_switch_sheet(i); + } else if let Some(r) = str_param(p, "range").or_else(|| str_param(p, "cell")) + && let (Some(_), _) = split_sheet(r) + { + let i = target_sheet(s, p)?; + s.doc_mut()?.wb_switch_sheet(i); + } + let ranges = target_ranges(s, p)?; + let active = cell_param(s, p, "active")?; + let d = s.doc_mut()?; + let active = active.or_else(|| ranges.last().map(|r| r.start)).unwrap_or_default(); + let mut sel = crate::Selection { active, anchor: ranges.last().map(|r| r.start).unwrap_or(active), ranges }; + if let Some(sh) = d.wb.active() { + sel.expand_merges(sh); + if !cell_selectable(&d.wb, sh, sel.active) { + return Err(EngineError::Other("The cell you are trying to change is on a protected sheet that does not allow selecting it.".into())); + } + } + d.selection = sel; + Ok(json!({"selection": d.selection.a1(), "active": d.selection.active.a1()})) +} + +impl crate::DocState { + /// Switches the active sheet, remembering each sheet's selection. + pub fn wb_switch_sheet(&mut self, i: usize) { + if i >= self.wb.sheets.len() || i == self.wb.active_sheet() { + return; + } + let cur = self.wb.active_sheet(); + if self.sheet_selections.len() < self.wb.sheets.len() { + self.sheet_selections.resize(self.wb.sheets.len(), crate::Selection::default()); + } + if let Some(slot) = self.sheet_selections.get_mut(cur) { + *slot = self.selection.clone(); + } + // View state: set in place, so the workbook is neither copied nor made dirty. + self.wb.set_active_sheet(i); + self.selection = self.sheet_selections.get(i).cloned().unwrap_or_default(); + } +} + +pub(super) fn selection_move(s: &mut Session, p: &Json) -> Result { + let dr = p.get("dr").and_then(Json::as_i64).unwrap_or(0); + let dc = p.get("dc").and_then(Json::as_i64).unwrap_or(0); + let extend = bool_param(p, "extend").unwrap_or(false); + let jmp = bool_param(p, "jump").unwrap_or(false); + let d = s.doc_mut()?; + let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; + let sel = &mut d.selection; + // Extension moves the far corner, not the active cell. + let from = if extend { sel.moving_end() } else { sel.active }; + // Skip hidden rows/columns and step over merged areas. + let mut to = if jmp { + jump(sheet, from, dr.signum(), dc.signum()) + } else { + let mut t = from.offset_clamped(dr, dc); + let mut guard = 0; + while (sheet.is_row_hidden(t.row) || sheet.is_col_hidden(t.col)) && guard < 100_000 { + let n = t.offset_clamped(dr.signum(), dc.signum()); + if n == t { + break; + } + t = n; + guard += 1; + } + if !extend && let Some(m) = sheet.merge_at(from) { + // Leaving a merged cell moves from its edge. + if dr > 0 { + t = CellRef::new(m.end.offset_clamped(dr, 0).row, from.col); + } else if dc > 0 { + t = CellRef::new(from.row, m.end.offset_clamped(0, dc).col); + } + } + t + }; + if let Some(m) = sheet.merge_at(to) + && !extend + { + to = m.start; + } + if !extend && !cell_selectable(&d.wb, sheet, to) { + // Step on to the next selectable cell in the same direction; stay put if there is none. + let (sr, sc) = (dr.signum(), dc.signum()); + let mut t = to; + let mut found = false; + if !jmp && (sr, sc) != (0, 0) { + for _ in 0..10_000 { + let n = t.offset_clamped(sr, sc); + if n == t { + break; + } + t = n; + if !sheet.is_row_hidden(t.row) && !sheet.is_col_hidden(t.col) && cell_selectable(&d.wb, sheet, t) { + found = true; + break; + } + } + } + if !found { + return Ok(json!({"selection": sel.a1(), "active": sel.active.a1()})); + } + to = t; + } + if extend { + sel.extend_to(to); + sel.expand_merges(sheet); + } else { + *sel = crate::Selection::at(to); + if let Some(m) = sheet.merge_at(to) { + sel.ranges = vec![m]; + } + } + Ok(json!({"selection": sel.a1(), "active": sel.active.a1()})) +} + +pub(super) fn selection_next(s: &mut Session, p: &Json) -> Result { + let fwd = bool_param(p, "forward").unwrap_or(true); + let by_row = bool_param(p, "byRow").unwrap_or(false); + let d = s.doc_mut()?; + d.selection.cycle(fwd, by_row); + Ok(json!({"active": d.selection.active.a1()})) +} + +pub(super) fn select_all(s: &mut Session, _: &Json) -> Result { + let d = s.doc_mut()?; + let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; + let region = current_region(sheet, d.selection.active); + let all = RangeRef::all(); + let r = if region.is_single() || d.selection.current() == region { all } else { region }; + let active = d.selection.active; + d.selection = crate::Selection { active, anchor: r.start, ranges: vec![r] }; + Ok(json!({"selection": d.selection.a1()})) +} + +pub(super) fn select_region(s: &mut Session, _: &Json) -> Result { + let d = s.doc_mut()?; + let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; + let r = current_region(sheet, d.selection.active); + let active = d.selection.active; + d.selection = crate::Selection { active, anchor: r.start, ranges: vec![r] }; + Ok(json!({"selection": d.selection.a1()})) +} + +pub(super) fn select_row(s: &mut Session, _: &Json) -> Result { + let d = s.doc_mut()?; + let r = d.selection.current(); + let active = d.selection.active; + d.selection = crate::Selection { active, anchor: CellRef::new(r.start.row, 0), ranges: vec![RangeRef::rows(r.start.row, r.end.row)] }; + Ok(json!({"selection": d.selection.a1()})) +} + +pub(super) fn select_col(s: &mut Session, _: &Json) -> Result { + let d = s.doc_mut()?; + let r = d.selection.current(); + let active = d.selection.active; + d.selection = crate::Selection { active, anchor: CellRef::new(0, r.start.col), ranges: vec![RangeRef::cols(r.start.col, r.end.col)] }; + Ok(json!({"selection": d.selection.a1()})) +} diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs new file mode 100644 index 0000000000..c9e1e7a931 --- /dev/null +++ b/crates/engine/src/cmd/edit/tests.rs @@ -0,0 +1,305 @@ +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![""]); + 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. + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": 123.5})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "value": 77})).unwrap(); + s.execute("cell.set", json!({"cell": "A3", "input": true})).unwrap(); + s.execute("cell.set", json!({"cell": "A4", "input": "=A1+A2"})).unwrap(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + assert_eq!(get(&mut s, "A1")["value"], json!(123.5)); + assert_eq!(get(&mut s, "A2")["value"], json!(77.0)); + assert_eq!(get(&mut s, "A3")["value"], json!(true)); + assert_eq!(get(&mut s, "A4")["value"], json!(200.5)); + assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err()); +} + +/// Every string in `values` went through typed input, read with en-US rules, in silence: a +/// caller sending "05.04.2026" from a dd.mm.yyyy export stored 4 May, "2.500" became 2.5 and +/// "4.25 %" became a percentage (#2255). `parse: false` stores a string as the text it is. +#[test] +fn set_values_can_store_strings_as_the_text_they_are() { + let mut s = Session::new(); + s.new_workbook(); + let strings = ["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"]; + let row: Vec = strings.iter().map(|v| json!(v)).collect(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + + // The default is unchanged: the dates swap, the thousands separator is a decimal point. + s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap(); + assert_eq!(get(&mut s, "A1")["text"], "5/4/2026", "dd.mm.yyyy read as mm.dd.yyyy"); + assert_eq!(get(&mut s, "C1")["value"], json!(2.5), "\"2.500\" read as a decimal"); + assert_eq!(get(&mut s, "D1")["value"], json!(0.0425), "\"4.25 %\" read as a percentage"); + + // With `parse: false` each string is the text it was given, and a formula is still a + // formula. + s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row.clone()]})).unwrap(); + for (i, want) in strings.iter().enumerate() { + let cell = format!("{}3", (b'A' + i as u8) as char); + let got = get(&mut s, &cell); + if *want == "=1+1" { + assert_eq!(got["value"], json!(2.0), "{cell} is still a formula: {got}"); + assert_eq!(got["formula"], "=1+1", "{got}"); + } else { + assert_eq!(got["value"], json!(*want), "{cell} is the text it was given: {got}"); + assert!(got["formula"].is_null(), "{cell} is not a formula: {got}"); + } + } + // Numbers and booleans are untouched either way. + s.execute("range.setValues", json!({"range": "A5", "parse": false, "values": [[12.5, true, null]]})).unwrap(); + assert_eq!(get(&mut s, "A5")["value"], json!(12.5)); + assert_eq!(get(&mut s, "B5")["value"], json!(true)); + assert_eq!(get(&mut s, "C5")["text"], ""); + // `parse` is a flag, and anything else is a usage error rather than a quiet default. + s.execute("range.setValues", json!({"range": "A7", "parse": true, "values": [["2.500"]]})).unwrap(); + assert_eq!(get(&mut s, "A7")["value"], json!(2.5)); + assert!(s.execute("range.setValues", json!({"range": "A8", "parse": "no", "values": [["x"]]})).is_err()); + assert!(s.execute("range.setValues", json!({"range": "A8", "parse": 0, "values": [["x"]]})).is_err()); + // A line break still turns on Wrap Text. + s.execute("range.setValues", json!({"range": "A9", "parse": false, "values": [["one\ntwo"]]})).unwrap(); + assert_eq!(get(&mut s, "A9")["value"], json!("one\ntwo")); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap); +} + +#[test] +fn legacy_array_entry() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "B2", "values": [[1], [2]]})).unwrap(); + s.execute("cell.set", json!({"cell": "C4", "input": "old"})).unwrap(); + s.execute("selection.set", json!({"range": "C2:C4"})).unwrap(); + s.execute("cell.set", json!({"cell": "C2", "input": "=B2:B3*2", "array": true})).unwrap(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + assert_eq!(get(&mut s, "C3")["value"], json!(4.0)); + // Past the result Excel shows #N/A; the old content of the range is replaced. + assert_eq!(get(&mut s, "C4")["text"], "#N/A"); + let bar = s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("C2").unwrap()).map(|c| c.bar_text()); + assert_eq!(bar.as_deref(), Some("{=B2:B3*2}")); +} + +#[test] +fn an_array_entered_outside_the_selection_covers_only_its_cell() { + // Found by the command-sequence fuzzer: the array took the selection's range even with + // `cell` elsewhere (or on another sheet), so its formula sat outside its own range and the + // selected cells were wiped. + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "K20", "input": "keep"})).unwrap(); + s.execute("selection.set", json!({"range": "K20:K22"})).unwrap(); + s.execute("cell.set", json!({"cell": "H5", "input": "=1+1", "array": true})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + let f = sh.cell(CellRef::parse("H5").unwrap()).and_then(|c| c.formula.clone()).unwrap(); + assert_eq!(f.array, Some(RangeRef::parse("H5").unwrap())); + assert_eq!(sh.value(CellRef::parse("K20").unwrap()), Value::text("keep")); +} + +#[test] +fn wildcards() { + assert!(wildcard("hello", "h*o")); + assert!(wildcard("hello", "h?llo")); + assert!(!wildcard("hello", "h?lo")); + assert!(wildcard("a*b", "a~*b")); + assert!(!wildcard("axb", "a~*b")); + assert!(wildcard("", "*")); +} + +#[test] +fn tsv() { + assert_eq!(parse_tsv("a\tb\n1\t2\n"), vec![vec!["a", "b"], vec!["1", "2"]]); + assert_eq!(parse_tsv("\"x\ty\"\tz"), vec![vec!["x\ty", "z"]]); + assert_eq!(parse_tsv("\"he said \"\"hi\"\"\""), vec![vec!["he said \"hi\""]]); +} + +#[test] +fn paste_special_operations_keep_text_errors_and_formulas() { + let mut s = Session::new(); + s.new_workbook(); + s.execute( + "range.setValues", + json!({"range": "A1", "values": [["'123", "Total", "=NA()", "=B3*2", 5], [1, null, null, null, null], [null, 4, null, null, null]]}), + ) + .unwrap(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + assert_eq!(get(&mut s, "A1")["value"], "123"); + s.execute("edit.copy", json!({"range": "A2"})).unwrap(); + s.execute("selection.set", json!({"range": "A1:E1"})).unwrap(); + s.execute("edit.pasteSpecial", json!({"what": "values", "operation": "multiply"})).unwrap(); + assert_eq!(get(&mut s, "A1")["value"], json!(123.0)); + assert_eq!(get(&mut s, "B1")["value"], "Total"); + assert_eq!(get(&mut s, "C1")["value"]["error"], "#N/A"); + assert_eq!(get(&mut s, "D1")["formula"], "=(B3*2)*1"); + assert_eq!(get(&mut s, "D1")["value"], json!(8.0)); + assert_eq!(get(&mut s, "E1")["value"], json!(5.0)); +} + +#[test] +fn cut_paste_that_overflows_the_grid_moves_nothing() { + // #997: the source was emptied first and the cells that fell off the grid were skipped. + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2]]})).unwrap(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap(); + // Transposed, the 2 rows become 2 columns and the second one is past XFD. + assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "transpose": true, "at": "XFD1"})).is_err()); + assert_eq!(get(&mut s, "A1")["value"], json!(1.0)); + assert_eq!(get(&mut s, "A2")["value"], json!(2.0)); + // Not transposed, the second row would be past the last row. + assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "at": "A1048576"})).is_err()); + assert_eq!(get(&mut s, "A1")["value"], json!(1.0)); + assert_eq!(get(&mut s, "A2")["value"], json!(2.0)); + // Not transposed it still fits in column XFD. + s.execute("edit.pasteSpecial", json!({"what": "all", "at": "XFD1"})).unwrap(); + assert_eq!(get(&mut s, "XFD1")["value"], json!(1.0)); + assert_eq!(get(&mut s, "XFD2")["value"], json!(2.0)); +} + +#[test] +fn replace_case_insensitive() { + assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear"); +} + +#[test] +fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() { + // U+212A KELVIN SIGN folds to `k` (3→1 bytes) while U+0130 folds to `i` plus a + // combining dot (2→3 bytes): the folded text is the same length but its byte positions + // no longer line up with the original, and the old slicing dropped the tail. + let mut s = Session::new(); + s.new_workbook(); + let text = "\u{212A}\u{0130}\u{0130}"; + s.execute("cell.set", json!({"cell": "A1", "input": text})).unwrap(); + s.execute("edit.replace", json!({"what": "k", "with": "x"})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text)); +} diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 3f8dee71e9..bf21433abb 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -7,6 +7,23 @@ use serde_json::{Value as Json, json}; use super::*; +mod cells; +mod chart; +mod data; +mod objects; +mod scripts; +mod window; + +pub(crate) use cells::is_checkbox_cell; +use cells::{background, checkbox, draw_border, new_cell_style, recently_used, theme_colors, theme_fonts, toggle_checkbox, use_in_formula}; +use chart::{add_element, change_colors, format_series, move_chart, quick_layout, select_data}; +use data::{from_csv, from_table_range, queries, table_resize}; +use objects::{align, arrange, rotate, selection_pane, shape_fill, shape_outline}; +pub(crate) use scripts::has_scripts; +use scripts::{all_scripts, delete_script, new_script, record, run_script, stop_action, trust_scripts}; +use window::{arrange_all, custom_views, hide_window, new_window, ruler, set_view, split, switch_windows, unhide_window, watch_window}; +pub(crate) use window::{keep_watch_list, window_param}; + pub fn specs() -> Vec { vec![ // Chart Design / Format @@ -308,430 +325,6 @@ pub fn specs() -> Vec { ] } -fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> { - let d = s.doc()?; - let si = d.wb.active_sheet(); - let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; - let idx = match u32_param(p, "chart") { - Some(id) => sh.charts.iter().position(|c| c.id == id), - None => sh.charts.len().checked_sub(1), - }; - idx.map(|i| (si, i)).ok_or_else(|| bad("chart", "no chart on this sheet")) -} - -fn with_chart(s: &mut Session, p: &Json, f: impl FnOnce(&mut Chart) -> Result<()>) -> Result { - let (si, ci) = find_chart(s, p)?; - edit(s, |cx| { - let c = cx.sheet_mut(si)?.charts.get_mut(ci).ok_or_else(|| bad("chart", "no such chart"))?; - f(c)?; - Ok(json!({"chart": c.id})) - }) -} - -fn add_element(s: &mut Session, p: &Json) -> Result { - let el = str_param(p, "element").unwrap_or("title").to_string(); - let on = bool_param(p, "on").unwrap_or(true); - let pos = str_param(p, "position").map(str::to_string); - with_chart(s, p, |c| { - match el.as_str() { - "title" => c.title = if on { c.title.clone().filter(|t| !t.is_empty()).or(Some("Chart Title".into())) } else { Some(String::new()) }, - "legend" => { - c.legend = if !on { - LegendPos::None - } else { - match pos.as_deref() { - Some("top") => LegendPos::Top, - Some("left") => LegendPos::Left, - Some("right") => LegendPos::Right, - _ => LegendPos::Bottom, - } - } - } - "dataLabels" => c.data_labels = on, - "gridlines" => c.gridlines = on, - "axisTitles" => { - c.x_title = on.then(|| c.x_title.clone().unwrap_or_else(|| "Axis Title".into())); - c.y_title = on.then(|| c.y_title.clone().unwrap_or_else(|| "Axis Title".into())); - } - "smooth" => { - for s in &mut c.series { - s.smooth = on; - } - } - "trendline" => { - let only = u32_param(p, "series").map(|i| i as usize); - if only.is_some_and(|i| i >= c.series.len()) { - return Err(bad("chart.addElement", "no such series")); - } - let targets: Vec = (0..c.series.len()).filter(|i| only.is_none_or(|o| o == *i)).collect(); - if !on { - for i in targets { - if let Some(se) = c.series.get_mut(i) { - se.trendlines.clear(); - } - } - return Ok(()); - } - let t = trendline_param(p)?; - let eligible: Vec = - targets.into_iter().filter(|i| gridcraft_chart::supports_trendline(gridcraft_chart::series_plot_kind(c, *i))).collect(); - if eligible.is_empty() { - return Err(bad("chart.addElement", "trendlines need a 2-D column, bar, line, area, scatter or bubble series")); - } - for i in eligible { - if let Some(se) = c.series.get_mut(i) { - se.trendlines.push(t.clone()); - } - } - } - other => return Err(bad("chart.addElement", format!("unknown element `{other}`"))), - } - Ok(()) - }) -} - -/// The trendline described by `chart.addElement`'s trendline parameters (Excel's Add Chart -/// Element ▸ Trendline choices, plus the Format Trendline options). -fn trendline_param(p: &Json) -> Result { - let num = |k: &str| Trendline::clamp_forecast(f64_param(p, k).unwrap_or(0.0)); - let mut t = Trendline { - forward: num("forward"), - backward: num("backward"), - display_equation: bool_param(p, "displayEquation").unwrap_or(false), - display_r_squared: bool_param(p, "displayRSquared").unwrap_or(false), - ..Trendline::default() - }; - t.kind = match str_param(p, "type").unwrap_or("linear") { - "linear" => TrendlineKind::Linear, - // Excel's Linear Forecast: a linear trendline projected two periods ahead. - "linearForecast" => { - if t.forward == 0.0 { - t.forward = 2.0; - } - TrendlineKind::Linear - } - "exponential" => TrendlineKind::Exponential, - "logarithmic" => TrendlineKind::Logarithmic, - "power" => TrendlineKind::Power, - "polynomial" => TrendlineKind::Polynomial(u32_param(p, "order").unwrap_or(2).clamp(2, 6) as u8), - "movingAverage" => TrendlineKind::MovingAverage(u32_param(p, "period").unwrap_or(2).clamp(2, 255)), - other => return Err(bad("chart.addElement", format!("unknown trendline type `{other}`"))), - }; - Ok(t) -} - -fn quick_layout(s: &mut Session, p: &Json) -> Result { - let n = u32_param(p, "layout").unwrap_or(1); - with_chart(s, p, |c| { - let (legend, labels, grid, axes) = match n { - 2 => (LegendPos::Top, true, false, false), - 3 => (LegendPos::Bottom, false, true, false), - 4 => (LegendPos::Bottom, true, false, false), - 5 => (LegendPos::None, false, true, true), - 6 => (LegendPos::Right, false, true, true), - _ => (LegendPos::Right, false, true, false), - }; - c.legend = legend; - c.data_labels = labels; - c.gridlines = grid; - if axes { - c.x_title.get_or_insert_with(|| "Axis Title".into()); - c.y_title.get_or_insert_with(|| "Axis Title".into()); - } else { - c.x_title = None; - c.y_title = None; - } - Ok(()) - }) -} - -fn change_colors(s: &mut Session, p: &Json) -> Result { - let explicit: Option> = - p.get("colors").and_then(Json::as_array).map(|a| a.iter().filter_map(|c| c.as_str().and_then(Color::from_hex)).collect()); - let palette = str_param(p, "palette").unwrap_or("colorful").to_string(); - with_chart(s, p, |c| { - for (i, se) in c.series.iter_mut().enumerate() { - se.color = match &explicit { - Some(cols) if !cols.is_empty() => cols.get(i % cols.len()).copied(), - _ => match palette.as_str() { - "monochrome" => Some(Color::Theme(4, (-400 + (i as i16 * 200)).clamp(-800, 800))), - p if p.starts_with("accent") => { - let k: u8 = p.trim_start_matches("accent").parse().unwrap_or(1); - Some(Color::Theme(3 + k.clamp(1, 6), (-500 + i as i16 * 250).clamp(-800, 800))) - } - _ => None, - }, - }; - } - Ok(()) - }) -} - -fn select_data(s: &mut Session, p: &Json) -> Result { - let d = s.doc()?; - // A new data range rebuilds the series, as in Excel's Select Data › Chart data range. - let rebuilt = match str_param(p, "range") { - Some(range) => { - let range = range.trim_start_matches('='); - let (sheet_name, body) = split_sheet(range); - let src_si = match sheet_name { - Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?, - None => d.wb.active_sheet(), - }; - let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?; - if r.count() > 1_000_000 { - return Err(bad("chart.selectData", "range too large for a chart")); - } - let src = d.wb.sheet(src_si).ok_or(EngineError::NoDocument)?; - let by_rows = bool_param(p, "byRows").unwrap_or(r.width() > r.height() + 1); - let series = super::insert::series_from_range(src, r, by_rows); - Some((series, format!("{}!{}", gridcraft_formula::quote_sheet(&src.name), r.a1()), by_rows)) - } - None => None, - }; - // Horizontal (Category) Axis Labels › Edit: one labels range for every series ("" clears). - let categories = match str_param(p, "categories") { - None => None, - Some(t) if t.trim().trim_start_matches('=').is_empty() => Some(None), - Some(t) => { - let t = t.trim().trim_start_matches('='); - let (sheet_name, body) = split_sheet(t); - let si = match sheet_name { - Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?, - None => d.wb.active_sheet(), - }; - let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "`categories` is not a range"))?; - let name = d.wb.sheet(si).map(|sh| sh.name.clone()).ok_or(EngineError::NoDocument)?; - let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1); - Some(Some(format!("{}!{}:{}", gridcraft_formula::quote_sheet(&name), abs(r.start), abs(r.end)))) - } - }; - let mut remove: Vec = p - .get("removeSeries") - .and_then(Json::as_array) - .map(|a| a.iter().filter_map(Json::as_u64).filter_map(|i| usize::try_from(i).ok()).collect()) - .unwrap_or_default(); - remove.sort_unstable(); - remove.dedup(); - if rebuilt.is_none() && categories.is_none() && remove.is_empty() { - return Err(bad("chart.selectData", "missing `range`, `categories` or `removeSeries`")); - } - with_chart(s, p, move |c| { - if let Some((series, source, by_rows)) = rebuilt { - c.series = series; - super::insert::scatter_x_from_first(c.kind, &mut c.series); - c.source = Some(source); - c.by_rows = by_rows; - } - for i in remove.iter().rev() { - if *i < c.series.len() && c.series.len() > 1 { - c.series.remove(*i); - } - } - if let Some(cats) = categories { - for se in &mut c.series { - se.categories = cats.clone(); - } - } - Ok(()) - }) -} - -fn move_chart(s: &mut Session, p: &Json) -> Result { - let (si, ci) = find_chart(s, p)?; - let target = match p.get("sheet").and_then(Json::as_str) { - Some(n) => Some(s.doc()?.wb.sheet_index(n).ok_or_else(|| bad("chart.move", "no such sheet"))?), - None => None, - }; - let at = cell_param_on(s, p, "at", target.unwrap_or(si))?; - edit(s, |cx| { - let mut chart = cx.sheet_mut(si)?.charts.remove(ci); - if let Some(a) = at { - chart.anchor.cell = a; - } - let dest = target.unwrap_or(si); - cx.sheet_mut(dest)?.charts.push(chart); - Ok(Json::Null) - }) -} - -fn format_series(s: &mut Session, p: &Json) -> Result { - let idx = u32_param(p, "series").unwrap_or(0) as usize; - let color = str_param(p, "color").and_then(Color::from_hex); - with_chart(s, p, |c| { - if let Some(se) = c.series.get_mut(idx) { - se.color = color; - } - Ok(()) - }) -} - -fn shape_fill(s: &mut Session, p: &Json) -> Result { - if str_param(p, "kind") == Some("shape") { - let color = str_param(p, "color").and_then(Color::from_hex).unwrap_or_default(); - return with_shape(s, p, |sh| sh.fill = color); - } - format_series(s, p) -} - -fn shape_outline(s: &mut Session, p: &Json) -> Result { - let color = str_param(p, "color").and_then(Color::from_hex).unwrap_or_default(); - with_shape(s, p, |sh| sh.line = color) -} - -fn with_shape(s: &mut Session, p: &Json, f: impl FnOnce(&mut Shape)) -> Result { - let si = s.doc()?.wb.active_sheet(); - let id = u32_param(p, "id"); - edit(s, |cx| { - let shapes = &mut cx.sheet_mut(si)?.shapes; - let sh = match id { - Some(id) => shapes.iter_mut().find(|x| x.id == id), - None => shapes.last_mut(), - } - .ok_or_else(|| bad("shape", "no such shape"))?; - f(sh); - Ok(Json::Null) - }) -} - -fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result { - let id = u32_param(p, "id").ok_or_else(|| bad("arrange", "missing `id`"))?; - let kind = str_param(p, "kind").unwrap_or("shape").to_string(); - let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false); - let si = s.doc()?.wb.active_sheet(); - fn reorder(v: &mut Vec, i: usize, forward: bool, extreme: bool) { - if i >= v.len() { - return; - } - let item = v.remove(i); - let j = match (forward, extreme) { - (true, true) => v.len(), - (false, true) => 0, - (true, false) => (i + 1).min(v.len()), - (false, false) => i.saturating_sub(1), - }; - v.insert(j, item); - } - edit(s, |cx| { - let sh = cx.sheet_mut(si)?; - match kind.as_str() { - "chart" => { - if let Some(i) = sh.charts.iter().position(|c| c.id == id) { - reorder(&mut sh.charts, i, forward, extreme); - } - } - "image" => { - if let Some(i) = sh.images.iter().position(|c| c.id == id) { - reorder(&mut sh.images, i, forward, extreme); - } - } - _ => { - if let Some(i) = sh.shapes.iter().position(|c| c.id == id) { - reorder(&mut sh.shapes, i, forward, extreme); - } - } - } - Ok(Json::Null) - }) -} - -fn selection_pane(s: &mut Session, _: &Json) -> Result { - let d = s.doc()?; - let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - let mut v = Vec::new(); - for c in &sh.shapes { - v.push(json!({"kind": "shape", "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); - } - for c in &sh.images { - v.push(json!({"kind": "image", "id": c.id, "name": format!("Picture {}", c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); - } - for c in &sh.charts { - v.push(json!({"kind": "chart", "id": c.id, "name": format!("Chart {}", c.id), "title": c.title, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); - } - Ok(Json::Array(v)) -} - -fn anchors_of<'a>(sh: &'a mut Sheet, kind: &str) -> Vec<(u32, &'a mut Anchor)> { - match kind { - "chart" => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), - "image" => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), - _ => sh.shapes.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), - } -} - -fn align(s: &mut Session, p: &Json) -> Result { - let kind = str_param(p, "kind").unwrap_or("shape").to_string(); - let ids: Vec = - p.get("ids").and_then(Json::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as u32)).collect()).unwrap_or_default(); - let how = str_param(p, "align").unwrap_or("left").to_string(); - let si = s.doc()?.wb.active_sheet(); - edit(s, |cx| { - let sheet = cx.sheet_mut(si)?; - let geo: Vec<(u32, f64, f64, f32, f32)> = { - let shc = sheet.clone(); - anchors_of(sheet, &kind) - .into_iter() - .filter(|(id, _)| ids.contains(id)) - .map(|(id, a)| (id, shc.col_left(a.cell.col) + a.dx as f64, shc.row_top(a.cell.row) + a.dy as f64, a.width, a.height)) - .collect() - }; - if geo.len() < 2 { - return Ok(Json::Null); - } - let left = geo.iter().map(|g| g.1).fold(f64::INFINITY, f64::min); - let right = geo.iter().map(|g| g.1 + g.3 as f64).fold(f64::NEG_INFINITY, f64::max); - let top = geo.iter().map(|g| g.2).fold(f64::INFINITY, f64::min); - let bottom = geo.iter().map(|g| g.2 + g.4 as f64).fold(f64::NEG_INFINITY, f64::max); - let shc = sheet.clone(); - for (id, a) in anchors_of(sheet, &kind) { - let Some(g) = geo.iter().find(|g| g.0 == id) else { continue }; - let (mut x, mut y) = (g.1, g.2); - match how.as_str() { - "left" => x = left, - "right" => x = right - g.3 as f64, - "center" => x = (left + right) / 2.0 - g.3 as f64 / 2.0, - "top" => y = top, - "bottom" => y = bottom - g.4 as f64, - _ => y = (top + bottom) / 2.0 - g.4 as f64 / 2.0, - } - let c = CellRef::new(shc.row_at(y), shc.col_at(x)); - a.cell = c; - a.dx = (x - shc.col_left(c.col)) as f32; - a.dy = (y - shc.row_top(c.row)) as f32; - } - Ok(Json::Null) - }) -} - -fn rotate(s: &mut Session, p: &Json) -> Result { - if p.get("flip").is_some_and(|f| !f.is_null()) { - return Err(bad("arrange.rotate", "`flip` is not supported: shapes have no flip state")); - } - with_shape(s, p, |sh| std::mem::swap(&mut sh.anchor.width, &mut sh.anchor.height)) -} - -/// Switches a sheet's view. Like zoom, the view is saved with the file but is not an edit: it -/// neither records an undo step nor marks a clean workbook as changed. -fn set_view(s: &mut Session, p: &Json, mode: SheetViewMode) -> Result { - let si = target_sheet(s, p)?; - let d = s.doc_mut()?; - let dirty = d.is_dirty(); - let wb = std::sync::Arc::make_mut(&mut d.wb); - if let Some(sh) = wb.sheet_mut(si) { - sh.view = mode; - } - if !dirty { - d.saved = d.wb.clone(); - } - Ok(json!({"view": mode.as_str()})) -} - -fn ruler(s: &mut Session, p: &Json) -> Result { - let on = bool_param(p, "on").unwrap_or(s.hide_ruler); - s.hide_ruler = !on; - Ok(json!({"on": on})) -} - const VIEWS_NAME: &str = "_sc_custom_views"; const SCRIPTS_NAME: &str = "_sc_scripts"; const WATCH_NAME: &str = "_sc_watch"; @@ -759,606 +352,10 @@ fn stash_set(wb: &mut Workbook, key: &str, v: &Json) { wb.names.push(DefinedName { name: key.into(), scope: None, formula, comment: String::new(), hidden: true }); } -/// The Watch Window list isn't an edit (Excel doesn't undo it): undo and redo keep the current -/// list when they swap in another snapshot of the workbook. -pub(crate) fn keep_watch_list(from: &Workbook, to: &mut std::sync::Arc) { - let find = |wb: &Workbook| wb.names.iter().find(|n| n.name == WATCH_NAME).cloned(); - let keep = find(from); - if keep == find(to) { - return; - } - let wb = std::sync::Arc::make_mut(to); - wb.names.retain(|n| n.name != WATCH_NAME); - wb.names.extend(keep); -} - -fn custom_views(s: &mut Session, p: &Json) -> Result { - let d = s.doc()?; - let mut views = stash_map(&d.wb, VIEWS_NAME); - if let Some(name) = str_param(p, "save") { - let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - views[name] = json!({"sheet": d.wb.active_sheet(), "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze}); - let v = views.clone(); - edit(s, |cx| { - stash_set(&mut cx.wb, VIEWS_NAME, &v); - Ok(()) - })?; - } else if let Some(name) = str_param(p, "show") { - let v = views.get(name).cloned().ok_or_else(|| bad("view.customViews", "no such view"))?; - if let Some(si) = v["sheet"].as_u64() { - s.execute("sheet.activate", json!({"sheet": si}))?; - } - if let Some(sel) = v["selection"].as_str() { - s.execute("selection.set", json!({"range": sel}))?; - } - if let Some(z) = v["zoom"].as_u64() { - s.execute("view.zoom", json!({"percent": z}))?; - } - if let Some(f) = v.get("freeze") { - let freeze = match (f[0].as_u64(), f[1].as_u64()) { - (Some(r), Some(c)) => u32::try_from(r).ok().zip(u32::try_from(c).ok()), - _ => None, - }; - edit(s, |cx| { - let si = cx.sheet_index(); - cx.sheet_mut(si)?.freeze = freeze; - Ok(()) - })?; - } - } else if let Some(name) = str_param(p, "delete") { - if let Some(o) = views.as_object_mut() { - o.remove(name); - } - let v = views.clone(); - edit(s, |cx| { - stash_set(&mut cx.wb, VIEWS_NAME, &v); - Ok(()) - })?; - } - Ok(stash_get(&s.doc()?.wb, VIEWS_NAME)) -} - -fn split(s: &mut Session, _: &Json) -> Result { - let has = s.doc()?.wb.active().is_some_and(|sh| sh.freeze.is_some()); - if has { s.execute("view.unfreezePanes", json!({})) } else { s.execute("view.freezePanes", json!({})) } -} - -fn new_window(s: &mut Session, _: &Json) -> Result { - let id = s.new_window()?; - let doc = s.window(id).map(|w| w.doc_uid); - Ok(json!({"window": id, "doc": doc, "title": s.window_title(id)})) -} - -/// The window a window command names: `window` (an id), else `doc` (a workbook id: its most -/// recent window), else the active window. -pub(crate) fn window_param(s: &Session, cmd: &str, p: &Json) -> Result { - let t = crate::Target::from_json(p).map_err(|e| bad(cmd, e.to_string()))?; - s.target_window(t).map_err(|e| bad(cmd, e.to_string())) -} - -/// View › Arrange All. The engine has no window geometry: it picks the windows and the layout, -/// and the interface moves its native windows ([`Session::window_list`] order, active first). -fn arrange_all(s: &mut Session, p: &Json) -> Result { - let arrange = match str_param(p, "arrange").unwrap_or("tiled") { - a @ ("tiled" | "horizontal" | "vertical" | "cascade") => a, - other => return Err(bad("view.arrangeAll", format!("`arrange` must be tiled, horizontal, vertical or cascade, not `{other}`"))), - }; - let active = s.active_window(); - let doc = active.and_then(|w| s.window(w)).map(|w| w.doc_uid); - let only_active = p.get("activeWorkbook").and_then(Json::as_bool).unwrap_or(false); - let mut windows: Vec = s.windows().iter().filter(|w| !w.hidden && (!only_active || Some(w.doc_uid) == doc)).map(|w| w.id).collect(); - if let Some(at) = windows.iter().position(|w| Some(*w) == active) - && let Some(first) = windows.get_mut(..=at) - { - first.rotate_right(1); - } - Ok(json!({"arrange": arrange, "windows": windows})) -} - -fn hide_window(s: &mut Session, p: &Json) -> Result { - let id = window_param(s, "view.hideWindow", p)?; - s.hide_window(id); - ok() -} - -fn unhide_window(s: &mut Session, p: &Json) -> Result { - let id = if p.get("window").is_some_and(|v| !v.is_null()) || p.get("doc").is_some_and(|v| !v.is_null()) { - Some(window_param(s, "view.unhideWindow", p)?) - } else if let Some(i) = u32_param(p, "index") { - // A workbook's position in the document list. - let uid = s.documents().get(i as usize).map(|d| d.uid).ok_or_else(|| bad("view.unhideWindow", "no such window"))?; - s.windows().iter().filter(|w| w.doc_uid == uid && w.hidden).map(|w| w.id).next_back().or_else(|| s.doc_window(uid)) - } else { - s.last_hidden_window() - }; - if let Some(id) = id { - s.unhide_window(id); - } - ok() -} - -fn switch_windows(s: &mut Session, p: &Json) -> Result { - if p.get("window").is_some_and(|v| !v.is_null()) || p.get("doc").is_some_and(|v| !v.is_null()) { - let id = window_param(s, "view.switchWindows", p)?; - s.unhide_window(id); - } - Ok(s.window_list()) -} - -fn table_resize(s: &mut Session, p: &Json) -> Result { - let r = str_param(p, "range").and_then(RangeRef::parse).ok_or_else(|| bad("table.resize", "missing `range`"))?; - let d = s.doc()?; - let si = d.wb.active_sheet(); - let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; - let ti = match str_param(p, "table") { - Some(n) => sh.tables.iter().position(|t| t.name.eq_ignore_ascii_case(n)), - None => sh.tables.iter().position(|t| t.range.contains(d.selection.active)), - } - .ok_or_else(|| bad("table.resize", "select a cell in a table"))?; - edit(s, |cx| { - let sheet = cx.sheet_mut(si)?; - let headers: Vec = (r.start.col..=r.end.col).map(|c| sheet.value(CellRef::new(r.start.row, c)).display()).collect(); - if sheet.tables.iter().enumerate().any(|(i, o)| i != ti && o.range.intersects(&r)) { - return Err(EngineError::Other("A table can't overlap another table.".into())); - } - let t = sheet.tables.get_mut(ti).ok_or_else(|| bad("table", "gone"))?; - if r.start.row != t.range.start.row || !r.intersects(&t.range) { - return Err(EngineError::Other( - "The headers must remain in the same row, and the resulting table range must overlap the original table range.".into(), - )); - } - let mut cols = Vec::new(); - for (i, h) in headers.iter().enumerate() { - let name = if h.is_empty() { format!("Column{}", i + 1) } else { h.clone() }; - let existing = t.columns.iter().find(|c| c.name.eq_ignore_ascii_case(&name)).cloned(); - cols.push(existing.unwrap_or(TableColumn { - name, - totals: TotalsFn::None, - totals_label: None, - formula: None, - unique_name: None, - xml: None, - })); - } - t.columns = cols; - t.range = r; - cx.structural = true; - Ok(Json::Null) - }) -} - -fn from_csv(s: &mut Session, p: &Json) -> Result { - let bytes = if let Some(t) = str_param(p, "text") { - t.as_bytes().to_vec() - } else if let Some(path) = str_param(p, "path") { - crate::io::read_file(path)? - } else { - s.ui_requests.push(crate::UiRequest::Dialog("open".into(), json!({}))); - return ok(); - }; - let text = String::from_utf8_lossy(&bytes); - // JSON array of objects → rows. - let rows: Vec> = - if text.trim_start().starts_with('[') { - let arr: Vec = serde_json::from_str(&text).map_err(|e| bad("data.getData", e.to_string()))?; - let mut keys: Vec = Vec::new(); - for o in &arr { - if let Some(m) = o.as_object() { - for k in m.keys() { - if !keys.contains(k) { - keys.push(k.clone()); - } - } - } - } - let mut out = vec![keys.iter().map(|k| json!(k)).collect::>()]; - for o in &arr { - out.push(keys.iter().map(|k| o.get(k).cloned().unwrap_or(Json::Null)).collect()); - } - out - } else { - let delim = str_param(p, "delimiter") - .and_then(|d| d.chars().next()) - .unwrap_or(if text.lines().next().is_some_and(|l| l.contains('\t')) { '\t' } else { ',' }); - let opts = gridcraft_xlsx::CsvOptions { delimiter: delim as u8, ..Default::default() }; - let wb = gridcraft_xlsx::read_csv(&bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?; - let sh = wb.sheet(0).ok_or(EngineError::NoDocument)?; - let Some(u) = sh.used_range() else { return Err(EngineError::Other("The file is empty.".into())) }; - (u.start.row..=u.end.row) - .map(|r| { - (u.start.col..=u.end.col) - .map(|c| match sh.value(CellRef::new(r, c)) { - Value::Number(n) => json!(n), - Value::Bool(b) => json!(b), - Value::Empty => Json::Null, - v => json!(v.display()), - }) - .collect() - }) - .collect() - }; - if rows.is_empty() || rows.len() > 1_048_576 { - return Err(EngineError::Other("Nothing to import.".into())); - } - let name = str_param(p, "sheet").map(str::to_string).or_else(|| { - str_param(p, "path").and_then(|x| std::path::Path::new(x).file_stem().and_then(std::ffi::OsStr::to_str).map(|f| f.chars().take(31).collect())) - }); - let r = s.execute("home.insertSheet", json!({"name": name})).or_else(|_| s.execute("home.insertSheet", json!({})))?; - let w = rows.iter().map(Vec::len).max().unwrap_or(1).max(1); - s.execute("range.setValues", json!({"range": "A1", "values": rows}))?; - let range = RangeRef::new(CellRef::new(0, 0), CellRef::new(rows.len() as u32 - 1, w as u32 - 1)); - let _ = s.execute("insert.table", json!({"range": range.a1(), "header": true})); - let _ = s.execute("home.autofitColumnWidth", json!({"cols": RangeRef::cols(0, w as u32 - 1).a1()})); - Ok(json!({"sheet": r["name"], "rows": rows.len(), "cols": w})) -} - -/// `data.fromTableRange`: the selected block as CSV text into a copy of its sheet. -fn from_table_range(s: &mut Session, p: &Json) -> Result { - let r = target_range(s, p)?; - let d = s.doc()?; - let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - let r = if r.is_single() { crate::selection::current_region(sh, r.start) } else { r }; - let text = crate::display::range_text(&d.wb, d.wb.active_sheet(), r); - from_csv(s, &json!({"text": text, "delimiter": "\t", "sheet": format!("{} (2)", sh.name).chars().take(31).collect::()})) -} - -/// `data.queriesConnections`: the saved From Web connections (source, last sync) followed by -/// the workbook's imported tables — the Queries & Connections pane's list. -fn queries(s: &mut Session, _: &Json) -> Result { - let d = s.doc()?; - let tables: Vec = - d.wb.sheets - .iter() - .flat_map(|sh| { - sh.tables - .iter() - .map(move |t| json!({"name": t.name, "sheet": sh.name, "range": t.range.a1(), "rows": t.range.height().saturating_sub(1)})) - }) - .collect(); - // The saved From Web connections, with the source and the last sync the pane shows. - let web: Vec = - d.wb.web_queries - .iter() - .map(|q| { - json!({ - "name": q.name, "sheet": q.sheet, "url": q.url, "table": q.table, - "last_synced": q.last_synced.unwrap_or(0), "web": true, - }) - }) - .collect(); - Ok(Json::Array([web, tables].concat())) -} - -fn record(s: &mut Session, p: &Json) -> Result { - if bool_param(p, "stop").unwrap_or(false) || s.recording.is_some() { - let Some(start) = s.recording.take() else { return Ok(json!({"recording": false})) }; - let cmds: Vec = - s.journal.iter().skip(start).filter(|(id, _)| !id.starts_with("automate.")).map(|(id, p)| json!({"command": id, "params": p})).collect(); - let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Script {}", start)); - s.execute("automate.newScript", json!({"name": name, "commands": cmds}))?; - return Ok(json!({"recording": false, "saved": name, "steps": cmds.len()})); - } - s.recording = Some(s.journal.len()); - Ok(json!({"recording": true})) -} - -fn new_script(s: &mut Session, p: &Json) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("automate.newScript", "missing `name`"))?.to_string(); - let cmds = p.get("commands").cloned().unwrap_or(json!([])); - if !cmds.is_array() { - return Err(bad("automate.newScript", "`commands` must be an array")); - } - edit(s, |cx| { - let mut all = stash_map(&cx.wb, SCRIPTS_NAME); - all[name.as_str()] = cmds.clone(); - stash_set(&mut cx.wb, SCRIPTS_NAME, &all); - Ok(json!({"name": name})) - }) -} - -fn all_scripts(s: &mut Session, _: &Json) -> Result { - let d = s.doc()?; - let (wb, trusted) = (&d.wb, d.scripts_trusted); - let all = stash_get(wb, SCRIPTS_NAME); - let mut list: Vec = all - .as_object() - .map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len), "trusted": trusted})).collect()) - .unwrap_or_default(); - // A preserved VBA project is reported alongside GridCraft's own scripts; it is carried on - // save but never executed here, so it has no step count. - if let Some(bytes) = &wb.vba_project { - list.push(json!({"name": "(VBA)", "steps": 0, "kind": "vba", "size": bytes.len(), "executed": false})); - } - Ok(Json::Array(list)) -} - -/// Whether `wb` carries GridCraft scripts (a workbook opened with them starts untrusted). -pub(crate) fn has_scripts(wb: &Workbook) -> bool { - stash_map(wb, SCRIPTS_NAME).as_object().is_some_and(|o| !o.is_empty()) -} - -/// Why a script step is refused, whatever the workbook's trust: scripts stay inside the -/// workbook. Anything that reads or writes files (`file.*`, or any `path` parameter: Get Data, -/// From Text/CSV, Insert Picture) or leaves the app (`link.*` opens URLs) is the caller's to run -/// directly, not a stored script's. (Nested `automate.*` steps are skipped.) This check goes by -/// command id and parameter name, so it is only the up-front refusal: the steps also run inside -/// an [`crate::io::ScriptSandbox`] (file access refused whatever the parameter is called) and -/// [`run_script`] drops any `OpenUrl` request a step raises. -fn denied_script_step(id: &str, params: &Json) -> Option { - 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.") || matches!(id, "view.switchWindows" | "view.newWindow" | "view.hideWindow" | "view.unhideWindow") { - 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}`")); - } - None -} - -fn trust_scripts(s: &mut Session, p: &Json) -> Result { - // Only a missing `trusted` means "trust"; `"false"`, `0` or `null` are mistakes, not consent. - let trusted = match p.get("trusted") { - None => true, - Some(Json::Bool(b)) => *b, - Some(_) => return Err(bad("automate.trustScripts", "`trusted` must be true or false")), - }; - s.doc_mut()?.scripts_trusted = trusted; - Ok(json!({"trusted": trusted})) -} - -/// Runs a stored script. Scripts in a workbook opened from a file run only once the workbook is -/// trusted for this session (`automate.trustScripts`, or `trusted: true` on this call); steps that -/// reach outside the workbook ([`denied_script_step`]) are refused before any step runs. -fn run_script(s: &mut Session, p: &Json) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("automate.runScript", "missing `name`"))?; - let d = s.doc()?; - let all = stash_get(&d.wb, SCRIPTS_NAME); - let steps = all.get(name).and_then(Json::as_array).cloned().ok_or_else(|| bad("automate.runScript", "no such script"))?; - if !d.scripts_trusted && !bool_param(p, "trusted").unwrap_or(false) { - return Err(bad( - "automate.runScript", - "scripts in this workbook came from a file and are disabled; trust the workbook first (Trust Scripts in View › Macros, Alt+F8; `automate.trustScripts`; or `trusted: true`)", - )); - } - let steps: Vec = steps.into_iter().take(100_000).collect(); - for st in &steps { - let id = st["command"].as_str().unwrap_or(""); - if let Some(why) = denied_script_step(id, &st["params"]) { - return Err(bad("automate.runScript", why)); - } - } - let _sandbox = crate::io::ScriptSandbox::enter(); - let mut n = 0; - for st in &steps { - let id = st["command"].as_str().unwrap_or(""); - if id.starts_with("automate.") { - continue; - } - let before = s.ui_requests.len(); - let ran = s.execute(id, st["params"].clone()); - // A step that asks the host to open a URL leaves the app: drop the request and stop. - if s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_)))) { - let mut i = 0; - s.ui_requests.retain(|r| { - i += 1; - i <= before || !matches!(r, crate::UiRequest::OpenUrl(_)) - }); - return Err(bad("automate.runScript", format!("scripts can't open links (`{id}`)"))); - } - ran?; - n += 1; - } - Ok(json!({"ran": n})) -} - -/// Automate › Stop Action: rolls back the undo groups open on the active workbook, so Undo and -/// Redo work there again. -fn stop_action(s: &mut Session, _: &Json) -> Result { - let uid = s.doc()?.uid; - Ok(json!({"stopped": s.abort_groups(uid)})) -} - -fn delete_script(s: &mut Session, p: &Json) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string(); - edit(s, |cx| { - let mut all = stash_map(&cx.wb, SCRIPTS_NAME); - if let Some(o) = all.as_object_mut() { - o.remove(&name); - } - stash_set(&mut cx.wb, SCRIPTS_NAME, &all); - Ok(Json::Null) - }) -} - /// Number format that marks checkbox cells (TRUE/FALSE values drawn as boxes). pub const CHECKBOX_FORMAT: &str = "\"☐\";\"☐\";\"☐\";[=1]\"☑\""; -/// Whether `c` has checkbox formatting. -pub(crate) fn is_checkbox_cell(wb: &gridcraft_model::Workbook, sh: &gridcraft_model::Sheet, c: CellRef) -> bool { - wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" -} - -fn checkbox(s: &mut Session, p: &Json) -> Result { - let ranges = target_ranges(s, p)?; - let si = target_sheet(s, p)?; - edit(s, |cx| { - for r in &ranges { - if r.count() > 100_000 { - return Err(bad("insert.checkbox", "range too large")); - } - for c in r.iter() { - let st = cx.wb.sheet(si).map(|sh| sh.style_id(c)).unwrap_or_default(); - let new = cx.wb.styles.derive(st, |s| { - s.num_fmt = NumFmt::new("checkbox"); - s.align.h = HAlign::Center; - }); - let sh = cx.sheet_mut(si)?; - // Values and formulas already there stay (only TRUE/FALSE draw as a box); an empty - // cell starts unchecked. - let mut cell = sh.cell(c).cloned().unwrap_or_default(); - if cell.value.is_empty() && cell.formula.is_none() { - cell.value = Value::Bool(false); - } - cell.style = new; - sh.set_cell(c, cell); - cx.touch(si, c); - } - } - Ok(Json::Null) - }) -} - -fn toggle_checkbox(s: &mut Session, p: &Json) -> Result { - let si = target_sheet(s, p)?; - let d = s.doc()?; - let cells: Vec = match cell_param_on(s, p, "cell", si)? { - Some(c) => vec![c], - None => d.selection.ranges.iter().flat_map(|r| r.iter().take(100_000).collect::>()).collect(), - }; - edit(s, |cx| { - for c in cells { - let checkbox = cx.wb.sheet(si).is_some_and(|sh| is_checkbox_cell(&cx.wb, sh, c)); - let sh = cx.sheet_mut(si)?; - if let Some(cell) = sh.cells.get_mut(c) - && cell.formula.is_none() - { - // An empty checkbox cell counts as FALSE. - let next = match cell.value { - Value::Bool(b) if checkbox => Value::Bool(!b), - Value::Empty if checkbox => Value::Bool(true), - _ => continue, - }; - cell.value = next; - cx.changed.push((si, c)); - } - } - Ok(Json::Null) - }) -} - /// Copy of the params that is always a JSON object, so keys can be assigned; any other JSON value counts as no params (like null). fn object_params(p: &Json) -> Json { Json::Object(p.as_object().cloned().unwrap_or_default()) } - -fn theme_colors(s: &mut Session, p: &Json) -> Result { - let mut params = object_params(p); - if params.get("name").is_none() { - params["name"] = json!(s.doc()?.wb.theme.name.clone()); - } - s.execute("pageLayout.theme", params) -} - -fn theme_fonts(s: &mut Session, p: &Json) -> Result { - let name = s.doc()?.wb.theme.name.clone(); - let colors: Vec = s.doc()?.wb.theme.colors.iter().map(|c| format!("#{c:06X}")).collect(); - s.execute("pageLayout.theme", json!({"name": name, "colors": colors, "majorFont": p.get("major"), "minorFont": p.get("minor")})) -} - -fn background(s: &mut Session, p: &Json) -> Result { - if bool_param(p, "clear").unwrap_or(false) { - let si = s.doc()?.wb.active_sheet(); - return edit(s, |cx| { - cx.sheet_mut(si)?.images.retain(|i| i.alt != "Sheet background"); - Ok(Json::Null) - }); - } - let mut params = object_params(p); - params["alt"] = json!("Sheet background"); - params["at"] = json!("A1"); - s.execute("insert.picture", params) -} - -fn draw_border(s: &mut Session, p: &Json) -> Result { - let edge = str_param(p, "edge").unwrap_or("grid"); - let preset = match edge { - "top" => "top", - "bottom" => "bottom", - "left" => "left", - "right" => "right", - "outline" => "outside", - _ => "all", - }; - let mut params = object_params(p); - params["preset"] = json!(preset); - s.execute("home.borders", params) -} - -fn new_cell_style(s: &mut Session, p: &Json) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("home.newCellStyle", "missing `name`"))?.to_string(); - let d = s.doc()?; - let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - let c = cell_param(s, p, "fromCell")?.unwrap_or(d.selection.active); - let style = d.wb.styles.get(sh.style_id(c)).clone(); - edit(s, |cx| { - cx.wb.cell_styles.retain(|(n, _)| !n.eq_ignore_ascii_case(&name)); - cx.wb.cell_styles.push((name.clone(), style.clone())); - Ok(json!({"name": name})) - }) -} - -fn recently_used(s: &mut Session, _: &Json) -> Result { - let mut seen: Vec = Vec::new(); - for (id, p) in s.journal.iter().rev() { - if id != "cell.set" { - continue; - } - let input = p.get("input").and_then(Json::as_str).unwrap_or(""); - if let Ok(e) = gridcraft_formula::parse(input.trim_start_matches('=')) { - for f in e.functions() { - if !seen.iter().any(|x| x == f) { - seen.push(f.to_string()); - } - } - } - if seen.len() >= 10 { - break; - } - } - Ok(json!(seen)) -} - -fn use_in_formula(s: &mut Session, p: &Json) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("formulas.useInFormula", "missing `name`"))?; - s.ui_requests.push(crate::UiRequest::InsertName(name.to_string())); - ok() -} - -fn watch_window(s: &mut Session, p: &Json) -> Result { - let mut list: Vec = - stash_get(&s.doc()?.wb, WATCH_NAME).as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default(); - let mut changed = false; - if let Some(a) = str_param(p, "add") { - if !list.iter().any(|x| x == a) { - list.push(a.to_string()); - } - changed = true; - } - if let Some(r) = str_param(p, "remove") { - list.retain(|x| x != r); - changed = true; - } - if changed { - let v = json!(list.clone()); - let d = s.doc_mut()?; - // The watch list is view state: don't add an undo step. - let wb = std::sync::Arc::make_mut(&mut d.wb); - stash_set(wb, WATCH_NAME, &v); - } - let d = s.doc()?; - let rows: Vec = list - .iter() - .map(|w| { - let (sheet, body) = split_sheet(w); - let si = sheet.and_then(|n| d.wb.sheet_index(&n)).unwrap_or(d.wb.active_sheet()); - let c = CellRef::parse(body).unwrap_or_default(); - let sh = d.wb.sheet(si); - json!({"cell": w, "value": sh.map(|s| s.value(c).display()), "formula": sh.and_then(|s| s.cell(c)).and_then(|x| x.formula.as_ref()).map(|f| format!("={}", f.text))}) - }) - .collect(); - Ok(Json::Array(rows)) -} diff --git a/crates/engine/src/cmd/extra/cells.rs b/crates/engine/src/cmd/extra/cells.rs new file mode 100644 index 0000000000..5cddf09822 --- /dev/null +++ b/crates/engine/src/cmd/extra/cells.rs @@ -0,0 +1,147 @@ +use super::*; + +/// Whether `c` has checkbox formatting. +pub(crate) fn is_checkbox_cell(wb: &gridcraft_model::Workbook, sh: &gridcraft_model::Sheet, c: CellRef) -> bool { + wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" +} + +pub(super) fn checkbox(s: &mut Session, p: &Json) -> Result { + let ranges = target_ranges(s, p)?; + let si = target_sheet(s, p)?; + edit(s, |cx| { + for r in &ranges { + if r.count() > 100_000 { + return Err(bad("insert.checkbox", "range too large")); + } + for c in r.iter() { + let st = cx.wb.sheet(si).map(|sh| sh.style_id(c)).unwrap_or_default(); + let new = cx.wb.styles.derive(st, |s| { + s.num_fmt = NumFmt::new("checkbox"); + s.align.h = HAlign::Center; + }); + let sh = cx.sheet_mut(si)?; + // Values and formulas already there stay (only TRUE/FALSE draw as a box); an empty + // cell starts unchecked. + let mut cell = sh.cell(c).cloned().unwrap_or_default(); + if cell.value.is_empty() && cell.formula.is_none() { + cell.value = Value::Bool(false); + } + cell.style = new; + sh.set_cell(c, cell); + cx.touch(si, c); + } + } + Ok(Json::Null) + }) +} + +pub(super) fn toggle_checkbox(s: &mut Session, p: &Json) -> Result { + let si = target_sheet(s, p)?; + let d = s.doc()?; + let cells: Vec = match cell_param_on(s, p, "cell", si)? { + Some(c) => vec![c], + None => d.selection.ranges.iter().flat_map(|r| r.iter().take(100_000).collect::>()).collect(), + }; + edit(s, |cx| { + for c in cells { + let checkbox = cx.wb.sheet(si).is_some_and(|sh| is_checkbox_cell(&cx.wb, sh, c)); + let sh = cx.sheet_mut(si)?; + if let Some(cell) = sh.cells.get_mut(c) + && cell.formula.is_none() + { + // An empty checkbox cell counts as FALSE. + let next = match cell.value { + Value::Bool(b) if checkbox => Value::Bool(!b), + Value::Empty if checkbox => Value::Bool(true), + _ => continue, + }; + cell.value = next; + cx.changed.push((si, c)); + } + } + Ok(Json::Null) + }) +} + +pub(super) fn theme_colors(s: &mut Session, p: &Json) -> Result { + let mut params = object_params(p); + if params.get("name").is_none() { + params["name"] = json!(s.doc()?.wb.theme.name.clone()); + } + s.execute("pageLayout.theme", params) +} + +pub(super) fn theme_fonts(s: &mut Session, p: &Json) -> Result { + let name = s.doc()?.wb.theme.name.clone(); + let colors: Vec = s.doc()?.wb.theme.colors.iter().map(|c| format!("#{c:06X}")).collect(); + s.execute("pageLayout.theme", json!({"name": name, "colors": colors, "majorFont": p.get("major"), "minorFont": p.get("minor")})) +} + +pub(super) fn background(s: &mut Session, p: &Json) -> Result { + if bool_param(p, "clear").unwrap_or(false) { + let si = s.doc()?.wb.active_sheet(); + return edit(s, |cx| { + cx.sheet_mut(si)?.images.retain(|i| i.alt != "Sheet background"); + Ok(Json::Null) + }); + } + let mut params = object_params(p); + params["alt"] = json!("Sheet background"); + params["at"] = json!("A1"); + s.execute("insert.picture", params) +} + +pub(super) fn draw_border(s: &mut Session, p: &Json) -> Result { + let edge = str_param(p, "edge").unwrap_or("grid"); + let preset = match edge { + "top" => "top", + "bottom" => "bottom", + "left" => "left", + "right" => "right", + "outline" => "outside", + _ => "all", + }; + let mut params = object_params(p); + params["preset"] = json!(preset); + s.execute("home.borders", params) +} + +pub(super) fn new_cell_style(s: &mut Session, p: &Json) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("home.newCellStyle", "missing `name`"))?.to_string(); + let d = s.doc()?; + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let c = cell_param(s, p, "fromCell")?.unwrap_or(d.selection.active); + let style = d.wb.styles.get(sh.style_id(c)).clone(); + edit(s, |cx| { + cx.wb.cell_styles.retain(|(n, _)| !n.eq_ignore_ascii_case(&name)); + cx.wb.cell_styles.push((name.clone(), style.clone())); + Ok(json!({"name": name})) + }) +} + +pub(super) fn recently_used(s: &mut Session, _: &Json) -> Result { + let mut seen: Vec = Vec::new(); + for (id, p) in s.journal.iter().rev() { + if id != "cell.set" { + continue; + } + let input = p.get("input").and_then(Json::as_str).unwrap_or(""); + if let Ok(e) = gridcraft_formula::parse(input.trim_start_matches('=')) { + for f in e.functions() { + if !seen.iter().any(|x| x == f) { + seen.push(f.to_string()); + } + } + } + if seen.len() >= 10 { + break; + } + } + Ok(json!(seen)) +} + +pub(super) fn use_in_formula(s: &mut Session, p: &Json) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("formulas.useInFormula", "missing `name`"))?; + s.ui_requests.push(crate::UiRequest::InsertName(name.to_string())); + ok() +} diff --git a/crates/engine/src/cmd/extra/chart.rs b/crates/engine/src/cmd/extra/chart.rs new file mode 100644 index 0000000000..2e16befa69 --- /dev/null +++ b/crates/engine/src/cmd/extra/chart.rs @@ -0,0 +1,259 @@ +use super::*; + +fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> { + let d = s.doc()?; + let si = d.wb.active_sheet(); + let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; + let idx = match u32_param(p, "chart") { + Some(id) => sh.charts.iter().position(|c| c.id == id), + None => sh.charts.len().checked_sub(1), + }; + idx.map(|i| (si, i)).ok_or_else(|| bad("chart", "no chart on this sheet")) +} + +fn with_chart(s: &mut Session, p: &Json, f: impl FnOnce(&mut Chart) -> Result<()>) -> Result { + let (si, ci) = find_chart(s, p)?; + edit(s, |cx| { + let c = cx.sheet_mut(si)?.charts.get_mut(ci).ok_or_else(|| bad("chart", "no such chart"))?; + f(c)?; + Ok(json!({"chart": c.id})) + }) +} + +pub(super) fn add_element(s: &mut Session, p: &Json) -> Result { + let el = str_param(p, "element").unwrap_or("title").to_string(); + let on = bool_param(p, "on").unwrap_or(true); + let pos = str_param(p, "position").map(str::to_string); + with_chart(s, p, |c| { + match el.as_str() { + "title" => c.title = if on { c.title.clone().filter(|t| !t.is_empty()).or(Some("Chart Title".into())) } else { Some(String::new()) }, + "legend" => { + c.legend = if !on { + LegendPos::None + } else { + match pos.as_deref() { + Some("top") => LegendPos::Top, + Some("left") => LegendPos::Left, + Some("right") => LegendPos::Right, + _ => LegendPos::Bottom, + } + } + } + "dataLabels" => c.data_labels = on, + "gridlines" => c.gridlines = on, + "axisTitles" => { + c.x_title = on.then(|| c.x_title.clone().unwrap_or_else(|| "Axis Title".into())); + c.y_title = on.then(|| c.y_title.clone().unwrap_or_else(|| "Axis Title".into())); + } + "smooth" => { + for s in &mut c.series { + s.smooth = on; + } + } + "trendline" => { + let only = u32_param(p, "series").map(|i| i as usize); + if only.is_some_and(|i| i >= c.series.len()) { + return Err(bad("chart.addElement", "no such series")); + } + let targets: Vec = (0..c.series.len()).filter(|i| only.is_none_or(|o| o == *i)).collect(); + if !on { + for i in targets { + if let Some(se) = c.series.get_mut(i) { + se.trendlines.clear(); + } + } + return Ok(()); + } + let t = trendline_param(p)?; + let eligible: Vec = + targets.into_iter().filter(|i| gridcraft_chart::supports_trendline(gridcraft_chart::series_plot_kind(c, *i))).collect(); + if eligible.is_empty() { + return Err(bad("chart.addElement", "trendlines need a 2-D column, bar, line, area, scatter or bubble series")); + } + for i in eligible { + if let Some(se) = c.series.get_mut(i) { + se.trendlines.push(t.clone()); + } + } + } + other => return Err(bad("chart.addElement", format!("unknown element `{other}`"))), + } + Ok(()) + }) +} + +/// The trendline described by `chart.addElement`'s trendline parameters (Excel's Add Chart +/// Element ▸ Trendline choices, plus the Format Trendline options). +fn trendline_param(p: &Json) -> Result { + let num = |k: &str| Trendline::clamp_forecast(f64_param(p, k).unwrap_or(0.0)); + let mut t = Trendline { + forward: num("forward"), + backward: num("backward"), + display_equation: bool_param(p, "displayEquation").unwrap_or(false), + display_r_squared: bool_param(p, "displayRSquared").unwrap_or(false), + ..Trendline::default() + }; + t.kind = match str_param(p, "type").unwrap_or("linear") { + "linear" => TrendlineKind::Linear, + // Excel's Linear Forecast: a linear trendline projected two periods ahead. + "linearForecast" => { + if t.forward == 0.0 { + t.forward = 2.0; + } + TrendlineKind::Linear + } + "exponential" => TrendlineKind::Exponential, + "logarithmic" => TrendlineKind::Logarithmic, + "power" => TrendlineKind::Power, + "polynomial" => TrendlineKind::Polynomial(u32_param(p, "order").unwrap_or(2).clamp(2, 6) as u8), + "movingAverage" => TrendlineKind::MovingAverage(u32_param(p, "period").unwrap_or(2).clamp(2, 255)), + other => return Err(bad("chart.addElement", format!("unknown trendline type `{other}`"))), + }; + Ok(t) +} + +pub(super) fn quick_layout(s: &mut Session, p: &Json) -> Result { + let n = u32_param(p, "layout").unwrap_or(1); + with_chart(s, p, |c| { + let (legend, labels, grid, axes) = match n { + 2 => (LegendPos::Top, true, false, false), + 3 => (LegendPos::Bottom, false, true, false), + 4 => (LegendPos::Bottom, true, false, false), + 5 => (LegendPos::None, false, true, true), + 6 => (LegendPos::Right, false, true, true), + _ => (LegendPos::Right, false, true, false), + }; + c.legend = legend; + c.data_labels = labels; + c.gridlines = grid; + if axes { + c.x_title.get_or_insert_with(|| "Axis Title".into()); + c.y_title.get_or_insert_with(|| "Axis Title".into()); + } else { + c.x_title = None; + c.y_title = None; + } + Ok(()) + }) +} + +pub(super) fn change_colors(s: &mut Session, p: &Json) -> Result { + let explicit: Option> = + p.get("colors").and_then(Json::as_array).map(|a| a.iter().filter_map(|c| c.as_str().and_then(Color::from_hex)).collect()); + let palette = str_param(p, "palette").unwrap_or("colorful").to_string(); + with_chart(s, p, |c| { + for (i, se) in c.series.iter_mut().enumerate() { + se.color = match &explicit { + Some(cols) if !cols.is_empty() => cols.get(i % cols.len()).copied(), + _ => match palette.as_str() { + "monochrome" => Some(Color::Theme(4, (-400 + (i as i16 * 200)).clamp(-800, 800))), + p if p.starts_with("accent") => { + let k: u8 = p.trim_start_matches("accent").parse().unwrap_or(1); + Some(Color::Theme(3 + k.clamp(1, 6), (-500 + i as i16 * 250).clamp(-800, 800))) + } + _ => None, + }, + }; + } + Ok(()) + }) +} + +pub(super) fn select_data(s: &mut Session, p: &Json) -> Result { + let d = s.doc()?; + // A new data range rebuilds the series, as in Excel's Select Data › Chart data range. + let rebuilt = match str_param(p, "range") { + Some(range) => { + let range = range.trim_start_matches('='); + let (sheet_name, body) = split_sheet(range); + let src_si = match sheet_name { + Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?, + None => d.wb.active_sheet(), + }; + let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?; + if r.count() > 1_000_000 { + return Err(bad("chart.selectData", "range too large for a chart")); + } + let src = d.wb.sheet(src_si).ok_or(EngineError::NoDocument)?; + let by_rows = bool_param(p, "byRows").unwrap_or(r.width() > r.height() + 1); + let series = crate::cmd::insert::series_from_range(src, r, by_rows); + Some((series, format!("{}!{}", gridcraft_formula::quote_sheet(&src.name), r.a1()), by_rows)) + } + None => None, + }; + // Horizontal (Category) Axis Labels › Edit: one labels range for every series ("" clears). + let categories = match str_param(p, "categories") { + None => None, + Some(t) if t.trim().trim_start_matches('=').is_empty() => Some(None), + Some(t) => { + let t = t.trim().trim_start_matches('='); + let (sheet_name, body) = split_sheet(t); + let si = match sheet_name { + Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?, + None => d.wb.active_sheet(), + }; + let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "`categories` is not a range"))?; + let name = d.wb.sheet(si).map(|sh| sh.name.clone()).ok_or(EngineError::NoDocument)?; + let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1); + Some(Some(format!("{}!{}:{}", gridcraft_formula::quote_sheet(&name), abs(r.start), abs(r.end)))) + } + }; + let mut remove: Vec = p + .get("removeSeries") + .and_then(Json::as_array) + .map(|a| a.iter().filter_map(Json::as_u64).filter_map(|i| usize::try_from(i).ok()).collect()) + .unwrap_or_default(); + remove.sort_unstable(); + remove.dedup(); + if rebuilt.is_none() && categories.is_none() && remove.is_empty() { + return Err(bad("chart.selectData", "missing `range`, `categories` or `removeSeries`")); + } + with_chart(s, p, move |c| { + if let Some((series, source, by_rows)) = rebuilt { + c.series = series; + crate::cmd::insert::scatter_x_from_first(c.kind, &mut c.series); + c.source = Some(source); + c.by_rows = by_rows; + } + for i in remove.iter().rev() { + if *i < c.series.len() && c.series.len() > 1 { + c.series.remove(*i); + } + } + if let Some(cats) = categories { + for se in &mut c.series { + se.categories = cats.clone(); + } + } + Ok(()) + }) +} + +pub(super) fn move_chart(s: &mut Session, p: &Json) -> Result { + let (si, ci) = find_chart(s, p)?; + let target = match p.get("sheet").and_then(Json::as_str) { + Some(n) => Some(s.doc()?.wb.sheet_index(n).ok_or_else(|| bad("chart.move", "no such sheet"))?), + None => None, + }; + let at = cell_param_on(s, p, "at", target.unwrap_or(si))?; + edit(s, |cx| { + let mut chart = cx.sheet_mut(si)?.charts.remove(ci); + if let Some(a) = at { + chart.anchor.cell = a; + } + let dest = target.unwrap_or(si); + cx.sheet_mut(dest)?.charts.push(chart); + Ok(Json::Null) + }) +} + +pub(super) fn format_series(s: &mut Session, p: &Json) -> Result { + let idx = u32_param(p, "series").unwrap_or(0) as usize; + let color = str_param(p, "color").and_then(Color::from_hex); + with_chart(s, p, |c| { + if let Some(se) = c.series.get_mut(idx) { + se.color = color; + } + Ok(()) + }) +} diff --git a/crates/engine/src/cmd/extra/data.rs b/crates/engine/src/cmd/extra/data.rs new file mode 100644 index 0000000000..76bf6d267a --- /dev/null +++ b/crates/engine/src/cmd/extra/data.rs @@ -0,0 +1,145 @@ +use super::*; + +pub(super) fn table_resize(s: &mut Session, p: &Json) -> Result { + let r = str_param(p, "range").and_then(RangeRef::parse).ok_or_else(|| bad("table.resize", "missing `range`"))?; + let d = s.doc()?; + let si = d.wb.active_sheet(); + let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; + let ti = match str_param(p, "table") { + Some(n) => sh.tables.iter().position(|t| t.name.eq_ignore_ascii_case(n)), + None => sh.tables.iter().position(|t| t.range.contains(d.selection.active)), + } + .ok_or_else(|| bad("table.resize", "select a cell in a table"))?; + edit(s, |cx| { + let sheet = cx.sheet_mut(si)?; + let headers: Vec = (r.start.col..=r.end.col).map(|c| sheet.value(CellRef::new(r.start.row, c)).display()).collect(); + if sheet.tables.iter().enumerate().any(|(i, o)| i != ti && o.range.intersects(&r)) { + return Err(EngineError::Other("A table can't overlap another table.".into())); + } + let t = sheet.tables.get_mut(ti).ok_or_else(|| bad("table", "gone"))?; + if r.start.row != t.range.start.row || !r.intersects(&t.range) { + return Err(EngineError::Other( + "The headers must remain in the same row, and the resulting table range must overlap the original table range.".into(), + )); + } + let mut cols = Vec::new(); + for (i, h) in headers.iter().enumerate() { + let name = if h.is_empty() { format!("Column{}", i + 1) } else { h.clone() }; + let existing = t.columns.iter().find(|c| c.name.eq_ignore_ascii_case(&name)).cloned(); + cols.push(existing.unwrap_or(TableColumn { + name, + totals: TotalsFn::None, + totals_label: None, + formula: None, + unique_name: None, + xml: None, + })); + } + t.columns = cols; + t.range = r; + cx.structural = true; + Ok(Json::Null) + }) +} + +pub(super) fn from_csv(s: &mut Session, p: &Json) -> Result { + let bytes = if let Some(t) = str_param(p, "text") { + t.as_bytes().to_vec() + } else if let Some(path) = str_param(p, "path") { + crate::io::read_file(path)? + } else { + s.ui_requests.push(crate::UiRequest::Dialog("open".into(), json!({}))); + return ok(); + }; + let text = String::from_utf8_lossy(&bytes); + // JSON array of objects → rows. + let rows: Vec> = + if text.trim_start().starts_with('[') { + let arr: Vec = serde_json::from_str(&text).map_err(|e| bad("data.getData", e.to_string()))?; + let mut keys: Vec = Vec::new(); + for o in &arr { + if let Some(m) = o.as_object() { + for k in m.keys() { + if !keys.contains(k) { + keys.push(k.clone()); + } + } + } + } + let mut out = vec![keys.iter().map(|k| json!(k)).collect::>()]; + for o in &arr { + out.push(keys.iter().map(|k| o.get(k).cloned().unwrap_or(Json::Null)).collect()); + } + out + } else { + let delim = str_param(p, "delimiter") + .and_then(|d| d.chars().next()) + .unwrap_or(if text.lines().next().is_some_and(|l| l.contains('\t')) { '\t' } else { ',' }); + let opts = gridcraft_xlsx::CsvOptions { delimiter: delim as u8, ..Default::default() }; + let wb = gridcraft_xlsx::read_csv(&bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?; + let sh = wb.sheet(0).ok_or(EngineError::NoDocument)?; + let Some(u) = sh.used_range() else { return Err(EngineError::Other("The file is empty.".into())) }; + (u.start.row..=u.end.row) + .map(|r| { + (u.start.col..=u.end.col) + .map(|c| match sh.value(CellRef::new(r, c)) { + Value::Number(n) => json!(n), + Value::Bool(b) => json!(b), + Value::Empty => Json::Null, + v => json!(v.display()), + }) + .collect() + }) + .collect() + }; + if rows.is_empty() || rows.len() > 1_048_576 { + return Err(EngineError::Other("Nothing to import.".into())); + } + let name = str_param(p, "sheet").map(str::to_string).or_else(|| { + str_param(p, "path").and_then(|x| std::path::Path::new(x).file_stem().and_then(std::ffi::OsStr::to_str).map(|f| f.chars().take(31).collect())) + }); + let r = s.execute("home.insertSheet", json!({"name": name})).or_else(|_| s.execute("home.insertSheet", json!({})))?; + let w = rows.iter().map(Vec::len).max().unwrap_or(1).max(1); + s.execute("range.setValues", json!({"range": "A1", "values": rows}))?; + let range = RangeRef::new(CellRef::new(0, 0), CellRef::new(rows.len() as u32 - 1, w as u32 - 1)); + let _ = s.execute("insert.table", json!({"range": range.a1(), "header": true})); + let _ = s.execute("home.autofitColumnWidth", json!({"cols": RangeRef::cols(0, w as u32 - 1).a1()})); + Ok(json!({"sheet": r["name"], "rows": rows.len(), "cols": w})) +} + +/// `data.fromTableRange`: the selected block as CSV text into a copy of its sheet. +pub(super) fn from_table_range(s: &mut Session, p: &Json) -> Result { + let r = target_range(s, p)?; + let d = s.doc()?; + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let r = if r.is_single() { crate::selection::current_region(sh, r.start) } else { r }; + let text = crate::display::range_text(&d.wb, d.wb.active_sheet(), r); + from_csv(s, &json!({"text": text, "delimiter": "\t", "sheet": format!("{} (2)", sh.name).chars().take(31).collect::()})) +} + +/// `data.queriesConnections`: the saved From Web connections (source, last sync) followed by +/// the workbook's imported tables — the Queries & Connections pane's list. +pub(super) fn queries(s: &mut Session, _: &Json) -> Result { + let d = s.doc()?; + let tables: Vec = + d.wb.sheets + .iter() + .flat_map(|sh| { + sh.tables + .iter() + .map(move |t| json!({"name": t.name, "sheet": sh.name, "range": t.range.a1(), "rows": t.range.height().saturating_sub(1)})) + }) + .collect(); + // The saved From Web connections, with the source and the last sync the pane shows. + let web: Vec = + d.wb.web_queries + .iter() + .map(|q| { + json!({ + "name": q.name, "sheet": q.sheet, "url": q.url, "table": q.table, + "last_synced": q.last_synced.unwrap_or(0), "web": true, + }) + }) + .collect(); + Ok(Json::Array([web, tables].concat())) +} diff --git a/crates/engine/src/cmd/extra/objects.rs b/crates/engine/src/cmd/extra/objects.rs new file mode 100644 index 0000000000..59ab8447a8 --- /dev/null +++ b/crates/engine/src/cmd/extra/objects.rs @@ -0,0 +1,146 @@ +use super::chart::format_series; +use super::*; + +pub(super) fn shape_fill(s: &mut Session, p: &Json) -> Result { + if str_param(p, "kind") == Some("shape") { + let color = str_param(p, "color").and_then(Color::from_hex).unwrap_or_default(); + return with_shape(s, p, |sh| sh.fill = color); + } + format_series(s, p) +} + +pub(super) fn shape_outline(s: &mut Session, p: &Json) -> Result { + let color = str_param(p, "color").and_then(Color::from_hex).unwrap_or_default(); + with_shape(s, p, |sh| sh.line = color) +} + +fn with_shape(s: &mut Session, p: &Json, f: impl FnOnce(&mut Shape)) -> Result { + let si = s.doc()?.wb.active_sheet(); + let id = u32_param(p, "id"); + edit(s, |cx| { + let shapes = &mut cx.sheet_mut(si)?.shapes; + let sh = match id { + Some(id) => shapes.iter_mut().find(|x| x.id == id), + None => shapes.last_mut(), + } + .ok_or_else(|| bad("shape", "no such shape"))?; + f(sh); + Ok(Json::Null) + }) +} + +pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result { + let id = u32_param(p, "id").ok_or_else(|| bad("arrange", "missing `id`"))?; + let kind = str_param(p, "kind").unwrap_or("shape").to_string(); + let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false); + let si = s.doc()?.wb.active_sheet(); + fn reorder(v: &mut Vec, i: usize, forward: bool, extreme: bool) { + if i >= v.len() { + return; + } + let item = v.remove(i); + let j = match (forward, extreme) { + (true, true) => v.len(), + (false, true) => 0, + (true, false) => (i + 1).min(v.len()), + (false, false) => i.saturating_sub(1), + }; + v.insert(j, item); + } + edit(s, |cx| { + let sh = cx.sheet_mut(si)?; + match kind.as_str() { + "chart" => { + if let Some(i) = sh.charts.iter().position(|c| c.id == id) { + reorder(&mut sh.charts, i, forward, extreme); + } + } + "image" => { + if let Some(i) = sh.images.iter().position(|c| c.id == id) { + reorder(&mut sh.images, i, forward, extreme); + } + } + _ => { + if let Some(i) = sh.shapes.iter().position(|c| c.id == id) { + reorder(&mut sh.shapes, i, forward, extreme); + } + } + } + Ok(Json::Null) + }) +} + +pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result { + let d = s.doc()?; + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let mut v = Vec::new(); + for c in &sh.shapes { + v.push(json!({"kind": "shape", "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); + } + for c in &sh.images { + v.push(json!({"kind": "image", "id": c.id, "name": format!("Picture {}", c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); + } + for c in &sh.charts { + v.push(json!({"kind": "chart", "id": c.id, "name": format!("Chart {}", c.id), "title": c.title, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); + } + Ok(Json::Array(v)) +} + +fn anchors_of<'a>(sh: &'a mut Sheet, kind: &str) -> Vec<(u32, &'a mut Anchor)> { + match kind { + "chart" => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), + "image" => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), + _ => sh.shapes.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), + } +} + +pub(super) fn align(s: &mut Session, p: &Json) -> Result { + let kind = str_param(p, "kind").unwrap_or("shape").to_string(); + let ids: Vec = + p.get("ids").and_then(Json::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as u32)).collect()).unwrap_or_default(); + let how = str_param(p, "align").unwrap_or("left").to_string(); + let si = s.doc()?.wb.active_sheet(); + edit(s, |cx| { + let sheet = cx.sheet_mut(si)?; + let geo: Vec<(u32, f64, f64, f32, f32)> = { + let shc = sheet.clone(); + anchors_of(sheet, &kind) + .into_iter() + .filter(|(id, _)| ids.contains(id)) + .map(|(id, a)| (id, shc.col_left(a.cell.col) + a.dx as f64, shc.row_top(a.cell.row) + a.dy as f64, a.width, a.height)) + .collect() + }; + if geo.len() < 2 { + return Ok(Json::Null); + } + let left = geo.iter().map(|g| g.1).fold(f64::INFINITY, f64::min); + let right = geo.iter().map(|g| g.1 + g.3 as f64).fold(f64::NEG_INFINITY, f64::max); + let top = geo.iter().map(|g| g.2).fold(f64::INFINITY, f64::min); + let bottom = geo.iter().map(|g| g.2 + g.4 as f64).fold(f64::NEG_INFINITY, f64::max); + let shc = sheet.clone(); + for (id, a) in anchors_of(sheet, &kind) { + let Some(g) = geo.iter().find(|g| g.0 == id) else { continue }; + let (mut x, mut y) = (g.1, g.2); + match how.as_str() { + "left" => x = left, + "right" => x = right - g.3 as f64, + "center" => x = (left + right) / 2.0 - g.3 as f64 / 2.0, + "top" => y = top, + "bottom" => y = bottom - g.4 as f64, + _ => y = (top + bottom) / 2.0 - g.4 as f64 / 2.0, + } + let c = CellRef::new(shc.row_at(y), shc.col_at(x)); + a.cell = c; + a.dx = (x - shc.col_left(c.col)) as f32; + a.dy = (y - shc.row_top(c.row)) as f32; + } + Ok(Json::Null) + }) +} + +pub(super) fn rotate(s: &mut Session, p: &Json) -> Result { + if p.get("flip").is_some_and(|f| !f.is_null()) { + return Err(bad("arrange.rotate", "`flip` is not supported: shapes have no flip state")); + } + with_shape(s, p, |sh| std::mem::swap(&mut sh.anchor.width, &mut sh.anchor.height)) +} diff --git a/crates/engine/src/cmd/extra/scripts.rs b/crates/engine/src/cmd/extra/scripts.rs new file mode 100644 index 0000000000..9f86837ab7 --- /dev/null +++ b/crates/engine/src/cmd/extra/scripts.rs @@ -0,0 +1,146 @@ +use super::*; + +pub(super) fn record(s: &mut Session, p: &Json) -> Result { + if bool_param(p, "stop").unwrap_or(false) || s.recording.is_some() { + let Some(start) = s.recording.take() else { return Ok(json!({"recording": false})) }; + let cmds: Vec = + s.journal.iter().skip(start).filter(|(id, _)| !id.starts_with("automate.")).map(|(id, p)| json!({"command": id, "params": p})).collect(); + let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Script {}", start)); + s.execute("automate.newScript", json!({"name": name, "commands": cmds}))?; + return Ok(json!({"recording": false, "saved": name, "steps": cmds.len()})); + } + s.recording = Some(s.journal.len()); + Ok(json!({"recording": true})) +} + +pub(super) fn new_script(s: &mut Session, p: &Json) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("automate.newScript", "missing `name`"))?.to_string(); + let cmds = p.get("commands").cloned().unwrap_or(json!([])); + if !cmds.is_array() { + return Err(bad("automate.newScript", "`commands` must be an array")); + } + edit(s, |cx| { + let mut all = stash_map(&cx.wb, SCRIPTS_NAME); + all[name.as_str()] = cmds.clone(); + stash_set(&mut cx.wb, SCRIPTS_NAME, &all); + Ok(json!({"name": name})) + }) +} + +pub(super) fn all_scripts(s: &mut Session, _: &Json) -> Result { + let d = s.doc()?; + let (wb, trusted) = (&d.wb, d.scripts_trusted); + let all = stash_get(wb, SCRIPTS_NAME); + let mut list: Vec = all + .as_object() + .map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len), "trusted": trusted})).collect()) + .unwrap_or_default(); + // A preserved VBA project is reported alongside GridCraft's own scripts; it is carried on + // save but never executed here, so it has no step count. + if let Some(bytes) = &wb.vba_project { + list.push(json!({"name": "(VBA)", "steps": 0, "kind": "vba", "size": bytes.len(), "executed": false})); + } + Ok(Json::Array(list)) +} + +/// Whether `wb` carries GridCraft scripts (a workbook opened with them starts untrusted). +pub(crate) fn has_scripts(wb: &Workbook) -> bool { + stash_map(wb, SCRIPTS_NAME).as_object().is_some_and(|o| !o.is_empty()) +} + +/// Why a script step is refused, whatever the workbook's trust: scripts stay inside the +/// workbook. Anything that reads or writes files (`file.*`, or any `path` parameter: Get Data, +/// From Text/CSV, Insert Picture) or leaves the app (`link.*` opens URLs) is the caller's to run +/// directly, not a stored script's. (Nested `automate.*` steps are skipped.) This check goes by +/// command id and parameter name, so it is only the up-front refusal: the steps also run inside +/// an [`crate::io::ScriptSandbox`] (file access refused whatever the parameter is called) and +/// [`run_script`] drops any `OpenUrl` request a step raises. +fn denied_script_step(id: &str, params: &Json) -> Option { + 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.") || matches!(id, "view.switchWindows" | "view.newWindow" | "view.hideWindow" | "view.unhideWindow") { + 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}`")); + } + None +} + +pub(super) fn trust_scripts(s: &mut Session, p: &Json) -> Result { + // Only a missing `trusted` means "trust"; `"false"`, `0` or `null` are mistakes, not consent. + let trusted = match p.get("trusted") { + None => true, + Some(Json::Bool(b)) => *b, + Some(_) => return Err(bad("automate.trustScripts", "`trusted` must be true or false")), + }; + s.doc_mut()?.scripts_trusted = trusted; + Ok(json!({"trusted": trusted})) +} + +/// Runs a stored script. Scripts in a workbook opened from a file run only once the workbook is +/// trusted for this session (`automate.trustScripts`, or `trusted: true` on this call); steps that +/// reach outside the workbook ([`denied_script_step`]) are refused before any step runs. +pub(super) fn run_script(s: &mut Session, p: &Json) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("automate.runScript", "missing `name`"))?; + let d = s.doc()?; + let all = stash_get(&d.wb, SCRIPTS_NAME); + let steps = all.get(name).and_then(Json::as_array).cloned().ok_or_else(|| bad("automate.runScript", "no such script"))?; + if !d.scripts_trusted && !bool_param(p, "trusted").unwrap_or(false) { + return Err(bad( + "automate.runScript", + "scripts in this workbook came from a file and are disabled; trust the workbook first (Trust Scripts in View › Macros, Alt+F8; `automate.trustScripts`; or `trusted: true`)", + )); + } + let steps: Vec = steps.into_iter().take(100_000).collect(); + for st in &steps { + let id = st["command"].as_str().unwrap_or(""); + if let Some(why) = denied_script_step(id, &st["params"]) { + return Err(bad("automate.runScript", why)); + } + } + let _sandbox = crate::io::ScriptSandbox::enter(); + let mut n = 0; + for st in &steps { + let id = st["command"].as_str().unwrap_or(""); + if id.starts_with("automate.") { + continue; + } + let before = s.ui_requests.len(); + let ran = s.execute(id, st["params"].clone()); + // A step that asks the host to open a URL leaves the app: drop the request and stop. + if s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_)))) { + let mut i = 0; + s.ui_requests.retain(|r| { + i += 1; + i <= before || !matches!(r, crate::UiRequest::OpenUrl(_)) + }); + return Err(bad("automate.runScript", format!("scripts can't open links (`{id}`)"))); + } + ran?; + n += 1; + } + Ok(json!({"ran": n})) +} + +/// Automate › Stop Action: rolls back the undo groups open on the active workbook, so Undo and +/// Redo work there again. +pub(super) fn stop_action(s: &mut Session, _: &Json) -> Result { + let uid = s.doc()?.uid; + Ok(json!({"stopped": s.abort_groups(uid)})) +} + +pub(super) fn delete_script(s: &mut Session, p: &Json) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string(); + edit(s, |cx| { + let mut all = stash_map(&cx.wb, SCRIPTS_NAME); + if let Some(o) = all.as_object_mut() { + o.remove(&name); + } + stash_set(&mut cx.wb, SCRIPTS_NAME, &all); + Ok(Json::Null) + }) +} diff --git a/crates/engine/src/cmd/extra/window.rs b/crates/engine/src/cmd/extra/window.rs new file mode 100644 index 0000000000..19e92743f7 --- /dev/null +++ b/crates/engine/src/cmd/extra/window.rs @@ -0,0 +1,184 @@ +use super::*; + +/// Switches a sheet's view. Like zoom, the view is saved with the file but is not an edit: it +/// neither records an undo step nor marks a clean workbook as changed. +pub(super) fn set_view(s: &mut Session, p: &Json, mode: SheetViewMode) -> Result { + let si = target_sheet(s, p)?; + let d = s.doc_mut()?; + let dirty = d.is_dirty(); + let wb = std::sync::Arc::make_mut(&mut d.wb); + if let Some(sh) = wb.sheet_mut(si) { + sh.view = mode; + } + if !dirty { + d.saved = d.wb.clone(); + } + Ok(json!({"view": mode.as_str()})) +} + +pub(super) fn ruler(s: &mut Session, p: &Json) -> Result { + let on = bool_param(p, "on").unwrap_or(s.hide_ruler); + s.hide_ruler = !on; + Ok(json!({"on": on})) +} + +/// The Watch Window list isn't an edit (Excel doesn't undo it): undo and redo keep the current +/// list when they swap in another snapshot of the workbook. +pub(crate) fn keep_watch_list(from: &Workbook, to: &mut std::sync::Arc) { + let find = |wb: &Workbook| wb.names.iter().find(|n| n.name == WATCH_NAME).cloned(); + let keep = find(from); + if keep == find(to) { + return; + } + let wb = std::sync::Arc::make_mut(to); + wb.names.retain(|n| n.name != WATCH_NAME); + wb.names.extend(keep); +} + +pub(super) fn custom_views(s: &mut Session, p: &Json) -> Result { + let d = s.doc()?; + let mut views = stash_map(&d.wb, VIEWS_NAME); + if let Some(name) = str_param(p, "save") { + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + views[name] = json!({"sheet": d.wb.active_sheet(), "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze}); + let v = views.clone(); + edit(s, |cx| { + stash_set(&mut cx.wb, VIEWS_NAME, &v); + Ok(()) + })?; + } else if let Some(name) = str_param(p, "show") { + let v = views.get(name).cloned().ok_or_else(|| bad("view.customViews", "no such view"))?; + if let Some(si) = v["sheet"].as_u64() { + s.execute("sheet.activate", json!({"sheet": si}))?; + } + if let Some(sel) = v["selection"].as_str() { + s.execute("selection.set", json!({"range": sel}))?; + } + if let Some(z) = v["zoom"].as_u64() { + s.execute("view.zoom", json!({"percent": z}))?; + } + if let Some(f) = v.get("freeze") { + let freeze = match (f[0].as_u64(), f[1].as_u64()) { + (Some(r), Some(c)) => u32::try_from(r).ok().zip(u32::try_from(c).ok()), + _ => None, + }; + edit(s, |cx| { + let si = cx.sheet_index(); + cx.sheet_mut(si)?.freeze = freeze; + Ok(()) + })?; + } + } else if let Some(name) = str_param(p, "delete") { + if let Some(o) = views.as_object_mut() { + o.remove(name); + } + let v = views.clone(); + edit(s, |cx| { + stash_set(&mut cx.wb, VIEWS_NAME, &v); + Ok(()) + })?; + } + Ok(stash_get(&s.doc()?.wb, VIEWS_NAME)) +} + +pub(super) fn split(s: &mut Session, _: &Json) -> Result { + let has = s.doc()?.wb.active().is_some_and(|sh| sh.freeze.is_some()); + if has { s.execute("view.unfreezePanes", json!({})) } else { s.execute("view.freezePanes", json!({})) } +} + +pub(super) fn new_window(s: &mut Session, _: &Json) -> Result { + let id = s.new_window()?; + let doc = s.window(id).map(|w| w.doc_uid); + Ok(json!({"window": id, "doc": doc, "title": s.window_title(id)})) +} + +/// The window a window command names: `window` (an id), else `doc` (a workbook id: its most +/// recent window), else the active window. +pub(crate) fn window_param(s: &Session, cmd: &str, p: &Json) -> Result { + let t = crate::Target::from_json(p).map_err(|e| bad(cmd, e.to_string()))?; + s.target_window(t).map_err(|e| bad(cmd, e.to_string())) +} + +/// View › Arrange All. The engine has no window geometry: it picks the windows and the layout, +/// and the interface moves its native windows ([`Session::window_list`] order, active first). +pub(super) fn arrange_all(s: &mut Session, p: &Json) -> Result { + let arrange = match str_param(p, "arrange").unwrap_or("tiled") { + a @ ("tiled" | "horizontal" | "vertical" | "cascade") => a, + other => return Err(bad("view.arrangeAll", format!("`arrange` must be tiled, horizontal, vertical or cascade, not `{other}`"))), + }; + let active = s.active_window(); + let doc = active.and_then(|w| s.window(w)).map(|w| w.doc_uid); + let only_active = p.get("activeWorkbook").and_then(Json::as_bool).unwrap_or(false); + let mut windows: Vec = s.windows().iter().filter(|w| !w.hidden && (!only_active || Some(w.doc_uid) == doc)).map(|w| w.id).collect(); + if let Some(at) = windows.iter().position(|w| Some(*w) == active) + && let Some(first) = windows.get_mut(..=at) + { + first.rotate_right(1); + } + Ok(json!({"arrange": arrange, "windows": windows})) +} + +pub(super) fn hide_window(s: &mut Session, p: &Json) -> Result { + let id = window_param(s, "view.hideWindow", p)?; + s.hide_window(id); + ok() +} + +pub(super) fn unhide_window(s: &mut Session, p: &Json) -> Result { + let id = if p.get("window").is_some_and(|v| !v.is_null()) || p.get("doc").is_some_and(|v| !v.is_null()) { + Some(window_param(s, "view.unhideWindow", p)?) + } else if let Some(i) = u32_param(p, "index") { + // A workbook's position in the document list. + let uid = s.documents().get(i as usize).map(|d| d.uid).ok_or_else(|| bad("view.unhideWindow", "no such window"))?; + s.windows().iter().filter(|w| w.doc_uid == uid && w.hidden).map(|w| w.id).next_back().or_else(|| s.doc_window(uid)) + } else { + s.last_hidden_window() + }; + if let Some(id) = id { + s.unhide_window(id); + } + ok() +} + +pub(super) fn switch_windows(s: &mut Session, p: &Json) -> Result { + if p.get("window").is_some_and(|v| !v.is_null()) || p.get("doc").is_some_and(|v| !v.is_null()) { + let id = window_param(s, "view.switchWindows", p)?; + s.unhide_window(id); + } + Ok(s.window_list()) +} + +pub(super) fn watch_window(s: &mut Session, p: &Json) -> Result { + let mut list: Vec = + stash_get(&s.doc()?.wb, WATCH_NAME).as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default(); + let mut changed = false; + if let Some(a) = str_param(p, "add") { + if !list.iter().any(|x| x == a) { + list.push(a.to_string()); + } + changed = true; + } + if let Some(r) = str_param(p, "remove") { + list.retain(|x| x != r); + changed = true; + } + if changed { + let v = json!(list.clone()); + let d = s.doc_mut()?; + // The watch list is view state: don't add an undo step. + let wb = std::sync::Arc::make_mut(&mut d.wb); + stash_set(wb, WATCH_NAME, &v); + } + let d = s.doc()?; + let rows: Vec = list + .iter() + .map(|w| { + let (sheet, body) = split_sheet(w); + let si = sheet.and_then(|n| d.wb.sheet_index(&n)).unwrap_or(d.wb.active_sheet()); + let c = CellRef::parse(body).unwrap_or_default(); + let sh = d.wb.sheet(si); + json!({"cell": w, "value": sh.map(|s| s.value(c).display()), "formula": sh.and_then(|s| s.cell(c)).and_then(|x| x.formula.as_ref()).map(|f| format!("={}", f.text))}) + }) + .collect(); + Ok(Json::Array(rows)) +} diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 10dd2989d8..744b6d1362 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -802,445 +802,15 @@ fn decimals(s: &mut Session, p: &Json, delta: i32) -> Result { set_fmt(s, p, &new) } -/// Built-in cell styles (our own palette). -pub fn builtin_cell_style(name: &str, theme: &Theme) -> Option\ - \ - \ - \ -
NameQty
Apples & <pears>3
Total
" - ); - let cell = "border:1px solid #d4d4d4;padding:2px 4px;white-space:pre-wrap;"; - assert_eq!( - copied(&wb, "A1:B2"), - format!( - "\ - \ -
NameQty
Apples & <pears>3
" - ) - ); - } - - #[test] - fn html_export_fails_instead_of_dropping_cells_past_the_row_limit() { - let mut wb = Workbook::new(); - let sh = wb.sheet_mut(0).unwrap(); - sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::text("near")); - sh.set_value(CellRef::new(149_999, 0), gridcraft_core::Value::text("far")); - assert!(save_bytes(&wb, "book.html").is_err()); - let sh = wb.sheet_mut(0).unwrap(); - sh.remove_cell(CellRef::new(149_999, 0)); - sh.set_value(CellRef::new(100_000, 0), gridcraft_core::Value::text("far")); - let html = String::from_utf8(save_bytes(&wb, "book.html").unwrap()).unwrap(); - assert!(html.contains(">near
` 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(); + wb.sheet_mut(0).unwrap().images.push(gridcraft_model::Image { + id: 1, + anchor: gridcraft_model::Anchor { cell: CellRef::new(0, 0), dx: 0.0, dy: 0.0, width: 72.0, height: 36.0, mode: Default::default() }, + data: vec![1, 2, 3].into(), + mime: "image/png".into(), + alt: "a \"logo\"".into(), + }); + let html = to_html(&wb, 0).unwrap(); + assert!( + html.contains("\"a"), + "{html}" + ); + assert!(html.contains("
"); +} + +#[test] +fn html_export_places_images_at_their_anchor_using_sheet_geometry() { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.cols.insert(1, gridcraft_model::LineInfo { size: Some(100.0), ..Default::default() }); + sh.rows.insert(1, gridcraft_model::LineInfo { size: Some(30.0), ..Default::default() }); + sh.images.push(gridcraft_model::Image { + id: 1, + anchor: gridcraft_model::Anchor { cell: CellRef::new(3, 2), dx: 5.0, dy: 7.0, width: 10.0, height: 20.0, mode: Default::default() }, + data: vec![1].into(), + mime: "image/png".into(), + alt: String::new(), + }); + sh.set_value(CellRef::new(1, 1), gridcraft_core::Value::Number(1.0)); + let html = to_html(&wb, 0).unwrap(); + // The table starts at B2; the picture is at C4: one 100 px column and two rows (30 + 20) further on. + assert!(html.contains("
"), "{html}"); + assert!(html.contains(""), "Latin text keeps its own direction: {html}"); + assert!(html.contains(""), "numbers mirror to the left and keep their sign in front: {html}"); + let clip = copied(&wb, "A1:B1"); + assert!(clip.starts_with("
Hello!-5
-5"), "{clip}"); + + let ltr = book(false, &[("A1", Value::text("Hello!"), HAlign::General)]); + assert!(to_html(<r, 0).unwrap().contains("
")); + assert!(copied(<r, "A1").starts_with("
مرحبا!"), "General Arabic text aligns by its direction: {html}"); + assert!(html.contains(""), "explicit alignment stays: {html}"); + assert!(html.contains(""), "{html}"); + assert!(html.contains(""), "{html}"); + assert!(html.contains("
שלוםمرحباHello
"), "a left-to-right sheet has no table direction: {html}"); + + let clip = copied(&wb, "A1:D1"); + let cells: Vec<&str> = clip.split("") && html.contains(">far")); +} + +#[test] +fn macros_are_saved_only_into_xlsm() { + let mut wb = Workbook::new(); + wb.vba_project = Some(b"vba".to_vec().into()); + let has_macros = |path: &str| { + let bytes = save_bytes(&wb, path).unwrap(); + gridcraft_xlsx::read_xlsx(&bytes).unwrap().0.vba_project.is_some() + }; + assert!(has_macros("book.xlsm")); + assert!(has_macros("BOOK.XLSM")); + assert!(!has_macros("book.xlsx"), "an .xlsx never carries a VBA project"); + assert!(has_macros("book.xltm"), "a macro-enabled template keeps the VBA project"); + assert!(!has_macros("book.xltx"), "a macro-free template never carries a VBA project"); + assert!(wb.vba_project.is_some(), "the open workbook keeps its macros"); +} + +#[test] +fn csv_named_history_does_not_get_the_reserved_sheet_name() { + for file in ["History.csv", "hIsToRy.csv", "history.tsv"] { + let (wb, _) = open_bytes(file, b"1\n").unwrap(); + let name = wb.sheets[0].name.clone(); + assert!(wb.check_sheet_name(&name, Some(0)).is_ok(), "{file} opened as {name:?}"); + } + let (wb, _) = open_bytes("Sales.csv", b"1\n").unwrap(); + assert_eq!(wb.sheets[0].name, "Sales"); +} + +/// A save that can't be put in place (here a folder already has the workbook's name, as +/// with a file another app holds locked) fails without leaving `.sctmp` behind. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn failed_save_leaves_no_temporary_file() { + let dir = std::env::temp_dir().join(format!("gridcraft-write-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let blocked = dir.join("Book1.xlsx"); + std::fs::create_dir(&blocked).unwrap(); + assert!(write_file(blocked.to_str().unwrap(), b"workbook").is_err()); + assert!(!dir.join("Book1.xlsx.sctmp").exists(), "the temporary file is removed"); + // An ordinary save still works and leaves only the workbook. + let ok = dir.join("Book2.xlsx"); + write_file(ok.to_str().unwrap(), b"workbook").unwrap(); + assert_eq!(std::fs::read(&ok).unwrap(), b"workbook"); + assert!(!dir.join("Book2.xlsx.sctmp").exists()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// While a script runs (#2178), file reads and writes are refused whatever command or +/// parameter asked for them; nesting and dropping the sandbox restore access. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn script_sandbox_refuses_file_access_until_dropped() { + let dir = std::env::temp_dir().join(format!("gridcraft-sandbox-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let existing = dir.join("in.csv"); + std::fs::write(&existing, b"a,b\n").unwrap(); + let (existing, out) = (existing.to_string_lossy().to_string(), dir.join("out.xlsx").to_string_lossy().to_string()); + { + let _outer = ScriptSandbox::enter(); + { + let _inner = ScriptSandbox::enter(); + assert!(read_file(&existing).is_err()); + } + assert!(read_file(&existing).is_err(), "the outer sandbox still holds"); + assert!(write_file(&out, b"leak").is_err()); + assert!(!std::path::Path::new(&out).exists()); + } + assert_eq!(read_file(&existing).unwrap(), b"a,b\n"); + write_file(&out, b"ok").unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a +/// root entry and the given stream names. +fn cfb(streams: &[&str]) -> Vec { + let mut b = vec![0u8; 512 * 3]; + let mut put = |at: usize, v: &[u8]| b[at..at + v.len()].copy_from_slice(v); + put(0, &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]); + put(30, &9u16.to_le_bytes()); + put(44, &1u32.to_le_bytes()); + put(48, &1u32.to_le_bytes()); + put(68, &0xFFFF_FFFEu32.to_le_bytes()); + for i in 1..109 { + put(76 + i * 4, &0xFFFF_FFFFu32.to_le_bytes()); + } + put(512, &0xFFFF_FFFDu32.to_le_bytes()); + put(516, &0xFFFF_FFFEu32.to_le_bytes()); + for (k, name) in std::iter::once("Root Entry").chain(streams.iter().copied()).enumerate() { + let units: Vec = name.encode_utf16().flat_map(u16::to_le_bytes).collect(); + put(1024 + k * 128, &units); + put(1024 + k * 128 + 64, &(units.len() as u16 + 2).to_le_bytes()); + } + b +} + +/// Cut-list rows as a CAD/CAM program exports them: `;`-separated, CRLF, part codes in the last +/// two columns, a part name containing a comma. +const CUT_LIST: &str = "1;511;184;P3_L1_led;2162PE;16;1MM_2162PE;1MM_2162PE;;;1244121406FB;\r\n\ + 1;562.5;510;Komoda, lijevo;2162PE;19;1MM_2162PE;;;;12441214071A;124412140710\r\n"; + +#[test] +fn csv_save_keeps_the_separator_it_was_opened_with() { + let (wb, _) = open_bytes("cut list.csv", CUT_LIST.as_bytes()).unwrap(); + assert_eq!(wb.csv_delimiter, Some(b';')); + assert_eq!(String::from_utf8(save_bytes(&wb, "cut list.csv").unwrap()).unwrap(), CUT_LIST); + // Save As another .csv name keeps it too; a comma file stays a comma file (quoting only + // fields that contain the separator in use). + assert_eq!(String::from_utf8(save_bytes(&wb, "copy.CSV").unwrap()).unwrap(), CUT_LIST); + let commas = "\"Komoda, lijevo\",b\r\n1,x;y\r\n"; + let (wb, _) = open_bytes("plain.csv", commas.as_bytes()).unwrap(); + assert_eq!(String::from_utf8(save_bytes(&wb, "plain.csv").unwrap()).unwrap(), commas); + // A tab-separated file named .csv round-trips with tabs. + let tabs = "a\tb\r\n1\t2\r\n"; + let (wb, _) = open_bytes("tabs.csv", tabs.as_bytes()).unwrap(); + assert_eq!(String::from_utf8(save_bytes(&wb, "tabs.csv").unwrap()).unwrap(), tabs); +} + +#[test] +fn csv_save_of_a_workbook_not_read_from_csv_uses_commas() { + let mut wb = Workbook::new(); + if let Some(sh) = wb.sheet_mut(0) { + sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::text("a")); + sh.set_value(CellRef::new(0, 1), gridcraft_core::Value::Number(1.0)); + } + assert_eq!(save_bytes(&wb, "new.csv").unwrap(), b"a,1\r\n"); + // Through .xlsx the CSV origin is gone, as in Excel. + let (from_csv, _) = open_bytes("cut list.csv", CUT_LIST.as_bytes()).unwrap(); + let (reopened, _) = open_bytes("book.xlsx", &save_bytes(&from_csv, "book.xlsx").unwrap()).unwrap(); + assert_eq!(reopened.csv_delimiter, None); + assert!(String::from_utf8(save_bytes(&reopened, "again.csv").unwrap()).unwrap().starts_with("1,511,184,")); + // A .tsv/.txt saved as .csv gets commas; as .tsv it keeps tabs. + let (tsv, _) = open_bytes("list.txt", b"a\tb\r\n").unwrap(); + assert_eq!(save_bytes(&tsv, "list.csv").unwrap(), b"a,b\r\n"); + assert_eq!(save_bytes(&tsv, "list.tsv").unwrap(), b"a\tb\r\n"); +} + +#[test] +fn encrypted_workbook_is_reported_not_misread() { + let file = gridcraft_xlsx::testing::encrypt_workbook(b"package", "pw"); + let msg = open_bytes("secret.xlsx", &file).unwrap_err().to_string(); + assert!(msg.contains(PASSWORD_REQUIRED), "got: {msg}"); + assert!(!msg.contains("zip"), "must not surface the zip error: {msg}"); + // A compound file naming the streams but unreadable is damage: no password is asked for, + // and none helps. + let file = cfb(&["EncryptionInfo", "EncryptedPackage"]); + for password in [None, Some("pw")] { + let msg = open_bytes_with_password("secret.xlsx", &file, password).unwrap_err().to_string(); + assert!(msg.contains("damaged") && !msg.contains(PASSWORD_REQUIRED), "got: {msg}"); + } +} + +#[test] +fn encryption_no_password_opens_is_reported_without_asking_for_one() { + let info: &[u8] = &[3, 0, 2, 0, 4, 0, 0, 0]; // Standard header flags: RC4, not AES + let file = gridcraft_xlsx::testing::compound_file(&[("EncryptionInfo", info), ("EncryptedPackage", &[0; 8])]); + let msg = open_bytes("rc4.xlsx", &file).unwrap_err().to_string(); + assert!(msg.contains("RC4") && !msg.contains(PASSWORD_REQUIRED), "got: {msg}"); + let irm = gridcraft_xlsx::testing::compound_file(&[("EncryptedPackage", &[0; 8])]); + let msg = open_bytes("irm.xlsx", &irm).unwrap_err().to_string(); + assert!(msg.contains("rights-management") && !msg.contains("damaged"), "got: {msg}"); +} + +#[test] +fn other_compound_files_are_not_called_encrypted() { + let err = open_bytes("old.doc", &cfb(&["WordDocument", "\u{5}SummaryInformation"])).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("not an Excel workbook"), "got: {msg}"); + assert!(!msg.contains("password"), "got: {msg}"); +} + +/// Save As CSV/TSV writes cells as displayed: number formats applied, formula results, +/// whole values in narrow columns (never `#####`), quoting unchanged. +#[test] +fn csv_writes_displayed_text() { + use gridcraft_core::{CellError, Value}; + use gridcraft_model::{Cell, Formula, LineInfo, NumFmt, StyleId}; + let mut wb = Workbook::new(); + let mut styled = |code: &str| wb.styles.derive(StyleId::DEFAULT, |s| s.num_fmt = NumFmt::new(code)); + let (date, money, pct, frac, text, fixed, thousands) = + (styled("yyyy-mm-dd"), styled("$#,##0.00"), styled("0.0%"), styled("# ?/?"), styled("@"), styled("0.00"), styled("#,##0")); + let sh = wb.sheet_mut(0).unwrap(); + let cells = [ + ("A1", Cell { value: Value::Number(46023.0), formula: None, style: date }), + ("B1", Cell { value: Value::Number(1234.5), formula: None, style: money }), + ("C1", Cell { value: Value::Number(0.125), formula: None, style: pct }), + ("D1", Cell { value: Value::Number(1.5), formula: None, style: frac }), + ("E1", Cell { value: Value::text("007"), formula: None, style: text }), + ("F1", Cell { value: Value::Error(CellError::NA), formula: None, style: money }), + ("G1", Cell { value: Value::Bool(true), formula: None, style: StyleId::DEFAULT }), + ("A2", Cell { value: Value::Number(46024.0), style: date, ..Cell::formula(Formula::new("A1+1")) }), + ("B2", Cell { value: Value::Number(12345678.9), formula: None, style: fixed }), + ("C2", Cell { value: Value::Number(-0.25), formula: None, style: StyleId::DEFAULT }), + ("D2", Cell { value: Value::Number(1234567.0), formula: None, style: thousands }), + ]; + for (a, c) in cells { + sh.set_cell(CellRef::parse(a).unwrap(), c); + } + // Column B is far too narrow for its numbers: the grid shows #####, the file must not. + sh.cols.insert(1, LineInfo { size: Some(1.0), ..Default::default() }); + sh.show_formulas = true; + let csv = String::from_utf8(save_bytes(&wb, "out.csv").unwrap()).unwrap(); + assert_eq!(csv, "2026-01-01,\"$1,234.50\",12.5%,1 1/2,007,#N/A,TRUE\r\n2026-01-02,12345678.90,-0.25,\"1,234,567\",,,\r\n"); + let tsv = String::from_utf8(save_bytes(&wb, "out.tsv").unwrap()).unwrap(); + assert!(tsv.starts_with("2026-01-01\t$1,234.50\t12.5%\t"), "{tsv}"); +} + +#[test] +fn delimited_exports_preserve_general_precision_and_explicit_number_formats() { + use gridcraft_core::Value; + use gridcraft_model::{Cell, NumFmt, StyleId}; + let mut workbook = Workbook::new(); + let scientific = workbook.styles.derive(StyleId::DEFAULT, |style| style.num_fmt = NumFmt::new("0.00E+00")); + let sheet = workbook.sheet_mut(0).unwrap(); + sheet.set_cell(CellRef::new(0, 0), Cell { value: Value::Number(124412140710.0), ..Default::default() }); + sheet.set_cell(CellRef::new(0, 1), Cell { value: Value::Number(1.23456789012345), ..Default::default() }); + sheet.set_cell(CellRef::new(0, 2), Cell { value: Value::Number(124412140710.0), style: scientific, ..Default::default() }); + for (path, separator) in [("data.csv", ","), ("data.tsv", "\t")] { + let output = String::from_utf8(save_bytes(&workbook, path).unwrap()).unwrap(); + assert_eq!(output, format!("124412140710{separator}1.23456789012345{separator}1.24E+11\r\n")); + } +} + +#[test] +fn template_extension_saves_template_content_type() { + let bytes = save_bytes(&Workbook::new(), "Budget.xltx").unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut types = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("[Content_Types].xml").unwrap(), &mut types).unwrap(); + assert!( + types.contains(r#"PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml""#), + "{types}" + ); +} + +/// Where `part`'s entry starts in the central directory of `package`. +fn central_entry(package: &[u8], part: &str) -> usize { + let mut at = 0; + while let Some(i) = package.get(at..).and_then(|rest| rest.windows(4).position(|w| w == b"PK\x01\x02")) { + let entry = at + i; + let len = u16::from_le_bytes([package[entry + 28], package[entry + 29]]) as usize; + if &package[entry + 46..entry + 46 + len] == part.as_bytes() { + return entry; + } + at = entry + 46; + } + panic!("{part} is not in the package"); +} + +/// `package` with `part` declaring `size` unpacked bytes: the reader refuses the part on the +/// declared size, so no real 512 MB part has to be built. +fn declaring(mut package: Vec, part: &str, size: u32) -> Vec { + let at = central_entry(&package, part); + package[at + 24..at + 28].copy_from_slice(&size.to_le_bytes()); + package +} + +#[test] +fn a_part_over_the_size_limit_says_how_to_get_the_data_in() { + let mut wb = Workbook::new(); + wb.sheet_mut(0).unwrap().set_value(CellRef::new(0, 0), gridcraft_core::Value::text("big")); + let package = save_bytes(&wb, "big.xlsx").unwrap(); + // The size reported in storytold/gridcraft#1242. + let msg = open_bytes("big.xlsx", &declaring(package.clone(), "xl/worksheets/sheet1.xml", 540_278_731)).unwrap_err().to_string(); + assert!(msg.starts_with("We can't open 'big.xlsx': "), "{msg}"); + assert!(msg.contains("xl/worksheets/sheet1.xml") && msg.contains("515 MB") && msg.contains("512 MB"), "{msg}"); + assert!(msg.contains("several sheets") && msg.contains("CSV"), "{msg}"); + assert!(!msg.contains("bytes"), "{msg}"); + // Splitting sheets doesn't shrink the shared strings, which every sheet uses: only CSV helps. + let msg = open_bytes("big.xlsx", &declaring(package.clone(), "xl/sharedStrings.xml", 540_278_731)).unwrap_err().to_string(); + assert!(msg.contains("xl/sharedStrings.xml") && msg.contains("515 MB") && msg.contains("CSV"), "{msg}"); + assert!(!msg.contains("several sheets"), "{msg}"); + // Just over the limit never reads as a size equal to it. + let msg = open_bytes("big.xlsx", &declaring(package, "xl/worksheets/sheet1.xml", 512 * 1024 * 1024 + 1)).unwrap_err().to_string(); + assert!(msg.contains("more than 512 MB"), "{msg}"); + // A package that fails for another reason keeps its own error, oversized part or not. + let mut broken = declaring(save_bytes(&wb, "big.xlsx").unwrap(), "xl/worksheets/sheet1.xml", 540_278_731); + let at = central_entry(&broken, "xl/workbook.xml"); + broken[at + 46 + "xl/workbook.xml".len() - 1] = b'x'; + let msg = open_bytes("big.xlsx", &broken).unwrap_err().to_string(); + assert!(msg.starts_with("We can't open 'big.xlsx': ") && !msg.contains("unpacks"), "{msg}"); +} + +#[test] +fn content_wins_over_a_mismatched_extension() { + let mut wb = Workbook::new(); + wb.sheet_mut(0).unwrap().set_value(CellRef::new(0, 0), gridcraft_core::Value::Number(7.0)); + let package = save_bytes(&wb, "book.xlsx").unwrap(); + for name in ["book.xlsx", "book.ods", "book.xlsb", "book.csv"] { + let (opened, _) = open_bytes(name, &package).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 0)), gridcraft_core::Value::Number(7.0), "{name}"); + } + for name in ["data.xlsx", "data.csv"] { + let (opened, _) = open_bytes(name, b"a,b\n1,2\n").unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 1)), gridcraft_core::Value::text("b"), "{name}"); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d437142ebf..4be080d31d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -390,9 +390,7 @@ impl Default for StatusBarStats { } } -/// The shipped defaults: Enter moves down, autocomplete on, no macros loaded. impl Default for Prefs { - /// The preferences a fresh install starts with. fn default() -> Self { Prefs { enter_direction: "down".into(), diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index a074f57298..fb72492d5a 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -18,6 +18,21 @@ use gridcraft_model::{ use crate::pivot_calc::CalcSet; +mod charts; +mod compute; +mod items; +mod output; + +use charts::rebind_charts; +pub use charts::{chart_link, chart_series, clear_output, links_to, parse_chart_link}; +use compute::compute_from; +pub use compute::{NOT_A_VALUE_CELL, detail_cells, detail_records}; +pub use items::{distinct_labels, func_caption, item_label}; +#[cfg(test)] +use output::data_cols_exceed_sheet; +pub use output::{CellKind, OutCell, Output, RowKind}; +use output::{cell_style, look}; + /// Largest source block read (cells). pub const MAX_SOURCE_CELLS: u64 = 10_000_000; /// Largest report written (cells). @@ -171,1267 +186,12 @@ pub fn read_source(wb: &Workbook, text: &str, default_sheet: usize) -> Result, - index: HashMap, -} - -fn cmp_items(a: &Item, b: &Item) -> Ordering { - a.class.cmp(&b.class).then(a.num.total_cmp(&b.num)).then_with(|| a.text.cmp(&b.text)) -} - -/// (identity key, item) for a source value under a field's date grouping. -fn make_item(wb: &Workbook, v: &Value, group: PivotDateGroup, fmt: &str, first: usize) -> (String, Item) { - let it = |value: Value, label: String, class: u8, num: f64, formatted: bool| Item { - text: label.to_lowercase(), - value, - label, - class, - num, - first, - formatted, - }; - match v { - Value::Empty => ("b".into(), it(Value::text("(blank)"), "(blank)".into(), 4, 0.0, false)), - Value::Number(n) => { - if group != PivotDateGroup::None - && let Some(dt) = datetime_from_serial(wb.date_system, *n) - { - let mon = MONTHS.get(dt.month.saturating_sub(1) as usize).copied().unwrap_or("Jan"); - let (label, key) = match group { - PivotDateGroup::Years => (dt.year.to_string(), dt.year as f64), - PivotDateGroup::Quarters => { - let q = (dt.month.saturating_sub(1)) / 3 + 1; - (format!("Qtr{q}"), q as f64) - } - PivotDateGroup::Months => (mon.to_string(), dt.month as f64), - _ => (format!("{}-{mon}", dt.day), (dt.month * 100 + dt.day) as f64), - }; - return (format!("g{key}"), it(Value::text(label.as_str()), label, 0, key, false)); - } - let label = crate::display::format(v, fmt, wb).text; - (format!("n{}", n.to_bits()), it(v.clone(), label, 0, *n, true)) - } - Value::Text(t) => (format!("t{}", t.to_lowercase()), it(v.clone(), t.to_string(), 1, 0.0, false)), - Value::Bool(b) => { - let l = if *b { "TRUE" } else { "FALSE" }; - (format!("l{l}"), it(Value::Bool(*b), l.into(), 2, *b as u8 as f64, false)) - } - Value::Error(e) => (format!("e{}", e.as_str()), it(v.clone(), e.as_str().into(), 3, 0.0, false)), - Value::Array(_) => make_item(wb, &v.scalar(), group, fmt, first), - } -} - -impl FieldItems { - fn intern(&mut self, wb: &Workbook, v: &Value, group: PivotDateGroup, fmt: &str, first: usize) -> u32 { - let (key, item) = make_item(wb, v, group, fmt, first); - if let Some(i) = self.index.get(&key) { - return *i; - } - let i = self.items.len() as u32; - self.items.push(item); - self.index.insert(key, i); - i - } - fn get(&self, i: u32) -> Option<&Item> { - self.items.get(i as usize) - } - fn label(&self, i: u32) -> String { - self.get(i).map(|x| x.label.clone()).unwrap_or_default() - } -} - -/// The label of a source value under a date grouping (for filters and field lists). -pub fn item_label(wb: &Workbook, v: &Value, group: PivotDateGroup, fmt: &str) -> String { - make_item(wb, v, group, fmt, 0).1.label -} - -/// Distinct item labels of a source column, sorted like the field list shows them. -pub fn distinct_labels(wb: &Workbook, src: &Source, col: usize, group: PivotDateGroup, limit: usize) -> Vec { - let mut fi = FieldItems::default(); - let fmt = src.formats.get(col).map(String::as_str).unwrap_or("General"); - for (i, r) in src.rows.iter().enumerate() { - if let Some(v) = r.get(col) { - fi.intern(wb, v, group, fmt, i); - } - if fi.items.len() > 100_000 { - break; - } - } - let mut items = fi.items; - items.sort_by(cmp_items); - items.into_iter().take(limit).map(|i| i.label).collect() -} - -// ---------------------------------------------------------------- aggregation - -#[derive(Clone, Debug, Default)] -struct Acc { - count: u64, - nums: u64, - sum: f64, - prod: f64, - min: f64, - max: f64, - mean: f64, - m2: f64, - err: Option, -} - -impl Acc { - fn add(&mut self, v: &Value) { - match v { - Value::Empty => {} - Value::Number(n) => { - let n = *n; - self.count += 1; - self.nums += 1; - self.sum += n; - if self.nums == 1 { - self.prod = n; - self.min = n; - self.max = n; - } else { - self.prod *= n; - self.min = self.min.min(n); - self.max = self.max.max(n); - } - let d = n - self.mean; - self.mean += d / self.nums as f64; - self.m2 += d * (n - self.mean); - } - Value::Error(e) => { - self.count += 1; - if self.err.is_none() { - self.err = Some(*e); - } - } - _ => self.count += 1, - } - } - fn result(&self, f: PivotFunc) -> Value { - let n = self.nums as f64; - match f { - PivotFunc::Count => return Value::number(self.count as f64), - PivotFunc::CountNumbers => return Value::number(n), - _ => {} - } - if let Some(e) = self.err { - return Value::Error(e); - } - let div0 = Value::Error(CellError::Div0); - match f { - PivotFunc::Sum => Value::number(self.sum), - PivotFunc::Average => { - if self.nums == 0 { - div0 - } else { - Value::number(self.sum / n) - } - } - PivotFunc::Max => Value::number(if self.nums == 0 { 0.0 } else { self.max }), - PivotFunc::Min => Value::number(if self.nums == 0 { 0.0 } else { self.min }), - PivotFunc::Product => Value::number(if self.nums == 0 { 0.0 } else { self.prod }), - PivotFunc::StdDev | PivotFunc::Var => { - if self.nums < 2 { - div0 - } else { - let var = (self.m2 / (n - 1.0)).max(0.0); - Value::number(if f == PivotFunc::Var { var } else { var.sqrt() }) - } - } - PivotFunc::StdDevP | PivotFunc::VarP => { - if self.nums < 1 { - div0 - } else { - let var = (self.m2 / n).max(0.0); - Value::number(if f == PivotFunc::VarP { var } else { var.sqrt() }) - } - } - PivotFunc::Count | PivotFunc::CountNumbers => Value::Empty, - } - } -} - -/// Excel's caption verb for a function ("Sum of Sales"). -pub fn func_caption(f: PivotFunc) -> &'static str { - match f { - PivotFunc::Sum => "Sum", - PivotFunc::Count => "Count", - PivotFunc::Average => "Average", - PivotFunc::Max => "Max", - PivotFunc::Min => "Min", - PivotFunc::Product => "Product", - PivotFunc::CountNumbers => "Count Numbers", - PivotFunc::StdDev => "StdDev", - PivotFunc::StdDevP => "StdDevp", - PivotFunc::Var => "Var", - PivotFunc::VarP => "Varp", - } -} - -// ---------------------------------------------------------------- trees - -#[derive(Clone, Debug)] -struct Node { - item: u32, - children: Vec, -} - -fn build_tree(paths: &[&[u32]], depth: usize, fields: &[&PivotField], items: &[FieldItems]) -> Vec { - let (Some(field), Some(fi)) = (fields.get(depth), items.get(depth)) else { return vec![] }; - let mut order: Vec = Vec::new(); - let mut groups: HashMap> = HashMap::new(); - for p in paths { - if let Some(&it) = p.get(depth) { - let g = groups.entry(it).or_default(); - if g.is_empty() { - order.push(it); - } - g.push(p); - } - } - let mut nodes: Vec = order - .into_iter() - .map(|it| { - let ps = groups.remove(&it).unwrap_or_default(); - Node { item: it, children: build_tree(&ps, depth + 1, fields, items) } - }) - .collect(); - let by = |a: &Node, b: &Node| match (fi.get(a.item), fi.get(b.item)) { - (Some(x), Some(y)) => match field.sort { - PivotSort::Asc => cmp_items(x, y), - PivotSort::Desc => cmp_items(y, x), - PivotSort::None => x.first.cmp(&y.first), - }, - _ => Ordering::Equal, - }; - nodes.sort_by(by); - nodes -} - -fn is_collapsed(field: &PivotField, label: &str) -> bool { - field.collapsed_items.iter().any(|c| c.eq_ignore_ascii_case(label)) -} - -fn collect_siblings(nodes: &[Node], prefix: &mut Vec, out: &mut HashMap, Vec>) { - out.insert(prefix.clone(), nodes.iter().map(|n| n.item).collect()); - for n in nodes { - prefix.push(n.item); - collect_siblings(&n.children, prefix, out); - prefix.pop(); - } -} - -// ---------------------------------------------------------------- output - -/// What a report cell is. -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -pub enum CellKind { - Empty, - Header, - RowLabel, - ColLabel, - Data, - Subtotal, - GrandTotal, - Filter, - Placeholder, -} - -/// What a report row is (for row-wide styling). -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] -pub enum RowKind { - Filter, - Blank, - Header, - /// Item row; `true` for a parent item (bold). - Item(bool), - Subtotal, - Grand, - Placeholder, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct OutCell { - pub value: Value, - pub kind: CellKind, - pub indent: u8, - pub num_fmt: Option, -} - -impl OutCell { - fn empty() -> OutCell { - OutCell { value: Value::Empty, kind: CellKind::Empty, indent: 0, num_fmt: None } - } -} - -/// A computed report: a dense grid relative to its top-left, with row kinds. -#[derive(Clone, Debug)] -pub struct Output { - pub grid: Vec>, - pub row_kinds: Vec, - /// Rows above the body (report filters plus a blank row). - pub body_offset: u32, - pub width: u32, - /// Grid rows of the innermost (or collapsed) items: a PivotChart's categories. - pub chart_rows: Vec, - /// Grid columns of item (not total) values with their series names: a PivotChart's series. - pub chart_cols: Vec<(u32, String)>, - /// Grid column holding the innermost row labels (`None` without row fields). - pub label_col: Option, -} - -impl Output { - pub fn height(&self) -> u32 { - self.grid.len() as u32 - } - /// Value at (row, col) relative to the top-left. - pub fn value(&self, r: usize, c: usize) -> Value { - self.grid.get(r).and_then(|row| row.get(c)).map(|x| x.value.clone()).unwrap_or_default() - } -} - -struct Grid { - rows: Vec>, - kinds: Vec, - width: usize, -} - -impl Grid { - fn push_row(&mut self, kind: RowKind) -> Result { - if (self.rows.len() as u64 + 1) * self.width.max(1) as u64 > MAX_OUTPUT_CELLS || self.rows.len() as u64 >= MAX_ROWS as u64 { - return Err("The PivotTable report is too large.".into()); - } - self.rows.push(vec![OutCell::empty(); self.width]); - self.kinds.push(kind); - Ok(self.rows.len() - 1) - } - fn set(&mut self, r: usize, c: usize, value: Value, kind: CellKind, indent: u8, fmt: Option) { - if let Some(cell) = self.rows.get_mut(r).and_then(|row| row.get_mut(c)) { - *cell = OutCell { value, kind, indent, num_fmt: fmt }; - } - } -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum LineKind { - Item(bool), - Subtotal, - Grand, -} - -struct Line { - kind: LineKind, - /// A category of a PivotChart: an innermost (or collapsed) item. - leaf: bool, - path: Vec, - values: bool, - /// (column, value, indent, number format) - labels: Vec<(usize, Value, u8, Option)>, - /// The value field of the line when Σ Values sits in Rows (`None`: one per data column). - vi: Option, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum ColKind { - Leaf, - Subtotal, - Grand, -} - -struct ColSpec { - kind: ColKind, - path: Vec, -} - -struct Ctx<'a> { - pt: &'a PivotTable, - rows: Vec<&'a PivotField>, - row_items: Vec, - row_fmts: Vec, - /// Depth of Σ Values among the row fields when it sits in Rows (two or more value fields). - vpos: Option, - /// Value field captions. - val_names: Vec, -} - -impl Ctx<'_> { - fn label_cell(&self, depth: usize, item: u32) -> (Value, Option, String) { - let Some(it) = self.row_items.get(depth).and_then(|fi| fi.get(item)) else { return (Value::Empty, None, String::new()) }; - let fmt = if it.formatted { self.row_fmts.get(depth).cloned() } else { None }; - (it.value.clone(), fmt, it.label.clone()) - } - - /// (column, indent) of a row level (Σ Values counts as a level) in Compact/Outline form. - fn level_cell(&self, level: usize) -> (usize, u8) { - if self.pt.layout == PivotLayout::Outline { (level, 0) } else { (0, level.min(250) as u8) } - } - - /// Σ Values comes right here, at row field depth `depth` (`vi`: the value field already chosen above). - fn values_here(&self, depth: usize, vi: Option) -> bool { - vi.is_none() && self.vpos == Some(depth) - } - - /// Per-value subtotal lines of an item with Σ Values below it ("East Sum of Sales"). - fn value_subtotals(&self, col: usize, indent: u8, label: &str, prefix: &[u32], out: &mut Vec) { - for (vi, name) in self.val_names.iter().enumerate() { - out.push(Line { - kind: LineKind::Subtotal, - leaf: false, - path: prefix.to_vec(), - values: true, - labels: vec![(col, Value::text(format!("{label} {name}")), indent, None)], - vi: Some(vi), - }); - } - } - - /// The Σ Values level at row field depth `depth`: one item per value field, each followed by - /// the row fields below it (`nodes`) or, innermost, holding that value. - fn flatten_values(&self, nodes: &[Node], depth: usize, prefix: &mut Vec, out: &mut Vec) { - let (col, indent) = self.level_cell(depth); - for (vi, name) in self.val_names.iter().enumerate() { - let labels = vec![(col, Value::text(name.as_str()), indent, None)]; - let leaf = nodes.is_empty(); - out.push(Line { kind: LineKind::Item(!leaf), leaf, path: prefix.clone(), values: leaf, labels, vi: Some(vi) }); - if !leaf { - self.flatten(nodes, depth, Some(vi), prefix, out); - } - } - } - - fn flatten(&self, nodes: &[Node], depth: usize, vi: Option, prefix: &mut Vec, out: &mut Vec) { - if self.values_here(depth, vi) { - self.flatten_values(nodes, depth, prefix, out); - return; - } - let n = self.rows.len(); - // Σ Values still to come below this level: totals need one line per value field. - let values_below = vi.is_none() && self.vpos.is_some(); - let (col, indent) = self.level_cell(depth + usize::from(vi.is_some() && self.vpos.is_some())); - for node in nodes { - prefix.push(node.item); - let (v, fmt, label) = self.label_cell(depth, node.item); - let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label)); - let leaf = depth + 1 >= n || collapsed || node.children.is_empty(); - if leaf && !values_below { - out.push(Line { - kind: LineKind::Item(depth + 1 < n), - leaf: true, - path: prefix.clone(), - values: true, - labels: vec![(col, v, indent, fmt)], - vi, - }); - } else if leaf { - out.push(Line { - kind: LineKind::Item(true), - leaf: false, - path: prefix.clone(), - values: false, - labels: vec![(col, v, indent, fmt)], - vi, - }); - // Outline gives each level its own column, so the captions go in the Values - // column even when the levels between are collapsed away. - let level = if self.pt.layout == PivotLayout::Outline { self.vpos.unwrap_or(depth + 1) } else { depth + 1 }; - self.flatten_values(&[], level, prefix, out); - } else if values_below { - // Subtotals per value field, below the group (one line can't hold them all). - out.push(Line { - kind: LineKind::Item(true), - leaf: false, - path: prefix.clone(), - values: false, - labels: vec![(col, v, indent, fmt)], - vi, - }); - self.flatten(&node.children, depth + 1, None, prefix, out); - if self.pt.subtotals { - self.value_subtotals(col, indent, &label, prefix, out); - } - } else { - let top = self.pt.subtotals && self.pt.subtotals_top; - out.push(Line { - kind: LineKind::Item(true), - leaf: false, - path: prefix.clone(), - values: top, - labels: vec![(col, v, indent, fmt)], - vi, - }); - self.flatten(&node.children, depth + 1, vi, prefix, out); - if self.pt.subtotals && !self.pt.subtotals_top { - out.push(Line { - kind: LineKind::Subtotal, - leaf: false, - path: prefix.clone(), - values: true, - labels: vec![(col, Value::text(format!("{label} Total")), indent, None)], - vi, - }); - } - } - prefix.pop(); - } - } - - /// Tabular form of [`Self::flatten_values`]: the first line also carries the parents' labels. - fn flatten_tabular_values( - &self, - nodes: &[Node], - depth: usize, - prefix: &mut Vec, - pending: &[(usize, Value, u8, Option)], - out: &mut Vec, - ) { - for (vi, name) in self.val_names.iter().enumerate() { - let mut labels: Vec<(usize, Value, u8, Option)> = if vi == 0 { pending.to_vec() } else { vec![] }; - labels.push((depth, Value::text(name.as_str()), 0, None)); - if nodes.is_empty() { - out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels, vi: Some(vi) }); - } else { - self.flatten_tabular(nodes, depth, Some(vi), prefix, &labels, out); - } - } - } - - fn flatten_tabular( - &self, - nodes: &[Node], - depth: usize, - vi: Option, - prefix: &mut Vec, - pending: &[(usize, Value, u8, Option)], - out: &mut Vec, - ) { - if self.values_here(depth, vi) { - self.flatten_tabular_values(nodes, depth, prefix, pending, out); - return; - } - let n = self.rows.len(); - let values_below = vi.is_none() && self.vpos.is_some(); - let col = depth + usize::from(vi.is_some() && self.vpos.is_some()); - for (i, node) in nodes.iter().enumerate() { - prefix.push(node.item); - let (v, fmt, label) = self.label_cell(depth, node.item); - let mut labels: Vec<(usize, Value, u8, Option)> = if i == 0 { pending.to_vec() } else { vec![] }; - labels.push((col, v, 0, fmt)); - let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label)); - if depth + 1 >= n || collapsed || node.children.is_empty() { - if values_below { - self.flatten_tabular_values(&[], self.vpos.unwrap_or(depth + 1), prefix, &labels, out); - } else { - out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels, vi }); - } - } else { - self.flatten_tabular(&node.children, depth + 1, vi, prefix, &labels, out); - if self.pt.subtotals && values_below { - self.value_subtotals(col, 0, &label, prefix, out); - } else if self.pt.subtotals { - out.push(Line { - kind: LineKind::Subtotal, - leaf: false, - path: prefix.clone(), - values: true, - labels: vec![(col, Value::text(format!("{label} Total")), 0, None)], - vi, - }); - } - } - prefix.pop(); - } - } -} - -fn flatten_cols( - nodes: &[Node], - depth: usize, - fields: &[&PivotField], - items: &[FieldItems], - subtotals: bool, - prefix: &mut Vec, - out: &mut Vec, -) { - let n = fields.len(); - for node in nodes { - prefix.push(node.item); - let label = items.get(depth).map(|fi| fi.label(node.item)).unwrap_or_default(); - let collapsed = depth + 1 < n && fields.get(depth).is_some_and(|f| is_collapsed(f, &label)); - if depth + 1 >= n || collapsed || node.children.is_empty() { - out.push(ColSpec { kind: ColKind::Leaf, path: prefix.clone() }); - } else { - flatten_cols(&node.children, depth + 1, fields, items, subtotals, prefix, out); - if subtotals { - out.push(ColSpec { kind: ColKind::Subtotal, path: prefix.clone() }); - } - } - prefix.pop(); - } -} - -type AggMap = HashMap<(Vec, Vec), Vec>; - -/// Where a value field's numbers come from. -#[derive(Clone, Copy)] -enum ValSrc { - /// A source column. - Col(usize), - /// A calculated field (index into the PivotTable's calculated fields). - Calc(usize), -} - -/// True when `col_groups` distinct column leaves (plus the grand-total column) times `values` value fields -/// (at least one data column per group) already exceed the sheet width. This is a lower bound on the final column count. -fn data_cols_exceed_sheet(col_groups: usize, grand_total: bool, values: usize) -> bool { - (col_groups + usize::from(grand_total)).saturating_mul(values.max(1)) > MAX_COLS as usize -} - /// Computes a PivotTable report from the workbook. pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result { let src = read_source(wb, &pt.source, pivot_sheet)?; compute_from(wb, pt, &src, None) } -/// Why Show Details turns a cell down when it isn't a value cell of a PivotTable (the front end -/// edits that cell instead). -pub const NOT_A_VALUE_CELL: &str = "select a value cell in a PivotTable"; - -/// Show Details: the source list and the indices of its records behind the value cell at -/// (`row`, `col`) of the report grid (relative to its top-left), in source order. `None` when -/// that cell holds no value (a label, a header, a blank). An error, for any cell, when the report -/// 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)>, String> { - let src = read_source(wb, &pt.source, pivot_sheet)?; - 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))?; - // 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, 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) - && r.width() == out.width.max(1) - && out.grid.iter().enumerate().all(|(ri, line)| { - line.iter().enumerate().all(|(ci, oc)| { - sh.value_ref(CellRef::new(r.start.row + ri as u32, r.start.col + ci as u32)).unwrap_or(&Value::Empty) == &oc.value - }) - }) - }); - if !current { - return Err("The PivotTable report is out of date. Refresh it first.".into()); - } - Ok(()) -} - -/// 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: Option<(usize, usize)>, - hit: Option<(Vec, Vec)>, - paths: Option, Vec)>>, - cells: Option>, -} - -fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<&mut Probe>) -> Result { - let fmt_of = |c: usize| src.formats.get(c).cloned().unwrap_or_else(|| "General".into()); - let rows: Vec<(usize, &PivotField)> = pt.rows.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect(); - let cols: Vec<(usize, &PivotField)> = pt.columns.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect(); - // Value fields: a source column, or a calculated field evaluated on sums (see `pivot_calc`). - let calc = CalcSet::new(pt, &src.headers); - let vals: Vec<(ValSrc, &PivotValue)> = pt - .values - .iter() - .filter_map(|v| match src.col(&v.source_col) { - Some(c) => Some((ValSrc::Col(c), v)), - None => calc.index(&v.source_col).map(|i| (ValSrc::Calc(i), v)), - }) - .collect(); - // Source columns summed for the calculated fields, accumulated after the value fields. - let mut extra: Vec = Vec::new(); - for (vs, _) in &vals { - if let ValSrc::Calc(i) = vs { - for name in calc.source_refs(*i) { - if let Some(c) = src.col(&name) - && !extra.contains(&c) - { - extra.push(c); - } - } - } - } - if rows.len() + cols.len() > MAX_AXIS_FIELDS { - return Err(format!("A PivotTable can have at most {MAX_AXIS_FIELDS} row and column fields.")); - } - if vals.len() > MAX_VALUE_FIELDS { - return Err(format!("A PivotTable can have at most {MAX_VALUE_FIELDS} value fields.")); - } - let axis_group = |name: &str| -> Option { - pt.rows.iter().chain(pt.columns.iter()).find(|f| f.source_col.eq_ignore_ascii_case(name)).map(|f| f.date_group) - }; - // (column, filter, grouping used for labels, shown as a report filter) - let filters: Vec<(usize, &PivotFilter, PivotDateGroup, bool)> = pt - .filters - .iter() - .filter_map(|f| { - let c = src.col(&f.source_col)?; - let g = axis_group(&f.source_col); - Some((c, f, g.unwrap_or_default(), g.is_none())) - }) - .collect(); - let shown_filters: Vec<&(usize, &PivotFilter, PivotDateGroup, bool)> = filters.iter().filter(|f| f.3).collect(); - - let k = vals.len(); - let empty = rows.is_empty() && cols.is_empty() && vals.is_empty(); - // Σ Values in Rows (it exists with two or more value fields): its depth among the row fields - // found in the source. - let vpos: Option = (pt.values_on_rows && k > 1).then(|| { - let p = pt.values_position.unwrap_or(pt.rows.len()); - pt.rows.iter().take(p).filter(|f| src.col(&f.source_col).is_some()).count() - }); - let on_rows = vpos.is_some(); - let row_levels = rows.len() + usize::from(on_rows); - - // ---- aggregate - let mut row_items: Vec = rows.iter().map(|_| FieldItems::default()).collect(); - let mut col_items: Vec = cols.iter().map(|_| FieldItems::default()).collect(); - let mut aggs: AggMap = HashMap::new(); - let mut row_set: HashSet> = HashSet::new(); - let mut col_set: HashSet> = HashSet::new(); - let mut col_leaves: HashSet> = HashSet::new(); - let filter_fmts: Vec = filters.iter().map(|f| fmt_of(f.0)).collect(); - let row_fmts: Vec = rows.iter().map(|(c, _)| fmt_of(*c)).collect(); - let col_fmts: Vec = cols.iter().map(|(c, _)| fmt_of(*c)).collect(); - 'rec: for (ri, rec) in src.rows.iter().enumerate() { - for ((c, f, g, _), fmt) in filters.iter().zip(filter_fmts.iter()) { - if let Some(sel) = &f.selected { - let label = item_label(wb, rec.get(*c).unwrap_or(&Value::Empty), *g, fmt); - if !sel.iter().any(|s| s.eq_ignore_ascii_case(&label)) { - continue 'rec; - } - } - } - if empty { - continue; - } - let mut rp = Vec::with_capacity(rows.len()); - for (((c, f), fi), fmt) in rows.iter().zip(row_items.iter_mut()).zip(row_fmts.iter()) { - rp.push(fi.intern(wb, rec.get(*c).unwrap_or(&Value::Empty), f.date_group, fmt, ri)); - } - let mut cp = Vec::with_capacity(cols.len()); - for (((c, f), fi), fmt) in cols.iter().zip(col_items.iter_mut()).zip(col_fmts.iter()) { - cp.push(fi.intern(wb, rec.get(*c).unwrap_or(&Value::Empty), f.date_group, fmt, ri)); - } - for a in 0..=rp.len() { - for b in 0..=cp.len() { - let e = aggs.entry((rp[..a].to_vec(), cp[..b].to_vec())).or_insert_with(|| vec![Acc::default(); k + extra.len()]); - for (acc, (vs, _)) in e.iter_mut().zip(vals.iter()) { - if let ValSrc::Col(c) = vs { - acc.add(rec.get(*c).unwrap_or(&Value::Empty)); - } - } - for (acc, c) in e.iter_mut().skip(k).zip(extra.iter()) { - acc.add(rec.get(*c).unwrap_or(&Value::Empty)); - } - } - } - // The column leaf this record lands in: its path cut after the first collapsed item (as in `flatten_cols`). - let shown = cols - .iter() - .zip(col_items.iter()) - .zip(cp.iter()) - .position(|(((_, f), fi), &it)| !f.collapsed_items.is_empty() && is_collapsed(f, &fi.label(it))) - .map_or(cp.len(), |d| d + 1); - if !col_leaves.contains(&cp[..shown]) { - col_leaves.insert(cp[..shown].to_vec()); - } - 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); - // Refuse a report that cannot fit before the accumulators for more column groups are allocated. - if !cols.is_empty() && data_cols_exceed_sheet(col_leaves.len(), pt.grand_totals_rows, k) { - return Err("The PivotTable report will not fit on the sheet.".into()); - } - if aggs.len() > 5_000_000 { - return Err("The PivotTable report is too large.".into()); - } - } - - // ---- axes - let row_fields: Vec<&PivotField> = rows.iter().map(|(_, f)| *f).collect(); - let col_fields: Vec<&PivotField> = cols.iter().map(|(_, f)| *f).collect(); - let row_paths: Vec<&[u32]> = row_set.iter().map(Vec::as_slice).collect(); - let col_paths: Vec<&[u32]> = col_set.iter().map(Vec::as_slice).collect(); - let row_tree = build_tree(&row_paths, 0, &row_fields, &row_items); - let col_tree = build_tree(&col_paths, 0, &col_fields, &col_items); - let mut siblings: HashMap, Vec> = HashMap::new(); - collect_siblings(&row_tree, &mut Vec::new(), &mut siblings); - let level0: Vec = row_tree.iter().map(|n| n.item).collect(); - - let val_names: Vec = vals.iter().map(|(_, v)| v.name.clone()).collect(); - let ctx = Ctx { pt, rows: row_fields.clone(), row_items, row_fmts: row_fmts.clone(), vpos, val_names: val_names.clone() }; - let mut lines: Vec = Vec::new(); - if row_levels == 0 { - lines.push(Line { kind: LineKind::Item(false), leaf: true, path: vec![], values: true, labels: vec![], vi: None }); - } else { - if pt.layout == PivotLayout::Tabular { - ctx.flatten_tabular(&row_tree, 0, None, &mut Vec::new(), &[], &mut lines); - } else { - ctx.flatten(&row_tree, 0, None, &mut Vec::new(), &mut lines); - } - // With only Σ Values in Rows its items are the totals already. - if pt.grand_totals_cols && !rows.is_empty() { - let grand = |text: String, vi: Option| Line { - kind: LineKind::Grand, - leaf: false, - path: vec![], - values: true, - labels: vec![(0, Value::text(text), 0, None)], - vi, - }; - if on_rows { - lines.extend(val_names.iter().enumerate().map(|(vi, name)| grand(format!("Total {name}"), Some(vi)))); - } else { - lines.push(grand("Grand Total".into(), None)); - } - } - } - let mut specs: Vec = Vec::new(); - if cols.is_empty() { - specs.push(ColSpec { kind: ColKind::Leaf, path: vec![] }); - } else { - flatten_cols(&col_tree, 0, &col_fields, &col_items, pt.subtotals, &mut Vec::new(), &mut specs); - if pt.grand_totals_rows { - specs.push(ColSpec { kind: ColKind::Grand, path: vec![] }); - } - } - let mut data_cols: Vec<(usize, Option)> = Vec::new(); - for (si, _) in specs.iter().enumerate() { - if on_rows { - // Each line names its value field; one data column per column item. - data_cols.push((si, None)); - } else if k == 0 { - if !cols.is_empty() { - data_cols.push((si, None)); - } - } else { - for vi in 0..k { - data_cols.push((si, Some(vi))); - } - } - if data_cols.len() > MAX_COLS as usize { - return Err("The PivotTable report will not fit on the sheet.".into()); - } - } - - let compact = pt.layout == PivotLayout::Compact; - let l = if row_levels == 0 && cols.is_empty() { - 0 - } else if compact { - 1 - } else { - row_levels.max(1) - }; - let mut width = l + data_cols.len(); - if !shown_filters.is_empty() { - width = width.max(2); - } - if empty { - width = width.max(1); - } - let width = width.max(1); - let mut g = Grid { rows: vec![], kinds: vec![], width }; - - // ---- report filters - for (c, f, _, _) in &shown_filters { - let r = g.push_row(RowKind::Filter)?; - let name = src.headers.get(*c).cloned().unwrap_or_default(); - let shown = match &f.selected { - None => "(All)".to_string(), - Some(v) if v.len() == 1 => v.first().cloned().unwrap_or_default(), - Some(_) => "(Multiple Items)".to_string(), - }; - g.set(r, 0, Value::text(name), CellKind::Filter, 0, None); - g.set(r, 1, Value::text(shown), CellKind::Filter, 0, None); - } - if !shown_filters.is_empty() { - g.push_row(RowKind::Blank)?; - } - let body_offset = g.rows.len() as u32; - - if empty { - let r = g.push_row(RowKind::Placeholder)?; - g.set(r, 0, Value::text(format!("{} — choose fields", pt.name)), CellKind::Placeholder, 0, None); - return Ok(Output { - width: g.width as u32, - grid: g.rows, - row_kinds: g.kinds, - body_offset, - chart_rows: vec![], - chart_cols: vec![], - label_col: None, - }); - } - - let val_name = |vi: usize| vals.get(vi).map(|(_, v)| v.name.clone()).unwrap_or_default(); - let headers_on = pt.show_headers; - let row_header = |g: &mut Grid, r: usize| { - if row_levels == 0 || !headers_on { - return; - } - if compact { - g.set(r, 0, Value::text("Row Labels"), CellKind::Header, 0, None); - } else { - // One column per row field, Σ Values ("Values") at its place. - let mut names: Vec<&str> = row_fields.iter().map(|f| f.source_col.as_str()).collect(); - if let Some(p) = vpos { - names.insert(p.min(names.len()), "Values"); - } - for (i, name) in names.into_iter().enumerate() { - g.set(r, i, Value::text(name), CellKind::Header, 0, None); - } - } - }; - - // ---- header rows - if cols.is_empty() { - let r = g.push_row(RowKind::Header)?; - row_header(&mut g, r); - for (j, (_, vi)) in data_cols.iter().enumerate() { - match vi { - Some(vi) => g.set(r, l + j, Value::text(val_name(*vi)), CellKind::Header, 0, None), - // Values on rows, no column field: the one data column is the grand total. - None if on_rows => g.set(r, l + j, Value::text("Total"), CellKind::Header, 0, None), - None => {} - } - } - } else { - let cap = g.push_row(RowKind::Header)?; - if l > 0 && k == 1 { - g.set(cap, 0, Value::text(val_name(0)), CellKind::Header, 0, None); - } - if headers_on { - let caption = if compact { "Column Labels".to_string() } else { col_fields.first().map(|f| f.source_col.clone()).unwrap_or_default() }; - g.set(cap, l, Value::text(caption), CellKind::Header, 0, None); - } - let levels = cols.len() + usize::from(k > 1 && !on_rows); - let first_level = g.rows.len(); - for _ in 0..levels { - g.push_row(RowKind::Header)?; - } - let mut prev: Option<&ColSpec> = None; - for (j, (si, vi)) in data_cols.iter().enumerate() { - let Some(spec) = specs.get(*si) else { continue }; - let c = l + j; - match spec.kind { - ColKind::Leaf => { - for (lv, &it) in spec.path.iter().enumerate() { - let same = prev.is_some_and(|p| p.kind != ColKind::Grand && p.path.get(..=lv) == spec.path.get(..=lv)); - if !same { - let Some(item) = col_items.get(lv).and_then(|fi| fi.get(it)) else { continue }; - let fmt = if item.formatted { cols.get(lv).map(|(cc, _)| fmt_of(*cc)) } else { None }; - g.set(first_level + lv, c, item.value.clone(), CellKind::ColLabel, 0, fmt); - } - } - if k > 1 - && let Some(vi) = vi - { - g.set(first_level + cols.len(), c, Value::text(val_name(*vi)), CellKind::ColLabel, 0, None); - } - } - ColKind::Subtotal => { - let lv = spec.path.len().saturating_sub(1); - let label = spec.path.last().and_then(|it| col_items.get(lv).map(|fi| fi.label(*it))).unwrap_or_default(); - let text = match vi { - Some(vi) if k > 1 => format!("{label} {}", val_name(*vi)), - _ => format!("{label} Total"), - }; - g.set(first_level + lv, c, Value::text(text), CellKind::ColLabel, 0, None); - } - ColKind::Grand => { - let text = match vi { - Some(vi) if k > 1 => format!("Total {}", val_name(*vi)), - _ => "Grand Total".to_string(), - }; - g.set(first_level, c, Value::text(text), CellKind::ColLabel, 0, None); - } - } - prev = Some(spec); - } - let last = g.rows.len() - 1; - row_header(&mut g, last); - } - - // ---- body - let get = |rp: &[u32], cp: &[u32], vi: usize| -> Option { - let (vs, v) = vals.get(vi)?; - let accs = aggs.get(&(rp.to_vec(), cp.to_vec()))?; - match vs { - ValSrc::Col(_) => accs.get(vi).map(|a| a.result(v.func)), - ValSrc::Calc(i) => { - let sum = |name: &str| -> Value { - src.col(name) - .and_then(|c| extra.iter().position(|x| *x == c)) - .and_then(|j| accs.get(k + j)) - .map(|a| a.result(PivotFunc::Sum)) - .unwrap_or(Value::Number(0.0)) - }; - Some(calc.value(wb, *i, &sum)) - } - } - }; - let num = |v: &Option| v.as_ref().and_then(Value::as_f64); - let ratio = |a: Value, b: Option| -> Value { - match (a, b) { - (Value::Error(e), _) => Value::Error(e), - (_, Some(Value::Error(e))) => Value::Error(e), - (Value::Number(x), Some(Value::Number(y))) => { - if y == 0.0 { - Value::Error(CellError::Div0) - } else { - Value::number(x / y) - } - } - (v, _) => v, - } - }; - let data_fmt: Vec> = vals - .iter() - .map(|(vs, v)| match v.show_as { - PivotShowAs::PercentOfGrandTotal | PivotShowAs::PercentOfColumnTotal | PivotShowAs::PercentOfRowTotal => { - Some(v.number_format.clone().unwrap_or_else(|| "0.00%".into())) - } - PivotShowAs::Rank => v.number_format.clone(), - _ => v.number_format.clone().or_else(|| match v.func { - PivotFunc::Count | PivotFunc::CountNumbers => None, - _ => { - let f = match vs { - ValSrc::Col(c) => fmt_of(*c), - ValSrc::Calc(_) => "General".to_string(), - }; - (f != "General").then_some(f) - } - }), - }) - .collect(); - let mut chart_rows: Vec = Vec::new(); - let chart_cols: Vec<(u32, String)> = data_cols - .iter() - .enumerate() - .filter_map(|(j, (si, vi))| { - let spec = specs.get(*si)?; - if spec.kind != ColKind::Leaf { - return None; - } - let mut parts: Vec = - spec.path.iter().enumerate().map(|(lv, it)| col_items.get(lv).map(|fi| fi.label(*it)).unwrap_or_default()).collect(); - if let Some(vi) = vi - && (k > 1 || parts.is_empty()) - { - parts.push(val_name(*vi)); - } else if parts.is_empty() { - parts.push("Total".into()); - } - Some(((l + j) as u32, parts.join(" - "))) - }) - .filter(|_| k > 0) - .collect(); - let label_col = if row_levels == 0 { - None - } else if compact { - Some(0) - } else { - Some(row_levels.saturating_sub(1) as u32) - }; - for line in &lines { - let rk = match line.kind { - LineKind::Item(b) => RowKind::Item(b), - LineKind::Subtotal => RowKind::Subtotal, - LineKind::Grand => RowKind::Grand, - }; - let ck = match line.kind { - LineKind::Item(_) => (CellKind::RowLabel, CellKind::Data), - LineKind::Subtotal => (CellKind::Subtotal, CellKind::Subtotal), - LineKind::Grand => (CellKind::GrandTotal, CellKind::GrandTotal), - }; - let r = g.push_row(rk)?; - if line.leaf && k > 0 { - chart_rows.push(r as u32); - } - for (c, v, indent, fmt) in &line.labels { - g.set(r, *c, v.clone(), ck.0, *indent, fmt.clone()); - } - if !line.values { - continue; - } - let rp = line.path.as_slice(); - for (j, (si, col_vi)) in data_cols.iter().enumerate() { - let line_vi = col_vi.or(line.vi); - let (Some(spec), Some(vi)) = (specs.get(*si), &line_vi) else { continue }; - let Some((_, vdef)) = vals.get(*vi) else { continue }; - let cp = spec.path.as_slice(); - let Some(raw) = get(rp, cp, *vi) else { continue }; - let shown = match vdef.show_as { - PivotShowAs::Normal => raw, - PivotShowAs::PercentOfGrandTotal => ratio(raw, get(&[], &[], *vi)), - PivotShowAs::PercentOfColumnTotal => ratio(raw, get(&[], cp, *vi)), - PivotShowAs::PercentOfRowTotal => ratio(raw, get(rp, &[], *vi)), - PivotShowAs::RunningTotal => match (rp.first(), &raw) { - (Some(first), Value::Number(_)) => { - let mut acc = 0.0; - for it in &level0 { - let mut p = rp.to_vec(); - if let Some(x) = p.first_mut() { - *x = *it; - } - acc += num(&get(&p, cp, *vi)).unwrap_or(0.0); - if it == first { - break; - } - } - Value::number(acc) - } - _ => raw, - }, - PivotShowAs::Rank => match (rp.split_last(), raw.as_f64()) { - (Some((_, parent)), Some(me)) => { - let sibs = siblings.get(parent).cloned().unwrap_or_default(); - let mut rank = 1u64; - for sib in sibs { - let mut p = parent.to_vec(); - p.push(sib); - if num(&get(&p, cp, *vi)).is_some_and(|x| x > me) { - rank += 1; - } - } - Value::number(rank as f64) - } - (None, _) => Value::Empty, - _ => raw, - }, - }; - 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()); - } - } - Ok(Output { width: g.width as u32, grid: g.rows, row_kinds: g.kinds, body_offset, chart_rows, chart_cols, label_col }) -} - -// ---------------------------------------------------------------- styling and writing - -/// Colours for a PivotTable style name (our own palette, keyed by the familiar -/// `PivotStyleLight1…28 / Medium1…28 / Dark1…28` names). -struct Look { - header_fill: Color, - header_font: Color, - line: Color, - filter_fill: Color, -} - -fn look(style: &str) -> Look { - let rest = style.strip_prefix("PivotStyle").unwrap_or(style); - let (fam, n) = ["Light", "Medium", "Dark"] - .iter() - .find_map(|f| rest.strip_prefix(f).map(|num| (*f, num.parse::().unwrap_or(16)))) - .unwrap_or(("Light", 16)); - let k = n.saturating_sub(1) % 7; - let acc = if k == 0 { None } else { Some(3 + k as u8) }; - let tone = |tint: i16| match acc { - Some(a) => Color::Theme(a, tint), - None => Color::Theme(1, tint.clamp(-1000, 1000)), - }; - match fam { - "Dark" => Look { header_fill: Color::Theme(1, 0), header_font: Color::Theme(0, 0), line: tone(0), filter_fill: tone(600) }, - "Medium" => Look { header_fill: tone(0), header_font: Color::Theme(0, 0), line: tone(0), filter_fill: tone(800) }, - _ => Look { header_fill: tone(800), header_font: Color::Auto, line: tone(400), filter_fill: tone(800) }, - } -} - -fn cell_style(lk: &Look, rk: RowKind, cell: &OutCell, last_header: bool) -> Style { - let mut s = Style::default(); - match rk { - RowKind::Header => { - s.font.bold = true; - s.fill = Fill::solid(lk.header_fill); - s.font.color = lk.header_font; - if last_header { - s.borders.bottom = BorderLine { style: BorderStyle::Thin, color: lk.line }; - } - } - RowKind::Filter => { - s.fill = Fill::solid(lk.filter_fill); - s.font.bold = last_header; - } - RowKind::Item(parent) => s.font.bold = parent, - RowKind::Subtotal => s.font.bold = true, - RowKind::Grand => { - s.font.bold = true; - s.borders.top = BorderLine { style: BorderStyle::Thin, color: lk.line }; - } - RowKind::Placeholder => { - s.font.italic = true; - s.font.color = Color::Theme(1, 500); - } - RowKind::Blank => {} - } - if matches!(cell.kind, CellKind::RowLabel | CellKind::ColLabel | CellKind::Subtotal | CellKind::GrandTotal) && !cell.value.is_number() { - s.align.h = HAlign::Left; - } - if matches!(cell.kind, CellKind::RowLabel | CellKind::ColLabel) && cell.value.is_number() { - s.align.h = HAlign::Left; - } - s.align.indent = cell.indent.min(15); - if let Some(f) = &cell.num_fmt { - s.num_fmt = NumFmt::new(f); - } - s -} - /// Recomputes a pivot and writes its report. With `replace` false, refuses when the target area /// holds other data. Returns the written area. pub fn refresh(wb: &mut Workbook, sheet: usize, idx: usize, replace: bool) -> Result { @@ -1554,112 +314,6 @@ pub fn refresh(wb: &mut Workbook, sheet: usize, idx: usize, replace: bool) -> Re Ok(area) } -// ---------------------------------------------------------------- PivotCharts - -/// The link a PivotChart stores to its PivotTable: `Sheet2!PivotTable1`. -pub fn chart_link(sheet: &str, pivot: &str) -> String { - format!("{}!{pivot}", quote_sheet(sheet)) -} - -/// (sheet, PivotTable name) of a PivotChart link; a leading `[Book.xlsx]` is ignored. -pub fn parse_chart_link(link: &str) -> Option<(String, String)> { - let t = link.trim(); - let t = match t.strip_prefix('[') { - Some(rest) => rest.split_once(']').map(|(_, r)| r).unwrap_or(rest), - None => t, - }; - let (sheet, name) = t.rsplit_once('!')?; - let sheet = sheet.trim(); - let sheet = gridcraft_core::unquote_sheet(sheet); - let name = name.trim(); - if sheet.is_empty() || name.is_empty() { - return None; - } - Some((sheet, name.to_string())) -} - -/// Whether a PivotChart link points at `pivot` on `sheet`. -pub fn links_to(link: &str, sheet: &str, pivot: &str) -> bool { - parse_chart_link(link).is_some_and(|(s, n)| s.eq_ignore_ascii_case(sheet) && n.eq_ignore_ascii_case(pivot)) -} - -/// An absolute reference to `col` over grid `rows` (contiguous runs joined into a union). -fn rows_ref(sheet: &str, origin: CellRef, col: u32, rows: &[u32]) -> Option { - let q = quote_sheet(sheet); - let c = origin.col.checked_add(col)?; - let letters = gridcraft_core::col_to_letters(c); - let mut parts: Vec = Vec::new(); - let mut i = 0; - while let Some(&start) = rows.get(i) { - let mut end = start; - while rows.get(i + 1).is_some_and(|n| Some(*n) == end.checked_add(1)) { - end += 1; - i += 1; - } - i += 1; - let (a, b) = (origin.row.checked_add(start)? + 1, origin.row.checked_add(end)? + 1); - parts.push(if a == b { format!("{q}!${letters}${a}") } else { format!("{q}!${letters}${a}:${letters}${b}") }); - } - match parts.len() { - 0 => None, - 1 => parts.pop(), - _ => Some(format!("({})", parts.join(","))), - } -} - -/// Most category rows a PivotChart plots. -const MAX_CHART_ROWS: usize = 10_000; - -/// A PivotChart's series for a report written at `origin`: categories are the innermost row -/// items, one series per item column (column items × value fields); totals are left out. -pub fn chart_series(sheet: &str, origin: CellRef, out: &Output) -> Vec { - let rows: Vec = out.chart_rows.iter().copied().take(MAX_CHART_ROWS).collect(); - let categories = out.label_col.and_then(|c| rows_ref(sheet, origin, c, &rows)); - out.chart_cols - .iter() - .filter_map(|(c, name)| { - Some(Series { - name: Some(format!("\"{}\"", name.replace('"', "\"\""))), - categories: categories.clone(), - values: rows_ref(sheet, origin, *c, &rows)?, - bubble_sizes: None, - color: None, - secondary: false, - kind: None, - quartiles: Default::default(), - smooth: false, - trendlines: Vec::new(), - }) - }) - .collect() -} - -/// Rebuilds the series of every PivotChart of a PivotTable after its report was written. -fn rebind_charts(wb: &mut Workbook, sheet: &str, pivot: &str, origin: CellRef, out: &Output) { - let linked = |ch: &Chart| ch.pivot.as_deref().is_some_and(|l| links_to(l, sheet, pivot)); - let hits: Vec = (0..wb.sheets.len()).filter(|i| wb.sheet(*i).is_some_and(|sh| sh.charts.iter().any(linked))).collect(); - if hits.is_empty() { - return; - } - let series = chart_series(sheet, origin, out); - for i in hits { - let Some(sh) = wb.sheet_mut(i) else { continue }; - for ch in sh.charts.iter_mut().filter(|c| linked(c)) { - ch.series = series.clone(); - } - } -} - -/// Clears a pivot's last output area. -pub fn clear_output(wb: &mut Workbook, sheet: usize, idx: usize) { - let Some(shm) = wb.sheet_mut(sheet) else { return }; - let Some(r) = shm.pivots.get(idx).and_then(|p| p.last_range) else { return }; - shm.take_cells(r); - if let Some(p) = shm.pivots.get_mut(idx) { - p.last_range = None; - } -} - #[cfg(test)] mod fit_tests { use super::data_cols_exceed_sheet; diff --git a/crates/engine/src/pivot/charts.rs b/crates/engine/src/pivot/charts.rs new file mode 100644 index 0000000000..0126d6c7b4 --- /dev/null +++ b/crates/engine/src/pivot/charts.rs @@ -0,0 +1,108 @@ +use super::output::Output; +use super::*; + +// ---------------------------------------------------------------- PivotCharts + +/// The link a PivotChart stores to its PivotTable: `Sheet2!PivotTable1`. +pub fn chart_link(sheet: &str, pivot: &str) -> String { + format!("{}!{pivot}", quote_sheet(sheet)) +} + +/// (sheet, PivotTable name) of a PivotChart link; a leading `[Book.xlsx]` is ignored. +pub fn parse_chart_link(link: &str) -> Option<(String, String)> { + let t = link.trim(); + let t = match t.strip_prefix('[') { + Some(rest) => rest.split_once(']').map(|(_, r)| r).unwrap_or(rest), + None => t, + }; + let (sheet, name) = t.rsplit_once('!')?; + let sheet = sheet.trim(); + let sheet = gridcraft_core::unquote_sheet(sheet); + let name = name.trim(); + if sheet.is_empty() || name.is_empty() { + return None; + } + Some((sheet, name.to_string())) +} + +/// Whether a PivotChart link points at `pivot` on `sheet`. +pub fn links_to(link: &str, sheet: &str, pivot: &str) -> bool { + parse_chart_link(link).is_some_and(|(s, n)| s.eq_ignore_ascii_case(sheet) && n.eq_ignore_ascii_case(pivot)) +} + +/// An absolute reference to `col` over grid `rows` (contiguous runs joined into a union). +fn rows_ref(sheet: &str, origin: CellRef, col: u32, rows: &[u32]) -> Option { + let q = quote_sheet(sheet); + let c = origin.col.checked_add(col)?; + let letters = gridcraft_core::col_to_letters(c); + let mut parts: Vec = Vec::new(); + let mut i = 0; + while let Some(&start) = rows.get(i) { + let mut end = start; + while rows.get(i + 1).is_some_and(|n| Some(*n) == end.checked_add(1)) { + end += 1; + i += 1; + } + i += 1; + let (a, b) = (origin.row.checked_add(start)? + 1, origin.row.checked_add(end)? + 1); + parts.push(if a == b { format!("{q}!${letters}${a}") } else { format!("{q}!${letters}${a}:${letters}${b}") }); + } + match parts.len() { + 0 => None, + 1 => parts.pop(), + _ => Some(format!("({})", parts.join(","))), + } +} + +/// Most category rows a PivotChart plots. +const MAX_CHART_ROWS: usize = 10_000; + +/// A PivotChart's series for a report written at `origin`: categories are the innermost row +/// items, one series per item column (column items × value fields); totals are left out. +pub fn chart_series(sheet: &str, origin: CellRef, out: &Output) -> Vec { + let rows: Vec = out.chart_rows.iter().copied().take(MAX_CHART_ROWS).collect(); + let categories = out.label_col.and_then(|c| rows_ref(sheet, origin, c, &rows)); + out.chart_cols + .iter() + .filter_map(|(c, name)| { + Some(Series { + name: Some(format!("\"{}\"", name.replace('"', "\"\""))), + categories: categories.clone(), + values: rows_ref(sheet, origin, *c, &rows)?, + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + quartiles: Default::default(), + smooth: false, + trendlines: Vec::new(), + }) + }) + .collect() +} + +/// Rebuilds the series of every PivotChart of a PivotTable after its report was written. +pub(super) fn rebind_charts(wb: &mut Workbook, sheet: &str, pivot: &str, origin: CellRef, out: &Output) { + let linked = |ch: &Chart| ch.pivot.as_deref().is_some_and(|l| links_to(l, sheet, pivot)); + let hits: Vec = (0..wb.sheets.len()).filter(|i| wb.sheet(*i).is_some_and(|sh| sh.charts.iter().any(linked))).collect(); + if hits.is_empty() { + return; + } + let series = chart_series(sheet, origin, out); + for i in hits { + let Some(sh) = wb.sheet_mut(i) else { continue }; + for ch in sh.charts.iter_mut().filter(|c| linked(c)) { + ch.series = series.clone(); + } + } +} + +/// Clears a pivot's last output area. +pub fn clear_output(wb: &mut Workbook, sheet: usize, idx: usize) { + let Some(shm) = wb.sheet_mut(sheet) else { return }; + let Some(r) = shm.pivots.get(idx).and_then(|p| p.last_range) else { return }; + shm.take_cells(r); + if let Some(p) = shm.pivots.get_mut(idx) { + p.last_range = None; + } +} diff --git a/crates/engine/src/pivot/compute.rs b/crates/engine/src/pivot/compute.rs new file mode 100644 index 0000000000..19864ab9a8 --- /dev/null +++ b/crates/engine/src/pivot/compute.rs @@ -0,0 +1,556 @@ +use super::items::{Acc, FieldItems, build_tree, collect_siblings, is_collapsed, item_label}; +use super::output::{AggMap, CellKind, ColKind, ColSpec, Ctx, Grid, Line, LineKind, Output, RowKind, ValSrc, data_cols_exceed_sheet, flatten_cols}; +use super::*; + +/// Why Show Details turns a cell down when it isn't a value cell of a PivotTable (the front end +/// edits that cell instead). +pub const NOT_A_VALUE_CELL: &str = "select a value cell in a PivotTable"; + +/// Show Details: the source list and the indices of its records behind the value cell at +/// (`row`, `col`) of the report grid (relative to its top-left), in source order. `None` when +/// that cell holds no value (a label, a header, a blank). An error, for any cell, when the report +/// 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)>, String> { + let src = read_source(wb, &pt.source, pivot_sheet)?; + 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))?; + // 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, 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) + && r.width() == out.width.max(1) + && out.grid.iter().enumerate().all(|(ri, line)| { + line.iter().enumerate().all(|(ci, oc)| { + sh.value_ref(CellRef::new(r.start.row + ri as u32, r.start.col + ci as u32)).unwrap_or(&Value::Empty) == &oc.value + }) + }) + }); + if !current { + return Err("The PivotTable report is out of date. Refresh it first.".into()); + } + Ok(()) +} + +/// 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). +pub(super) struct Probe { + at: Option<(usize, usize)>, + hit: Option<(Vec, Vec)>, + paths: Option, Vec)>>, + cells: Option>, +} + +pub(super) fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<&mut Probe>) -> Result { + let fmt_of = |c: usize| src.formats.get(c).cloned().unwrap_or_else(|| "General".into()); + let rows: Vec<(usize, &PivotField)> = pt.rows.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect(); + let cols: Vec<(usize, &PivotField)> = pt.columns.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect(); + // Value fields: a source column, or a calculated field evaluated on sums (see `pivot_calc`). + let calc = CalcSet::new(pt, &src.headers); + let vals: Vec<(ValSrc, &PivotValue)> = pt + .values + .iter() + .filter_map(|v| match src.col(&v.source_col) { + Some(c) => Some((ValSrc::Col(c), v)), + None => calc.index(&v.source_col).map(|i| (ValSrc::Calc(i), v)), + }) + .collect(); + // Source columns summed for the calculated fields, accumulated after the value fields. + let mut extra: Vec = Vec::new(); + for (vs, _) in &vals { + if let ValSrc::Calc(i) = vs { + for name in calc.source_refs(*i) { + if let Some(c) = src.col(&name) + && !extra.contains(&c) + { + extra.push(c); + } + } + } + } + if rows.len() + cols.len() > MAX_AXIS_FIELDS { + return Err(format!("A PivotTable can have at most {MAX_AXIS_FIELDS} row and column fields.")); + } + if vals.len() > MAX_VALUE_FIELDS { + return Err(format!("A PivotTable can have at most {MAX_VALUE_FIELDS} value fields.")); + } + let axis_group = |name: &str| -> Option { + pt.rows.iter().chain(pt.columns.iter()).find(|f| f.source_col.eq_ignore_ascii_case(name)).map(|f| f.date_group) + }; + // (column, filter, grouping used for labels, shown as a report filter) + let filters: Vec<(usize, &PivotFilter, PivotDateGroup, bool)> = pt + .filters + .iter() + .filter_map(|f| { + let c = src.col(&f.source_col)?; + let g = axis_group(&f.source_col); + Some((c, f, g.unwrap_or_default(), g.is_none())) + }) + .collect(); + let shown_filters: Vec<&(usize, &PivotFilter, PivotDateGroup, bool)> = filters.iter().filter(|f| f.3).collect(); + + let k = vals.len(); + let empty = rows.is_empty() && cols.is_empty() && vals.is_empty(); + // Σ Values in Rows (it exists with two or more value fields): its depth among the row fields + // found in the source. + let vpos: Option = (pt.values_on_rows && k > 1).then(|| { + let p = pt.values_position.unwrap_or(pt.rows.len()); + pt.rows.iter().take(p).filter(|f| src.col(&f.source_col).is_some()).count() + }); + let on_rows = vpos.is_some(); + let row_levels = rows.len() + usize::from(on_rows); + + // ---- aggregate + let mut row_items: Vec = rows.iter().map(|_| FieldItems::default()).collect(); + let mut col_items: Vec = cols.iter().map(|_| FieldItems::default()).collect(); + let mut aggs: AggMap = HashMap::new(); + let mut row_set: HashSet> = HashSet::new(); + let mut col_set: HashSet> = HashSet::new(); + let mut col_leaves: HashSet> = HashSet::new(); + let filter_fmts: Vec = filters.iter().map(|f| fmt_of(f.0)).collect(); + let row_fmts: Vec = rows.iter().map(|(c, _)| fmt_of(*c)).collect(); + let col_fmts: Vec = cols.iter().map(|(c, _)| fmt_of(*c)).collect(); + 'rec: for (ri, rec) in src.rows.iter().enumerate() { + for ((c, f, g, _), fmt) in filters.iter().zip(filter_fmts.iter()) { + if let Some(sel) = &f.selected { + let label = item_label(wb, rec.get(*c).unwrap_or(&Value::Empty), *g, fmt); + if !sel.iter().any(|s| s.eq_ignore_ascii_case(&label)) { + continue 'rec; + } + } + } + if empty { + continue; + } + let mut rp = Vec::with_capacity(rows.len()); + for (((c, f), fi), fmt) in rows.iter().zip(row_items.iter_mut()).zip(row_fmts.iter()) { + rp.push(fi.intern(wb, rec.get(*c).unwrap_or(&Value::Empty), f.date_group, fmt, ri)); + } + let mut cp = Vec::with_capacity(cols.len()); + for (((c, f), fi), fmt) in cols.iter().zip(col_items.iter_mut()).zip(col_fmts.iter()) { + cp.push(fi.intern(wb, rec.get(*c).unwrap_or(&Value::Empty), f.date_group, fmt, ri)); + } + for a in 0..=rp.len() { + for b in 0..=cp.len() { + let e = aggs.entry((rp[..a].to_vec(), cp[..b].to_vec())).or_insert_with(|| vec![Acc::default(); k + extra.len()]); + for (acc, (vs, _)) in e.iter_mut().zip(vals.iter()) { + if let ValSrc::Col(c) = vs { + acc.add(rec.get(*c).unwrap_or(&Value::Empty)); + } + } + for (acc, c) in e.iter_mut().skip(k).zip(extra.iter()) { + acc.add(rec.get(*c).unwrap_or(&Value::Empty)); + } + } + } + // The column leaf this record lands in: its path cut after the first collapsed item (as in `flatten_cols`). + let shown = cols + .iter() + .zip(col_items.iter()) + .zip(cp.iter()) + .position(|(((_, f), fi), &it)| !f.collapsed_items.is_empty() && is_collapsed(f, &fi.label(it))) + .map_or(cp.len(), |d| d + 1); + if !col_leaves.contains(&cp[..shown]) { + col_leaves.insert(cp[..shown].to_vec()); + } + 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); + // Refuse a report that cannot fit before the accumulators for more column groups are allocated. + if !cols.is_empty() && data_cols_exceed_sheet(col_leaves.len(), pt.grand_totals_rows, k) { + return Err("The PivotTable report will not fit on the sheet.".into()); + } + if aggs.len() > 5_000_000 { + return Err("The PivotTable report is too large.".into()); + } + } + + // ---- axes + let row_fields: Vec<&PivotField> = rows.iter().map(|(_, f)| *f).collect(); + let col_fields: Vec<&PivotField> = cols.iter().map(|(_, f)| *f).collect(); + let row_paths: Vec<&[u32]> = row_set.iter().map(Vec::as_slice).collect(); + let col_paths: Vec<&[u32]> = col_set.iter().map(Vec::as_slice).collect(); + let row_tree = build_tree(&row_paths, 0, &row_fields, &row_items); + let col_tree = build_tree(&col_paths, 0, &col_fields, &col_items); + let mut siblings: HashMap, Vec> = HashMap::new(); + collect_siblings(&row_tree, &mut Vec::new(), &mut siblings); + let level0: Vec = row_tree.iter().map(|n| n.item).collect(); + + let val_names: Vec = vals.iter().map(|(_, v)| v.name.clone()).collect(); + let ctx = Ctx { pt, rows: row_fields.clone(), row_items, row_fmts: row_fmts.clone(), vpos, val_names: val_names.clone() }; + let mut lines: Vec = Vec::new(); + if row_levels == 0 { + lines.push(Line { kind: LineKind::Item(false), leaf: true, path: vec![], values: true, labels: vec![], vi: None }); + } else { + if pt.layout == PivotLayout::Tabular { + ctx.flatten_tabular(&row_tree, 0, None, &mut Vec::new(), &[], &mut lines); + } else { + ctx.flatten(&row_tree, 0, None, &mut Vec::new(), &mut lines); + } + // With only Σ Values in Rows its items are the totals already. + if pt.grand_totals_cols && !rows.is_empty() { + let grand = |text: String, vi: Option| Line { + kind: LineKind::Grand, + leaf: false, + path: vec![], + values: true, + labels: vec![(0, Value::text(text), 0, None)], + vi, + }; + if on_rows { + lines.extend(val_names.iter().enumerate().map(|(vi, name)| grand(format!("Total {name}"), Some(vi)))); + } else { + lines.push(grand("Grand Total".into(), None)); + } + } + } + let mut specs: Vec = Vec::new(); + if cols.is_empty() { + specs.push(ColSpec { kind: ColKind::Leaf, path: vec![] }); + } else { + flatten_cols(&col_tree, 0, &col_fields, &col_items, pt.subtotals, &mut Vec::new(), &mut specs); + if pt.grand_totals_rows { + specs.push(ColSpec { kind: ColKind::Grand, path: vec![] }); + } + } + let mut data_cols: Vec<(usize, Option)> = Vec::new(); + for (si, _) in specs.iter().enumerate() { + if on_rows { + // Each line names its value field; one data column per column item. + data_cols.push((si, None)); + } else if k == 0 { + if !cols.is_empty() { + data_cols.push((si, None)); + } + } else { + for vi in 0..k { + data_cols.push((si, Some(vi))); + } + } + if data_cols.len() > MAX_COLS as usize { + return Err("The PivotTable report will not fit on the sheet.".into()); + } + } + + let compact = pt.layout == PivotLayout::Compact; + let l = if row_levels == 0 && cols.is_empty() { + 0 + } else if compact { + 1 + } else { + row_levels.max(1) + }; + let mut width = l + data_cols.len(); + if !shown_filters.is_empty() { + width = width.max(2); + } + if empty { + width = width.max(1); + } + let width = width.max(1); + let mut g = Grid { rows: vec![], kinds: vec![], width }; + + // ---- report filters + for (c, f, _, _) in &shown_filters { + let r = g.push_row(RowKind::Filter)?; + let name = src.headers.get(*c).cloned().unwrap_or_default(); + let shown = match &f.selected { + None => "(All)".to_string(), + Some(v) if v.len() == 1 => v.first().cloned().unwrap_or_default(), + Some(_) => "(Multiple Items)".to_string(), + }; + g.set(r, 0, Value::text(name), CellKind::Filter, 0, None); + g.set(r, 1, Value::text(shown), CellKind::Filter, 0, None); + } + if !shown_filters.is_empty() { + g.push_row(RowKind::Blank)?; + } + let body_offset = g.rows.len() as u32; + + if empty { + let r = g.push_row(RowKind::Placeholder)?; + g.set(r, 0, Value::text(format!("{} — choose fields", pt.name)), CellKind::Placeholder, 0, None); + return Ok(Output { + width: g.width as u32, + grid: g.rows, + row_kinds: g.kinds, + body_offset, + chart_rows: vec![], + chart_cols: vec![], + label_col: None, + }); + } + + let val_name = |vi: usize| vals.get(vi).map(|(_, v)| v.name.clone()).unwrap_or_default(); + let headers_on = pt.show_headers; + let row_header = |g: &mut Grid, r: usize| { + if row_levels == 0 || !headers_on { + return; + } + if compact { + g.set(r, 0, Value::text("Row Labels"), CellKind::Header, 0, None); + } else { + // One column per row field, Σ Values ("Values") at its place. + let mut names: Vec<&str> = row_fields.iter().map(|f| f.source_col.as_str()).collect(); + if let Some(p) = vpos { + names.insert(p.min(names.len()), "Values"); + } + for (i, name) in names.into_iter().enumerate() { + g.set(r, i, Value::text(name), CellKind::Header, 0, None); + } + } + }; + + // ---- header rows + if cols.is_empty() { + let r = g.push_row(RowKind::Header)?; + row_header(&mut g, r); + for (j, (_, vi)) in data_cols.iter().enumerate() { + match vi { + Some(vi) => g.set(r, l + j, Value::text(val_name(*vi)), CellKind::Header, 0, None), + // Values on rows, no column field: the one data column is the grand total. + None if on_rows => g.set(r, l + j, Value::text("Total"), CellKind::Header, 0, None), + None => {} + } + } + } else { + let cap = g.push_row(RowKind::Header)?; + if l > 0 && k == 1 { + g.set(cap, 0, Value::text(val_name(0)), CellKind::Header, 0, None); + } + if headers_on { + let caption = if compact { "Column Labels".to_string() } else { col_fields.first().map(|f| f.source_col.clone()).unwrap_or_default() }; + g.set(cap, l, Value::text(caption), CellKind::Header, 0, None); + } + let levels = cols.len() + usize::from(k > 1 && !on_rows); + let first_level = g.rows.len(); + for _ in 0..levels { + g.push_row(RowKind::Header)?; + } + let mut prev: Option<&ColSpec> = None; + for (j, (si, vi)) in data_cols.iter().enumerate() { + let Some(spec) = specs.get(*si) else { continue }; + let c = l + j; + match spec.kind { + ColKind::Leaf => { + for (lv, &it) in spec.path.iter().enumerate() { + let same = prev.is_some_and(|p| p.kind != ColKind::Grand && p.path.get(..=lv) == spec.path.get(..=lv)); + if !same { + let Some(item) = col_items.get(lv).and_then(|fi| fi.get(it)) else { continue }; + let fmt = if item.formatted { cols.get(lv).map(|(cc, _)| fmt_of(*cc)) } else { None }; + g.set(first_level + lv, c, item.value.clone(), CellKind::ColLabel, 0, fmt); + } + } + if k > 1 + && let Some(vi) = vi + { + g.set(first_level + cols.len(), c, Value::text(val_name(*vi)), CellKind::ColLabel, 0, None); + } + } + ColKind::Subtotal => { + let lv = spec.path.len().saturating_sub(1); + let label = spec.path.last().and_then(|it| col_items.get(lv).map(|fi| fi.label(*it))).unwrap_or_default(); + let text = match vi { + Some(vi) if k > 1 => format!("{label} {}", val_name(*vi)), + _ => format!("{label} Total"), + }; + g.set(first_level + lv, c, Value::text(text), CellKind::ColLabel, 0, None); + } + ColKind::Grand => { + let text = match vi { + Some(vi) if k > 1 => format!("Total {}", val_name(*vi)), + _ => "Grand Total".to_string(), + }; + g.set(first_level, c, Value::text(text), CellKind::ColLabel, 0, None); + } + } + prev = Some(spec); + } + let last = g.rows.len() - 1; + row_header(&mut g, last); + } + + // ---- body + let get = |rp: &[u32], cp: &[u32], vi: usize| -> Option { + let (vs, v) = vals.get(vi)?; + let accs = aggs.get(&(rp.to_vec(), cp.to_vec()))?; + match vs { + ValSrc::Col(_) => accs.get(vi).map(|a| a.result(v.func)), + ValSrc::Calc(i) => { + let sum = |name: &str| -> Value { + src.col(name) + .and_then(|c| extra.iter().position(|x| *x == c)) + .and_then(|j| accs.get(k + j)) + .map(|a| a.result(PivotFunc::Sum)) + .unwrap_or(Value::Number(0.0)) + }; + Some(calc.value(wb, *i, &sum)) + } + } + }; + let num = |v: &Option| v.as_ref().and_then(Value::as_f64); + let ratio = |a: Value, b: Option| -> Value { + match (a, b) { + (Value::Error(e), _) => Value::Error(e), + (_, Some(Value::Error(e))) => Value::Error(e), + (Value::Number(x), Some(Value::Number(y))) => { + if y == 0.0 { + Value::Error(CellError::Div0) + } else { + Value::number(x / y) + } + } + (v, _) => v, + } + }; + let data_fmt: Vec> = vals + .iter() + .map(|(vs, v)| match v.show_as { + PivotShowAs::PercentOfGrandTotal | PivotShowAs::PercentOfColumnTotal | PivotShowAs::PercentOfRowTotal => { + Some(v.number_format.clone().unwrap_or_else(|| "0.00%".into())) + } + PivotShowAs::Rank => v.number_format.clone(), + _ => v.number_format.clone().or_else(|| match v.func { + PivotFunc::Count | PivotFunc::CountNumbers => None, + _ => { + let f = match vs { + ValSrc::Col(c) => fmt_of(*c), + ValSrc::Calc(_) => "General".to_string(), + }; + (f != "General").then_some(f) + } + }), + }) + .collect(); + let mut chart_rows: Vec = Vec::new(); + let chart_cols: Vec<(u32, String)> = data_cols + .iter() + .enumerate() + .filter_map(|(j, (si, vi))| { + let spec = specs.get(*si)?; + if spec.kind != ColKind::Leaf { + return None; + } + let mut parts: Vec = + spec.path.iter().enumerate().map(|(lv, it)| col_items.get(lv).map(|fi| fi.label(*it)).unwrap_or_default()).collect(); + if let Some(vi) = vi + && (k > 1 || parts.is_empty()) + { + parts.push(val_name(*vi)); + } else if parts.is_empty() { + parts.push("Total".into()); + } + Some(((l + j) as u32, parts.join(" - "))) + }) + .filter(|_| k > 0) + .collect(); + let label_col = if row_levels == 0 { + None + } else if compact { + Some(0) + } else { + Some(row_levels.saturating_sub(1) as u32) + }; + for line in &lines { + let rk = match line.kind { + LineKind::Item(b) => RowKind::Item(b), + LineKind::Subtotal => RowKind::Subtotal, + LineKind::Grand => RowKind::Grand, + }; + let ck = match line.kind { + LineKind::Item(_) => (CellKind::RowLabel, CellKind::Data), + LineKind::Subtotal => (CellKind::Subtotal, CellKind::Subtotal), + LineKind::Grand => (CellKind::GrandTotal, CellKind::GrandTotal), + }; + let r = g.push_row(rk)?; + if line.leaf && k > 0 { + chart_rows.push(r as u32); + } + for (c, v, indent, fmt) in &line.labels { + g.set(r, *c, v.clone(), ck.0, *indent, fmt.clone()); + } + if !line.values { + continue; + } + let rp = line.path.as_slice(); + for (j, (si, col_vi)) in data_cols.iter().enumerate() { + let line_vi = col_vi.or(line.vi); + let (Some(spec), Some(vi)) = (specs.get(*si), &line_vi) else { continue }; + let Some((_, vdef)) = vals.get(*vi) else { continue }; + let cp = spec.path.as_slice(); + let Some(raw) = get(rp, cp, *vi) else { continue }; + let shown = match vdef.show_as { + PivotShowAs::Normal => raw, + PivotShowAs::PercentOfGrandTotal => ratio(raw, get(&[], &[], *vi)), + PivotShowAs::PercentOfColumnTotal => ratio(raw, get(&[], cp, *vi)), + PivotShowAs::PercentOfRowTotal => ratio(raw, get(rp, &[], *vi)), + PivotShowAs::RunningTotal => match (rp.first(), &raw) { + (Some(first), Value::Number(_)) => { + let mut acc = 0.0; + for it in &level0 { + let mut p = rp.to_vec(); + if let Some(x) = p.first_mut() { + *x = *it; + } + acc += num(&get(&p, cp, *vi)).unwrap_or(0.0); + if it == first { + break; + } + } + Value::number(acc) + } + _ => raw, + }, + PivotShowAs::Rank => match (rp.split_last(), raw.as_f64()) { + (Some((_, parent)), Some(me)) => { + let sibs = siblings.get(parent).cloned().unwrap_or_default(); + let mut rank = 1u64; + for sib in sibs { + let mut p = parent.to_vec(); + p.push(sib); + if num(&get(&p, cp, *vi)).is_some_and(|x| x > me) { + rank += 1; + } + } + Value::number(rank as f64) + } + (None, _) => Value::Empty, + _ => raw, + }, + }; + 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()); + } + } + Ok(Output { width: g.width as u32, grid: g.rows, row_kinds: g.kinds, body_offset, chart_rows, chart_cols, label_col }) +} diff --git a/crates/engine/src/pivot/items.rs b/crates/engine/src/pivot/items.rs new file mode 100644 index 0000000000..2877d0ef05 --- /dev/null +++ b/crates/engine/src/pivot/items.rs @@ -0,0 +1,268 @@ +use super::*; + +// ---------------------------------------------------------------- items + +#[derive(Clone, Debug)] +pub(super) struct Item { + pub(super) value: Value, + pub(super) label: String, + class: u8, + num: f64, + text: String, + first: usize, + /// The value is a number shown with the source format. + pub(super) formatted: bool, +} + +#[derive(Default)] +pub(super) struct FieldItems { + items: Vec, + index: HashMap, +} + +fn cmp_items(a: &Item, b: &Item) -> Ordering { + a.class.cmp(&b.class).then(a.num.total_cmp(&b.num)).then_with(|| a.text.cmp(&b.text)) +} + +/// (identity key, item) for a source value under a field's date grouping. +fn make_item(wb: &Workbook, v: &Value, group: PivotDateGroup, fmt: &str, first: usize) -> (String, Item) { + let it = |value: Value, label: String, class: u8, num: f64, formatted: bool| Item { + text: label.to_lowercase(), + value, + label, + class, + num, + first, + formatted, + }; + match v { + Value::Empty => ("b".into(), it(Value::text("(blank)"), "(blank)".into(), 4, 0.0, false)), + Value::Number(n) => { + if group != PivotDateGroup::None + && let Some(dt) = datetime_from_serial(wb.date_system, *n) + { + let mon = MONTHS.get(dt.month.saturating_sub(1) as usize).copied().unwrap_or("Jan"); + let (label, key) = match group { + PivotDateGroup::Years => (dt.year.to_string(), dt.year as f64), + PivotDateGroup::Quarters => { + let q = (dt.month.saturating_sub(1)) / 3 + 1; + (format!("Qtr{q}"), q as f64) + } + PivotDateGroup::Months => (mon.to_string(), dt.month as f64), + _ => (format!("{}-{mon}", dt.day), (dt.month * 100 + dt.day) as f64), + }; + return (format!("g{key}"), it(Value::text(label.as_str()), label, 0, key, false)); + } + let label = crate::display::format(v, fmt, wb).text; + (format!("n{}", n.to_bits()), it(v.clone(), label, 0, *n, true)) + } + Value::Text(t) => (format!("t{}", t.to_lowercase()), it(v.clone(), t.to_string(), 1, 0.0, false)), + Value::Bool(b) => { + let l = if *b { "TRUE" } else { "FALSE" }; + (format!("l{l}"), it(Value::Bool(*b), l.into(), 2, *b as u8 as f64, false)) + } + Value::Error(e) => (format!("e{}", e.as_str()), it(v.clone(), e.as_str().into(), 3, 0.0, false)), + Value::Array(_) => make_item(wb, &v.scalar(), group, fmt, first), + } +} + +impl FieldItems { + pub(super) fn intern(&mut self, wb: &Workbook, v: &Value, group: PivotDateGroup, fmt: &str, first: usize) -> u32 { + let (key, item) = make_item(wb, v, group, fmt, first); + if let Some(i) = self.index.get(&key) { + return *i; + } + let i = self.items.len() as u32; + self.items.push(item); + self.index.insert(key, i); + i + } + pub(super) fn get(&self, i: u32) -> Option<&Item> { + self.items.get(i as usize) + } + pub(super) fn label(&self, i: u32) -> String { + self.get(i).map(|x| x.label.clone()).unwrap_or_default() + } +} + +/// The label of a source value under a date grouping (for filters and field lists). +pub fn item_label(wb: &Workbook, v: &Value, group: PivotDateGroup, fmt: &str) -> String { + make_item(wb, v, group, fmt, 0).1.label +} + +/// Distinct item labels of a source column, sorted like the field list shows them. +pub fn distinct_labels(wb: &Workbook, src: &Source, col: usize, group: PivotDateGroup, limit: usize) -> Vec { + let mut fi = FieldItems::default(); + let fmt = src.formats.get(col).map(String::as_str).unwrap_or("General"); + for (i, r) in src.rows.iter().enumerate() { + if let Some(v) = r.get(col) { + fi.intern(wb, v, group, fmt, i); + } + if fi.items.len() > 100_000 { + break; + } + } + let mut items = fi.items; + items.sort_by(cmp_items); + items.into_iter().take(limit).map(|i| i.label).collect() +} + +// ---------------------------------------------------------------- aggregation + +#[derive(Clone, Debug, Default)] +pub(super) struct Acc { + count: u64, + nums: u64, + sum: f64, + prod: f64, + min: f64, + max: f64, + mean: f64, + m2: f64, + err: Option, +} + +impl Acc { + pub(super) fn add(&mut self, v: &Value) { + match v { + Value::Empty => {} + Value::Number(n) => { + let n = *n; + self.count += 1; + self.nums += 1; + self.sum += n; + if self.nums == 1 { + self.prod = n; + self.min = n; + self.max = n; + } else { + self.prod *= n; + self.min = self.min.min(n); + self.max = self.max.max(n); + } + let d = n - self.mean; + self.mean += d / self.nums as f64; + self.m2 += d * (n - self.mean); + } + Value::Error(e) => { + self.count += 1; + if self.err.is_none() { + self.err = Some(*e); + } + } + _ => self.count += 1, + } + } + pub(super) fn result(&self, f: PivotFunc) -> Value { + let n = self.nums as f64; + match f { + PivotFunc::Count => return Value::number(self.count as f64), + PivotFunc::CountNumbers => return Value::number(n), + _ => {} + } + if let Some(e) = self.err { + return Value::Error(e); + } + let div0 = Value::Error(CellError::Div0); + match f { + PivotFunc::Sum => Value::number(self.sum), + PivotFunc::Average => { + if self.nums == 0 { + div0 + } else { + Value::number(self.sum / n) + } + } + PivotFunc::Max => Value::number(if self.nums == 0 { 0.0 } else { self.max }), + PivotFunc::Min => Value::number(if self.nums == 0 { 0.0 } else { self.min }), + PivotFunc::Product => Value::number(if self.nums == 0 { 0.0 } else { self.prod }), + PivotFunc::StdDev | PivotFunc::Var => { + if self.nums < 2 { + div0 + } else { + let var = (self.m2 / (n - 1.0)).max(0.0); + Value::number(if f == PivotFunc::Var { var } else { var.sqrt() }) + } + } + PivotFunc::StdDevP | PivotFunc::VarP => { + if self.nums < 1 { + div0 + } else { + let var = (self.m2 / n).max(0.0); + Value::number(if f == PivotFunc::VarP { var } else { var.sqrt() }) + } + } + PivotFunc::Count | PivotFunc::CountNumbers => Value::Empty, + } + } +} + +/// Excel's caption verb for a function ("Sum of Sales"). +pub fn func_caption(f: PivotFunc) -> &'static str { + match f { + PivotFunc::Sum => "Sum", + PivotFunc::Count => "Count", + PivotFunc::Average => "Average", + PivotFunc::Max => "Max", + PivotFunc::Min => "Min", + PivotFunc::Product => "Product", + PivotFunc::CountNumbers => "Count Numbers", + PivotFunc::StdDev => "StdDev", + PivotFunc::StdDevP => "StdDevp", + PivotFunc::Var => "Var", + PivotFunc::VarP => "Varp", + } +} + +// ---------------------------------------------------------------- trees + +#[derive(Clone, Debug)] +pub(super) struct Node { + pub(super) item: u32, + pub(super) children: Vec, +} + +pub(super) fn build_tree(paths: &[&[u32]], depth: usize, fields: &[&PivotField], items: &[FieldItems]) -> Vec { + let (Some(field), Some(fi)) = (fields.get(depth), items.get(depth)) else { return vec![] }; + let mut order: Vec = Vec::new(); + let mut groups: HashMap> = HashMap::new(); + for p in paths { + if let Some(&it) = p.get(depth) { + let g = groups.entry(it).or_default(); + if g.is_empty() { + order.push(it); + } + g.push(p); + } + } + let mut nodes: Vec = order + .into_iter() + .map(|it| { + let ps = groups.remove(&it).unwrap_or_default(); + Node { item: it, children: build_tree(&ps, depth + 1, fields, items) } + }) + .collect(); + let by = |a: &Node, b: &Node| match (fi.get(a.item), fi.get(b.item)) { + (Some(x), Some(y)) => match field.sort { + PivotSort::Asc => cmp_items(x, y), + PivotSort::Desc => cmp_items(y, x), + PivotSort::None => x.first.cmp(&y.first), + }, + _ => Ordering::Equal, + }; + nodes.sort_by(by); + nodes +} + +pub(super) fn is_collapsed(field: &PivotField, label: &str) -> bool { + field.collapsed_items.iter().any(|c| c.eq_ignore_ascii_case(label)) +} + +pub(super) fn collect_siblings(nodes: &[Node], prefix: &mut Vec, out: &mut HashMap, Vec>) { + out.insert(prefix.clone(), nodes.iter().map(|n| n.item).collect()); + for n in nodes { + prefix.push(n.item); + collect_siblings(&n.children, prefix, out); + prefix.pop(); + } +} diff --git a/crates/engine/src/pivot/output.rs b/crates/engine/src/pivot/output.rs new file mode 100644 index 0000000000..9dbf8836c2 --- /dev/null +++ b/crates/engine/src/pivot/output.rs @@ -0,0 +1,437 @@ +use super::items::{Acc, FieldItems, Node, is_collapsed}; +use super::*; + +// ---------------------------------------------------------------- output + +/// What a report cell is. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +pub enum CellKind { + Empty, + Header, + RowLabel, + ColLabel, + Data, + Subtotal, + GrandTotal, + Filter, + Placeholder, +} + +/// What a report row is (for row-wide styling). +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +pub enum RowKind { + Filter, + Blank, + Header, + /// Item row; `true` for a parent item (bold). + Item(bool), + Subtotal, + Grand, + Placeholder, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct OutCell { + pub value: Value, + pub kind: CellKind, + pub indent: u8, + pub num_fmt: Option, +} + +impl OutCell { + fn empty() -> OutCell { + OutCell { value: Value::Empty, kind: CellKind::Empty, indent: 0, num_fmt: None } + } +} + +/// A computed report: a dense grid relative to its top-left, with row kinds. +#[derive(Clone, Debug)] +pub struct Output { + pub grid: Vec>, + pub row_kinds: Vec, + /// Rows above the body (report filters plus a blank row). + pub body_offset: u32, + pub width: u32, + /// Grid rows of the innermost (or collapsed) items: a PivotChart's categories. + pub chart_rows: Vec, + /// Grid columns of item (not total) values with their series names: a PivotChart's series. + pub chart_cols: Vec<(u32, String)>, + /// Grid column holding the innermost row labels (`None` without row fields). + pub label_col: Option, +} + +impl Output { + pub fn height(&self) -> u32 { + self.grid.len() as u32 + } + /// Value at (row, col) relative to the top-left. + pub fn value(&self, r: usize, c: usize) -> Value { + self.grid.get(r).and_then(|row| row.get(c)).map(|x| x.value.clone()).unwrap_or_default() + } +} + +pub(super) struct Grid { + pub(super) rows: Vec>, + pub(super) kinds: Vec, + pub(super) width: usize, +} + +impl Grid { + pub(super) fn push_row(&mut self, kind: RowKind) -> Result { + if (self.rows.len() as u64 + 1) * self.width.max(1) as u64 > MAX_OUTPUT_CELLS || self.rows.len() as u64 >= MAX_ROWS as u64 { + return Err("The PivotTable report is too large.".into()); + } + self.rows.push(vec![OutCell::empty(); self.width]); + self.kinds.push(kind); + Ok(self.rows.len() - 1) + } + pub(super) fn set(&mut self, r: usize, c: usize, value: Value, kind: CellKind, indent: u8, fmt: Option) { + if let Some(cell) = self.rows.get_mut(r).and_then(|row| row.get_mut(c)) { + *cell = OutCell { value, kind, indent, num_fmt: fmt }; + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum LineKind { + Item(bool), + Subtotal, + Grand, +} + +pub(super) struct Line { + pub(super) kind: LineKind, + /// A category of a PivotChart: an innermost (or collapsed) item. + pub(super) leaf: bool, + pub(super) path: Vec, + pub(super) values: bool, + /// (column, value, indent, number format) + pub(super) labels: Vec<(usize, Value, u8, Option)>, + /// The value field of the line when Σ Values sits in Rows (`None`: one per data column). + pub(super) vi: Option, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum ColKind { + Leaf, + Subtotal, + Grand, +} + +pub(super) struct ColSpec { + pub(super) kind: ColKind, + pub(super) path: Vec, +} + +pub(super) struct Ctx<'a> { + pub(super) pt: &'a PivotTable, + pub(super) rows: Vec<&'a PivotField>, + pub(super) row_items: Vec, + pub(super) row_fmts: Vec, + /// Depth of Σ Values among the row fields when it sits in Rows (two or more value fields). + pub(super) vpos: Option, + /// Value field captions. + pub(super) val_names: Vec, +} + +impl Ctx<'_> { + fn label_cell(&self, depth: usize, item: u32) -> (Value, Option, String) { + let Some(it) = self.row_items.get(depth).and_then(|fi| fi.get(item)) else { return (Value::Empty, None, String::new()) }; + let fmt = if it.formatted { self.row_fmts.get(depth).cloned() } else { None }; + (it.value.clone(), fmt, it.label.clone()) + } + + /// (column, indent) of a row level (Σ Values counts as a level) in Compact/Outline form. + fn level_cell(&self, level: usize) -> (usize, u8) { + if self.pt.layout == PivotLayout::Outline { (level, 0) } else { (0, level.min(250) as u8) } + } + + /// Σ Values comes right here, at row field depth `depth` (`vi`: the value field already chosen above). + fn values_here(&self, depth: usize, vi: Option) -> bool { + vi.is_none() && self.vpos == Some(depth) + } + + /// Per-value subtotal lines of an item with Σ Values below it ("East Sum of Sales"). + fn value_subtotals(&self, col: usize, indent: u8, label: &str, prefix: &[u32], out: &mut Vec) { + for (vi, name) in self.val_names.iter().enumerate() { + out.push(Line { + kind: LineKind::Subtotal, + leaf: false, + path: prefix.to_vec(), + values: true, + labels: vec![(col, Value::text(format!("{label} {name}")), indent, None)], + vi: Some(vi), + }); + } + } + + /// The Σ Values level at row field depth `depth`: one item per value field, each followed by + /// the row fields below it (`nodes`) or, innermost, holding that value. + fn flatten_values(&self, nodes: &[Node], depth: usize, prefix: &mut Vec, out: &mut Vec) { + let (col, indent) = self.level_cell(depth); + for (vi, name) in self.val_names.iter().enumerate() { + let labels = vec![(col, Value::text(name.as_str()), indent, None)]; + let leaf = nodes.is_empty(); + out.push(Line { kind: LineKind::Item(!leaf), leaf, path: prefix.clone(), values: leaf, labels, vi: Some(vi) }); + if !leaf { + self.flatten(nodes, depth, Some(vi), prefix, out); + } + } + } + + pub(super) fn flatten(&self, nodes: &[Node], depth: usize, vi: Option, prefix: &mut Vec, out: &mut Vec) { + if self.values_here(depth, vi) { + self.flatten_values(nodes, depth, prefix, out); + return; + } + let n = self.rows.len(); + // Σ Values still to come below this level: totals need one line per value field. + let values_below = vi.is_none() && self.vpos.is_some(); + let (col, indent) = self.level_cell(depth + usize::from(vi.is_some() && self.vpos.is_some())); + for node in nodes { + prefix.push(node.item); + let (v, fmt, label) = self.label_cell(depth, node.item); + let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label)); + let leaf = depth + 1 >= n || collapsed || node.children.is_empty(); + if leaf && !values_below { + out.push(Line { + kind: LineKind::Item(depth + 1 < n), + leaf: true, + path: prefix.clone(), + values: true, + labels: vec![(col, v, indent, fmt)], + vi, + }); + } else if leaf { + out.push(Line { + kind: LineKind::Item(true), + leaf: false, + path: prefix.clone(), + values: false, + labels: vec![(col, v, indent, fmt)], + vi, + }); + // Outline gives each level its own column, so the captions go in the Values + // column even when the levels between are collapsed away. + let level = if self.pt.layout == PivotLayout::Outline { self.vpos.unwrap_or(depth + 1) } else { depth + 1 }; + self.flatten_values(&[], level, prefix, out); + } else if values_below { + // Subtotals per value field, below the group (one line can't hold them all). + out.push(Line { + kind: LineKind::Item(true), + leaf: false, + path: prefix.clone(), + values: false, + labels: vec![(col, v, indent, fmt)], + vi, + }); + self.flatten(&node.children, depth + 1, None, prefix, out); + if self.pt.subtotals { + self.value_subtotals(col, indent, &label, prefix, out); + } + } else { + let top = self.pt.subtotals && self.pt.subtotals_top; + out.push(Line { + kind: LineKind::Item(true), + leaf: false, + path: prefix.clone(), + values: top, + labels: vec![(col, v, indent, fmt)], + vi, + }); + self.flatten(&node.children, depth + 1, vi, prefix, out); + if self.pt.subtotals && !self.pt.subtotals_top { + out.push(Line { + kind: LineKind::Subtotal, + leaf: false, + path: prefix.clone(), + values: true, + labels: vec![(col, Value::text(format!("{label} Total")), indent, None)], + vi, + }); + } + } + prefix.pop(); + } + } + + /// Tabular form of [`Self::flatten_values`]: the first line also carries the parents' labels. + fn flatten_tabular_values( + &self, + nodes: &[Node], + depth: usize, + prefix: &mut Vec, + pending: &[(usize, Value, u8, Option)], + out: &mut Vec, + ) { + for (vi, name) in self.val_names.iter().enumerate() { + let mut labels: Vec<(usize, Value, u8, Option)> = if vi == 0 { pending.to_vec() } else { vec![] }; + labels.push((depth, Value::text(name.as_str()), 0, None)); + if nodes.is_empty() { + out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels, vi: Some(vi) }); + } else { + self.flatten_tabular(nodes, depth, Some(vi), prefix, &labels, out); + } + } + } + + pub(super) fn flatten_tabular( + &self, + nodes: &[Node], + depth: usize, + vi: Option, + prefix: &mut Vec, + pending: &[(usize, Value, u8, Option)], + out: &mut Vec, + ) { + if self.values_here(depth, vi) { + self.flatten_tabular_values(nodes, depth, prefix, pending, out); + return; + } + let n = self.rows.len(); + let values_below = vi.is_none() && self.vpos.is_some(); + let col = depth + usize::from(vi.is_some() && self.vpos.is_some()); + for (i, node) in nodes.iter().enumerate() { + prefix.push(node.item); + let (v, fmt, label) = self.label_cell(depth, node.item); + let mut labels: Vec<(usize, Value, u8, Option)> = if i == 0 { pending.to_vec() } else { vec![] }; + labels.push((col, v, 0, fmt)); + let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label)); + if depth + 1 >= n || collapsed || node.children.is_empty() { + if values_below { + self.flatten_tabular_values(&[], self.vpos.unwrap_or(depth + 1), prefix, &labels, out); + } else { + out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels, vi }); + } + } else { + self.flatten_tabular(&node.children, depth + 1, vi, prefix, &labels, out); + if self.pt.subtotals && values_below { + self.value_subtotals(col, 0, &label, prefix, out); + } else if self.pt.subtotals { + out.push(Line { + kind: LineKind::Subtotal, + leaf: false, + path: prefix.clone(), + values: true, + labels: vec![(col, Value::text(format!("{label} Total")), 0, None)], + vi, + }); + } + } + prefix.pop(); + } + } +} + +pub(super) fn flatten_cols( + nodes: &[Node], + depth: usize, + fields: &[&PivotField], + items: &[FieldItems], + subtotals: bool, + prefix: &mut Vec, + out: &mut Vec, +) { + let n = fields.len(); + for node in nodes { + prefix.push(node.item); + let label = items.get(depth).map(|fi| fi.label(node.item)).unwrap_or_default(); + let collapsed = depth + 1 < n && fields.get(depth).is_some_and(|f| is_collapsed(f, &label)); + if depth + 1 >= n || collapsed || node.children.is_empty() { + out.push(ColSpec { kind: ColKind::Leaf, path: prefix.clone() }); + } else { + flatten_cols(&node.children, depth + 1, fields, items, subtotals, prefix, out); + if subtotals { + out.push(ColSpec { kind: ColKind::Subtotal, path: prefix.clone() }); + } + } + prefix.pop(); + } +} + +pub(super) type AggMap = HashMap<(Vec, Vec), Vec>; + +/// Where a value field's numbers come from. +#[derive(Clone, Copy)] +pub(super) enum ValSrc { + /// A source column. + Col(usize), + /// A calculated field (index into the PivotTable's calculated fields). + Calc(usize), +} + +/// True when `col_groups` distinct column leaves (plus the grand-total column) times `values` value fields +/// (at least one data column per group) already exceed the sheet width. This is a lower bound on the final column count. +pub(super) fn data_cols_exceed_sheet(col_groups: usize, grand_total: bool, values: usize) -> bool { + (col_groups + usize::from(grand_total)).saturating_mul(values.max(1)) > MAX_COLS as usize +} + +// ---------------------------------------------------------------- styling and writing + +/// Colours for a PivotTable style name (our own palette, keyed by the familiar +/// `PivotStyleLight1…28 / Medium1…28 / Dark1…28` names). +pub(super) struct Look { + header_fill: Color, + header_font: Color, + line: Color, + filter_fill: Color, +} + +pub(super) fn look(style: &str) -> Look { + let rest = style.strip_prefix("PivotStyle").unwrap_or(style); + let (fam, n) = ["Light", "Medium", "Dark"] + .iter() + .find_map(|f| rest.strip_prefix(f).map(|num| (*f, num.parse::().unwrap_or(16)))) + .unwrap_or(("Light", 16)); + let k = n.saturating_sub(1) % 7; + let acc = if k == 0 { None } else { Some(3 + k as u8) }; + let tone = |tint: i16| match acc { + Some(a) => Color::Theme(a, tint), + None => Color::Theme(1, tint.clamp(-1000, 1000)), + }; + match fam { + "Dark" => Look { header_fill: Color::Theme(1, 0), header_font: Color::Theme(0, 0), line: tone(0), filter_fill: tone(600) }, + "Medium" => Look { header_fill: tone(0), header_font: Color::Theme(0, 0), line: tone(0), filter_fill: tone(800) }, + _ => Look { header_fill: tone(800), header_font: Color::Auto, line: tone(400), filter_fill: tone(800) }, + } +} + +pub(super) fn cell_style(lk: &Look, rk: RowKind, cell: &OutCell, last_header: bool) -> Style { + let mut s = Style::default(); + match rk { + RowKind::Header => { + s.font.bold = true; + s.fill = Fill::solid(lk.header_fill); + s.font.color = lk.header_font; + if last_header { + s.borders.bottom = BorderLine { style: BorderStyle::Thin, color: lk.line }; + } + } + RowKind::Filter => { + s.fill = Fill::solid(lk.filter_fill); + s.font.bold = last_header; + } + RowKind::Item(parent) => s.font.bold = parent, + RowKind::Subtotal => s.font.bold = true, + RowKind::Grand => { + s.font.bold = true; + s.borders.top = BorderLine { style: BorderStyle::Thin, color: lk.line }; + } + RowKind::Placeholder => { + s.font.italic = true; + s.font.color = Color::Theme(1, 500); + } + RowKind::Blank => {} + } + if matches!(cell.kind, CellKind::RowLabel | CellKind::ColLabel | CellKind::Subtotal | CellKind::GrandTotal) && !cell.value.is_number() { + s.align.h = HAlign::Left; + } + if matches!(cell.kind, CellKind::RowLabel | CellKind::ColLabel) && cell.value.is_number() { + s.align.h = HAlign::Left; + } + s.align.indent = cell.indent.min(15); + if let Some(f) = &cell.num_fmt { + s.num_fmt = NumFmt::new(f); + } + s +} diff --git a/crates/functions/src/engineering.rs b/crates/functions/src/engineering.rs index 86ab7d7c76..ed2572f906 100644 --- a/crates/functions/src/engineering.rs +++ b/crates/functions/src/engineering.rs @@ -197,283 +197,6 @@ fn bitrshift(a: &[Arg], _c: &mut dyn Ctx) -> R { shift(a, false) } -// --------------------------------------------------------------------------------------------- -// CONVERT - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum Dim { - Mass, - Length, - Time, - Pressure, - Force, - Energy, - Power, - Magnetism, - Temperature, - Volume, - Area, - Information, - Speed, -} - -/// How a unit accepts prefixes: none, SI (raised to `power` for areas/volumes) or SI + binary. -#[derive(Clone, Copy, PartialEq, Eq)] -enum Pre { - No, - Si(i32), - Bin, -} - -struct Unit { - names: &'static [&'static str], - dim: Dim, - factor: f64, - pre: Pre, -} - -const fn u(names: &'static [&'static str], dim: Dim, factor: f64, pre: Pre) -> Unit { - Unit { names, dim, factor, pre } -} - -const IN: f64 = 0.0254; -const FT: f64 = 0.3048; -const YD: f64 = 0.9144; -const MI: f64 = 1609.344; -const NMI: f64 = 1852.0; -const LY: f64 = 9_460_730_472_580_800.0; -const PICA_PT: f64 = 0.0254 / 72.0; -const PICA: f64 = 0.0254 / 6.0; -const GAL: f64 = 0.003_785_411_784; - -const UNITS: &[Unit] = &[ - // Mass (gram) - u(&["g"], Dim::Mass, 1.0, Pre::Si(1)), - u(&["sg"], Dim::Mass, 14_593.902_937_206_4, Pre::No), - u(&["lbm"], Dim::Mass, 453.592_37, Pre::No), - u(&["u"], Dim::Mass, 1.660_539_066_6e-24, Pre::Si(1)), - u(&["ozm"], Dim::Mass, 28.349_523_125, Pre::No), - u(&["grain"], Dim::Mass, 0.064_798_91, Pre::No), - u(&["cwt", "shweight"], Dim::Mass, 45_359.237, Pre::No), - u(&["uk_cwt", "lcwt", "hweight"], Dim::Mass, 50_802.345_44, Pre::No), - u(&["stone"], Dim::Mass, 6_350.293_18, Pre::No), - u(&["ton"], Dim::Mass, 907_184.74, Pre::No), - u(&["uk_ton", "LTON", "brton"], Dim::Mass, 1_016_046.908_8, Pre::No), - // Length (metre) - u(&["m"], Dim::Length, 1.0, Pre::Si(1)), - u(&["mi"], Dim::Length, MI, Pre::No), - u(&["Nmi"], Dim::Length, NMI, Pre::No), - u(&["in"], Dim::Length, IN, Pre::No), - u(&["ft"], Dim::Length, FT, Pre::No), - u(&["yd"], Dim::Length, YD, Pre::No), - u(&["ang"], Dim::Length, 1e-10, Pre::Si(1)), - u(&["ell"], Dim::Length, 1.143, Pre::No), - u(&["ly"], Dim::Length, LY, Pre::Si(1)), - u(&["parsec", "pc"], Dim::Length, 30_856_775_814_913_673.0, Pre::Si(1)), - u(&["Picapt", "Pica"], Dim::Length, PICA_PT, Pre::No), - u(&["pica"], Dim::Length, PICA, Pre::No), - u(&["survey_mi"], Dim::Length, 1_609.347_218_694_437, Pre::No), - // Time (second) - u(&["yr"], Dim::Time, 31_557_600.0, Pre::No), - u(&["day", "d"], Dim::Time, 86_400.0, Pre::No), - u(&["hr"], Dim::Time, 3_600.0, Pre::No), - u(&["mn", "min"], Dim::Time, 60.0, Pre::No), - u(&["sec", "s"], Dim::Time, 1.0, Pre::Si(1)), - // Pressure (pascal) - u(&["Pa", "p"], Dim::Pressure, 1.0, Pre::Si(1)), - u(&["atm", "at"], Dim::Pressure, 101_325.0, Pre::Si(1)), - u(&["mmHg"], Dim::Pressure, 133.322_387_415, Pre::Si(1)), - u(&["psi"], Dim::Pressure, 6_894.757_293_168_36, Pre::No), - u(&["Torr"], Dim::Pressure, 101_325.0 / 760.0, Pre::No), - // Force (newton) - u(&["N"], Dim::Force, 1.0, Pre::Si(1)), - u(&["dyn", "dy"], Dim::Force, 1e-5, Pre::Si(1)), - u(&["lbf"], Dim::Force, 4.448_221_615_260_5, Pre::No), - u(&["pond"], Dim::Force, 0.009_806_65, Pre::Si(1)), - // Energy (joule) - u(&["J"], Dim::Energy, 1.0, Pre::Si(1)), - u(&["e"], Dim::Energy, 1e-7, Pre::Si(1)), - u(&["c"], Dim::Energy, 4.184, Pre::Si(1)), - u(&["cal"], Dim::Energy, 4.1868, Pre::Si(1)), - u(&["eV", "ev"], Dim::Energy, 1.602_176_634e-19, Pre::Si(1)), - u(&["HPh", "hh"], Dim::Energy, 2_684_519.537_696_17, Pre::No), - u(&["Wh", "wh"], Dim::Energy, 3_600.0, Pre::Si(1)), - u(&["flb"], Dim::Energy, 1.355_817_948_331_4, Pre::No), - u(&["BTU", "btu"], Dim::Energy, 1_055.055_852_62, Pre::No), - // Power (watt) - u(&["W", "w"], Dim::Power, 1.0, Pre::Si(1)), - u(&["HP", "h"], Dim::Power, 745.699_871_582_270_2, Pre::No), - u(&["PS"], Dim::Power, 735.498_75, Pre::No), - // Magnetism (tesla) - u(&["T"], Dim::Magnetism, 1.0, Pre::Si(1)), - u(&["ga"], Dim::Magnetism, 1e-4, Pre::Si(1)), - // Temperature (handled specially; factor unused) - u(&["C", "cel"], Dim::Temperature, 1.0, Pre::No), - u(&["F", "fah"], Dim::Temperature, 1.0, Pre::No), - u(&["K", "kel"], Dim::Temperature, 1.0, Pre::Si(1)), - u(&["Rank"], Dim::Temperature, 1.0, Pre::No), - u(&["Reau"], Dim::Temperature, 1.0, Pre::No), - // Volume (cubic metre) - u(&["l", "L", "lt"], Dim::Volume, 0.001, Pre::Si(1)), - u(&["tsp"], Dim::Volume, GAL / 768.0, Pre::No), - u(&["tspm"], Dim::Volume, 5e-6, Pre::No), - u(&["tbs"], Dim::Volume, GAL / 256.0, Pre::No), - u(&["oz"], Dim::Volume, GAL / 128.0, Pre::No), - u(&["cup"], Dim::Volume, GAL / 16.0, Pre::No), - u(&["pt", "us_pt"], Dim::Volume, GAL / 8.0, Pre::No), - u(&["uk_pt"], Dim::Volume, 0.000_568_261_25, Pre::No), - u(&["qt"], Dim::Volume, GAL / 4.0, Pre::No), - u(&["uk_qt"], Dim::Volume, 0.001_136_522_5, Pre::No), - u(&["gal"], Dim::Volume, GAL, Pre::No), - u(&["uk_gal"], Dim::Volume, 0.004_546_09, Pre::No), - u(&["m3"], Dim::Volume, 1.0, Pre::Si(3)), - u(&["mi3"], Dim::Volume, MI * MI * MI, Pre::No), - u(&["Nmi3"], Dim::Volume, NMI * NMI * NMI, Pre::No), - u(&["yd3"], Dim::Volume, YD * YD * YD, Pre::No), - u(&["ft3"], Dim::Volume, FT * FT * FT, Pre::No), - u(&["in3"], Dim::Volume, IN * IN * IN, Pre::No), - u(&["ang3"], Dim::Volume, 1e-30, Pre::Si(3)), - u(&["ly3"], Dim::Volume, LY * LY * LY, Pre::No), - u(&["Picapt3", "Pica3"], Dim::Volume, PICA_PT * PICA_PT * PICA_PT, Pre::No), - u(&["barrel"], Dim::Volume, 0.158_987_294_928, Pre::No), - u(&["bushel"], Dim::Volume, 0.035_239_070_166_88, Pre::No), - u(&["MTON"], Dim::Volume, 1.132_673_863_68, Pre::No), - u(&["GRT", "regton"], Dim::Volume, 2.831_684_659_2, Pre::No), - // Area (square metre) - u(&["m2"], Dim::Area, 1.0, Pre::Si(2)), - u(&["ha"], Dim::Area, 10_000.0, Pre::No), - u(&["ar"], Dim::Area, 100.0, Pre::Si(1)), - u(&["ft2"], Dim::Area, FT * FT, Pre::No), - u(&["in2"], Dim::Area, IN * IN, Pre::No), - u(&["yd2"], Dim::Area, YD * YD, Pre::No), - u(&["mi2"], Dim::Area, MI * MI, Pre::No), - u(&["Nmi2"], Dim::Area, NMI * NMI, Pre::No), - u(&["ang2"], Dim::Area, 1e-20, Pre::Si(2)), - u(&["ly2"], Dim::Area, LY * LY, Pre::No), - u(&["Picapt2", "Pica2"], Dim::Area, PICA_PT * PICA_PT, Pre::No), - u(&["uk_acre"], Dim::Area, 4_046.856_422_4, Pre::No), - u(&["us_acre"], Dim::Area, 4_046.872_609_874_252, Pre::No), - u(&["Morgen"], Dim::Area, 2_500.0, Pre::No), - // Information (bit) - u(&["bit"], Dim::Information, 1.0, Pre::Bin), - u(&["byte"], Dim::Information, 8.0, Pre::Bin), - // Speed (metres per second) - u(&["m/s", "m/sec"], Dim::Speed, 1.0, Pre::Si(1)), - u(&["m/h", "m/hr"], Dim::Speed, 1.0 / 3600.0, Pre::Si(1)), - u(&["mph"], Dim::Speed, MI / 3600.0, Pre::No), - u(&["kn"], Dim::Speed, NMI / 3600.0, Pre::No), - u(&["admkn"], Dim::Speed, 6080.0 * FT / 3600.0, Pre::No), -]; - -const SI_PREFIXES: &[(&str, f64)] = &[ - ("da", 1e1), - ("Y", 1e24), - ("Z", 1e21), - ("E", 1e18), - ("P", 1e15), - ("T", 1e12), - ("G", 1e9), - ("M", 1e6), - ("k", 1e3), - ("h", 1e2), - ("e", 1e1), - ("d", 1e-1), - ("c", 1e-2), - ("m", 1e-3), - ("u", 1e-6), - ("n", 1e-9), - ("p", 1e-12), - ("f", 1e-15), - ("a", 1e-18), - ("z", 1e-21), - ("y", 1e-24), -]; - -const BIN_PREFIXES: &[(&str, f64)] = &[ - ("ki", 1024.0), - ("Mi", 1_048_576.0), - ("Gi", 1_073_741_824.0), - ("Ti", 1_099_511_627_776.0), - ("Pi", 1_125_899_906_842_624.0), - ("Ei", 1_152_921_504_606_846_976.0), - ("Zi", 1_180_591_620_717_411_303_424.0), - ("Yi", 1_208_925_819_614_629_174_706_176.0), -]; - -fn find_unit(name: &str) -> Option<&'static Unit> { - UNITS.iter().find(|u| u.names.contains(&name)) -} - -/// Resolves a unit name to (unit, prefix multiplier). Exact names win over prefixed ones. -fn resolve(name: &str) -> Option<(&'static Unit, f64)> { - let name = name.replace("^2", "2").replace("^3", "3"); - if let Some(u) = find_unit(&name) { - return Some((u, 1.0)); - } - for (p, f) in BIN_PREFIXES { - if let Some(rest) = name.strip_prefix(p) - && let Some(u) = find_unit(rest) - && u.pre == Pre::Bin - { - return Some((u, *f)); - } - } - for (p, f) in SI_PREFIXES { - if let Some(rest) = name.strip_prefix(p) - && let Some(u) = find_unit(rest) - { - match u.pre { - Pre::Si(pow) => return Some((u, f.powi(pow))), - Pre::Bin => return Some((u, *f)), - Pre::No => {} - } - } - } - None -} - -fn to_kelvin(name: &str, x: f64) -> f64 { - match name { - "C" | "cel" => x + 273.15, - "F" | "fah" => (x - 32.0) * 5.0 / 9.0 + 273.15, - "Rank" => x * 5.0 / 9.0, - "Reau" => x * 1.25 + 273.15, - _ => x, - } -} - -fn from_kelvin(name: &str, k: f64) -> f64 { - match name { - "C" | "cel" => k - 273.15, - "F" | "fah" => (k - 273.15) * 9.0 / 5.0 + 32.0, - "Rank" => k * 9.0 / 5.0, - "Reau" => (k - 273.15) / 1.25, - _ => k, - } -} - -fn convert(a: &[Arg], _c: &mut dyn Ctx) -> R { - let x = num(a, 0)?; - let from = text(a, 1)?; - let to = text(a, 2)?; - let (fu, fp) = resolve(&from).ok_or(CellError::NA)?; - let (tu, tp) = resolve(&to).ok_or(CellError::NA)?; - if fu.dim != tu.dim { - return Err(CellError::NA); - } - if fu.dim == Dim::Temperature { - let fname = fu.names.first().copied().unwrap_or("K"); - let tname = tu.names.first().copied().unwrap_or("K"); - if fname == tname && fp == tp { - return num_val(x); - } - let k = to_kelvin(fname, x * fp); - return num_val(from_kelvin(tname, k) / tp); - } - let base = x * fu.factor * fp; - num_val(base / (tu.factor * tp)) -} - // --------------------------------------------------------------------------------------------- // DELTA, GESTEP, ERF @@ -502,201 +225,6 @@ fn erfc_fn(a: &[Arg], _c: &mut dyn Ctx) -> R { num_val(erfc(num(a, 0)?)) } -// --------------------------------------------------------------------------------------------- -// Complex numbers - -#[derive(Clone, Copy, Debug, PartialEq)] -struct Cx { - re: f64, - im: f64, -} - -impl Cx { - fn new(re: f64, im: f64) -> Cx { - Cx { re, im } - } - fn add(self, o: Cx) -> Cx { - Cx::new(self.re + o.re, self.im + o.im) - } - fn sub(self, o: Cx) -> Cx { - Cx::new(self.re - o.re, self.im - o.im) - } - fn mul(self, o: Cx) -> Cx { - Cx::new(self.re * o.re - self.im * o.im, self.re * o.im + self.im * o.re) - } - fn div(self, o: Cx) -> R { - let d = o.re * o.re + o.im * o.im; - if d == 0.0 { - return Err(CellError::Num); - } - Ok(Cx::new((self.re * o.re + self.im * o.im) / d, (self.im * o.re - self.re * o.im) / d)) - } - fn abs(self) -> f64 { - self.re.hypot(self.im) - } - fn arg(self) -> f64 { - self.im.atan2(self.re) - } - fn exp(self) -> Cx { - let e = self.re.exp(); - Cx::new(e * self.im.cos(), e * self.im.sin()) - } - fn ln(self) -> R { - if self.re == 0.0 && self.im == 0.0 { - return Err(CellError::Num); - } - Ok(Cx::new(self.abs().ln(), self.arg())) - } - fn sin(self) -> Cx { - Cx::new(self.re.sin() * self.im.cosh(), self.re.cos() * self.im.sinh()) - } - fn cos(self) -> Cx { - Cx::new(self.re.cos() * self.im.cosh(), -self.re.sin() * self.im.sinh()) - } - fn sinh(self) -> Cx { - Cx::new(self.re.sinh() * self.im.cos(), self.re.cosh() * self.im.sin()) - } - fn cosh(self) -> Cx { - Cx::new(self.re.cosh() * self.im.cos(), self.re.sinh() * self.im.sin()) - } - fn powf(self, n: f64) -> R { - if self.re == 0.0 && self.im == 0.0 { - return if n > 0.0 { Ok(Cx::new(0.0, 0.0)) } else { Err(CellError::Num) }; - } - let r = self.abs().powf(n); - let t = self.arg() * n; - Ok(Cx::new(r * t.cos(), r * t.sin())) - } -} - -const ONE: Cx = Cx { re: 1.0, im: 0.0 }; - -/// Parses a real number written in a complex string (no spaces, optional exponent). -fn parse_real(s: &str) -> Option { - if s.is_empty() || s.contains(char::is_whitespace) { - return None; - } - let ok = s.chars().all(|c| c.is_ascii_digit() || matches!(c, '.' | 'e' | 'E' | '+' | '-')); - if !ok { - return None; - } - s.parse::().ok().filter(|v| v.is_finite()) -} - -/// Parses "a+bi" forms. Returns the number and its suffix ('i', 'j' or none). -fn parse_complex(s: &str) -> R<(Cx, Option)> { - if s.is_empty() { - return Ok((Cx::new(0.0, 0.0), None)); - } - let suffix = match s.chars().last() { - Some(c @ ('i' | 'j')) => Some(c), - _ => None, - }; - let Some(sfx) = suffix else { - return parse_real(s).map(|r| (Cx::new(r, 0.0), None)).ok_or(CellError::Num); - }; - let body = &s[..s.len() - 1]; - // Split at the last sign that is not at the start and not part of an exponent. - let bytes = body.as_bytes(); - let mut split = None; - for i in (1..bytes.len()).rev() { - let c = bytes.get(i).copied().unwrap_or(0); - let prev = bytes.get(i - 1).copied().unwrap_or(0); - if (c == b'+' || c == b'-') && prev != b'e' && prev != b'E' { - split = Some(i); - break; - } - } - let (re_s, im_s) = match split { - Some(i) => (&body[..i], &body[i..]), - None => ("", body), - }; - let re = if re_s.is_empty() { 0.0 } else { parse_real(re_s).ok_or(CellError::Num)? }; - let im = match im_s { - "" | "+" => 1.0, - "-" => -1.0, - t => parse_real(t).ok_or(CellError::Num)?, - }; - Ok((Cx::new(re, im), Some(sfx))) -} - -fn cx_of(v: &Value) -> R<(Cx, Option)> { - match v { - Value::Number(n) => Ok((Cx::new(*n, 0.0), None)), - Value::Empty => Ok((Cx::new(0.0, 0.0), None)), - Value::Bool(_) => Err(CellError::Value), - Value::Error(e) => Err(*e), - Value::Text(t) => parse_complex(t), - Value::Array(a) => cx_of(a.data.first().unwrap_or(&Value::Empty)), - } -} - -fn cx_arg(a: &[Arg], i: usize) -> R<(Cx, Option)> { - cx_of(&a.get(i).map(scalar).unwrap_or(Value::Empty)) -} - -fn merge_suffix(acc: Option, s: Option) -> R> { - match (acc, s) { - (Some(x), Some(y)) if x != y => Err(CellError::Value), - (Some(x), _) => Ok(Some(x)), - (None, y) => Ok(y), - } -} - -fn fmt_complex(z: Cx, suffix: char) -> R { - if !z.re.is_finite() || !z.im.is_finite() { - return Err(CellError::Num); - } - let re = clean(z.re); - let im = clean(z.im); - let mut s = String::new(); - if im == 0.0 { - s.push_str(&number_to_text(re)); - return text_val(s); - } - if re != 0.0 { - s.push_str(&number_to_text(re)); - if im > 0.0 { - s.push('+'); - } - } - if im == -1.0 { - s.push('-'); - } else if im != 1.0 { - s.push_str(&number_to_text(im)); - } - s.push(suffix); - text_val(s) -} - -/// Rounds to 15 significant digits so values like 0.999999999999999999 print as 1. -fn clean(x: f64) -> f64 { - let s = format!("{:.14e}", x); - s.parse().unwrap_or(x) -} - -fn complex(a: &[Arg], _c: &mut dyn Ctx) -> R { - let re = num(a, 0)?; - let im = num(a, 1)?; - let sfx = if has(a, 2) { text(a, 2)? } else { "i".into() }; - let sfx = match sfx.as_str() { - "i" | "" => 'i', - "j" => 'j', - _ => return Err(CellError::Value), - }; - fmt_complex(Cx::new(re, im), sfx) -} - -fn im_unary(a: &[Arg], f: fn(Cx) -> R) -> R { - let (z, s) = cx_arg(a, 0)?; - fmt_complex(f(z)?, s.unwrap_or('i')) -} - -fn im_real_fn(a: &[Arg], f: fn(Cx) -> R) -> R { - let (z, _) = cx_arg(a, 0)?; - num_val(f(z)?) -} - macro_rules! im1 { ($name:ident, $f:expr) => { fn $name(a: &[Arg], _c: &mut dyn Ctx) -> R { @@ -705,6 +233,16 @@ macro_rules! im1 { }; } +mod bessel; +mod complex; +mod units; + +use bessel::{besseli, besselj, besselk, bessely}; +#[cfg(test)] +use complex::parse_complex; +use complex::{Cx, ONE, complex, im_unary, imabs, imaginary, imargument, imdiv, impower, improduct, imreal, imsub, imsum}; +use units::convert; + im1!(imconjugate, |z: Cx| Ok(Cx::new(z.re, -z.im))); im1!(imexp, |z: Cx| Ok(z.exp())); im1!(imln, |z: Cx| z.ln()); @@ -727,310 +265,6 @@ im1!(imcosh, |z: Cx| Ok(z.cosh())); im1!(imsech, |z: Cx| ONE.div(z.cosh())); im1!(imcsch, |z: Cx| ONE.div(z.sinh())); -fn imabs(a: &[Arg], _c: &mut dyn Ctx) -> R { - im_real_fn(a, |z| Ok(z.abs())) -} -fn imreal(a: &[Arg], _c: &mut dyn Ctx) -> R { - im_real_fn(a, |z| Ok(z.re)) -} -fn imaginary(a: &[Arg], _c: &mut dyn Ctx) -> R { - im_real_fn(a, |z| Ok(z.im)) -} -fn imargument(a: &[Arg], _c: &mut dyn Ctx) -> R { - im_real_fn(a, |z| if z.re == 0.0 && z.im == 0.0 { Err(CellError::Div0) } else { Ok(z.arg()) }) -} - -fn im_binary(a: &[Arg], f: fn(Cx, Cx) -> R) -> R { - let (x, s1) = cx_arg(a, 0)?; - let (y, s2) = cx_arg(a, 1)?; - let s = merge_suffix(s1, s2)?; - fmt_complex(f(x, y)?, s.unwrap_or('i')) -} -fn imdiv(a: &[Arg], _c: &mut dyn Ctx) -> R { - im_binary(a, Cx::div) -} -fn imsub(a: &[Arg], _c: &mut dyn Ctx) -> R { - im_binary(a, |x, y| Ok(x.sub(y))) -} -fn impower(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (z, s) = cx_arg(a, 0)?; - let n = num(a, 1)?; - // Integer powers by repeated multiplication are exact for small Gaussian integers. - let r = if n == n.trunc() && n.abs() <= 64.0 && !(z.re == 0.0 && z.im == 0.0) { - let mut acc = ONE; - for _ in 0..(n.abs() as u32) { - acc = acc.mul(z); - } - if n < 0.0 { ONE.div(acc)? } else { acc } - } else { - z.powf(n)? - }; - fmt_complex(r, s.unwrap_or('i')) -} - -fn im_fold(a: &[Arg], init: Cx, f: fn(Cx, Cx) -> Cx) -> R { - let mut acc = init; - let mut sfx = None; - for arg in a { - let direct = !matches!(arg.value, Value::Array(_)); - for v in flatten(std::slice::from_ref(arg)) { - if matches!(v, Value::Empty) && !direct { - continue; - } - let (z, s) = cx_of(&v)?; - sfx = merge_suffix(sfx, s)?; - acc = f(acc, z); - } - } - fmt_complex(acc, sfx.unwrap_or('i')) -} -fn imsum(a: &[Arg], _c: &mut dyn Ctx) -> R { - im_fold(a, Cx::new(0.0, 0.0), Cx::add) -} -fn improduct(a: &[Arg], _c: &mut dyn Ctx) -> R { - im_fold(a, ONE, Cx::mul) -} - -// --------------------------------------------------------------------------------------------- -// Bessel functions - -const MAX_BESSEL_POINTS: f64 = 4_000_000.0; - -/// J_n(x) for integer n ≥ 0 via Bessel's integral, J_n(x) = (1/2π)∫₀^{2π} cos(nτ − x sin τ) dτ, -/// evaluated with the trapezoid rule, which converges exponentially for periodic integrands. -fn bessel_j(n: u32, x: f64) -> Option { - let pts = (2.0 * (x.abs() + n as f64) + 64.0).ceil(); - if pts > MAX_BESSEL_POINTS { - return if x.abs() > 1e6 { hankel(n, x.abs()).map(|(j, _)| if x < 0.0 && n % 2 == 1 { -j } else { j }) } else { None }; - } - let m = pts as usize; - let h = 2.0 * PI / m as f64; - let nf = n as f64; - let mut sum = 0.0; - for k in 0..m { - let t = k as f64 * h; - sum += (nf * t - x * t.sin()).cos(); - } - Some(sum / m as f64) -} - -/// Hankel asymptotic expansion: (J_n(x), Y_n(x)) for large x. -fn hankel(n: u32, x: f64) -> Option<(f64, f64)> { - let mu = 4.0 * (n as f64) * (n as f64); - let mut p = 0.0; - let mut q = 0.0; - let mut term: f64 = 1.0; - let mut last = f64::INFINITY; - for k in 0..200 { - if term.abs() > last { - break; - } - let t = if k % 4 < 2 { term } else { -term }; - if k % 2 == 0 { - p += t; - } else { - q += t; - } - last = term.abs(); - if last < 1e-17 { - break; - } - let kk = (k + 1) as f64; - let odd = 2.0 * kk - 1.0; - term *= (mu - odd * odd) / (kk * 8.0 * x); - } - let chi = x - (n as f64 / 2.0 + 0.25) * PI; - let f = (2.0 / (PI * x)).sqrt(); - let j = f * (p * chi.cos() - q * chi.sin()); - let y = f * (p * chi.sin() + q * chi.cos()); - if j.is_finite() && y.is_finite() { Some((j, y)) } else { None } -} - -const EULER: f64 = std::f64::consts::EULER_GAMMA; - -/// J_0..J_m(x) for 0 < x ≤ ~100 by Miller's backward recurrence, normalised with -/// J_0 + 2ΣJ_{2k} = 1. -fn bessel_j_all(x: f64) -> Vec { - let top = x.ceil() as usize + 30 + (40.0 * x.max(1.0)).sqrt() as usize; - let m = top + top % 2; - let mut j = vec![0.0f64; m + 2]; - if let Some(v) = j.get_mut(m) { - *v = 1e-30; - } - for k in (1..=m).rev() { - let jk = j.get(k).copied().unwrap_or(0.0); - let jk1 = j.get(k + 1).copied().unwrap_or(0.0); - let mut prev = 2.0 * k as f64 / x * jk - jk1; - if prev.abs() > 1e250 { - for v in j.iter_mut() { - *v *= 1e-250; - } - prev *= 1e-250; - } - if let Some(v) = j.get_mut(k - 1) { - *v = prev; - } - } - let norm: f64 = j.first().copied().unwrap_or(0.0) + 2.0 * j.iter().skip(2).step_by(2).sum::(); - if norm != 0.0 { - for v in j.iter_mut() { - *v /= norm; - } - } - j -} - -/// Y_0 and Y_1 by Neumann series over J_k (x ≤ 25) or the asymptotic expansion. -fn bessel_y01(x: f64) -> Option<(f64, f64)> { - if x > 25.0 { - let (_, y0) = hankel(0, x)?; - let (_, y1) = hankel(1, x)?; - return Some((y0, y1)); - } - let j = bessel_j_all(x); - let jj = |k: usize| j.get(k).copied().unwrap_or(0.0); - let l = (x / 2.0).ln() + EULER; - // Y0 = (2/π)(L J0) − (4/π) Σ (−1)^k J_{2k}/k - // Y1 = −Y0' = −(2/π)(J0/x − L J1) + (2/π) Σ (−1)^k (J_{2k−1} − J_{2k+1})/k - let mut s0 = 0.0; - let mut s1 = 0.0; - let mut k = 1usize; - while 2 * k + 1 < j.len() { - let sign = if k.is_multiple_of(2) { 1.0 } else { -1.0 }; - s0 += sign * jj(2 * k) / k as f64; - s1 += sign * (jj(2 * k - 1) - jj(2 * k + 1)) / k as f64; - k += 1; - } - let y0 = (2.0 / PI) * l * jj(0) - (4.0 / PI) * s0; - let y1 = -(2.0 / PI) * (jj(0) / x - l * jj(1)) + (2.0 / PI) * s1; - if y0.is_finite() && y1.is_finite() { Some((y0, y1)) } else { None } -} - -fn bessel_y(n: u32, x: f64) -> Option { - let (y0, y1) = bessel_y01(x)?; - if n == 0 { - return Some(y0); - } - let (mut a, mut b) = (y0, y1); - for k in 1..n { - let c = 2.0 * k as f64 / x * b - a; - a = b; - b = c; - if !b.is_finite() { - return None; - } - } - Some(b) -} - -/// I_n(x) by its power series (all terms positive), computed with a log-scaled first term. -fn bessel_i(n: u32, x: f64) -> Option { - if x == 0.0 { - return Some(if n == 0 { 1.0 } else { 0.0 }); - } - let ax = x.abs(); - let nf = n as f64; - let ln_t0 = nf * (ax / 2.0).ln() - crate::special::ln_gamma(nf + 1.0); - let q = ax * ax / 4.0; - let mut term = 1.0; - let mut sum = 1.0; - let mut k = 0.0; - while k < 1e6 { - k += 1.0; - term *= q / (k * (k + nf)); - sum += term; - if term < 1e-17 * sum { - break; - } - if !sum.is_finite() { - return None; - } - } - let r = (ln_t0 + sum.ln()).exp(); - let r = if x < 0.0 && n % 2 == 1 { -r } else { r }; - if r.is_finite() { Some(r) } else { None } -} - -/// K_n(x) for x > 0: K_0 and K_1 from ∫₀^∞ e^{−x cosh t} cosh(νt) dt (trapezoid rule), then -/// upward recurrence. -fn bessel_k(n: u32, x: f64) -> Option { - let k_int = |nu: f64| -> f64 { - let h: f64 = 0.02; - let mut sum = 0.5 * (-x).exp(); - let mut t: f64 = h; - while t < 800.0 { - let v = (-x * t.cosh() + (nu * t).ln_cosh_safe()).exp(); - sum += v; - if v < 1e-18 * sum && x * t.cosh() > 50.0 { - break; - } - t += h; - } - sum * h - }; - let k0 = k_int(0.0); - if n == 0 { - return Some(k0); - } - let k1 = k_int(1.0); - let (mut a, mut b) = (k0, k1); - for k in 1..n { - let c = a + 2.0 * k as f64 / x * b; - a = b; - b = c; - if !b.is_finite() { - return None; - } - } - if b.is_finite() { Some(b) } else { None } -} - -trait LnCosh { - fn ln_cosh_safe(self) -> f64; -} -impl LnCosh for f64 { - /// ln(cosh(x)) without overflow. - fn ln_cosh_safe(self) -> f64 { - let a = self.abs(); - a + (-2.0 * a).exp().ln_1p() - std::f64::consts::LN_2 - } -} - -fn bessel_args(a: &[Arg]) -> R<(f64, u32)> { - let x = num(a, 0)?; - let n = num(a, 1)?.trunc(); - if n < 0.0 { - return Err(CellError::Num); - } - if n > 100_000.0 { - return Err(CellError::Num); - } - Ok((x, n as u32)) -} - -fn besselj(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (x, n) = bessel_args(a)?; - num_val(bessel_j(n, x).ok_or(CellError::Num)?) -} -fn bessely(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (x, n) = bessel_args(a)?; - if x <= 0.0 { - return Err(CellError::Num); - } - num_val(bessel_y(n, x).ok_or(CellError::Num)?) -} -fn besseli(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (x, n) = bessel_args(a)?; - num_val(bessel_i(n, x).ok_or(CellError::Num)?) -} -fn besselk(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (x, n) = bessel_args(a)?; - if x <= 0.0 { - return Err(CellError::Num); - } - num_val(bessel_k(n, x).ok_or(CellError::Num)?) -} - pub(crate) fn specs() -> Vec { vec![ f!("BIN2DEC", 1, 1, Engineering, S, "BIN2DEC(number)", "Converts a binary number to decimal.", bin2dec), diff --git a/crates/functions/src/engineering/bessel.rs b/crates/functions/src/engineering/bessel.rs new file mode 100644 index 0000000000..34be817838 --- /dev/null +++ b/crates/functions/src/engineering/bessel.rs @@ -0,0 +1,245 @@ +use super::*; + +// --------------------------------------------------------------------------------------------- +// Bessel functions + +const MAX_BESSEL_POINTS: f64 = 4_000_000.0; + +/// J_n(x) for integer n ≥ 0 via Bessel's integral, J_n(x) = (1/2π)∫₀^{2π} cos(nτ − x sin τ) dτ, +/// evaluated with the trapezoid rule, which converges exponentially for periodic integrands. +fn bessel_j(n: u32, x: f64) -> Option { + let pts = (2.0 * (x.abs() + n as f64) + 64.0).ceil(); + if pts > MAX_BESSEL_POINTS { + return if x.abs() > 1e6 { hankel(n, x.abs()).map(|(j, _)| if x < 0.0 && n % 2 == 1 { -j } else { j }) } else { None }; + } + let m = pts as usize; + let h = 2.0 * PI / m as f64; + let nf = n as f64; + let mut sum = 0.0; + for k in 0..m { + let t = k as f64 * h; + sum += (nf * t - x * t.sin()).cos(); + } + Some(sum / m as f64) +} + +/// Hankel asymptotic expansion: (J_n(x), Y_n(x)) for large x. +fn hankel(n: u32, x: f64) -> Option<(f64, f64)> { + let mu = 4.0 * (n as f64) * (n as f64); + let mut p = 0.0; + let mut q = 0.0; + let mut term: f64 = 1.0; + let mut last = f64::INFINITY; + for k in 0..200 { + if term.abs() > last { + break; + } + let t = if k % 4 < 2 { term } else { -term }; + if k % 2 == 0 { + p += t; + } else { + q += t; + } + last = term.abs(); + if last < 1e-17 { + break; + } + let kk = (k + 1) as f64; + let odd = 2.0 * kk - 1.0; + term *= (mu - odd * odd) / (kk * 8.0 * x); + } + let chi = x - (n as f64 / 2.0 + 0.25) * PI; + let f = (2.0 / (PI * x)).sqrt(); + let j = f * (p * chi.cos() - q * chi.sin()); + let y = f * (p * chi.sin() + q * chi.cos()); + if j.is_finite() && y.is_finite() { Some((j, y)) } else { None } +} + +const EULER: f64 = std::f64::consts::EULER_GAMMA; + +/// J_0..J_m(x) for 0 < x ≤ ~100 by Miller's backward recurrence, normalised with +/// J_0 + 2ΣJ_{2k} = 1. +fn bessel_j_all(x: f64) -> Vec { + let top = x.ceil() as usize + 30 + (40.0 * x.max(1.0)).sqrt() as usize; + let m = top + top % 2; + let mut j = vec![0.0f64; m + 2]; + if let Some(v) = j.get_mut(m) { + *v = 1e-30; + } + for k in (1..=m).rev() { + let jk = j.get(k).copied().unwrap_or(0.0); + let jk1 = j.get(k + 1).copied().unwrap_or(0.0); + let mut prev = 2.0 * k as f64 / x * jk - jk1; + if prev.abs() > 1e250 { + for v in j.iter_mut() { + *v *= 1e-250; + } + prev *= 1e-250; + } + if let Some(v) = j.get_mut(k - 1) { + *v = prev; + } + } + let norm: f64 = j.first().copied().unwrap_or(0.0) + 2.0 * j.iter().skip(2).step_by(2).sum::(); + if norm != 0.0 { + for v in j.iter_mut() { + *v /= norm; + } + } + j +} + +/// Y_0 and Y_1 by Neumann series over J_k (x ≤ 25) or the asymptotic expansion. +fn bessel_y01(x: f64) -> Option<(f64, f64)> { + if x > 25.0 { + let (_, y0) = hankel(0, x)?; + let (_, y1) = hankel(1, x)?; + return Some((y0, y1)); + } + let j = bessel_j_all(x); + let jj = |k: usize| j.get(k).copied().unwrap_or(0.0); + let l = (x / 2.0).ln() + EULER; + // Y0 = (2/π)(L J0) − (4/π) Σ (−1)^k J_{2k}/k + // Y1 = −Y0' = −(2/π)(J0/x − L J1) + (2/π) Σ (−1)^k (J_{2k−1} − J_{2k+1})/k + let mut s0 = 0.0; + let mut s1 = 0.0; + let mut k = 1usize; + while 2 * k + 1 < j.len() { + let sign = if k.is_multiple_of(2) { 1.0 } else { -1.0 }; + s0 += sign * jj(2 * k) / k as f64; + s1 += sign * (jj(2 * k - 1) - jj(2 * k + 1)) / k as f64; + k += 1; + } + let y0 = (2.0 / PI) * l * jj(0) - (4.0 / PI) * s0; + let y1 = -(2.0 / PI) * (jj(0) / x - l * jj(1)) + (2.0 / PI) * s1; + if y0.is_finite() && y1.is_finite() { Some((y0, y1)) } else { None } +} + +fn bessel_y(n: u32, x: f64) -> Option { + let (y0, y1) = bessel_y01(x)?; + if n == 0 { + return Some(y0); + } + let (mut a, mut b) = (y0, y1); + for k in 1..n { + let c = 2.0 * k as f64 / x * b - a; + a = b; + b = c; + if !b.is_finite() { + return None; + } + } + Some(b) +} + +/// I_n(x) by its power series (all terms positive), computed with a log-scaled first term. +fn bessel_i(n: u32, x: f64) -> Option { + if x == 0.0 { + return Some(if n == 0 { 1.0 } else { 0.0 }); + } + let ax = x.abs(); + let nf = n as f64; + let ln_t0 = nf * (ax / 2.0).ln() - crate::special::ln_gamma(nf + 1.0); + let q = ax * ax / 4.0; + let mut term = 1.0; + let mut sum = 1.0; + let mut k = 0.0; + while k < 1e6 { + k += 1.0; + term *= q / (k * (k + nf)); + sum += term; + if term < 1e-17 * sum { + break; + } + if !sum.is_finite() { + return None; + } + } + let r = (ln_t0 + sum.ln()).exp(); + let r = if x < 0.0 && n % 2 == 1 { -r } else { r }; + if r.is_finite() { Some(r) } else { None } +} + +/// K_n(x) for x > 0: K_0 and K_1 from ∫₀^∞ e^{−x cosh t} cosh(νt) dt (trapezoid rule), then +/// upward recurrence. +fn bessel_k(n: u32, x: f64) -> Option { + let k_int = |nu: f64| -> f64 { + let h: f64 = 0.02; + let mut sum = 0.5 * (-x).exp(); + let mut t: f64 = h; + while t < 800.0 { + let v = (-x * t.cosh() + (nu * t).ln_cosh_safe()).exp(); + sum += v; + if v < 1e-18 * sum && x * t.cosh() > 50.0 { + break; + } + t += h; + } + sum * h + }; + let k0 = k_int(0.0); + if n == 0 { + return Some(k0); + } + let k1 = k_int(1.0); + let (mut a, mut b) = (k0, k1); + for k in 1..n { + let c = a + 2.0 * k as f64 / x * b; + a = b; + b = c; + if !b.is_finite() { + return None; + } + } + if b.is_finite() { Some(b) } else { None } +} + +trait LnCosh { + fn ln_cosh_safe(self) -> f64; +} + +impl LnCosh for f64 { + /// ln(cosh(x)) without overflow. + fn ln_cosh_safe(self) -> f64 { + let a = self.abs(); + a + (-2.0 * a).exp().ln_1p() - std::f64::consts::LN_2 + } +} + +fn bessel_args(a: &[Arg]) -> R<(f64, u32)> { + let x = num(a, 0)?; + let n = num(a, 1)?.trunc(); + if n < 0.0 { + return Err(CellError::Num); + } + if n > 100_000.0 { + return Err(CellError::Num); + } + Ok((x, n as u32)) +} + +pub(super) fn besselj(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (x, n) = bessel_args(a)?; + num_val(bessel_j(n, x).ok_or(CellError::Num)?) +} + +pub(super) fn bessely(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (x, n) = bessel_args(a)?; + if x <= 0.0 { + return Err(CellError::Num); + } + num_val(bessel_y(n, x).ok_or(CellError::Num)?) +} + +pub(super) fn besseli(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (x, n) = bessel_args(a)?; + num_val(bessel_i(n, x).ok_or(CellError::Num)?) +} + +pub(super) fn besselk(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (x, n) = bessel_args(a)?; + if x <= 0.0 { + return Err(CellError::Num); + } + num_val(bessel_k(n, x).ok_or(CellError::Num)?) +} diff --git a/crates/functions/src/engineering/complex.rs b/crates/functions/src/engineering/complex.rs new file mode 100644 index 0000000000..86ab6b5356 --- /dev/null +++ b/crates/functions/src/engineering/complex.rs @@ -0,0 +1,268 @@ +use super::*; + +// --------------------------------------------------------------------------------------------- +// Complex numbers + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct Cx { + pub(super) re: f64, + pub(super) im: f64, +} + +impl Cx { + pub(super) fn new(re: f64, im: f64) -> Cx { + Cx { re, im } + } + fn add(self, o: Cx) -> Cx { + Cx::new(self.re + o.re, self.im + o.im) + } + fn sub(self, o: Cx) -> Cx { + Cx::new(self.re - o.re, self.im - o.im) + } + fn mul(self, o: Cx) -> Cx { + Cx::new(self.re * o.re - self.im * o.im, self.re * o.im + self.im * o.re) + } + pub(super) fn div(self, o: Cx) -> R { + let d = o.re * o.re + o.im * o.im; + if d == 0.0 { + return Err(CellError::Num); + } + Ok(Cx::new((self.re * o.re + self.im * o.im) / d, (self.im * o.re - self.re * o.im) / d)) + } + pub(super) fn abs(self) -> f64 { + self.re.hypot(self.im) + } + fn arg(self) -> f64 { + self.im.atan2(self.re) + } + pub(super) fn exp(self) -> Cx { + let e = self.re.exp(); + Cx::new(e * self.im.cos(), e * self.im.sin()) + } + pub(super) fn ln(self) -> R { + if self.re == 0.0 && self.im == 0.0 { + return Err(CellError::Num); + } + Ok(Cx::new(self.abs().ln(), self.arg())) + } + pub(super) fn sin(self) -> Cx { + Cx::new(self.re.sin() * self.im.cosh(), self.re.cos() * self.im.sinh()) + } + pub(super) fn cos(self) -> Cx { + Cx::new(self.re.cos() * self.im.cosh(), -self.re.sin() * self.im.sinh()) + } + pub(super) fn sinh(self) -> Cx { + Cx::new(self.re.sinh() * self.im.cos(), self.re.cosh() * self.im.sin()) + } + pub(super) fn cosh(self) -> Cx { + Cx::new(self.re.cosh() * self.im.cos(), self.re.sinh() * self.im.sin()) + } + fn powf(self, n: f64) -> R { + if self.re == 0.0 && self.im == 0.0 { + return if n > 0.0 { Ok(Cx::new(0.0, 0.0)) } else { Err(CellError::Num) }; + } + let r = self.abs().powf(n); + let t = self.arg() * n; + Ok(Cx::new(r * t.cos(), r * t.sin())) + } +} + +pub(super) const ONE: Cx = Cx { re: 1.0, im: 0.0 }; + +/// Parses a real number written in a complex string (no spaces, optional exponent). +fn parse_real(s: &str) -> Option { + if s.is_empty() || s.contains(char::is_whitespace) { + return None; + } + let ok = s.chars().all(|c| c.is_ascii_digit() || matches!(c, '.' | 'e' | 'E' | '+' | '-')); + if !ok { + return None; + } + s.parse::().ok().filter(|v| v.is_finite()) +} + +/// Parses "a+bi" forms. Returns the number and its suffix ('i', 'j' or none). +pub(super) fn parse_complex(s: &str) -> R<(Cx, Option)> { + if s.is_empty() { + return Ok((Cx::new(0.0, 0.0), None)); + } + let suffix = match s.chars().last() { + Some(c @ ('i' | 'j')) => Some(c), + _ => None, + }; + let Some(sfx) = suffix else { + return parse_real(s).map(|r| (Cx::new(r, 0.0), None)).ok_or(CellError::Num); + }; + let body = &s[..s.len() - 1]; + // Split at the last sign that is not at the start and not part of an exponent. + let bytes = body.as_bytes(); + let mut split = None; + for i in (1..bytes.len()).rev() { + let c = bytes.get(i).copied().unwrap_or(0); + let prev = bytes.get(i - 1).copied().unwrap_or(0); + if (c == b'+' || c == b'-') && prev != b'e' && prev != b'E' { + split = Some(i); + break; + } + } + let (re_s, im_s) = match split { + Some(i) => (&body[..i], &body[i..]), + None => ("", body), + }; + let re = if re_s.is_empty() { 0.0 } else { parse_real(re_s).ok_or(CellError::Num)? }; + let im = match im_s { + "" | "+" => 1.0, + "-" => -1.0, + t => parse_real(t).ok_or(CellError::Num)?, + }; + Ok((Cx::new(re, im), Some(sfx))) +} + +fn cx_of(v: &Value) -> R<(Cx, Option)> { + match v { + Value::Number(n) => Ok((Cx::new(*n, 0.0), None)), + Value::Empty => Ok((Cx::new(0.0, 0.0), None)), + Value::Bool(_) => Err(CellError::Value), + Value::Error(e) => Err(*e), + Value::Text(t) => parse_complex(t), + Value::Array(a) => cx_of(a.data.first().unwrap_or(&Value::Empty)), + } +} + +fn cx_arg(a: &[Arg], i: usize) -> R<(Cx, Option)> { + cx_of(&a.get(i).map(scalar).unwrap_or(Value::Empty)) +} + +fn merge_suffix(acc: Option, s: Option) -> R> { + match (acc, s) { + (Some(x), Some(y)) if x != y => Err(CellError::Value), + (Some(x), _) => Ok(Some(x)), + (None, y) => Ok(y), + } +} + +fn fmt_complex(z: Cx, suffix: char) -> R { + if !z.re.is_finite() || !z.im.is_finite() { + return Err(CellError::Num); + } + let re = clean(z.re); + let im = clean(z.im); + let mut s = String::new(); + if im == 0.0 { + s.push_str(&number_to_text(re)); + return text_val(s); + } + if re != 0.0 { + s.push_str(&number_to_text(re)); + if im > 0.0 { + s.push('+'); + } + } + if im == -1.0 { + s.push('-'); + } else if im != 1.0 { + s.push_str(&number_to_text(im)); + } + s.push(suffix); + text_val(s) +} + +/// Rounds to 15 significant digits so values like 0.999999999999999999 print as 1. +fn clean(x: f64) -> f64 { + let s = format!("{:.14e}", x); + s.parse().unwrap_or(x) +} + +pub(super) fn complex(a: &[Arg], _c: &mut dyn Ctx) -> R { + let re = num(a, 0)?; + let im = num(a, 1)?; + let sfx = if has(a, 2) { text(a, 2)? } else { "i".into() }; + let sfx = match sfx.as_str() { + "i" | "" => 'i', + "j" => 'j', + _ => return Err(CellError::Value), + }; + fmt_complex(Cx::new(re, im), sfx) +} + +pub(super) fn im_unary(a: &[Arg], f: fn(Cx) -> R) -> R { + let (z, s) = cx_arg(a, 0)?; + fmt_complex(f(z)?, s.unwrap_or('i')) +} + +fn im_real_fn(a: &[Arg], f: fn(Cx) -> R) -> R { + let (z, _) = cx_arg(a, 0)?; + num_val(f(z)?) +} + +pub(super) fn imabs(a: &[Arg], _c: &mut dyn Ctx) -> R { + im_real_fn(a, |z| Ok(z.abs())) +} + +pub(super) fn imreal(a: &[Arg], _c: &mut dyn Ctx) -> R { + im_real_fn(a, |z| Ok(z.re)) +} + +pub(super) fn imaginary(a: &[Arg], _c: &mut dyn Ctx) -> R { + im_real_fn(a, |z| Ok(z.im)) +} + +pub(super) fn imargument(a: &[Arg], _c: &mut dyn Ctx) -> R { + im_real_fn(a, |z| if z.re == 0.0 && z.im == 0.0 { Err(CellError::Div0) } else { Ok(z.arg()) }) +} + +fn im_binary(a: &[Arg], f: fn(Cx, Cx) -> R) -> R { + let (x, s1) = cx_arg(a, 0)?; + let (y, s2) = cx_arg(a, 1)?; + let s = merge_suffix(s1, s2)?; + fmt_complex(f(x, y)?, s.unwrap_or('i')) +} + +pub(super) fn imdiv(a: &[Arg], _c: &mut dyn Ctx) -> R { + im_binary(a, Cx::div) +} + +pub(super) fn imsub(a: &[Arg], _c: &mut dyn Ctx) -> R { + im_binary(a, |x, y| Ok(x.sub(y))) +} + +pub(super) fn impower(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (z, s) = cx_arg(a, 0)?; + let n = num(a, 1)?; + // Integer powers by repeated multiplication are exact for small Gaussian integers. + let r = if n == n.trunc() && n.abs() <= 64.0 && !(z.re == 0.0 && z.im == 0.0) { + let mut acc = ONE; + for _ in 0..(n.abs() as u32) { + acc = acc.mul(z); + } + if n < 0.0 { ONE.div(acc)? } else { acc } + } else { + z.powf(n)? + }; + fmt_complex(r, s.unwrap_or('i')) +} + +fn im_fold(a: &[Arg], init: Cx, f: fn(Cx, Cx) -> Cx) -> R { + let mut acc = init; + let mut sfx = None; + for arg in a { + let direct = !matches!(arg.value, Value::Array(_)); + for v in flatten(std::slice::from_ref(arg)) { + if matches!(v, Value::Empty) && !direct { + continue; + } + let (z, s) = cx_of(&v)?; + sfx = merge_suffix(sfx, s)?; + acc = f(acc, z); + } + } + fmt_complex(acc, sfx.unwrap_or('i')) +} + +pub(super) fn imsum(a: &[Arg], _c: &mut dyn Ctx) -> R { + im_fold(a, Cx::new(0.0, 0.0), Cx::add) +} + +pub(super) fn improduct(a: &[Arg], _c: &mut dyn Ctx) -> R { + im_fold(a, ONE, Cx::mul) +} diff --git a/crates/functions/src/engineering/units.rs b/crates/functions/src/engineering/units.rs new file mode 100644 index 0000000000..e54abd87ad --- /dev/null +++ b/crates/functions/src/engineering/units.rs @@ -0,0 +1,286 @@ +use super::*; + +// --------------------------------------------------------------------------------------------- +// CONVERT + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Dim { + Mass, + Length, + Time, + Pressure, + Force, + Energy, + Power, + Magnetism, + Temperature, + Volume, + Area, + Information, + Speed, +} + +/// How a unit accepts prefixes: none, SI (raised to `power` for areas/volumes) or SI + binary. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Pre { + No, + Si(i32), + Bin, +} + +struct Unit { + names: &'static [&'static str], + dim: Dim, + factor: f64, + pre: Pre, +} + +const fn u(names: &'static [&'static str], dim: Dim, factor: f64, pre: Pre) -> Unit { + Unit { names, dim, factor, pre } +} + +const IN: f64 = 0.0254; + +const FT: f64 = 0.3048; + +const YD: f64 = 0.9144; + +const MI: f64 = 1609.344; + +const NMI: f64 = 1852.0; + +const LY: f64 = 9_460_730_472_580_800.0; + +const PICA_PT: f64 = 0.0254 / 72.0; + +const PICA: f64 = 0.0254 / 6.0; + +const GAL: f64 = 0.003_785_411_784; + +const UNITS: &[Unit] = &[ + // Mass (gram) + u(&["g"], Dim::Mass, 1.0, Pre::Si(1)), + u(&["sg"], Dim::Mass, 14_593.902_937_206_4, Pre::No), + u(&["lbm"], Dim::Mass, 453.592_37, Pre::No), + u(&["u"], Dim::Mass, 1.660_539_066_6e-24, Pre::Si(1)), + u(&["ozm"], Dim::Mass, 28.349_523_125, Pre::No), + u(&["grain"], Dim::Mass, 0.064_798_91, Pre::No), + u(&["cwt", "shweight"], Dim::Mass, 45_359.237, Pre::No), + u(&["uk_cwt", "lcwt", "hweight"], Dim::Mass, 50_802.345_44, Pre::No), + u(&["stone"], Dim::Mass, 6_350.293_18, Pre::No), + u(&["ton"], Dim::Mass, 907_184.74, Pre::No), + u(&["uk_ton", "LTON", "brton"], Dim::Mass, 1_016_046.908_8, Pre::No), + // Length (metre) + u(&["m"], Dim::Length, 1.0, Pre::Si(1)), + u(&["mi"], Dim::Length, MI, Pre::No), + u(&["Nmi"], Dim::Length, NMI, Pre::No), + u(&["in"], Dim::Length, IN, Pre::No), + u(&["ft"], Dim::Length, FT, Pre::No), + u(&["yd"], Dim::Length, YD, Pre::No), + u(&["ang"], Dim::Length, 1e-10, Pre::Si(1)), + u(&["ell"], Dim::Length, 1.143, Pre::No), + u(&["ly"], Dim::Length, LY, Pre::Si(1)), + u(&["parsec", "pc"], Dim::Length, 30_856_775_814_913_673.0, Pre::Si(1)), + u(&["Picapt", "Pica"], Dim::Length, PICA_PT, Pre::No), + u(&["pica"], Dim::Length, PICA, Pre::No), + u(&["survey_mi"], Dim::Length, 1_609.347_218_694_437, Pre::No), + // Time (second) + u(&["yr"], Dim::Time, 31_557_600.0, Pre::No), + u(&["day", "d"], Dim::Time, 86_400.0, Pre::No), + u(&["hr"], Dim::Time, 3_600.0, Pre::No), + u(&["mn", "min"], Dim::Time, 60.0, Pre::No), + u(&["sec", "s"], Dim::Time, 1.0, Pre::Si(1)), + // Pressure (pascal) + u(&["Pa", "p"], Dim::Pressure, 1.0, Pre::Si(1)), + u(&["atm", "at"], Dim::Pressure, 101_325.0, Pre::Si(1)), + u(&["mmHg"], Dim::Pressure, 133.322_387_415, Pre::Si(1)), + u(&["psi"], Dim::Pressure, 6_894.757_293_168_36, Pre::No), + u(&["Torr"], Dim::Pressure, 101_325.0 / 760.0, Pre::No), + // Force (newton) + u(&["N"], Dim::Force, 1.0, Pre::Si(1)), + u(&["dyn", "dy"], Dim::Force, 1e-5, Pre::Si(1)), + u(&["lbf"], Dim::Force, 4.448_221_615_260_5, Pre::No), + u(&["pond"], Dim::Force, 0.009_806_65, Pre::Si(1)), + // Energy (joule) + u(&["J"], Dim::Energy, 1.0, Pre::Si(1)), + u(&["e"], Dim::Energy, 1e-7, Pre::Si(1)), + u(&["c"], Dim::Energy, 4.184, Pre::Si(1)), + u(&["cal"], Dim::Energy, 4.1868, Pre::Si(1)), + u(&["eV", "ev"], Dim::Energy, 1.602_176_634e-19, Pre::Si(1)), + u(&["HPh", "hh"], Dim::Energy, 2_684_519.537_696_17, Pre::No), + u(&["Wh", "wh"], Dim::Energy, 3_600.0, Pre::Si(1)), + u(&["flb"], Dim::Energy, 1.355_817_948_331_4, Pre::No), + u(&["BTU", "btu"], Dim::Energy, 1_055.055_852_62, Pre::No), + // Power (watt) + u(&["W", "w"], Dim::Power, 1.0, Pre::Si(1)), + u(&["HP", "h"], Dim::Power, 745.699_871_582_270_2, Pre::No), + u(&["PS"], Dim::Power, 735.498_75, Pre::No), + // Magnetism (tesla) + u(&["T"], Dim::Magnetism, 1.0, Pre::Si(1)), + u(&["ga"], Dim::Magnetism, 1e-4, Pre::Si(1)), + // Temperature (handled specially; factor unused) + u(&["C", "cel"], Dim::Temperature, 1.0, Pre::No), + u(&["F", "fah"], Dim::Temperature, 1.0, Pre::No), + u(&["K", "kel"], Dim::Temperature, 1.0, Pre::Si(1)), + u(&["Rank"], Dim::Temperature, 1.0, Pre::No), + u(&["Reau"], Dim::Temperature, 1.0, Pre::No), + // Volume (cubic metre) + u(&["l", "L", "lt"], Dim::Volume, 0.001, Pre::Si(1)), + u(&["tsp"], Dim::Volume, GAL / 768.0, Pre::No), + u(&["tspm"], Dim::Volume, 5e-6, Pre::No), + u(&["tbs"], Dim::Volume, GAL / 256.0, Pre::No), + u(&["oz"], Dim::Volume, GAL / 128.0, Pre::No), + u(&["cup"], Dim::Volume, GAL / 16.0, Pre::No), + u(&["pt", "us_pt"], Dim::Volume, GAL / 8.0, Pre::No), + u(&["uk_pt"], Dim::Volume, 0.000_568_261_25, Pre::No), + u(&["qt"], Dim::Volume, GAL / 4.0, Pre::No), + u(&["uk_qt"], Dim::Volume, 0.001_136_522_5, Pre::No), + u(&["gal"], Dim::Volume, GAL, Pre::No), + u(&["uk_gal"], Dim::Volume, 0.004_546_09, Pre::No), + u(&["m3"], Dim::Volume, 1.0, Pre::Si(3)), + u(&["mi3"], Dim::Volume, MI * MI * MI, Pre::No), + u(&["Nmi3"], Dim::Volume, NMI * NMI * NMI, Pre::No), + u(&["yd3"], Dim::Volume, YD * YD * YD, Pre::No), + u(&["ft3"], Dim::Volume, FT * FT * FT, Pre::No), + u(&["in3"], Dim::Volume, IN * IN * IN, Pre::No), + u(&["ang3"], Dim::Volume, 1e-30, Pre::Si(3)), + u(&["ly3"], Dim::Volume, LY * LY * LY, Pre::No), + u(&["Picapt3", "Pica3"], Dim::Volume, PICA_PT * PICA_PT * PICA_PT, Pre::No), + u(&["barrel"], Dim::Volume, 0.158_987_294_928, Pre::No), + u(&["bushel"], Dim::Volume, 0.035_239_070_166_88, Pre::No), + u(&["MTON"], Dim::Volume, 1.132_673_863_68, Pre::No), + u(&["GRT", "regton"], Dim::Volume, 2.831_684_659_2, Pre::No), + // Area (square metre) + u(&["m2"], Dim::Area, 1.0, Pre::Si(2)), + u(&["ha"], Dim::Area, 10_000.0, Pre::No), + u(&["ar"], Dim::Area, 100.0, Pre::Si(1)), + u(&["ft2"], Dim::Area, FT * FT, Pre::No), + u(&["in2"], Dim::Area, IN * IN, Pre::No), + u(&["yd2"], Dim::Area, YD * YD, Pre::No), + u(&["mi2"], Dim::Area, MI * MI, Pre::No), + u(&["Nmi2"], Dim::Area, NMI * NMI, Pre::No), + u(&["ang2"], Dim::Area, 1e-20, Pre::Si(2)), + u(&["ly2"], Dim::Area, LY * LY, Pre::No), + u(&["Picapt2", "Pica2"], Dim::Area, PICA_PT * PICA_PT, Pre::No), + u(&["uk_acre"], Dim::Area, 4_046.856_422_4, Pre::No), + u(&["us_acre"], Dim::Area, 4_046.872_609_874_252, Pre::No), + u(&["Morgen"], Dim::Area, 2_500.0, Pre::No), + // Information (bit) + u(&["bit"], Dim::Information, 1.0, Pre::Bin), + u(&["byte"], Dim::Information, 8.0, Pre::Bin), + // Speed (metres per second) + u(&["m/s", "m/sec"], Dim::Speed, 1.0, Pre::Si(1)), + u(&["m/h", "m/hr"], Dim::Speed, 1.0 / 3600.0, Pre::Si(1)), + u(&["mph"], Dim::Speed, MI / 3600.0, Pre::No), + u(&["kn"], Dim::Speed, NMI / 3600.0, Pre::No), + u(&["admkn"], Dim::Speed, 6080.0 * FT / 3600.0, Pre::No), +]; + +const SI_PREFIXES: &[(&str, f64)] = &[ + ("da", 1e1), + ("Y", 1e24), + ("Z", 1e21), + ("E", 1e18), + ("P", 1e15), + ("T", 1e12), + ("G", 1e9), + ("M", 1e6), + ("k", 1e3), + ("h", 1e2), + ("e", 1e1), + ("d", 1e-1), + ("c", 1e-2), + ("m", 1e-3), + ("u", 1e-6), + ("n", 1e-9), + ("p", 1e-12), + ("f", 1e-15), + ("a", 1e-18), + ("z", 1e-21), + ("y", 1e-24), +]; + +const BIN_PREFIXES: &[(&str, f64)] = &[ + ("ki", 1024.0), + ("Mi", 1_048_576.0), + ("Gi", 1_073_741_824.0), + ("Ti", 1_099_511_627_776.0), + ("Pi", 1_125_899_906_842_624.0), + ("Ei", 1_152_921_504_606_846_976.0), + ("Zi", 1_180_591_620_717_411_303_424.0), + ("Yi", 1_208_925_819_614_629_174_706_176.0), +]; + +fn find_unit(name: &str) -> Option<&'static Unit> { + UNITS.iter().find(|u| u.names.contains(&name)) +} + +/// Resolves a unit name to (unit, prefix multiplier). Exact names win over prefixed ones. +fn resolve(name: &str) -> Option<(&'static Unit, f64)> { + let name = name.replace("^2", "2").replace("^3", "3"); + if let Some(u) = find_unit(&name) { + return Some((u, 1.0)); + } + for (p, f) in BIN_PREFIXES { + if let Some(rest) = name.strip_prefix(p) + && let Some(u) = find_unit(rest) + && u.pre == Pre::Bin + { + return Some((u, *f)); + } + } + for (p, f) in SI_PREFIXES { + if let Some(rest) = name.strip_prefix(p) + && let Some(u) = find_unit(rest) + { + match u.pre { + Pre::Si(pow) => return Some((u, f.powi(pow))), + Pre::Bin => return Some((u, *f)), + Pre::No => {} + } + } + } + None +} + +fn to_kelvin(name: &str, x: f64) -> f64 { + match name { + "C" | "cel" => x + 273.15, + "F" | "fah" => (x - 32.0) * 5.0 / 9.0 + 273.15, + "Rank" => x * 5.0 / 9.0, + "Reau" => x * 1.25 + 273.15, + _ => x, + } +} + +fn from_kelvin(name: &str, k: f64) -> f64 { + match name { + "C" | "cel" => k - 273.15, + "F" | "fah" => (k - 273.15) * 9.0 / 5.0 + 32.0, + "Rank" => k * 9.0 / 5.0, + "Reau" => (k - 273.15) / 1.25, + _ => k, + } +} + +pub(super) fn convert(a: &[Arg], _c: &mut dyn Ctx) -> R { + let x = num(a, 0)?; + let from = text(a, 1)?; + let to = text(a, 2)?; + let (fu, fp) = resolve(&from).ok_or(CellError::NA)?; + let (tu, tp) = resolve(&to).ok_or(CellError::NA)?; + if fu.dim != tu.dim { + return Err(CellError::NA); + } + if fu.dim == Dim::Temperature { + let fname = fu.names.first().copied().unwrap_or("K"); + let tname = tu.names.first().copied().unwrap_or("K"); + if fname == tname && fp == tp { + return num_val(x); + } + let k = to_kelvin(fname, x * fp); + return num_val(from_kelvin(tname, k) / tp); + } + let base = x * fu.factor * fp; + num_val(base / (tu.factor * tp)) +} diff --git a/crates/functions/src/financial.rs b/crates/functions/src/financial.rs index 55c570c068..b569c16178 100644 --- a/crates/functions/src/financial.rs +++ b/crates/functions/src/financial.rs @@ -9,1318 +9,26 @@ use gridcraft_core::{CellError, DateSystem, Value}; use crate::util::{R, S, array_numbers, as_array, flatten, has, num, num_val, numbers, opt_bool, opt_num, round_half_away, text, to_sig_digits}; use crate::{Arg, Ctx, FnSpec, VAR}; -// --------------------------------------------------------------------------------------------- -// Time value of money -// --------------------------------------------------------------------------------------------- - -/// The payment per period. `(1 + rate)^nper` comes from `nper * ln_1p(rate)` and `p - 1` from -/// `exp_m1`: rounding `1 + rate` first throws away the low bits of a small rate, which left PMT up to -/// about 1000 ULPs from Excel (observed over 400 synthetic probes; this form is within 4). A loan -/// schedule's last `IF(p - SUM(principal) > 0, ...)` turns on those bits. Excel gives #NUM! for a -/// rate of -1 or less, and -pv*rate (no divide) once `(1 + rate)^nper` overflows. -fn pmt_calc(rate: f64, nper: f64, pv: f64, fv: f64, typ: f64) -> f64 { - if rate == 0.0 { - return -(pv + fv) / nper; - } - if rate <= -1.0 { - return f64::NAN; - } - let l = nper * rate.ln_1p(); - let m = l.exp_m1(); - let k = 1.0 + rate * typ; - if m > 0.0 { - // (fv + pv*p) / (p - 1) = pv + (pv + fv) / (p - 1), without the cancellation in p - 1. - // rate / m (about 1 / nper for a tiny rate) is taken first so (pv + fv) / m can't overflow. - -(pv * rate + (pv + fv) * (rate / m)) / k - } else if m > -0.5 { - // p near 1 from below: fv + pv*p = (pv + fv) + pv*m keeps the small terms that rounding p would drop. - -(rate * (pv + fv + pv * m)) / (k * m) - } else { - // p well below 1: m nears -1 and pv*m would swallow pv + fv, so use p itself. - -(rate * (fv + pv * l.exp())) / (k * m) - } -} - -fn fv_calc(rate: f64, nper: f64, pmt: f64, pv: f64, typ: f64) -> f64 { - if rate == 0.0 { - return -(pv + pmt * nper); - } - let p = (1.0 + rate).powf(nper); - -(pv * p + pmt * (1.0 + rate * typ) * (p - 1.0) / rate) -} - -fn pv_calc(rate: f64, nper: f64, pmt: f64, fv: f64, typ: f64) -> f64 { - if rate == 0.0 { - return -(fv + pmt * nper); - } - let p = (1.0 + rate).powf(nper); - -(fv + pmt * (1.0 + rate * typ) * (p - 1.0) / rate) / p -} +mod day_count; +mod depreciation; +mod securities; +mod tvm; + +use day_count::{coupdaybs, coupdays, coupdaysnc, coupncd, coupnum, couppcd}; +use depreciation::{amordegrc, amorlinc, db, ddb, sln, syd, vdb}; +use securities::{ + accrint, accrintm, disc, duration, intrate, mduration, oddfprice, oddfyield, oddlprice, oddlyield, price, pricedisc, pricemat, received, tbilleq, + tbillprice, tbillyield, yield_, yielddisc, yieldmat, +}; +use tvm::{ + cumipmt, cumprinc, dollarde, dollarfr, effect, fv, fvschedule, ipmt, irr, ispmt, mirr, nominal, nper, npv, pduration, pmt, ppmt, pv, rate, rri, + xirr, xnpv, +}; fn type_arg(args: &[Arg], i: usize) -> R { Ok(if opt_num(args, i, 0.0)? != 0.0 { 1.0 } else { 0.0 }) } -fn pmt(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (rate, nper, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - let (fv, typ) = (opt_num(a, 3, 0.0)?, type_arg(a, 4)?); - if nper == 0.0 { - return Err(CellError::Num); - } - num_val(pmt_calc(rate, nper, pv, fv, typ)) -} - -fn fv(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (rate, nper, pmt) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - num_val(fv_calc(rate, nper, pmt, opt_num(a, 3, 0.0)?, type_arg(a, 4)?)) -} - -fn pv(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (rate, nper, pmt) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - num_val(pv_calc(rate, nper, pmt, opt_num(a, 3, 0.0)?, type_arg(a, 4)?)) -} - -fn nper(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (rate, pmt, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - let (fv, typ) = (opt_num(a, 3, 0.0)?, type_arg(a, 4)?); - if rate == 0.0 { - if pmt == 0.0 { - return Err(CellError::Num); - } - return num_val(-(pv + fv) / pmt); - } - let numer = pmt * (1.0 + rate * typ) - fv * rate; - let denom = pv * rate + pmt * (1.0 + rate * typ); - if denom == 0.0 { - return Err(CellError::Num); - } - let q = numer / denom; - if q <= 0.0 || rate <= -1.0 { - return Err(CellError::Num); - } - num_val(q.ln() / (1.0 + rate).ln()) -} - -fn ipmt_calc(rate: f64, per: f64, nper: f64, pv: f64, fv: f64, typ: f64) -> f64 { - let p = pmt_calc(rate, nper, pv, fv, typ); - if typ == 1.0 && per == 1.0 { - return 0.0; - } - let bal = fv_calc(rate, per - 1.0, p, pv, typ); - let i = bal * rate; - if typ == 1.0 { i / (1.0 + rate) } else { i } -} - -fn ipmt(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (rate, per, np, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?); - let (fv, typ) = (opt_num(a, 4, 0.0)?, type_arg(a, 5)?); - if per < 1.0 || per > np || np == 0.0 { - return Err(CellError::Num); - } - num_val(ipmt_calc(rate, per, np, pv, fv, typ)) -} - -fn ppmt(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (rate, per, np, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?); - let (fv, typ) = (opt_num(a, 4, 0.0)?, type_arg(a, 5)?); - if per < 1.0 || per > np || np == 0.0 { - return Err(CellError::Num); - } - num_val(pmt_calc(rate, np, pv, fv, typ) - ipmt_calc(rate, per, np, pv, fv, typ)) -} - -fn cumulative(a: &[Arg], principal: bool) -> R { - let (rate, np, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - let (start, end, typ) = (num(a, 3)?.trunc(), num(a, 4)?.trunc(), num(a, 5)?); - let np_i = np.trunc(); - if rate <= 0.0 || np_i <= 0.0 || pv <= 0.0 || start < 1.0 || end < start || end > np_i || (typ != 0.0 && typ != 1.0) { - return Err(CellError::Num); - } - if end - start > 1e7 || np_i > 1e15 { - return Err(CellError::Num); - } - let p = pmt_calc(rate, np_i, pv, 0.0, typ); - let mut sum = 0.0; - let mut per = start; - while per <= end { - let i = ipmt_calc(rate, per, np_i, pv, 0.0, typ); - sum += if principal { p - i } else { i }; - per += 1.0; - } - num_val(sum) -} - -fn cumipmt(a: &[Arg], _c: &mut dyn Ctx) -> R { - cumulative(a, false) -} - -fn cumprinc(a: &[Arg], _c: &mut dyn Ctx) -> R { - cumulative(a, true) -} - -/// Newton iteration with a numeric derivative, keeping the rate above -1. -fn newton(f: impl Fn(f64) -> f64, guess: f64) -> Option { - let mut x = guess; - for _ in 0..200 { - let y = f(x); - if !y.is_finite() { - return None; - } - let h = 1e-6 * x.abs().max(1e-3); - let d = (f(x + h) - f(x - h)) / (2.0 * h); - if d == 0.0 || !d.is_finite() { - return None; - } - let mut nx = x - y / d; - if nx <= -1.0 { - nx = (x - 1.0) / 2.0; - if nx <= -1.0 { - nx = -0.999_999_999; - } - } - if (nx - x).abs() < 1e-12 * nx.abs().max(1.0) { - return Some(nx); - } - x = nx; - } - None -} - -/// Tries Newton from the guess and a few fallback starting points; then bisection over a -/// bracket if one can be found. -fn solve_rate(f: impl Fn(f64) -> f64, guess: f64) -> R { - let check = |r: f64| f(r).is_finite() && f(r).abs() < 1e-7 * (1.0 + f(guess).abs().min(1e12)); - for g in [guess, 0.1, 0.01, 0.5, -0.5, 1.0, 0.001, 5.0, -0.9, 10.0] { - if let Some(r) = newton(&f, g) - && r > -1.0 - && check(r) - { - return Ok(r); - } - } - // Scan for a sign change and bisect. - let grid: Vec = [-0.999_999, -0.99, -0.9, -0.5, -0.2, 0.0, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 100.0, 1000.0].to_vec(); - for w in grid.windows(2) { - let (mut lo, mut hi) = match w { - [a, b] => (*a, *b), - _ => continue, - }; - let (flo, fhi) = (f(lo), f(hi)); - if !(flo.is_finite() && fhi.is_finite()) || flo * fhi > 0.0 { - continue; - } - let lo_neg = flo < 0.0; - for _ in 0..200 { - let mid = 0.5 * (lo + hi); - let fm = f(mid); - if (fm < 0.0) == lo_neg { - lo = mid; - } else { - hi = mid; - } - } - return Ok(0.5 * (lo + hi)); - } - Err(CellError::Num) -} - -fn rate(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (np, pmt, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - let (fv, typ, guess) = (opt_num(a, 3, 0.0)?, type_arg(a, 4)?, opt_num(a, 5, 0.1)?); - if np <= 0.0 { - return Err(CellError::Num); - } - let f = |r: f64| { - if r.abs() < 1e-12 { - pv + pmt * np + fv - } else { - let p = (1.0 + r).powf(np); - pv * p + pmt * (1.0 + r * typ) * (p - 1.0) / r + fv - } - }; - num_val(solve_rate(f, guess)?) -} - -fn npv(a: &[Arg], _c: &mut dyn Ctx) -> R { - let rate = num(a, 0)?; - if rate == -1.0 { - return Err(CellError::Div0); - } - let vals = numbers(a.get(1..).unwrap_or(&[]))?; - let mut sum = 0.0; - let mut d = 1.0; - for v in vals { - d *= 1.0 + rate; - sum += v / d; - } - num_val(sum) -} - -/// Values and dates for XNPV/XIRR: both must be numeric and of equal length. -fn dated_flows(values: &Value, dates: &Value) -> R<(Vec, Vec)> { - let va = as_array(values); - let da = as_array(dates); - if va.data.len() != da.data.len() { - return Err(CellError::Num); - } - let mut v = Vec::with_capacity(va.data.len()); - let mut d = Vec::with_capacity(da.data.len()); - for (x, y) in va.data.iter().zip(da.data.iter()) { - match x { - Value::Number(n) => v.push(*n), - Value::Error(e) => return Err(*e), - _ => return Err(CellError::Value), - } - match y { - Value::Number(n) => d.push(n.trunc()), - Value::Error(e) => return Err(*e), - Value::Text(_) => d.push(y.to_number()?.trunc()), - _ => return Err(CellError::Value), - } - } - let d0 = *d.first().ok_or(CellError::Num)?; - if d.iter().any(|&x| x < d0 || x < 0.0) { - return Err(CellError::Num); - } - Ok((v, d)) -} - -fn xnpv_calc(rate: f64, v: &[f64], d: &[f64]) -> f64 { - let d0 = d.first().copied().unwrap_or(0.0); - v.iter().zip(d).map(|(x, t)| x / (1.0 + rate).powf((t - d0) / 365.0)).sum() -} - -fn xnpv(a: &[Arg], _c: &mut dyn Ctx) -> R { - let rate = num(a, 0)?; - if rate <= -1.0 { - return Err(CellError::Num); - } - let (v, d) = dated_flows(&a.get(1).ok_or(CellError::Value)?.value, &a.get(2).ok_or(CellError::Value)?.value)?; - num_val(xnpv_calc(rate, &v, &d)) -} - -fn xirr(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (v, d) = dated_flows(&a.first().ok_or(CellError::Value)?.value, &a.get(1).ok_or(CellError::Value)?.value)?; - let guess = opt_num(a, 2, 0.1)?; - if !v.iter().any(|x| *x > 0.0) || !v.iter().any(|x| *x < 0.0) { - return Err(CellError::Num); - } - num_val(solve_rate(|r| xnpv_calc(r, &v, &d), guess)?) -} - -fn irr(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = array_numbers(&a.first().ok_or(CellError::Value)?.value)?; - let guess = opt_num(a, 1, 0.1)?; - if !v.iter().any(|x| *x > 0.0) || !v.iter().any(|x| *x < 0.0) { - return Err(CellError::Num); - } - let f = |r: f64| { - let mut s = 0.0; - let mut d = 1.0; - for x in &v { - s += x / d; - d *= 1.0 + r; - } - s - }; - num_val(solve_rate(f, guess)?) -} - -fn mirr(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = array_numbers(&a.first().ok_or(CellError::Value)?.value)?; - let (fr, rr) = (num(a, 1)?, num(a, 2)?); - let n = v.len() as f64; - if v.len() < 2 { - return Err(CellError::Div0); - } - let (mut pos, mut neg) = (0.0, 0.0); - for (i, x) in v.iter().enumerate() { - if *x > 0.0 { - pos += x / (1.0 + rr).powi(i as i32); - } else if *x < 0.0 { - neg += x / (1.0 + fr).powi(i as i32); - } - } - // No positive flow is not an error in Excel: the formula below gives -1. - if neg == 0.0 { - return Err(CellError::Div0); - } - let r = (-pos * (1.0 + rr).powf(n - 1.0) / neg).powf(1.0 / (n - 1.0)) - 1.0; - num_val(r) -} - -fn fvschedule(a: &[Arg], _c: &mut dyn Ctx) -> R { - let mut p = num(a, 0)?; - for v in flatten(a.get(1..2).unwrap_or(&[])) { - let r = match v { - Value::Number(n) => n, - Value::Empty => 0.0, - Value::Error(e) => return Err(e), - _ => return Err(CellError::Value), - }; - p *= 1.0 + r; - } - num_val(p) -} - -fn effect(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (nom, n) = (num(a, 0)?, num(a, 1)?.trunc()); - if nom <= 0.0 || n < 1.0 { - return Err(CellError::Num); - } - num_val((1.0 + nom / n).powf(n) - 1.0) -} - -fn nominal(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (eff, n) = (num(a, 0)?, num(a, 1)?.trunc()); - if eff <= 0.0 || n < 1.0 { - return Err(CellError::Num); - } - num_val(n * ((1.0 + eff).powf(1.0 / n) - 1.0)) -} - -fn ispmt(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (rate, per, np, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?); - if np == 0.0 { - return Err(CellError::Div0); - } - num_val(pv * rate * (per / np - 1.0)) -} - -fn pduration(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (rate, pv, fv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - if rate <= 0.0 || pv <= 0.0 || fv <= 0.0 { - return Err(CellError::Num); - } - num_val((fv.ln() - pv.ln()) / (1.0 + rate).ln()) -} - -fn rri(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (np, pv, fv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - if np <= 0.0 || pv == 0.0 { - return Err(CellError::Num); - } - num_val((fv / pv).powf(1.0 / np) - 1.0) -} - -fn dollar_digits(fraction: f64) -> R { - if fraction < 0.0 { - return Err(CellError::Num); - } - if fraction < 1.0 { - return Err(CellError::Div0); - } - Ok(10f64.powf(fraction.log10().ceil())) -} - -fn dollarde(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (x, f) = (num(a, 0)?, num(a, 1)?.trunc()); - let p = dollar_digits(f)?; - let int = x.trunc(); - num_val(int + (x - int) * p / f) -} - -fn dollarfr(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (x, f) = (num(a, 0)?, num(a, 1)?.trunc()); - let p = dollar_digits(f)?; - let int = x.trunc(); - num_val(int + (x - int) * f / p) -} - -// --------------------------------------------------------------------------------------------- -// Depreciation -// --------------------------------------------------------------------------------------------- - -fn sln(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (cost, salvage, life) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - if life == 0.0 { - return Err(CellError::Div0); - } - num_val((cost - salvage) / life) -} - -fn syd(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (cost, salvage, life, per) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?); - if life <= 0.0 || per <= 0.0 || per > life || salvage < 0.0 { - return Err(CellError::Num); - } - num_val((cost - salvage) * (life - per + 1.0) * 2.0 / (life * (life + 1.0))) -} - -fn db(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (cost, salvage, life, period) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?.trunc()); - let month = opt_num(a, 4, 12.0)?.trunc(); - if cost < 0.0 || salvage < 0.0 || life <= 0.0 || period <= 0.0 || !(1.0..=12.0).contains(&month) { - return Err(CellError::Num); - } - let max_period = if month == 12.0 { life.trunc() } else { life.trunc() + 1.0 }; - if period > max_period.max(1.0) || period > 1e7 { - return Err(CellError::Num); - } - if cost == 0.0 { - return num_val(0.0); - } - let rate = crate::util::round_half_away(1.0 - (salvage / cost).powf(1.0 / life), 3); - let mut total = 0.0; - let mut dep = 0.0; - let mut p = 1.0; - while p <= period { - dep = if p == 1.0 { - cost * rate * month / 12.0 - } else if p == life.trunc() + 1.0 { - (cost - total) * rate * (12.0 - month) / 12.0 - } else { - (cost - total) * rate - }; - total += dep; - p += 1.0; - } - num_val(dep) -} - -fn ddb(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (cost, salvage, life, period) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?); - let factor = opt_num(a, 4, 2.0)?; - if cost < 0.0 || salvage < 0.0 || life <= 0.0 || period <= 0.0 || factor <= 0.0 || period > life { - return Err(CellError::Num); - } - let mut rate = factor / life; - let old; - if rate >= 1.0 { - rate = 1.0; - old = if period == 1.0 { cost } else { 0.0 }; - } else { - old = cost * (1.0 - rate).powf(period - 1.0); - } - let new = cost * (1.0 - rate).powf(period); - let dep = if new < salvage { old - salvage } else { old - new }; - num_val(dep.max(0.0)) -} - -/// Per-period declining-balance depreciation with optional switch to straight line. -fn vdb_schedule(cost: f64, salvage: f64, life: f64, factor: f64, no_switch: bool, periods: usize) -> Vec { - let mut out = Vec::with_capacity(periods); - let mut book = cost; - let mut sl: Option = None; - for i in 0..periods { - let dep = if let Some(s) = sl { - s.min((book - salvage).max(0.0)) - } else { - let ddb = (book * factor / life).min((book - salvage).max(0.0)); - let remaining = life - i as f64; - let slv = if remaining > 0.0 { (book - salvage) / remaining.max(1.0) } else { 0.0 }; - if !no_switch && slv > ddb { - sl = Some(slv); - slv.min((book - salvage).max(0.0)) - } else { - ddb - } - }; - out.push(dep); - book -= dep; - } - out -} - -fn vdb(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (cost, salvage, life, start, end) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?, num(a, 4)?); - let factor = opt_num(a, 5, 2.0)?; - let no_switch = opt_bool(a, 6, false)?; - if cost < 0.0 || salvage < 0.0 || life <= 0.0 || start < 0.0 || end < start || end > life || factor <= 0.0 { - return Err(CellError::Num); - } - let periods = end.ceil(); - if periods > 1e7 { - return Err(CellError::Num); - } - let sched = vdb_schedule(cost, salvage, life, factor, no_switch, periods as usize); - let mut total = 0.0; - for (i, d) in sched.iter().enumerate() { - let (p0, p1) = (i as f64, i as f64 + 1.0); - let overlap = (p1.min(end) - p0.max(start)).max(0.0); - total += d * overlap; - } - num_val(total) -} - -fn amorlinc(a: &[Arg], c: &mut dyn Ctx) -> R { - let (cost, purchased, first, salvage) = (num(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?); - let (period, rate, basis) = (num(a, 4)?.trunc(), num(a, 5)?, basis_arg(a, 6)?); - if cost < 0.0 || salvage < 0.0 || salvage > cost || period < 0.0 || rate <= 0.0 || first < purchased { - return Err(CellError::Num); - } - let full = cost * rate; - let limit = cost - salvage; - let dep0 = (full * year_frac(c.date_system(), purchased, first, basis)?).min(limit); - if period == 0.0 { - return num_val(dep0); - } - let remaining = limit - dep0 - (period - 1.0) * full; - num_val(full.min(remaining.max(0.0))) -} - -fn amordegrc(a: &[Arg], c: &mut dyn Ctx) -> R { - let (cost, purchased, first, salvage) = (num(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?); - let (period, rate, basis) = (num(a, 4)?.trunc(), num(a, 5)?, basis_arg(a, 6)?); - if cost < 0.0 || salvage < 0.0 || salvage > cost || period < 0.0 || rate <= 0.0 || first < purchased || period > 1e6 { - return Err(CellError::Num); - } - let life = 1.0 / rate; - let coef = if life < 3.0 { - 1.0 - } else if life < 5.0 { - 1.5 - } else if life <= 6.0 { - 2.0 - } else { - 2.5 - }; - let r = rate * coef; - let mut book = cost; - let mut dep = (year_frac(c.date_system(), purchased, first, basis)? * r * cost).round(); - if period == 0.0 { - return num_val(dep); - } - book -= dep; - let mut rest = book - salvage; - let mut n = 1.0; - while n <= period { - dep = (r * book).round(); - rest -= dep; - if rest < 0.0 { - // The two final periods split what remains; afterwards nothing is left. - let left = period - n; - return num_val(if left <= 1.0 { (book * 0.5).round() } else { 0.0 }); - } - book -= dep; - n += 1.0; - } - num_val(dep) -} - -// --------------------------------------------------------------------------------------------- -// Dates and day counts -// --------------------------------------------------------------------------------------------- - -fn date(a: &[Arg], i: usize) -> R { - let d = num(a, i)?.trunc(); - if !(0.0..=2_958_465.0).contains(&d) { - return Err(CellError::Num); - } - Ok(d) -} - -fn ymd(sys: DateSystem, s: f64) -> R<(i32, u32, u32)> { - let dt = datetime_from_serial(sys, s.trunc()).ok_or(CellError::Num)?; - Ok((dt.year, dt.month, dt.day.max(1))) -} - -fn serial(sys: DateSystem, y: i32, m: u32, d: u32) -> R { - serial_from_ymd(sys, y as i64, m as i64, d as i64).ok_or(CellError::Num) -} - -fn basis_arg(a: &[Arg], i: usize) -> R { - let b = opt_num(a, i, 0.0)?.trunc(); - if !(0.0..=4.0).contains(&b) { - return Err(CellError::Num); - } - Ok(b as u32) -} - -fn freq_arg(a: &[Arg], i: usize) -> R { - let f = num(a, i)?.trunc(); - if f != 1.0 && f != 2.0 && f != 4.0 { - return Err(CellError::Num); - } - Ok(f) -} - -/// 30/360 day count (US/NASD when `us`, European otherwise). -fn days360(sys: DateSystem, s1: f64, s2: f64, us: bool) -> R { - let (y1, m1, mut d1) = ymd(sys, s1)?; - let (y2, m2, mut d2) = ymd(sys, s2)?; - if us { - let feb_end1 = m1 == 2 && d1 >= days_in_month(y1, 2); - let feb_end2 = m2 == 2 && d2 >= days_in_month(y2, 2); - if feb_end1 && feb_end2 { - d2 = 30; - } - if feb_end1 { - d1 = 30; - } - if d2 == 31 && d1 >= 30 { - d2 = 30; - } - if d1 == 31 { - d1 = 30; - } - } else { - d1 = d1.min(30); - d2 = d2.min(30); - } - Ok((y2 - y1) as f64 * 360.0 + (m2 as f64 - m1 as f64) * 30.0 + (d2 as f64 - d1 as f64)) -} - -/// Days between two dates under a basis. -fn day_count(sys: DateSystem, s1: f64, s2: f64, basis: u32) -> R { - match basis { - 0 => days360(sys, s1, s2, true), - 4 => days360(sys, s1, s2, false), - _ => Ok(s2 - s1), - } -} - -/// Year fraction between two dates under a basis (YEARFRAC semantics). -fn year_frac(sys: DateSystem, s1: f64, s2: f64, basis: u32) -> R { - let (s1, s2) = if s1 <= s2 { (s1, s2) } else { (s2, s1) }; - match basis { - 0 => Ok(days360(sys, s1, s2, true)? / 360.0), - 2 => Ok((s2 - s1) / 360.0), - 3 => Ok((s2 - s1) / 365.0), - 4 => Ok(days360(sys, s1, s2, false)? / 360.0), - _ => { - let (y1, m1, d1) = ymd(sys, s1)?; - let (y2, m2, d2) = ymd(sys, s2)?; - let days = s2 - s1; - if y1 == y2 { - return Ok(days / if is_leap(y1) { 366.0 } else { 365.0 }); - } - let within_year = y2 == y1 + 1 && (m2 < m1 || (m2 == m1 && d2 <= d1)); - if within_year { - let feb29_1 = is_leap(y1) && (m1 < 2 || (m1 == 2 && d1 <= 29)); - let feb29_2 = is_leap(y2) && (m2 > 2 || (m2 == 2 && d2 == 29)); - let denom = if feb29_1 || feb29_2 { 366.0 } else { 365.0 }; - return Ok(days / denom); - } - let start = serial(sys, y1, 1, 1)?; - let end = serial(sys, y2 + 1, 1, 1).unwrap_or(max_serial(sys) as f64 + 1.0); - let avg = (end - start) / (y2 - y1 + 1) as f64; - Ok(days / avg) - } - } -} - -/// Days in a year for discount securities. -fn year_basis(sys: DateSystem, basis: u32, s: f64) -> R { - Ok(match basis { - 0 | 2 | 4 => 360.0, - 3 => 365.0, - _ => { - if is_leap(ymd(sys, s)?.0) { - 366.0 - } else { - 365.0 - } - } - }) -} - -/// Date `months` months before/after an anchor date, keeping end-of-month alignment. -fn shift_months(sys: DateSystem, anchor: (i32, u32, u32), months: i64, eom: bool) -> R { - let (y, m, d) = anchor; - let total = y as i64 * 12 + (m as i64 - 1) + months; - let ny = total.div_euclid(12); - let nm = (total.rem_euclid(12) + 1) as u32; - if !(1900..=9999).contains(&ny) { - return Err(CellError::Num); - } - let dim = days_in_month(ny as i32, nm); - let nd = if eom { dim } else { d.min(dim) }; - serial(sys, ny as i32, nm, nd) -} - -struct Coupon { - pcd: f64, - ncd: f64, - num: f64, -} - -fn coupons(sys: DateSystem, settle: f64, mat: f64, freq: f64) -> R { - if settle >= mat { - return Err(CellError::Num); - } - let anchor = ymd(sys, mat)?; - let eom = anchor.2 == days_in_month(anchor.0, anchor.1); - let step = (12.0 / freq) as i64; - // Estimate the count from the span, then adjust. - let mut k = (((mat - settle) / 365.25 * freq).floor() as i64).max(1); - while k > 1 && shift_months(sys, anchor, -(k - 1) * step, eom)? <= settle { - k -= 1; - } - let mut guard = 0; - while shift_months(sys, anchor, -k * step, eom)? > settle { - k += 1; - guard += 1; - if guard > 100_000 { - return Err(CellError::Num); - } - } - Ok(Coupon { pcd: shift_months(sys, anchor, -k * step, eom)?, ncd: shift_months(sys, anchor, -(k - 1) * step, eom)?, num: k as f64 }) -} - -fn coup_days(c: &Coupon, freq: f64, basis: u32) -> f64 { - match basis { - 1 => c.ncd - c.pcd, - 3 => 365.0 / freq, - _ => 360.0 / freq, - } -} - -fn coup_daybs(sys: DateSystem, c: &Coupon, settle: f64, basis: u32) -> R { - day_count(sys, c.pcd, settle, basis) -} - -fn coup_daysnc(sys: DateSystem, c: &Coupon, settle: f64, freq: f64, basis: u32) -> R { - match basis { - 0 | 4 => Ok(coup_days(c, freq, basis) - coup_daybs(sys, c, settle, basis)?), - _ => Ok(c.ncd - settle), - } -} - -/// settlement, maturity, frequency, basis at positions 0..=3 for the COUP* functions. -fn coup_args(a: &[Arg], sys: DateSystem) -> R<(f64, f64, f64, u32, Coupon)> { - let (s, m, f, b) = (date(a, 0)?, date(a, 1)?, freq_arg(a, 2)?, basis_arg(a, 3)?); - let c = coupons(sys, s, m, f)?; - Ok((s, m, f, b, c)) -} - -fn coupdaybs(a: &[Arg], cx: &mut dyn Ctx) -> R { - let (s, _, _, b, c) = coup_args(a, cx.date_system())?; - num_val(coup_daybs(cx.date_system(), &c, s, b)?) -} - -fn coupdays(a: &[Arg], cx: &mut dyn Ctx) -> R { - let (_, _, f, b, c) = coup_args(a, cx.date_system())?; - num_val(coup_days(&c, f, b)) -} - -fn coupdaysnc(a: &[Arg], cx: &mut dyn Ctx) -> R { - let (s, _, f, b, c) = coup_args(a, cx.date_system())?; - num_val(coup_daysnc(cx.date_system(), &c, s, f, b)?) -} - -fn coupncd(a: &[Arg], c: &mut dyn Ctx) -> R { - num_val(coup_args(a, c.date_system())?.4.ncd) -} - -fn couppcd(a: &[Arg], c: &mut dyn Ctx) -> R { - num_val(coup_args(a, c.date_system())?.4.pcd) -} - -fn coupnum(a: &[Arg], c: &mut dyn Ctx) -> R { - num_val(coup_args(a, c.date_system())?.4.num) -} - -// --------------------------------------------------------------------------------------------- -// Securities -// --------------------------------------------------------------------------------------------- - -fn accrint(a: &[Arg], c: &mut dyn Ctx) -> R { - let sys = c.date_system(); - let (issue, first, settle, rate) = (date(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?); - let par = if has(a, 4) { num(a, 4)? } else { 1000.0 }; - let freq = freq_arg(a, 5)?; - let basis = basis_arg(a, 6)?; - let calc_method = opt_bool(a, 7, true)?; - if issue >= settle || rate <= 0.0 || par <= 0.0 { - return Err(CellError::Num); - } - let anchor = ymd(sys, first)?; - let eom = anchor.2 == days_in_month(anchor.0, anchor.1); - let step = (12.0 / freq) as i64; - let cd = |j: i64| shift_months(sys, anchor, j * step, eom); - // Coupon index of the period containing a date: cd(j) <= date < cd(j+1). - let index_of = |d: f64| -> R { - let mut j = (((d - first) / 365.25 * freq).floor() as i64).clamp(-200_000, 200_000); - let mut guard = 0; - while cd(j)? > d { - j -= 1; - guard += 1; - if guard > 400_000 { - return Err(CellError::Num); - } - } - while cd(j + 1)? <= d { - j += 1; - guard += 1; - if guard > 400_000 { - return Err(CellError::Num); - } - } - Ok(j) - }; - let start = if calc_method || settle <= first { issue } else { cd(index_of(settle)?)? }; - let mut j = index_of(start)?; - let mut sum = 0.0; - let mut guard = 0; - loop { - let (p0, p1) = (cd(j)?, cd(j + 1)?); - if p0 >= settle { - break; - } - let from = p0.max(start); - let to = p1.min(settle); - if to > from { - let nl = match basis { - 1 => p1 - p0, - 3 => 365.0 / freq, - _ => 360.0 / freq, - }; - sum += day_count(sys, from, to, basis)? / nl; - } - j += 1; - guard += 1; - if guard > 100_000 { - return Err(CellError::Num); - } - } - num_val(par * rate / freq * sum) -} - -fn accrintm(a: &[Arg], c: &mut dyn Ctx) -> R { - let (issue, settle, rate) = (date(a, 0)?, date(a, 1)?, num(a, 2)?); - let par = if has(a, 3) { num(a, 3)? } else { 1000.0 }; - let basis = basis_arg(a, 4)?; - if issue >= settle || rate <= 0.0 || par <= 0.0 { - return Err(CellError::Num); - } - num_val(par * rate * year_frac(c.date_system(), issue, settle, basis)?) -} - -/// settlement, maturity, value, value, [basis] for discount securities. -fn disc_args(a: &[Arg]) -> R<(f64, f64, f64, f64, u32)> { - let (s, m, x, y, b) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?, basis_arg(a, 4)?); - if s >= m || x <= 0.0 || y <= 0.0 { - return Err(CellError::Num); - } - Ok((s, m, x, y, b)) -} - -fn disc_frac(sys: DateSystem, s: f64, m: f64, b: u32) -> R { - Ok(day_count(sys, s, m, b)? / year_basis(sys, b, s)?) -} - -fn disc(a: &[Arg], c: &mut dyn Ctx) -> R { - let (s, m, pr, red, b) = disc_args(a)?; - num_val((1.0 - pr / red) / disc_frac(c.date_system(), s, m, b)?) -} - -fn pricedisc(a: &[Arg], c: &mut dyn Ctx) -> R { - let (s, m, d, red, b) = disc_args(a)?; - num_val(red - d * red * disc_frac(c.date_system(), s, m, b)?) -} - -fn yielddisc(a: &[Arg], c: &mut dyn Ctx) -> R { - let (s, m, pr, red, b) = disc_args(a)?; - num_val((red / pr - 1.0) / disc_frac(c.date_system(), s, m, b)?) -} - -fn intrate(a: &[Arg], c: &mut dyn Ctx) -> R { - let (s, m, inv, red, b) = disc_args(a)?; - num_val((red - inv) / inv / disc_frac(c.date_system(), s, m, b)?) -} - -fn received(a: &[Arg], c: &mut dyn Ctx) -> R { - let (s, m, inv, d, b) = disc_args(a)?; - let denom = 1.0 - d * disc_frac(c.date_system(), s, m, b)?; - if denom <= 0.0 { - return Err(CellError::Num); - } - num_val(inv / denom) -} - -fn tbill_args(a: &[Arg], sys: DateSystem) -> R<(f64, f64)> { - let (s, m, x) = (date(a, 0)?, date(a, 1)?, num(a, 2)?); - if s >= m || x <= 0.0 { - return Err(CellError::Num); - } - // At most one year after settlement. - let (y, mo, d) = ymd(sys, s)?; - let limit = shift_months(sys, (y, mo, d), 12, false)?; - if m > limit { - return Err(CellError::Num); - } - Ok((m - s, x)) -} - -fn tbillprice(a: &[Arg], c: &mut dyn Ctx) -> R { - let (dsm, d) = tbill_args(a, c.date_system())?; - let p = 100.0 * (1.0 - d * dsm / 360.0); - if p <= 0.0 { - return Err(CellError::Num); - } - num_val(p) -} - -fn tbillyield(a: &[Arg], c: &mut dyn Ctx) -> R { - let (dsm, pr) = tbill_args(a, c.date_system())?; - num_val((100.0 - pr) / pr * 360.0 / dsm) -} - -fn tbilleq(a: &[Arg], c: &mut dyn Ctx) -> R { - let (dsm, d) = tbill_args(a, c.date_system())?; - if dsm <= 182.0 { - return num_val(365.0 * d / (360.0 - d * dsm)); - } - let price = 100.0 * (1.0 - d * dsm / 360.0); - if price <= 0.0 { - return Err(CellError::Num); - } - let t = dsm / 365.0; - let disc = t * t - (2.0 * t - 1.0) * (1.0 - 100.0 / price); - if disc < 0.0 { - return Err(CellError::Num); - } - num_val((-t + disc.sqrt()) / (t - 0.5)) -} - -/// settlement, maturity, issue, rate, value, [basis]. -fn mat_terms(a: &[Arg], sys: DateSystem) -> R<(f64, f64, f64, f64, f64)> { - let (s, m, issue, rate, x, b) = (date(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?, num(a, 4)?, basis_arg(a, 5)?); - if s >= m || issue >= s || rate < 0.0 || x < 0.0 { - return Err(CellError::Num); - } - let dim = year_frac(sys, issue, m, b)?; - let dsm = year_frac(sys, s, m, b)?; - let ai = year_frac(sys, issue, s, b)?; - Ok((dim, dsm, ai, rate, x)) -} - -fn pricemat(a: &[Arg], c: &mut dyn Ctx) -> R { - let (dim, dsm, ai, rate, yld) = mat_terms(a, c.date_system())?; - num_val((100.0 + dim * rate * 100.0) / (1.0 + dsm * yld) - ai * rate * 100.0) -} - -fn yieldmat(a: &[Arg], c: &mut dyn Ctx) -> R { - let (dim, dsm, ai, rate, pr) = mat_terms(a, c.date_system())?; - if pr <= 0.0 || dsm == 0.0 { - return Err(CellError::Num); - } - let base = pr / 100.0 + ai * rate; - num_val(((1.0 + dim * rate) - base) / base / dsm) -} - -struct Bond { - n: f64, - e: f64, - a: f64, - dsc: f64, - freq: f64, -} - -fn bond(sys: DateSystem, settle: f64, mat: f64, freq: f64, basis: u32) -> R { - let c = coupons(sys, settle, mat, freq)?; - let e = coup_days(&c, freq, basis); - let a = coup_daybs(sys, &c, settle, basis)?; - let dsc = coup_daysnc(sys, &c, settle, freq, basis)?; - if e <= 0.0 { - return Err(CellError::Num); - } - Ok(Bond { n: c.num, e, a, dsc, freq }) -} - -fn bond_price(b: &Bond, rate: f64, yld: f64, red: f64) -> f64 { - let f = b.freq; - let coupon = 100.0 * rate / f; - let accrued = coupon * b.a / b.e; - if b.n == 1.0 { - return (red + coupon) / (1.0 + b.dsc / b.e * yld / f) - accrued; - } - let base = 1.0 + yld / f; - let frac = b.dsc / b.e; - let mut sum = red / base.powf(b.n - 1.0 + frac); - let mut k = 1.0; - while k <= b.n { - sum += coupon / base.powf(k - 1.0 + frac); - k += 1.0; - } - sum - accrued -} - -fn price(a: &[Arg], c: &mut dyn Ctx) -> R { - let (s, m, rate, yld, red) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?, num(a, 4)?); - let (f, basis) = (freq_arg(a, 5)?, basis_arg(a, 6)?); - if rate < 0.0 || yld < 0.0 || red <= 0.0 { - return Err(CellError::Num); - } - let b = bond(c.date_system(), s, m, f, basis)?; - num_val(bond_price(&b, rate, yld, red)) -} - -fn yield_(a: &[Arg], c: &mut dyn Ctx) -> R { - let (s, m, rate, pr, red) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?, num(a, 4)?); - let (f, basis) = (freq_arg(a, 5)?, basis_arg(a, 6)?); - if rate < 0.0 || pr <= 0.0 || red <= 0.0 { - return Err(CellError::Num); - } - let b = bond(c.date_system(), s, m, f, basis)?; - if b.n == 1.0 { - let base = pr / 100.0 + b.a / b.e * rate / f; - return num_val(((red / 100.0 + rate / f) - base) / base * f * b.e / b.dsc); - } - // Price is decreasing in yield: bisect on [0, hi]. - let g = |y: f64| bond_price(&b, rate, y, red) - pr; - let (mut lo, mut hi) = (-0.999_999 * f, 1.0); - let mut guard = 0; - while g(hi) > 0.0 && guard < 60 { - hi *= 2.0; - guard += 1; - } - if !(g(lo) >= 0.0 && g(hi) <= 0.0) { - return Err(CellError::Num); - } - for _ in 0..200 { - let mid = 0.5 * (lo + hi); - if g(mid) > 0.0 { - lo = mid; - } else { - hi = mid; - } - } - num_val(0.5 * (lo + hi)) -} - -fn duration_calc(a: &[Arg], sys: DateSystem) -> R<(f64, f64, f64)> { - let (s, m, coupon, yld) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?); - let (f, basis) = (freq_arg(a, 4)?, basis_arg(a, 5)?); - if coupon < 0.0 || yld < 0.0 { - return Err(CellError::Num); - } - let b = bond(sys, s, m, f, basis)?; - let c = coupon * 100.0 / f; - let y = 1.0 + yld / f; - let frac = b.dsc / b.e; - let (mut wsum, mut psum) = (0.0, 0.0); - let mut k = 1.0; - while k <= b.n { - let t = k - 1.0 + frac; - let cf = if k == b.n { c + 100.0 } else { c }; - let pv = cf / y.powf(t); - wsum += t * pv; - psum += pv; - k += 1.0; - } - if psum == 0.0 { - return Err(CellError::Num); - } - Ok((wsum / psum / f, yld, f)) -} - -fn duration(a: &[Arg], c: &mut dyn Ctx) -> R { - num_val(duration_calc(a, c.date_system())?.0) -} - -fn mduration(a: &[Arg], c: &mut dyn Ctx) -> R { - let (d, y, f) = duration_calc(a, c.date_system())?; - num_val(d / (1.0 + y / f)) -} - -// --------------------------------------------------------------------------------------------- -// Odd first and last coupon periods -// --------------------------------------------------------------------------------------------- -// -// An odd period is split into quasi-coupon periods: the regular coupon periods the security -// would have had, counted back from the first coupon (ODDF*) or forward from the last interest -// date (ODDL*). For quasi-period i, NL is its normal length, DC the days of it inside the odd -// period, A the accrued days of it before settlement and DSC the days of it from settlement on. - -/// Most quasi-coupon periods an odd period may span (the date range allows ~32,400 quarters). -const MAX_QUASI: i64 = 40_000; - -/// Normal length of the coupon period `[start, end]` under a basis (COUPDAYS semantics). -fn normal_length(start: f64, end: f64, freq: f64, basis: u32) -> f64 { - match basis { - 1 => end - start, - 3 => 365.0 / freq, - _ => 360.0 / freq, - } -} - -/// Days of `[from, to]` under a basis, or 0 when the span is empty. -fn span(sys: DateSystem, from: f64, to: f64, basis: u32) -> R { - if to > from { day_count(sys, from, to, basis) } else { Ok(0.0) } -} - -/// The odd-first-period terms that ODDFPRICE and ODDFYIELD share. -struct OddFirst { - /// Coupons after the first coupon up to maturity. - n: f64, - /// Whole quasi-coupon periods between settlement and the first coupon. - nq: f64, - /// DSC / NL of the quasi-period holding settlement. - frac: f64, - /// Σ DCi / NLi over the odd period. - dc: f64, - /// Σ Ai / NLi over the odd period. - acc: f64, - freq: f64, -} - -fn odd_first(sys: DateSystem, settle: f64, mat: f64, issue: f64, first: f64, freq: f64, basis: u32) -> R { - if !(mat > first && first > settle && settle > issue) { - return Err(CellError::Num); - } - let anchor = ymd(sys, first)?; - let eom = anchor.2 == days_in_month(anchor.0, anchor.1); - let step = (12.0 / freq) as i64; - let quasi = |k: i64| shift_months(sys, anchor, -k * step, eom); - let (mut dc, mut acc) = (0.0, 0.0); - let (mut nq, mut frac) = (None, 0.0); - // Walk back from the first coupon: period k runs from quasi(k + 1) to quasi(k). - let mut k = 0; - loop { - if k >= MAX_QUASI { - return Err(CellError::Num); - } - let (end, start) = (quasi(k)?, quasi(k + 1)?); - let nl = normal_length(start, end, freq, basis); - if nl <= 0.0 { - return Err(CellError::Num); - } - let from = start.max(issue); - dc += span(sys, from, end, basis)? / nl; - acc += span(sys, from, settle.min(end), basis)? / nl; - if start <= settle && settle < end { - nq = Some(k as f64); - frac = span(sys, settle, end, basis)? / nl; - } - if start <= issue { - break; - } - k += 1; - } - let nq = nq.ok_or(CellError::Num)?; - let n = coupons(sys, first, mat, freq)?.num; - Ok(OddFirst { n, nq, frac, dc, acc, freq }) -} - -fn odd_first_price(t: &OddFirst, rate: f64, yld: f64, red: f64) -> f64 { - let coupon = 100.0 * rate / t.freq; - let base = 1.0 + yld / t.freq; - let lead = t.nq + t.frac; - let mut p = red / base.powf(t.n + lead) + coupon * t.dc / base.powf(lead); - let mut k = 1.0; - while k <= t.n { - p += coupon / base.powf(k + lead); - k += 1.0; - } - p - coupon * t.acc -} - -/// settlement, maturity, issue, first_coupon, rate, yld/pr, redemption, frequency, [basis]. -fn odd_first_args(a: &[Arg], sys: DateSystem) -> R<(OddFirst, f64, f64, f64)> { - let (s, m, issue, first) = (date(a, 0)?, date(a, 1)?, date(a, 2)?, date(a, 3)?); - let (rate, x, red) = (num(a, 4)?, num(a, 5)?, num(a, 6)?); - let (f, basis) = (freq_arg(a, 7)?, basis_arg(a, 8)?); - if rate < 0.0 || red <= 0.0 { - return Err(CellError::Num); - } - Ok((odd_first(sys, s, m, issue, first, f, basis)?, rate, x, red)) -} - -fn oddfprice(a: &[Arg], c: &mut dyn Ctx) -> R { - let (t, rate, yld, red) = odd_first_args(a, c.date_system())?; - if yld < 0.0 { - return Err(CellError::Num); - } - num_val(odd_first_price(&t, rate, yld, red)) -} - -fn oddfyield(a: &[Arg], c: &mut dyn Ctx) -> R { - let (t, rate, pr, red) = odd_first_args(a, c.date_system())?; - if pr <= 0.0 { - return Err(CellError::Num); - } - // The price falls as the yield rises: bracket the root, then bisect. - let g = |y: f64| odd_first_price(&t, rate, y, red) - pr; - let (mut lo, mut hi) = (-0.999_999 * t.freq, 1.0); - let mut guard = 0; - while g(hi) > 0.0 && guard < 60 { - hi *= 2.0; - guard += 1; - } - // NaN fails both comparisons. - if !(g(lo) >= 0.0 && g(hi) <= 0.0) { - return Err(CellError::Num); - } - for _ in 0..200 { - let mid = 0.5 * (lo + hi); - if g(mid) > 0.0 { - lo = mid; - } else { - hi = mid; - } - } - num_val(0.5 * (lo + hi)) -} - -/// Σ DCi/NLi, Σ Ai/NLi and Σ DSCi/NLi over an odd last period. -fn odd_last(sys: DateSystem, settle: f64, mat: f64, last: f64, freq: f64, basis: u32) -> R<(f64, f64, f64)> { - if !(mat > settle && settle > last) { - return Err(CellError::Num); - } - let anchor = ymd(sys, last)?; - let eom = anchor.2 == days_in_month(anchor.0, anchor.1); - let step = (12.0 / freq) as i64; - let (mut dc, mut acc, mut dsc) = (0.0, 0.0, 0.0); - // Walk forward from the last interest date: period k runs from quasi(k - 1) to quasi(k). - let mut start = last; - let mut k = 1; - loop { - if k > MAX_QUASI { - return Err(CellError::Num); - } - let end = shift_months(sys, anchor, k * step, eom)?; - let nl = normal_length(start, end, freq, basis); - if nl <= 0.0 { - return Err(CellError::Num); - } - let stop = end.min(mat); - dc += span(sys, start, stop, basis)? / nl; - acc += span(sys, start, settle.min(stop), basis)? / nl; - dsc += span(sys, settle.max(start), stop, basis)? / nl; - if end >= mat { - break; - } - start = end; - k += 1; - } - Ok((dc, acc, dsc)) -} - -/// settlement, maturity, last_interest, rate, yld/pr, redemption, frequency, [basis]. -fn odd_last_args(a: &[Arg], sys: DateSystem) -> R<((f64, f64, f64), f64, f64, f64, f64)> { - let (s, m, last) = (date(a, 0)?, date(a, 1)?, date(a, 2)?); - let (rate, x, red) = (num(a, 3)?, num(a, 4)?, num(a, 5)?); - let (f, basis) = (freq_arg(a, 6)?, basis_arg(a, 7)?); - if rate < 0.0 || red <= 0.0 { - return Err(CellError::Num); - } - Ok((odd_last(sys, s, m, last, f, basis)?, rate, x, red, f)) -} - -fn oddlprice(a: &[Arg], c: &mut dyn Ctx) -> R { - let ((dc, acc, dsc), rate, yld, red, f) = odd_last_args(a, c.date_system())?; - if yld < 0.0 { - return Err(CellError::Num); - } - let coupon = 100.0 * rate / f; - num_val((red + dc * coupon) / (1.0 + dsc * yld / f) - acc * coupon) -} - -fn oddlyield(a: &[Arg], c: &mut dyn Ctx) -> R { - let ((dc, acc, dsc), rate, pr, red, f) = odd_last_args(a, c.date_system())?; - if pr <= 0.0 { - return Err(CellError::Num); - } - let coupon = 100.0 * rate / f; - let paid = pr + acc * coupon; - if paid <= 0.0 || dsc <= 0.0 { - return Err(CellError::Num); - } - num_val((red + dc * coupon - paid) / paid * f / dsc) -} - // --------------------------------------------------------------------------------------------- // Euro conversion // --------------------------------------------------------------------------------------------- @@ -1774,327 +482,4 @@ pub(crate) fn specs() -> Vec { } #[cfg(test)] -mod tests { - use crate::Arg; - use crate::util::testutil::*; - use gridcraft_core::{CellError, DateSystem, Value, date::serial_from_ymd}; - - fn d(y: i64, m: i64, dd: i64) -> Arg { - n(serial_from_ymd(DateSystem::D1900, y, m, dd).unwrap()) - } - - #[test] - fn tvm() { - close_tol(ev("PMT", vec![n(0.08 / 12.0), n(10.0), n(10000.0)]), -1037.032089, 1e-9); - close_tol(ev("PMT", vec![n(0.06 / 12.0), n(216.0), n(0.0), n(50000.0)]), -129.0811609, 1e-9); - close(ev("PMT", vec![n(0.0), n(10.0), n(1000.0)]), -100.0); - is_err(ev("PMT", vec![n(0.1), n(0.0), n(1000.0)]), CellError::Num); - close_tol(ev("FV", vec![n(0.06 / 12.0), n(10.0), n(-200.0), n(-500.0), n(1.0)]), 2581.403374, 1e-9); - close_tol(ev("FV", vec![n(0.01), n(12.0), n(-1000.0)]), 12682.50301, 1e-9); - close(ev("FV", vec![n(0.0), n(10.0), n(-100.0)]), 1000.0); - close_tol(ev("PV", vec![n(0.08 / 12.0), n(240.0), n(500.0)]), -59777.14585, 1e-9); - close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0), n(10000.0), n(1.0)]), 59.6738657, 1e-8); - close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0), n(10000.0)]), 60.0821229, 1e-8); - close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0)]), -9.57859404, 1e-8); - close_tol(ev("IPMT", vec![n(0.1 / 12.0), n(1.0), n(36.0), n(8000.0)]), -66.66666667, 1e-9); - close_tol(ev("IPMT", vec![n(0.1), n(3.0), n(3.0), n(8000.0)]), -292.4471299, 1e-9); - is_err(ev("IPMT", vec![n(0.1), n(4.0), n(3.0), n(8000.0)]), CellError::Num); - close_tol(ev("PPMT", vec![n(0.1 / 12.0), n(1.0), n(24.0), n(2000.0)]), -75.62318601, 1e-9); - close_tol(ev("PPMT", vec![n(0.08), n(10.0), n(10.0), n(200000.0)]), -27598.05346, 1e-9); - close_tol(ev("RATE", vec![n(48.0), n(-200.0), n(8000.0)]), 0.0077014725, 1e-7); - close_tol(ev("CUMIPMT", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(13.0), n(24.0), n(0.0)]), -11135.23213, 1e-9); - close_tol(ev("CUMIPMT", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), -937.5, 1e-9); - close_tol(ev("CUMPRINC", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(13.0), n(24.0), n(0.0)]), -934.1071234, 1e-9); - close_tol(ev("CUMPRINC", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), -68.27827118, 1e-9); - is_err(ev("CUMIPMT", vec![n(0.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), CellError::Num); - is_err(ev("CUMIPMT", vec![n(0.01), n(360.0), n(125000.0), n(1.0), n(1.0), n(2.0)]), CellError::Num); - } - - /// PMT within about 4 ULPs of Excel's (Value2 of synthetic probes; libm differs by an ULP across - /// platforms). The old `(1 + rate)^nper` form was 25 to 91 ULPs off on these, enough to flip a loan schedule's final `IF(p - SUM(r) > 0, ...)`. - #[test] - fn pmt_matches_excel_within_a_few_ulps() { - for (rate, nper, pv, fv, typ, excel) in [ - (0.141 / 12.0, 12.0, -4653300.0, 0.0, 0.0, 418025.37230211904), - (0.005716666666666666, 26.0, 42678411.0, -26010.62, 0.0, -1770235.3828507778), - (0.012164, 29.0, 16722649.0, 0.0, 1.0, -679516.0784762775), - (0.009916666666666666, 5.0, 8590842.8, 0.0, 0.0, -1769620.32624324), - (0.011158333333333333, 36.0, 21457108.0, 49218.26, 1.0, -720095.3912155787), - (-0.5, 2.5, 100.0, 0.0, 0.0, -10.736861692729645), - ] { - let got = ev("PMT", vec![n(rate), n(nper), n(pv), n(fv), n(typ)]); - close_tol(got, excel, 1e-15); - } - } - - /// Edge cases observed in Excel: a rate of -1 or less is #NUM!, a huge nper leaves -pv*rate, and a - /// tiny rate no longer loses its low digits in 1 + rate. - #[test] - fn pmt_edge_cases_follow_excel() { - is_err(ev("PMT", vec![n(-2.0), n(3.0), n(100.0)]), CellError::Num); - is_err(ev("PMT", vec![n(-1.0), n(3.0), n(100.0)]), CellError::Num); - is_err(ev("PMT", vec![n(-2.0), n(2.5), n(100.0)]), CellError::Num); - assert_eq!(ev("PMT", vec![n(0.1), n(10000.0), n(100.0)]), Value::Number(-10.0)); - assert_eq!(ev("PMT", vec![n(5.0), n(1000.0), n(100.0)]), Value::Number(-500.0)); - assert_eq!(ev("PMT", vec![n(1e-300), n(10.0), n(100.0)]), Value::Number(-10.0)); - close_tol(ev("PMT", vec![n(1e-12), n(10.0), n(100.0)]), -10.000000000055001, 1e-15); - close_tol(ev("PMT", vec![n(1e-300), n(10.0), n(1e12)]), -1e11, 1e-15); - close_tol(ev("PMT", vec![n(-0.999), n(5.0), n(100.0)]), -9.990_000_000_000_092e-14, 1e-15); - close_tol(ev("PMT", vec![n(0.05), n(-10.0), n(100.0)]), 7.950_457_496_545_667, 1e-15); - close_tol(ev("PMT", vec![n(-1e-17), n(1.0), n(1e18), n(-1e18)]), 10.0, 1e-9); - close_tol(ev("PMT", vec![n(-0.5), n(60.0), n(1e18)]), -0.433_680_868_994_201_8, 1e-6); - close_tol(ev("PMT", vec![n(0.05), n(10.0), n(100.0), n(50.0), n(1.0)]), -16.119701185541427, 1e-15); - } - - #[test] - fn cash_flows() { - close_tol(ev("NPV", vec![n(0.1), n(-10000.0), n(3000.0), n(4200.0), n(6800.0)]), 1188.443412, 1e-9); - close_tol(ev("NPV", vec![n(0.08), rf(col(&[8000.0, 9200.0, 10000.0, 12000.0, 14500.0]))]), 41922.06155, 1e-9); - is_err(ev("NPV", vec![n(-1.0), n(1.0)]), CellError::Div0); - let flows = col(&[-70000.0, 12000.0, 15000.0, 18000.0, 21000.0]); - close_tol(ev("IRR", vec![rf(flows)]), -0.021244848, 1e-7); - close_tol(ev("IRR", vec![rf(col(&[-70000.0, 12000.0, 15000.0, 18000.0, 21000.0, 26000.0]))]), 0.086630948, 1e-7); - close_tol(ev("IRR", vec![rf(col(&[-70000.0, 12000.0, 15000.0])), n(-0.1)]), -0.44350694, 1e-7); - is_err(ev("IRR", vec![rf(col(&[1.0, 2.0]))]), CellError::Num); - let vals = col(&[-10000.0, 2750.0, 4250.0, 3250.0, 2750.0]); - let dates = col(&[39448.0, 39508.0, 39751.0, 39859.0, 39904.0]); - close_tol(ev("XNPV", vec![n(0.09), rf(vals.clone()), rf(dates.clone())]), 2086.647602, 1e-9); - close_tol(ev("XIRR", vec![rf(vals.clone()), rf(dates)]), 0.373362535, 1e-7); - is_err(ev("XNPV", vec![n(0.09), rf(vals), rf(col(&[1.0, 2.0]))]), CellError::Num); - let m = col(&[-120000.0, 39000.0, 30000.0, 21000.0, 37000.0, 46000.0]); - close_tol(ev("MIRR", vec![rf(m), n(0.1), n(0.12)]), 0.1260943, 1e-6); - close_tol(ev("MIRR", vec![rf(col(&[-120000.0, 39000.0, 30000.0, 21000.0])), n(0.1), n(0.12)]), -0.048044655, 1e-7); - is_err(ev("MIRR", vec![rf(col(&[1.0, 2.0])), n(0.1), n(0.1)]), CellError::Div0); - // Excel 365 gives -1 when every flow is negative: the positive flows are worth nothing. - close_tol(ev("MIRR", vec![rf(col(&[-10000.0, -3400.0, -6500.0, -1000.0])), n(0.07), n(0.01)]), -1.0, 1e-12); - close(ev("FVSCHEDULE", vec![n(1.0), av(row(&[0.09, 0.11, 0.1]))]), 1.33089); - close(ev("EFFECT", vec![n(0.0525), n(4.0)]), 0.0535426673707584); - is_err(ev("EFFECT", vec![n(-0.1), n(4.0)]), CellError::Num); - close_tol(ev("NOMINAL", vec![n(0.053543), n(4.0)]), 0.052500319, 1e-8); - close_tol(ev("ISPMT", vec![n(0.1 / 12.0), n(0.0), n(36.0), n(8000000.0)]), -66666.66667, 1e-9); - is_err(ev("ISPMT", vec![n(0.1), n(1.0), n(0.0), n(1.0)]), CellError::Div0); - close_tol(ev("PDURATION", vec![n(0.025), n(2000.0), n(2200.0)]), 3.859866163, 1e-9); - is_err(ev("PDURATION", vec![n(0.0), n(2000.0), n(2200.0)]), CellError::Num); - close_tol(ev("RRI", vec![n(96.0), n(10000.0), n(11000.0)]), 0.0009933, 1e-4); - close(ev("DOLLARDE", vec![n(1.02), n(16.0)]), 1.125); - close(ev("DOLLARDE", vec![n(1.1), n(32.0)]), 1.3125); - close(ev("DOLLARFR", vec![n(1.125), n(16.0)]), 1.02); - close(ev("DOLLARFR", vec![n(1.125), n(32.0)]), 1.04); - is_err(ev("DOLLARDE", vec![n(1.02), n(0.0)]), CellError::Div0); - is_err(ev("DOLLARDE", vec![n(1.02), n(-1.0)]), CellError::Num); - } - - #[test] - fn depreciation() { - close(ev("SLN", vec![n(30000.0), n(7500.0), n(10.0)]), 2250.0); - is_err(ev("SLN", vec![n(30000.0), n(7500.0), n(0.0)]), CellError::Div0); - close_tol(ev("SYD", vec![n(30000.0), n(7500.0), n(10.0), n(1.0)]), 4090.909091, 1e-9); - close_tol(ev("SYD", vec![n(30000.0), n(7500.0), n(10.0), n(10.0)]), 409.0909091, 1e-9); - let dbv = |p: f64| ev("DB", vec![n(1000000.0), n(100000.0), n(6.0), n(p), n(7.0)]); - close_tol(dbv(1.0), 186083.3333, 1e-9); - close_tol(dbv(2.0), 259639.4167, 1e-9); - close_tol(dbv(3.0), 176814.4428, 1e-9); - close_tol(dbv(7.0), 15845.09847, 1e-9); - is_err(dbv(8.0), CellError::Num); - close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(3650.0), n(1.0)]), 1.315068493, 1e-8); - close(ev("DDB", vec![n(2400.0), n(300.0), n(120.0), n(1.0), n(2.0)]), 40.0); - close(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(1.0), n(2.0)]), 480.0); - close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(2.0), n(1.5)]), 306.0, 1e-12); - close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(10.0)]), 22.1225472, 1e-8); - is_err(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(11.0)]), CellError::Num); - close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(3650.0), n(0.0), n(1.0)]), 1.315068493, 1e-8); - close(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(0.0), n(1.0)]), 40.0); - close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(1.0)]), 480.0); - close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(6.0), n(18.0)]), 396.3060533, 1e-8); - close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(6.0), n(18.0), n(1.5)]), 311.8089367, 1e-8); - close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(0.875), n(1.5)]), 315.0); - // Over the whole life the total depreciation is cost - salvage. - close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(10.0)]), 2100.0); - is_err(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(5.0), n(4.0)]), CellError::Num); - close_tol(ev("AMORLINC", vec![n(2400.0), d(2008, 8, 19), d(2008, 12, 31), n(300.0), n(1.0), n(0.15), n(1.0)]), 360.0, 1e-9); - close(ev("AMORDEGRC", vec![n(2400.0), d(2008, 8, 19), d(2008, 12, 31), n(300.0), n(1.0), n(0.15), n(1.0)]), 776.0); - } - - #[test] - fn coupons() { - let args = |f: &str| ev(f, vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(1.0)]); - close(args("COUPDAYBS"), 71.0); - close(args("COUPDAYS"), 181.0); - close(args("COUPDAYSNC"), 110.0); - close(args("COUPNCD"), 40678.0); - close(args("COUPPCD"), 40497.0); - close(args("COUPNUM"), 2.0); - close(ev("COUPDAYS", vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(0.0)]), 180.0); - close(ev("COUPDAYS", vec![d(2011, 1, 25), d(2011, 11, 15), n(4.0), n(3.0)]), 91.25); - close(ev("COUPNUM", vec![d(2007, 1, 25), d(2008, 11, 15), n(2.0), n(1.0)]), 4.0); - is_err(ev("COUPNUM", vec![d(2012, 1, 25), d(2011, 11, 15), n(2.0)]), CellError::Num); - is_err(ev("COUPNUM", vec![d(2011, 1, 25), d(2011, 11, 15), n(3.0)]), CellError::Num); - is_err(ev("COUPNUM", vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(5.0)]), CellError::Num); - // End-of-month maturity keeps month-end coupon dates. - close(ev("COUPPCD", vec![d(2011, 3, 15), d(2011, 11, 30), n(2.0)]), serial_from_ymd(DateSystem::D1900, 2010, 11, 30).unwrap()); - close(ev("COUPNCD", vec![d(2011, 3, 15), d(2011, 11, 30), n(2.0)]), serial_from_ymd(DateSystem::D1900, 2011, 5, 31).unwrap()); - } - - #[test] - fn securities() { - close_tol(ev("DISC", vec![d(2007, 1, 25), d(2007, 6, 15), n(97.975), n(100.0), n(1.0)]), 0.052420213, 1e-8); - close_tol(ev("PRICEDISC", vec![d(2008, 2, 16), d(2008, 3, 1), n(0.0525), n(100.0), n(2.0)]), 99.79583333, 1e-9); - close_tol(ev("YIELDDISC", vec![d(2008, 2, 16), d(2008, 3, 1), n(99.795), n(100.0), n(2.0)]), 0.052823, 1e-5); - close_tol(ev("INTRATE", vec![d(2008, 2, 15), d(2008, 5, 15), n(1000000.0), n(1014420.0), n(2.0)]), 0.05768, 1e-9); - close_tol(ev("RECEIVED", vec![d(2008, 2, 15), d(2008, 5, 15), n(1000000.0), n(0.0575), n(2.0)]), 1014584.654, 1e-9); - is_err(ev("DISC", vec![d(2008, 2, 15), d(2008, 1, 15), n(97.0), n(100.0)]), CellError::Num); - close_tol(ev("TBILLPRICE", vec![d(2008, 3, 31), d(2008, 6, 1), n(0.09)]), 98.45, 1e-12); - close_tol(ev("TBILLYIELD", vec![d(2008, 3, 31), d(2008, 6, 1), n(98.45)]), 0.091417, 1e-6); - close_tol(ev("TBILLEQ", vec![d(2008, 3, 31), d(2008, 6, 1), n(0.0914)]), 0.094151, 1e-6); - is_err(ev("TBILLPRICE", vec![d(2008, 3, 31), d(2009, 6, 1), n(0.09)]), CellError::Num); - close_tol(ev("ACCRINTM", vec![d(2008, 4, 1), d(2008, 6, 15), n(0.1), n(1000.0), n(3.0)]), 20.54794521, 1e-9); - is_err(ev("ACCRINTM", vec![d(2008, 6, 15), d(2008, 4, 1), n(0.1)]), CellError::Num); - close_tol(ev("ACCRINT", vec![d(2008, 3, 1), d(2008, 8, 31), d(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(0.0)]), 16.66666667, 1e-9); - close_tol(ev("ACCRINT", vec![d(2008, 3, 5), d(2008, 8, 31), d(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(0.0), b(false)]), 15.55555556, 1e-9); - close_tol(ev("PRICEMAT", vec![d(2008, 2, 15), d(2008, 4, 13), d(2007, 11, 11), n(0.061), n(0.061), n(0.0)]), 99.98449888, 1e-9); - close_tol(ev("YIELDMAT", vec![d(2008, 3, 15), d(2008, 11, 3), d(2007, 11, 8), n(0.0625), n(100.0123), n(0.0)]), 0.060954, 1e-6); - close_tol(ev("PRICE", vec![d(2008, 2, 15), d(2017, 11, 15), n(0.0575), n(0.065), n(100.0), n(2.0), n(0.0)]), 94.63436, 1e-7); - close_tol(ev("YIELD", vec![d(2008, 2, 15), d(2016, 11, 15), n(0.0575), n(95.04287), n(100.0), n(2.0), n(0.0)]), 0.065, 1e-6); - is_err(ev("PRICE", vec![d(2008, 2, 15), d(2017, 11, 15), n(-0.01), n(0.065), n(100.0), n(2.0)]), CellError::Num); - close_tol(ev("DURATION", vec![d(2018, 7, 1), d(2048, 1, 1), n(0.08), n(0.09), n(2.0), n(1.0)]), 10.9191453, 1e-8); - close_tol(ev("MDURATION", vec![d(2008, 1, 1), d(2016, 1, 1), n(0.08), n(0.09), n(2.0), n(1.0)]), 5.73567, 1e-6); - // A one-coupon bond uses the closed form for yield; PRICE and YIELD invert each other. - let p = ev("PRICE", vec![d(2008, 2, 15), d(2008, 6, 15), n(0.05), n(0.04), n(100.0), n(2.0)]); - let pv = match p { - Value::Number(x) => x, - _ => panic!(), - }; - close_tol(ev("YIELD", vec![d(2008, 2, 15), d(2008, 6, 15), n(0.05), n(pv), n(100.0), n(2.0)]), 0.04, 1e-9); - } - - fn number(v: Value) -> f64 { - match v { - Value::Number(x) => x, - other => panic!("expected a number, got {other:?}"), - } - } - - #[test] - fn odd_periods() { - // The worked examples on Microsoft's function pages (results as printed there). - let first = |x: f64, rest: &[f64]| { - let mut a = vec![d(2008, 11, 11), d(2021, 3, 1), d(2008, 10, 15), d(2009, 3, 1), n(x)]; - a.extend(rest.iter().map(|v| n(*v))); - a - }; - close_tol(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 1.0])), 113.60, 0.005 / 113.6); - close_tol(ev("ODDFYIELD", first(0.0575, &[84.50, 100.0, 2.0, 0.0])), 0.0772, 0.00005); - let lp = vec![d(2008, 2, 7), d(2008, 6, 15), d(2007, 10, 15), n(0.0375), n(0.0405), n(100.0), n(2.0), n(0.0)]; - close_tol(ev("ODDLPRICE", lp), 99.88, 0.005 / 99.88); - let ly = vec![d(2008, 4, 20), d(2008, 6, 15), d(2007, 12, 24), n(0.0375), n(99.875), n(100.0), n(2.0), n(0.0)]; - close_tol(ev("ODDLYIELD", ly), 0.04519, 0.000005); - - // Yield → price → yield, for a short and a long odd first period and a long odd last period. - for (issue, basis) in [(d(2008, 10, 15), 0.0), (d(2007, 1, 20), 1.0), (d(2008, 1, 31), 3.0)] { - let args = |x: f64| vec![d(2008, 11, 11), d(2021, 3, 1), issue.clone(), d(2009, 3, 1), n(0.06), n(x), n(100.0), n(2.0), n(basis)]; - let p = number(ev("ODDFPRICE", args(0.05))); - close_tol(ev("ODDFYIELD", args(p)), 0.05, 1e-9); - } - let last = |x: f64| vec![d(2009, 2, 1), d(2009, 12, 15), d(2008, 3, 15), n(0.04), n(x), n(100.0), n(4.0), n(1.0)]; - let p = number(ev("ODDLPRICE", last(0.07))); - close_tol(ev("ODDLYIELD", last(p)), 0.07, 1e-9); - - // Argument checks: frequency, basis, date order, negative yield, non-positive price. - is_err(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 3.0, 1.0])), CellError::Num); - is_err(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 5.0])), CellError::Num); - is_err(ev("ODDFPRICE", first(0.0785, &[-0.01, 100.0, 2.0, 1.0])), CellError::Num); - is_err(ev("ODDFYIELD", first(0.0575, &[0.0, 100.0, 2.0, 0.0])), CellError::Num); - is_err( - ev("ODDFPRICE", vec![d(2009, 4, 1), d(2021, 3, 1), d(2008, 10, 15), d(2009, 3, 1), n(0.07), n(0.06), n(100.0), n(2.0)]), - CellError::Num, - ); - is_err(ev("ODDLPRICE", vec![d(2007, 10, 1), d(2008, 6, 15), d(2007, 10, 15), n(0.0375), n(0.0405), n(100.0), n(2.0)]), CellError::Num); - is_err(ev("ODDLYIELD", vec![d(2008, 4, 20), d(2008, 6, 15), d(2007, 12, 24), n(0.0375), n(-1.0), n(100.0), n(2.0)]), CellError::Num); - is_err( - ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 1.0]).into_iter().take(4).chain([t("x"), n(0.06), n(100.0), n(2.0)]).collect()), - CellError::Value, - ); - } - - #[test] - fn odd_periods_survive_hostile_input() { - // Quarterly quasi-periods across the whole date range, huge rates and yields: a value or - // an error, never a panic or a hang. - let wide = |x: f64, y: f64| vec![n(2_957_000.0), n(2_958_465.0), n(400.0), n(2_957_900.0), n(x), n(y), n(1e300), n(4.0), n(1.0)]; - for (x, y) in [(0.05, 0.05), (1e300, 1e300), (0.0, 1e-300)] { - let _ = ev("ODDFPRICE", wide(x, y)); - let _ = ev("ODDFYIELD", wide(x, y)); - } - // The long walk itself works: settled early in an odd first period of ~32,000 quarters, - // a small yield round-trips. - let long = |y: f64| vec![n(500.0), n(2_958_465.0), n(400.0), n(2_957_900.0), n(0.05), n(y), n(100.0), n(4.0), n(1.0)]; - let p = number(ev("ODDFPRICE", long(0.0001))); - close_tol(ev("ODDFYIELD", long(p)), 0.0001, 1e-9); - let last = |x: f64, y: f64| vec![n(2_958_000.0), n(2_958_465.0), n(400.0), n(x), n(y), n(1e300), n(4.0), n(1.0)]; - for (x, y) in [(0.05, 0.05), (1e300, 1e300), (0.0, f64::MAX)] { - let _ = ev("ODDLPRICE", last(x, y)); - let _ = ev("ODDLYIELD", last(x, y)); - } - is_err(ev("ODDFPRICE", vec![n(-5.0), n(1e20), n(0.0), n(1e10), n(0.05), n(0.05), n(100.0), n(2.0)]), CellError::Num); - } - - #[test] - fn euroconvert() { - // The examples on Microsoft's EUROCONVERT page. - let ec = |x: f64, s: &str, tg: &str, rest: Vec| { - let mut a = vec![n(x), t(s), t(tg)]; - a.extend(rest); - ev("EUROCONVERT", a) - }; - close(ec(1.2, "DEM", "EUR", vec![]), 0.61); - close(ec(1.0, "FRF", "EUR", vec![b(true), n(3.0)]), 0.152); - close(ec(1.0, "FRF", "EUR", vec![b(false), n(3.0)]), 0.15); - close(ec(1.0, "FRF", "DEM", vec![b(true), n(3.0)]), 0.29728616); - // Euro to a member currency rounds to the target's calculation precision (lira: 0). - close(ec(10.0, "eur", "ITL", vec![]), 19363.0); - close(ec(100.0, "EUR", "IEP", vec![b(true)]), 78.7564); - // Same currency returns the number untouched. - close(ec(1.23456, "DEM", "DEM", vec![]), 1.23456); - // Unknown codes and a triangulation precision below 3 are #VALUE!. - is_err(ec(1.0, "USD", "EUR", vec![]), CellError::Value); - is_err(ec(1.0, "FRF", "DEM", vec![b(true), n(2.0)]), CellError::Value); - // Hostile magnitudes give a number or #NUM!, never a panic. - is_err(ec(f64::MAX, "EUR", "ITL", vec![]), CellError::Num); - close(ec(1.0, "FRF", "DEM", vec![b(true), n(1e300)]), 1.95583 / 6.55957); - } - - #[test] - fn lifting() { - let v = ev("SLN", vec![av(row(&[100.0, 200.0])), n(0.0), n(10.0)]); - assert_eq!(rows_of(&v), vec![vec![nv(10.0), nv(20.0)]]); - is_err(ev("PMT", vec![t("abc"), n(1.0), n(1.0)]), CellError::Value); - is_err(ev("PMT", vec![e(CellError::NA), n(1.0), n(1.0)]), CellError::NA); - } - - #[test] - fn securities_read_dates_in_the_1904_system() { - let d4 = |y, m, dd| n(serial_from_ymd(DateSystem::D1904, y, m, dd).unwrap()); - let ev4 = |name: &str, args: Vec| { - let mut ctx = TestCtx { sys: DateSystem::D1904, ..TestCtx::default() }; - crate::call(crate::lookup(name).unwrap(), &args, &mut ctx) - }; - let (s, m) = (d4(2011, 3, 15), d4(2011, 11, 30)); - close(ev4("COUPNCD", vec![s.clone(), m.clone(), n(2.0)]), 39232.0); - close(ev4("COUPPCD", vec![s.clone(), m.clone(), n(4.0), n(0.0)]), 39140.0); - close(ev4("COUPDAYBS", vec![s.clone(), m.clone(), n(4.0), n(0.0)]), 15.0); - let accrint = vec![d4(2008, 3, 1), d4(2008, 8, 31), d4(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(1.0)]; - close_tol(ev4("ACCRINT", accrint), 16.576086956522, 1e-9); - // Results that already matched Excel keep matching. - let semi = |name| ev4(name, vec![s.clone(), m.clone(), n(2.0), n(0.0)]); - close(semi("COUPPCD"), 39050.0); - close(semi("COUPDAYS"), 180.0); - close(semi("COUPDAYBS"), 105.0); - close(semi("COUPDAYSNC"), 75.0); - close(semi("COUPNUM"), 2.0); - let price = vec![s.clone(), m.clone(), n(0.0575), n(0.065), n(100.0), n(2.0), n(0.0)]; - close_tol(ev4("PRICE", price), 99.47768286339101, 1e-9); - close_tol(ev4("DURATION", vec![s, m, n(0.08), n(0.09), n(2.0), n(0.0)]), 0.6890136809022, 1e-9); - let odd = vec![d4(2008, 11, 11), d4(2021, 3, 1), d4(2008, 10, 15), d4(2009, 3, 1), n(0.0785), n(0.0625), n(100.0), n(2.0), n(1.0)]; - close_tol(ev4("ODDFPRICE", odd), 113.597717474079, 1e-9); - } -} +mod tests; diff --git a/crates/functions/src/financial/day_count.rs b/crates/functions/src/financial/day_count.rs new file mode 100644 index 0000000000..f91e7968f1 --- /dev/null +++ b/crates/functions/src/financial/day_count.rs @@ -0,0 +1,214 @@ +use super::*; + +// --------------------------------------------------------------------------------------------- +// Dates and day counts +// --------------------------------------------------------------------------------------------- + +pub(super) fn date(a: &[Arg], i: usize) -> R { + let d = num(a, i)?.trunc(); + if !(0.0..=2_958_465.0).contains(&d) { + return Err(CellError::Num); + } + Ok(d) +} + +pub(super) fn ymd(sys: DateSystem, s: f64) -> R<(i32, u32, u32)> { + let dt = datetime_from_serial(sys, s.trunc()).ok_or(CellError::Num)?; + Ok((dt.year, dt.month, dt.day.max(1))) +} + +fn serial(sys: DateSystem, y: i32, m: u32, d: u32) -> R { + serial_from_ymd(sys, y as i64, m as i64, d as i64).ok_or(CellError::Num) +} + +pub(super) fn basis_arg(a: &[Arg], i: usize) -> R { + let b = opt_num(a, i, 0.0)?.trunc(); + if !(0.0..=4.0).contains(&b) { + return Err(CellError::Num); + } + Ok(b as u32) +} + +pub(super) fn freq_arg(a: &[Arg], i: usize) -> R { + let f = num(a, i)?.trunc(); + if f != 1.0 && f != 2.0 && f != 4.0 { + return Err(CellError::Num); + } + Ok(f) +} + +/// 30/360 day count (US/NASD when `us`, European otherwise). +fn days360(sys: DateSystem, s1: f64, s2: f64, us: bool) -> R { + let (y1, m1, mut d1) = ymd(sys, s1)?; + let (y2, m2, mut d2) = ymd(sys, s2)?; + if us { + let feb_end1 = m1 == 2 && d1 >= days_in_month(y1, 2); + let feb_end2 = m2 == 2 && d2 >= days_in_month(y2, 2); + if feb_end1 && feb_end2 { + d2 = 30; + } + if feb_end1 { + d1 = 30; + } + if d2 == 31 && d1 >= 30 { + d2 = 30; + } + if d1 == 31 { + d1 = 30; + } + } else { + d1 = d1.min(30); + d2 = d2.min(30); + } + Ok((y2 - y1) as f64 * 360.0 + (m2 as f64 - m1 as f64) * 30.0 + (d2 as f64 - d1 as f64)) +} + +/// Days between two dates under a basis. +pub(super) fn day_count(sys: DateSystem, s1: f64, s2: f64, basis: u32) -> R { + match basis { + 0 => days360(sys, s1, s2, true), + 4 => days360(sys, s1, s2, false), + _ => Ok(s2 - s1), + } +} + +/// Year fraction between two dates under a basis (YEARFRAC semantics). +pub(super) fn year_frac(sys: DateSystem, s1: f64, s2: f64, basis: u32) -> R { + let (s1, s2) = if s1 <= s2 { (s1, s2) } else { (s2, s1) }; + match basis { + 0 => Ok(days360(sys, s1, s2, true)? / 360.0), + 2 => Ok((s2 - s1) / 360.0), + 3 => Ok((s2 - s1) / 365.0), + 4 => Ok(days360(sys, s1, s2, false)? / 360.0), + _ => { + let (y1, m1, d1) = ymd(sys, s1)?; + let (y2, m2, d2) = ymd(sys, s2)?; + let days = s2 - s1; + if y1 == y2 { + return Ok(days / if is_leap(y1) { 366.0 } else { 365.0 }); + } + let within_year = y2 == y1 + 1 && (m2 < m1 || (m2 == m1 && d2 <= d1)); + if within_year { + let feb29_1 = is_leap(y1) && (m1 < 2 || (m1 == 2 && d1 <= 29)); + let feb29_2 = is_leap(y2) && (m2 > 2 || (m2 == 2 && d2 == 29)); + let denom = if feb29_1 || feb29_2 { 366.0 } else { 365.0 }; + return Ok(days / denom); + } + let start = serial(sys, y1, 1, 1)?; + let end = serial(sys, y2 + 1, 1, 1).unwrap_or(max_serial(sys) as f64 + 1.0); + let avg = (end - start) / (y2 - y1 + 1) as f64; + Ok(days / avg) + } + } +} + +/// Days in a year for discount securities. +pub(super) fn year_basis(sys: DateSystem, basis: u32, s: f64) -> R { + Ok(match basis { + 0 | 2 | 4 => 360.0, + 3 => 365.0, + _ => { + if is_leap(ymd(sys, s)?.0) { + 366.0 + } else { + 365.0 + } + } + }) +} + +/// Date `months` months before/after an anchor date, keeping end-of-month alignment. +pub(super) fn shift_months(sys: DateSystem, anchor: (i32, u32, u32), months: i64, eom: bool) -> R { + let (y, m, d) = anchor; + let total = y as i64 * 12 + (m as i64 - 1) + months; + let ny = total.div_euclid(12); + let nm = (total.rem_euclid(12) + 1) as u32; + if !(1900..=9999).contains(&ny) { + return Err(CellError::Num); + } + let dim = days_in_month(ny as i32, nm); + let nd = if eom { dim } else { d.min(dim) }; + serial(sys, ny as i32, nm, nd) +} + +pub(super) struct Coupon { + pcd: f64, + ncd: f64, + pub(super) num: f64, +} + +pub(super) fn coupons(sys: DateSystem, settle: f64, mat: f64, freq: f64) -> R { + if settle >= mat { + return Err(CellError::Num); + } + let anchor = ymd(sys, mat)?; + let eom = anchor.2 == days_in_month(anchor.0, anchor.1); + let step = (12.0 / freq) as i64; + // Estimate the count from the span, then adjust. + let mut k = (((mat - settle) / 365.25 * freq).floor() as i64).max(1); + while k > 1 && shift_months(sys, anchor, -(k - 1) * step, eom)? <= settle { + k -= 1; + } + let mut guard = 0; + while shift_months(sys, anchor, -k * step, eom)? > settle { + k += 1; + guard += 1; + if guard > 100_000 { + return Err(CellError::Num); + } + } + Ok(Coupon { pcd: shift_months(sys, anchor, -k * step, eom)?, ncd: shift_months(sys, anchor, -(k - 1) * step, eom)?, num: k as f64 }) +} + +pub(super) fn coup_days(c: &Coupon, freq: f64, basis: u32) -> f64 { + match basis { + 1 => c.ncd - c.pcd, + 3 => 365.0 / freq, + _ => 360.0 / freq, + } +} + +pub(super) fn coup_daybs(sys: DateSystem, c: &Coupon, settle: f64, basis: u32) -> R { + day_count(sys, c.pcd, settle, basis) +} + +pub(super) fn coup_daysnc(sys: DateSystem, c: &Coupon, settle: f64, freq: f64, basis: u32) -> R { + match basis { + 0 | 4 => Ok(coup_days(c, freq, basis) - coup_daybs(sys, c, settle, basis)?), + _ => Ok(c.ncd - settle), + } +} + +/// settlement, maturity, frequency, basis at positions 0..=3 for the COUP* functions. +fn coup_args(a: &[Arg], sys: DateSystem) -> R<(f64, f64, f64, u32, Coupon)> { + let (s, m, f, b) = (date(a, 0)?, date(a, 1)?, freq_arg(a, 2)?, basis_arg(a, 3)?); + let c = coupons(sys, s, m, f)?; + Ok((s, m, f, b, c)) +} + +pub(super) fn coupdaybs(a: &[Arg], cx: &mut dyn Ctx) -> R { + let (s, _, _, b, c) = coup_args(a, cx.date_system())?; + num_val(coup_daybs(cx.date_system(), &c, s, b)?) +} + +pub(super) fn coupdays(a: &[Arg], cx: &mut dyn Ctx) -> R { + let (_, _, f, b, c) = coup_args(a, cx.date_system())?; + num_val(coup_days(&c, f, b)) +} + +pub(super) fn coupdaysnc(a: &[Arg], cx: &mut dyn Ctx) -> R { + let (s, _, f, b, c) = coup_args(a, cx.date_system())?; + num_val(coup_daysnc(cx.date_system(), &c, s, f, b)?) +} + +pub(super) fn coupncd(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(coup_args(a, c.date_system())?.4.ncd) +} + +pub(super) fn couppcd(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(coup_args(a, c.date_system())?.4.pcd) +} + +pub(super) fn coupnum(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(coup_args(a, c.date_system())?.4.num) +} diff --git a/crates/functions/src/financial/depreciation.rs b/crates/functions/src/financial/depreciation.rs new file mode 100644 index 0000000000..d656aa76f0 --- /dev/null +++ b/crates/functions/src/financial/depreciation.rs @@ -0,0 +1,173 @@ +use super::day_count::{basis_arg, date, year_frac}; +use super::*; + +// --------------------------------------------------------------------------------------------- +// Depreciation +// --------------------------------------------------------------------------------------------- + +pub(super) fn sln(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (cost, salvage, life) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + if life == 0.0 { + return Err(CellError::Div0); + } + num_val((cost - salvage) / life) +} + +pub(super) fn syd(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (cost, salvage, life, per) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?); + if life <= 0.0 || per <= 0.0 || per > life || salvage < 0.0 { + return Err(CellError::Num); + } + num_val((cost - salvage) * (life - per + 1.0) * 2.0 / (life * (life + 1.0))) +} + +pub(super) fn db(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (cost, salvage, life, period) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?.trunc()); + let month = opt_num(a, 4, 12.0)?.trunc(); + if cost < 0.0 || salvage < 0.0 || life <= 0.0 || period <= 0.0 || !(1.0..=12.0).contains(&month) { + return Err(CellError::Num); + } + let max_period = if month == 12.0 { life.trunc() } else { life.trunc() + 1.0 }; + if period > max_period.max(1.0) || period > 1e7 { + return Err(CellError::Num); + } + if cost == 0.0 { + return num_val(0.0); + } + let rate = crate::util::round_half_away(1.0 - (salvage / cost).powf(1.0 / life), 3); + let mut total = 0.0; + let mut dep = 0.0; + let mut p = 1.0; + while p <= period { + dep = if p == 1.0 { + cost * rate * month / 12.0 + } else if p == life.trunc() + 1.0 { + (cost - total) * rate * (12.0 - month) / 12.0 + } else { + (cost - total) * rate + }; + total += dep; + p += 1.0; + } + num_val(dep) +} + +pub(super) fn ddb(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (cost, salvage, life, period) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?); + let factor = opt_num(a, 4, 2.0)?; + if cost < 0.0 || salvage < 0.0 || life <= 0.0 || period <= 0.0 || factor <= 0.0 || period > life { + return Err(CellError::Num); + } + let mut rate = factor / life; + let old; + if rate >= 1.0 { + rate = 1.0; + old = if period == 1.0 { cost } else { 0.0 }; + } else { + old = cost * (1.0 - rate).powf(period - 1.0); + } + let new = cost * (1.0 - rate).powf(period); + let dep = if new < salvage { old - salvage } else { old - new }; + num_val(dep.max(0.0)) +} + +/// Per-period declining-balance depreciation with optional switch to straight line. +fn vdb_schedule(cost: f64, salvage: f64, life: f64, factor: f64, no_switch: bool, periods: usize) -> Vec { + let mut out = Vec::with_capacity(periods); + let mut book = cost; + let mut sl: Option = None; + for i in 0..periods { + let dep = if let Some(s) = sl { + s.min((book - salvage).max(0.0)) + } else { + let ddb = (book * factor / life).min((book - salvage).max(0.0)); + let remaining = life - i as f64; + let slv = if remaining > 0.0 { (book - salvage) / remaining.max(1.0) } else { 0.0 }; + if !no_switch && slv > ddb { + sl = Some(slv); + slv.min((book - salvage).max(0.0)) + } else { + ddb + } + }; + out.push(dep); + book -= dep; + } + out +} + +pub(super) fn vdb(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (cost, salvage, life, start, end) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?, num(a, 4)?); + let factor = opt_num(a, 5, 2.0)?; + let no_switch = opt_bool(a, 6, false)?; + if cost < 0.0 || salvage < 0.0 || life <= 0.0 || start < 0.0 || end < start || end > life || factor <= 0.0 { + return Err(CellError::Num); + } + let periods = end.ceil(); + if periods > 1e7 { + return Err(CellError::Num); + } + let sched = vdb_schedule(cost, salvage, life, factor, no_switch, periods as usize); + let mut total = 0.0; + for (i, d) in sched.iter().enumerate() { + let (p0, p1) = (i as f64, i as f64 + 1.0); + let overlap = (p1.min(end) - p0.max(start)).max(0.0); + total += d * overlap; + } + num_val(total) +} + +pub(super) fn amorlinc(a: &[Arg], c: &mut dyn Ctx) -> R { + let (cost, purchased, first, salvage) = (num(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?); + let (period, rate, basis) = (num(a, 4)?.trunc(), num(a, 5)?, basis_arg(a, 6)?); + if cost < 0.0 || salvage < 0.0 || salvage > cost || period < 0.0 || rate <= 0.0 || first < purchased { + return Err(CellError::Num); + } + let full = cost * rate; + let limit = cost - salvage; + let dep0 = (full * year_frac(c.date_system(), purchased, first, basis)?).min(limit); + if period == 0.0 { + return num_val(dep0); + } + let remaining = limit - dep0 - (period - 1.0) * full; + num_val(full.min(remaining.max(0.0))) +} + +pub(super) fn amordegrc(a: &[Arg], c: &mut dyn Ctx) -> R { + let (cost, purchased, first, salvage) = (num(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?); + let (period, rate, basis) = (num(a, 4)?.trunc(), num(a, 5)?, basis_arg(a, 6)?); + if cost < 0.0 || salvage < 0.0 || salvage > cost || period < 0.0 || rate <= 0.0 || first < purchased || period > 1e6 { + return Err(CellError::Num); + } + let life = 1.0 / rate; + let coef = if life < 3.0 { + 1.0 + } else if life < 5.0 { + 1.5 + } else if life <= 6.0 { + 2.0 + } else { + 2.5 + }; + let r = rate * coef; + let mut book = cost; + let mut dep = (year_frac(c.date_system(), purchased, first, basis)? * r * cost).round(); + if period == 0.0 { + return num_val(dep); + } + book -= dep; + let mut rest = book - salvage; + let mut n = 1.0; + while n <= period { + dep = (r * book).round(); + rest -= dep; + if rest < 0.0 { + // The two final periods split what remains; afterwards nothing is left. + let left = period - n; + return num_val(if left <= 1.0 { (book * 0.5).round() } else { 0.0 }); + } + book -= dep; + n += 1.0; + } + num_val(dep) +} diff --git a/crates/functions/src/financial/securities.rs b/crates/functions/src/financial/securities.rs new file mode 100644 index 0000000000..cfecd763a4 --- /dev/null +++ b/crates/functions/src/financial/securities.rs @@ -0,0 +1,512 @@ +use super::day_count::{basis_arg, coup_daybs, coup_days, coup_daysnc, coupons, date, day_count, freq_arg, shift_months, year_basis, year_frac, ymd}; +use super::*; + +// --------------------------------------------------------------------------------------------- +// Securities +// --------------------------------------------------------------------------------------------- + +pub(super) fn accrint(a: &[Arg], c: &mut dyn Ctx) -> R { + let sys = c.date_system(); + let (issue, first, settle, rate) = (date(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?); + let par = if has(a, 4) { num(a, 4)? } else { 1000.0 }; + let freq = freq_arg(a, 5)?; + let basis = basis_arg(a, 6)?; + let calc_method = opt_bool(a, 7, true)?; + if issue >= settle || rate <= 0.0 || par <= 0.0 { + return Err(CellError::Num); + } + let anchor = ymd(sys, first)?; + let eom = anchor.2 == days_in_month(anchor.0, anchor.1); + let step = (12.0 / freq) as i64; + let cd = |j: i64| shift_months(sys, anchor, j * step, eom); + // Coupon index of the period containing a date: cd(j) <= date < cd(j+1). + let index_of = |d: f64| -> R { + let mut j = (((d - first) / 365.25 * freq).floor() as i64).clamp(-200_000, 200_000); + let mut guard = 0; + while cd(j)? > d { + j -= 1; + guard += 1; + if guard > 400_000 { + return Err(CellError::Num); + } + } + while cd(j + 1)? <= d { + j += 1; + guard += 1; + if guard > 400_000 { + return Err(CellError::Num); + } + } + Ok(j) + }; + let start = if calc_method || settle <= first { issue } else { cd(index_of(settle)?)? }; + let mut j = index_of(start)?; + let mut sum = 0.0; + let mut guard = 0; + loop { + let (p0, p1) = (cd(j)?, cd(j + 1)?); + if p0 >= settle { + break; + } + let from = p0.max(start); + let to = p1.min(settle); + if to > from { + let nl = match basis { + 1 => p1 - p0, + 3 => 365.0 / freq, + _ => 360.0 / freq, + }; + sum += day_count(sys, from, to, basis)? / nl; + } + j += 1; + guard += 1; + if guard > 100_000 { + return Err(CellError::Num); + } + } + num_val(par * rate / freq * sum) +} + +pub(super) fn accrintm(a: &[Arg], c: &mut dyn Ctx) -> R { + let (issue, settle, rate) = (date(a, 0)?, date(a, 1)?, num(a, 2)?); + let par = if has(a, 3) { num(a, 3)? } else { 1000.0 }; + let basis = basis_arg(a, 4)?; + if issue >= settle || rate <= 0.0 || par <= 0.0 { + return Err(CellError::Num); + } + num_val(par * rate * year_frac(c.date_system(), issue, settle, basis)?) +} + +/// settlement, maturity, value, value, [basis] for discount securities. +fn disc_args(a: &[Arg]) -> R<(f64, f64, f64, f64, u32)> { + let (s, m, x, y, b) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?, basis_arg(a, 4)?); + if s >= m || x <= 0.0 || y <= 0.0 { + return Err(CellError::Num); + } + Ok((s, m, x, y, b)) +} + +fn disc_frac(sys: DateSystem, s: f64, m: f64, b: u32) -> R { + Ok(day_count(sys, s, m, b)? / year_basis(sys, b, s)?) +} + +pub(super) fn disc(a: &[Arg], c: &mut dyn Ctx) -> R { + let (s, m, pr, red, b) = disc_args(a)?; + num_val((1.0 - pr / red) / disc_frac(c.date_system(), s, m, b)?) +} + +pub(super) fn pricedisc(a: &[Arg], c: &mut dyn Ctx) -> R { + let (s, m, d, red, b) = disc_args(a)?; + num_val(red - d * red * disc_frac(c.date_system(), s, m, b)?) +} + +pub(super) fn yielddisc(a: &[Arg], c: &mut dyn Ctx) -> R { + let (s, m, pr, red, b) = disc_args(a)?; + num_val((red / pr - 1.0) / disc_frac(c.date_system(), s, m, b)?) +} + +pub(super) fn intrate(a: &[Arg], c: &mut dyn Ctx) -> R { + let (s, m, inv, red, b) = disc_args(a)?; + num_val((red - inv) / inv / disc_frac(c.date_system(), s, m, b)?) +} + +pub(super) fn received(a: &[Arg], c: &mut dyn Ctx) -> R { + let (s, m, inv, d, b) = disc_args(a)?; + let denom = 1.0 - d * disc_frac(c.date_system(), s, m, b)?; + if denom <= 0.0 { + return Err(CellError::Num); + } + num_val(inv / denom) +} + +fn tbill_args(a: &[Arg], sys: DateSystem) -> R<(f64, f64)> { + let (s, m, x) = (date(a, 0)?, date(a, 1)?, num(a, 2)?); + if s >= m || x <= 0.0 { + return Err(CellError::Num); + } + // At most one year after settlement. + let (y, mo, d) = ymd(sys, s)?; + let limit = shift_months(sys, (y, mo, d), 12, false)?; + if m > limit { + return Err(CellError::Num); + } + Ok((m - s, x)) +} + +pub(super) fn tbillprice(a: &[Arg], c: &mut dyn Ctx) -> R { + let (dsm, d) = tbill_args(a, c.date_system())?; + let p = 100.0 * (1.0 - d * dsm / 360.0); + if p <= 0.0 { + return Err(CellError::Num); + } + num_val(p) +} + +pub(super) fn tbillyield(a: &[Arg], c: &mut dyn Ctx) -> R { + let (dsm, pr) = tbill_args(a, c.date_system())?; + num_val((100.0 - pr) / pr * 360.0 / dsm) +} + +pub(super) fn tbilleq(a: &[Arg], c: &mut dyn Ctx) -> R { + let (dsm, d) = tbill_args(a, c.date_system())?; + if dsm <= 182.0 { + return num_val(365.0 * d / (360.0 - d * dsm)); + } + let price = 100.0 * (1.0 - d * dsm / 360.0); + if price <= 0.0 { + return Err(CellError::Num); + } + let t = dsm / 365.0; + let disc = t * t - (2.0 * t - 1.0) * (1.0 - 100.0 / price); + if disc < 0.0 { + return Err(CellError::Num); + } + num_val((-t + disc.sqrt()) / (t - 0.5)) +} + +/// settlement, maturity, issue, rate, value, [basis]. +fn mat_terms(a: &[Arg], sys: DateSystem) -> R<(f64, f64, f64, f64, f64)> { + let (s, m, issue, rate, x, b) = (date(a, 0)?, date(a, 1)?, date(a, 2)?, num(a, 3)?, num(a, 4)?, basis_arg(a, 5)?); + if s >= m || issue >= s || rate < 0.0 || x < 0.0 { + return Err(CellError::Num); + } + let dim = year_frac(sys, issue, m, b)?; + let dsm = year_frac(sys, s, m, b)?; + let ai = year_frac(sys, issue, s, b)?; + Ok((dim, dsm, ai, rate, x)) +} + +pub(super) fn pricemat(a: &[Arg], c: &mut dyn Ctx) -> R { + let (dim, dsm, ai, rate, yld) = mat_terms(a, c.date_system())?; + num_val((100.0 + dim * rate * 100.0) / (1.0 + dsm * yld) - ai * rate * 100.0) +} + +pub(super) fn yieldmat(a: &[Arg], c: &mut dyn Ctx) -> R { + let (dim, dsm, ai, rate, pr) = mat_terms(a, c.date_system())?; + if pr <= 0.0 || dsm == 0.0 { + return Err(CellError::Num); + } + let base = pr / 100.0 + ai * rate; + num_val(((1.0 + dim * rate) - base) / base / dsm) +} + +struct Bond { + n: f64, + e: f64, + a: f64, + dsc: f64, + freq: f64, +} + +fn bond(sys: DateSystem, settle: f64, mat: f64, freq: f64, basis: u32) -> R { + let c = coupons(sys, settle, mat, freq)?; + let e = coup_days(&c, freq, basis); + let a = coup_daybs(sys, &c, settle, basis)?; + let dsc = coup_daysnc(sys, &c, settle, freq, basis)?; + if e <= 0.0 { + return Err(CellError::Num); + } + Ok(Bond { n: c.num, e, a, dsc, freq }) +} + +fn bond_price(b: &Bond, rate: f64, yld: f64, red: f64) -> f64 { + let f = b.freq; + let coupon = 100.0 * rate / f; + let accrued = coupon * b.a / b.e; + if b.n == 1.0 { + return (red + coupon) / (1.0 + b.dsc / b.e * yld / f) - accrued; + } + let base = 1.0 + yld / f; + let frac = b.dsc / b.e; + let mut sum = red / base.powf(b.n - 1.0 + frac); + let mut k = 1.0; + while k <= b.n { + sum += coupon / base.powf(k - 1.0 + frac); + k += 1.0; + } + sum - accrued +} + +pub(super) fn price(a: &[Arg], c: &mut dyn Ctx) -> R { + let (s, m, rate, yld, red) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?, num(a, 4)?); + let (f, basis) = (freq_arg(a, 5)?, basis_arg(a, 6)?); + if rate < 0.0 || yld < 0.0 || red <= 0.0 { + return Err(CellError::Num); + } + let b = bond(c.date_system(), s, m, f, basis)?; + num_val(bond_price(&b, rate, yld, red)) +} + +pub(super) fn yield_(a: &[Arg], c: &mut dyn Ctx) -> R { + let (s, m, rate, pr, red) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?, num(a, 4)?); + let (f, basis) = (freq_arg(a, 5)?, basis_arg(a, 6)?); + if rate < 0.0 || pr <= 0.0 || red <= 0.0 { + return Err(CellError::Num); + } + let b = bond(c.date_system(), s, m, f, basis)?; + if b.n == 1.0 { + let base = pr / 100.0 + b.a / b.e * rate / f; + return num_val(((red / 100.0 + rate / f) - base) / base * f * b.e / b.dsc); + } + // Price is decreasing in yield: bisect on [0, hi]. + let g = |y: f64| bond_price(&b, rate, y, red) - pr; + let (mut lo, mut hi) = (-0.999_999 * f, 1.0); + let mut guard = 0; + while g(hi) > 0.0 && guard < 60 { + hi *= 2.0; + guard += 1; + } + if !(g(lo) >= 0.0 && g(hi) <= 0.0) { + return Err(CellError::Num); + } + for _ in 0..200 { + let mid = 0.5 * (lo + hi); + if g(mid) > 0.0 { + lo = mid; + } else { + hi = mid; + } + } + num_val(0.5 * (lo + hi)) +} + +fn duration_calc(a: &[Arg], sys: DateSystem) -> R<(f64, f64, f64)> { + let (s, m, coupon, yld) = (date(a, 0)?, date(a, 1)?, num(a, 2)?, num(a, 3)?); + let (f, basis) = (freq_arg(a, 4)?, basis_arg(a, 5)?); + if coupon < 0.0 || yld < 0.0 { + return Err(CellError::Num); + } + let b = bond(sys, s, m, f, basis)?; + let c = coupon * 100.0 / f; + let y = 1.0 + yld / f; + let frac = b.dsc / b.e; + let (mut wsum, mut psum) = (0.0, 0.0); + let mut k = 1.0; + while k <= b.n { + let t = k - 1.0 + frac; + let cf = if k == b.n { c + 100.0 } else { c }; + let pv = cf / y.powf(t); + wsum += t * pv; + psum += pv; + k += 1.0; + } + if psum == 0.0 { + return Err(CellError::Num); + } + Ok((wsum / psum / f, yld, f)) +} + +pub(super) fn duration(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(duration_calc(a, c.date_system())?.0) +} + +pub(super) fn mduration(a: &[Arg], c: &mut dyn Ctx) -> R { + let (d, y, f) = duration_calc(a, c.date_system())?; + num_val(d / (1.0 + y / f)) +} + +// --------------------------------------------------------------------------------------------- +// Odd first and last coupon periods +// --------------------------------------------------------------------------------------------- +// +// An odd period is split into quasi-coupon periods: the regular coupon periods the security +// would have had, counted back from the first coupon (ODDF*) or forward from the last interest +// date (ODDL*). For quasi-period i, NL is its normal length, DC the days of it inside the odd +// period, A the accrued days of it before settlement and DSC the days of it from settlement on. + +/// Most quasi-coupon periods an odd period may span (the date range allows ~32,400 quarters). +const MAX_QUASI: i64 = 40_000; + +/// Normal length of the coupon period `[start, end]` under a basis (COUPDAYS semantics). +fn normal_length(start: f64, end: f64, freq: f64, basis: u32) -> f64 { + match basis { + 1 => end - start, + 3 => 365.0 / freq, + _ => 360.0 / freq, + } +} + +/// Days of `[from, to]` under a basis, or 0 when the span is empty. +fn span(sys: DateSystem, from: f64, to: f64, basis: u32) -> R { + if to > from { day_count(sys, from, to, basis) } else { Ok(0.0) } +} + +/// The odd-first-period terms that ODDFPRICE and ODDFYIELD share. +struct OddFirst { + /// Coupons after the first coupon up to maturity. + n: f64, + /// Whole quasi-coupon periods between settlement and the first coupon. + nq: f64, + /// DSC / NL of the quasi-period holding settlement. + frac: f64, + /// Σ DCi / NLi over the odd period. + dc: f64, + /// Σ Ai / NLi over the odd period. + acc: f64, + freq: f64, +} + +fn odd_first(sys: DateSystem, settle: f64, mat: f64, issue: f64, first: f64, freq: f64, basis: u32) -> R { + if !(mat > first && first > settle && settle > issue) { + return Err(CellError::Num); + } + let anchor = ymd(sys, first)?; + let eom = anchor.2 == days_in_month(anchor.0, anchor.1); + let step = (12.0 / freq) as i64; + let quasi = |k: i64| shift_months(sys, anchor, -k * step, eom); + let (mut dc, mut acc) = (0.0, 0.0); + let (mut nq, mut frac) = (None, 0.0); + // Walk back from the first coupon: period k runs from quasi(k + 1) to quasi(k). + let mut k = 0; + loop { + if k >= MAX_QUASI { + return Err(CellError::Num); + } + let (end, start) = (quasi(k)?, quasi(k + 1)?); + let nl = normal_length(start, end, freq, basis); + if nl <= 0.0 { + return Err(CellError::Num); + } + let from = start.max(issue); + dc += span(sys, from, end, basis)? / nl; + acc += span(sys, from, settle.min(end), basis)? / nl; + if start <= settle && settle < end { + nq = Some(k as f64); + frac = span(sys, settle, end, basis)? / nl; + } + if start <= issue { + break; + } + k += 1; + } + let nq = nq.ok_or(CellError::Num)?; + let n = coupons(sys, first, mat, freq)?.num; + Ok(OddFirst { n, nq, frac, dc, acc, freq }) +} + +fn odd_first_price(t: &OddFirst, rate: f64, yld: f64, red: f64) -> f64 { + let coupon = 100.0 * rate / t.freq; + let base = 1.0 + yld / t.freq; + let lead = t.nq + t.frac; + let mut p = red / base.powf(t.n + lead) + coupon * t.dc / base.powf(lead); + let mut k = 1.0; + while k <= t.n { + p += coupon / base.powf(k + lead); + k += 1.0; + } + p - coupon * t.acc +} + +/// settlement, maturity, issue, first_coupon, rate, yld/pr, redemption, frequency, [basis]. +fn odd_first_args(a: &[Arg], sys: DateSystem) -> R<(OddFirst, f64, f64, f64)> { + let (s, m, issue, first) = (date(a, 0)?, date(a, 1)?, date(a, 2)?, date(a, 3)?); + let (rate, x, red) = (num(a, 4)?, num(a, 5)?, num(a, 6)?); + let (f, basis) = (freq_arg(a, 7)?, basis_arg(a, 8)?); + if rate < 0.0 || red <= 0.0 { + return Err(CellError::Num); + } + Ok((odd_first(sys, s, m, issue, first, f, basis)?, rate, x, red)) +} + +pub(super) fn oddfprice(a: &[Arg], c: &mut dyn Ctx) -> R { + let (t, rate, yld, red) = odd_first_args(a, c.date_system())?; + if yld < 0.0 { + return Err(CellError::Num); + } + num_val(odd_first_price(&t, rate, yld, red)) +} + +pub(super) fn oddfyield(a: &[Arg], c: &mut dyn Ctx) -> R { + let (t, rate, pr, red) = odd_first_args(a, c.date_system())?; + if pr <= 0.0 { + return Err(CellError::Num); + } + // The price falls as the yield rises: bracket the root, then bisect. + let g = |y: f64| odd_first_price(&t, rate, y, red) - pr; + let (mut lo, mut hi) = (-0.999_999 * t.freq, 1.0); + let mut guard = 0; + while g(hi) > 0.0 && guard < 60 { + hi *= 2.0; + guard += 1; + } + // NaN fails both comparisons. + if !(g(lo) >= 0.0 && g(hi) <= 0.0) { + return Err(CellError::Num); + } + for _ in 0..200 { + let mid = 0.5 * (lo + hi); + if g(mid) > 0.0 { + lo = mid; + } else { + hi = mid; + } + } + num_val(0.5 * (lo + hi)) +} + +/// Σ DCi/NLi, Σ Ai/NLi and Σ DSCi/NLi over an odd last period. +fn odd_last(sys: DateSystem, settle: f64, mat: f64, last: f64, freq: f64, basis: u32) -> R<(f64, f64, f64)> { + if !(mat > settle && settle > last) { + return Err(CellError::Num); + } + let anchor = ymd(sys, last)?; + let eom = anchor.2 == days_in_month(anchor.0, anchor.1); + let step = (12.0 / freq) as i64; + let (mut dc, mut acc, mut dsc) = (0.0, 0.0, 0.0); + // Walk forward from the last interest date: period k runs from quasi(k - 1) to quasi(k). + let mut start = last; + let mut k = 1; + loop { + if k > MAX_QUASI { + return Err(CellError::Num); + } + let end = shift_months(sys, anchor, k * step, eom)?; + let nl = normal_length(start, end, freq, basis); + if nl <= 0.0 { + return Err(CellError::Num); + } + let stop = end.min(mat); + dc += span(sys, start, stop, basis)? / nl; + acc += span(sys, start, settle.min(stop), basis)? / nl; + dsc += span(sys, settle.max(start), stop, basis)? / nl; + if end >= mat { + break; + } + start = end; + k += 1; + } + Ok((dc, acc, dsc)) +} + +/// settlement, maturity, last_interest, rate, yld/pr, redemption, frequency, [basis]. +fn odd_last_args(a: &[Arg], sys: DateSystem) -> R<((f64, f64, f64), f64, f64, f64, f64)> { + let (s, m, last) = (date(a, 0)?, date(a, 1)?, date(a, 2)?); + let (rate, x, red) = (num(a, 3)?, num(a, 4)?, num(a, 5)?); + let (f, basis) = (freq_arg(a, 6)?, basis_arg(a, 7)?); + if rate < 0.0 || red <= 0.0 { + return Err(CellError::Num); + } + Ok((odd_last(sys, s, m, last, f, basis)?, rate, x, red, f)) +} + +pub(super) fn oddlprice(a: &[Arg], c: &mut dyn Ctx) -> R { + let ((dc, acc, dsc), rate, yld, red, f) = odd_last_args(a, c.date_system())?; + if yld < 0.0 { + return Err(CellError::Num); + } + let coupon = 100.0 * rate / f; + num_val((red + dc * coupon) / (1.0 + dsc * yld / f) - acc * coupon) +} + +pub(super) fn oddlyield(a: &[Arg], c: &mut dyn Ctx) -> R { + let ((dc, acc, dsc), rate, pr, red, f) = odd_last_args(a, c.date_system())?; + if pr <= 0.0 { + return Err(CellError::Num); + } + let coupon = 100.0 * rate / f; + let paid = pr + acc * coupon; + if paid <= 0.0 || dsc <= 0.0 { + return Err(CellError::Num); + } + num_val((red + dc * coupon - paid) / paid * f / dsc) +} diff --git a/crates/functions/src/financial/tests.rs b/crates/functions/src/financial/tests.rs new file mode 100644 index 0000000000..2788da94aa --- /dev/null +++ b/crates/functions/src/financial/tests.rs @@ -0,0 +1,319 @@ +use crate::Arg; +use crate::util::testutil::*; +use gridcraft_core::{CellError, DateSystem, Value, date::serial_from_ymd}; + +fn d(y: i64, m: i64, dd: i64) -> Arg { + n(serial_from_ymd(DateSystem::D1900, y, m, dd).unwrap()) +} + +#[test] +fn tvm() { + close_tol(ev("PMT", vec![n(0.08 / 12.0), n(10.0), n(10000.0)]), -1037.032089, 1e-9); + close_tol(ev("PMT", vec![n(0.06 / 12.0), n(216.0), n(0.0), n(50000.0)]), -129.0811609, 1e-9); + close(ev("PMT", vec![n(0.0), n(10.0), n(1000.0)]), -100.0); + is_err(ev("PMT", vec![n(0.1), n(0.0), n(1000.0)]), CellError::Num); + close_tol(ev("FV", vec![n(0.06 / 12.0), n(10.0), n(-200.0), n(-500.0), n(1.0)]), 2581.403374, 1e-9); + close_tol(ev("FV", vec![n(0.01), n(12.0), n(-1000.0)]), 12682.50301, 1e-9); + close(ev("FV", vec![n(0.0), n(10.0), n(-100.0)]), 1000.0); + close_tol(ev("PV", vec![n(0.08 / 12.0), n(240.0), n(500.0)]), -59777.14585, 1e-9); + close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0), n(10000.0), n(1.0)]), 59.6738657, 1e-8); + close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0), n(10000.0)]), 60.0821229, 1e-8); + close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0)]), -9.57859404, 1e-8); + close_tol(ev("IPMT", vec![n(0.1 / 12.0), n(1.0), n(36.0), n(8000.0)]), -66.66666667, 1e-9); + close_tol(ev("IPMT", vec![n(0.1), n(3.0), n(3.0), n(8000.0)]), -292.4471299, 1e-9); + is_err(ev("IPMT", vec![n(0.1), n(4.0), n(3.0), n(8000.0)]), CellError::Num); + close_tol(ev("PPMT", vec![n(0.1 / 12.0), n(1.0), n(24.0), n(2000.0)]), -75.62318601, 1e-9); + close_tol(ev("PPMT", vec![n(0.08), n(10.0), n(10.0), n(200000.0)]), -27598.05346, 1e-9); + close_tol(ev("RATE", vec![n(48.0), n(-200.0), n(8000.0)]), 0.0077014725, 1e-7); + close_tol(ev("CUMIPMT", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(13.0), n(24.0), n(0.0)]), -11135.23213, 1e-9); + close_tol(ev("CUMIPMT", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), -937.5, 1e-9); + close_tol(ev("CUMPRINC", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(13.0), n(24.0), n(0.0)]), -934.1071234, 1e-9); + close_tol(ev("CUMPRINC", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), -68.27827118, 1e-9); + is_err(ev("CUMIPMT", vec![n(0.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), CellError::Num); + is_err(ev("CUMIPMT", vec![n(0.01), n(360.0), n(125000.0), n(1.0), n(1.0), n(2.0)]), CellError::Num); +} + +/// PMT within about 4 ULPs of Excel's (Value2 of synthetic probes; libm differs by an ULP across +/// platforms). The old `(1 + rate)^nper` form was 25 to 91 ULPs off on these, enough to flip a loan schedule's final `IF(p - SUM(r) > 0, ...)`. +#[test] +fn pmt_matches_excel_within_a_few_ulps() { + for (rate, nper, pv, fv, typ, excel) in [ + (0.141 / 12.0, 12.0, -4653300.0, 0.0, 0.0, 418025.37230211904), + (0.005716666666666666, 26.0, 42678411.0, -26010.62, 0.0, -1770235.3828507778), + (0.012164, 29.0, 16722649.0, 0.0, 1.0, -679516.0784762775), + (0.009916666666666666, 5.0, 8590842.8, 0.0, 0.0, -1769620.32624324), + (0.011158333333333333, 36.0, 21457108.0, 49218.26, 1.0, -720095.3912155787), + (-0.5, 2.5, 100.0, 0.0, 0.0, -10.736861692729645), + ] { + let got = ev("PMT", vec![n(rate), n(nper), n(pv), n(fv), n(typ)]); + close_tol(got, excel, 1e-15); + } +} + +/// Edge cases observed in Excel: a rate of -1 or less is #NUM!, a huge nper leaves -pv*rate, and a +/// tiny rate no longer loses its low digits in 1 + rate. +#[test] +fn pmt_edge_cases_follow_excel() { + is_err(ev("PMT", vec![n(-2.0), n(3.0), n(100.0)]), CellError::Num); + is_err(ev("PMT", vec![n(-1.0), n(3.0), n(100.0)]), CellError::Num); + is_err(ev("PMT", vec![n(-2.0), n(2.5), n(100.0)]), CellError::Num); + assert_eq!(ev("PMT", vec![n(0.1), n(10000.0), n(100.0)]), Value::Number(-10.0)); + assert_eq!(ev("PMT", vec![n(5.0), n(1000.0), n(100.0)]), Value::Number(-500.0)); + assert_eq!(ev("PMT", vec![n(1e-300), n(10.0), n(100.0)]), Value::Number(-10.0)); + close_tol(ev("PMT", vec![n(1e-12), n(10.0), n(100.0)]), -10.000000000055001, 1e-15); + close_tol(ev("PMT", vec![n(1e-300), n(10.0), n(1e12)]), -1e11, 1e-15); + close_tol(ev("PMT", vec![n(-0.999), n(5.0), n(100.0)]), -9.990_000_000_000_092e-14, 1e-15); + close_tol(ev("PMT", vec![n(0.05), n(-10.0), n(100.0)]), 7.950_457_496_545_667, 1e-15); + close_tol(ev("PMT", vec![n(-1e-17), n(1.0), n(1e18), n(-1e18)]), 10.0, 1e-9); + close_tol(ev("PMT", vec![n(-0.5), n(60.0), n(1e18)]), -0.433_680_868_994_201_8, 1e-6); + close_tol(ev("PMT", vec![n(0.05), n(10.0), n(100.0), n(50.0), n(1.0)]), -16.119701185541427, 1e-15); +} + +#[test] +fn cash_flows() { + close_tol(ev("NPV", vec![n(0.1), n(-10000.0), n(3000.0), n(4200.0), n(6800.0)]), 1188.443412, 1e-9); + close_tol(ev("NPV", vec![n(0.08), rf(col(&[8000.0, 9200.0, 10000.0, 12000.0, 14500.0]))]), 41922.06155, 1e-9); + is_err(ev("NPV", vec![n(-1.0), n(1.0)]), CellError::Div0); + let flows = col(&[-70000.0, 12000.0, 15000.0, 18000.0, 21000.0]); + close_tol(ev("IRR", vec![rf(flows)]), -0.021244848, 1e-7); + close_tol(ev("IRR", vec![rf(col(&[-70000.0, 12000.0, 15000.0, 18000.0, 21000.0, 26000.0]))]), 0.086630948, 1e-7); + close_tol(ev("IRR", vec![rf(col(&[-70000.0, 12000.0, 15000.0])), n(-0.1)]), -0.44350694, 1e-7); + is_err(ev("IRR", vec![rf(col(&[1.0, 2.0]))]), CellError::Num); + let vals = col(&[-10000.0, 2750.0, 4250.0, 3250.0, 2750.0]); + let dates = col(&[39448.0, 39508.0, 39751.0, 39859.0, 39904.0]); + close_tol(ev("XNPV", vec![n(0.09), rf(vals.clone()), rf(dates.clone())]), 2086.647602, 1e-9); + close_tol(ev("XIRR", vec![rf(vals.clone()), rf(dates)]), 0.373362535, 1e-7); + is_err(ev("XNPV", vec![n(0.09), rf(vals), rf(col(&[1.0, 2.0]))]), CellError::Num); + let m = col(&[-120000.0, 39000.0, 30000.0, 21000.0, 37000.0, 46000.0]); + close_tol(ev("MIRR", vec![rf(m), n(0.1), n(0.12)]), 0.1260943, 1e-6); + close_tol(ev("MIRR", vec![rf(col(&[-120000.0, 39000.0, 30000.0, 21000.0])), n(0.1), n(0.12)]), -0.048044655, 1e-7); + is_err(ev("MIRR", vec![rf(col(&[1.0, 2.0])), n(0.1), n(0.1)]), CellError::Div0); + // Excel 365 gives -1 when every flow is negative: the positive flows are worth nothing. + close_tol(ev("MIRR", vec![rf(col(&[-10000.0, -3400.0, -6500.0, -1000.0])), n(0.07), n(0.01)]), -1.0, 1e-12); + close(ev("FVSCHEDULE", vec![n(1.0), av(row(&[0.09, 0.11, 0.1]))]), 1.33089); + close(ev("EFFECT", vec![n(0.0525), n(4.0)]), 0.0535426673707584); + is_err(ev("EFFECT", vec![n(-0.1), n(4.0)]), CellError::Num); + close_tol(ev("NOMINAL", vec![n(0.053543), n(4.0)]), 0.052500319, 1e-8); + close_tol(ev("ISPMT", vec![n(0.1 / 12.0), n(0.0), n(36.0), n(8000000.0)]), -66666.66667, 1e-9); + is_err(ev("ISPMT", vec![n(0.1), n(1.0), n(0.0), n(1.0)]), CellError::Div0); + close_tol(ev("PDURATION", vec![n(0.025), n(2000.0), n(2200.0)]), 3.859866163, 1e-9); + is_err(ev("PDURATION", vec![n(0.0), n(2000.0), n(2200.0)]), CellError::Num); + close_tol(ev("RRI", vec![n(96.0), n(10000.0), n(11000.0)]), 0.0009933, 1e-4); + close(ev("DOLLARDE", vec![n(1.02), n(16.0)]), 1.125); + close(ev("DOLLARDE", vec![n(1.1), n(32.0)]), 1.3125); + close(ev("DOLLARFR", vec![n(1.125), n(16.0)]), 1.02); + close(ev("DOLLARFR", vec![n(1.125), n(32.0)]), 1.04); + is_err(ev("DOLLARDE", vec![n(1.02), n(0.0)]), CellError::Div0); + is_err(ev("DOLLARDE", vec![n(1.02), n(-1.0)]), CellError::Num); +} + +#[test] +fn depreciation() { + close(ev("SLN", vec![n(30000.0), n(7500.0), n(10.0)]), 2250.0); + is_err(ev("SLN", vec![n(30000.0), n(7500.0), n(0.0)]), CellError::Div0); + close_tol(ev("SYD", vec![n(30000.0), n(7500.0), n(10.0), n(1.0)]), 4090.909091, 1e-9); + close_tol(ev("SYD", vec![n(30000.0), n(7500.0), n(10.0), n(10.0)]), 409.0909091, 1e-9); + let dbv = |p: f64| ev("DB", vec![n(1000000.0), n(100000.0), n(6.0), n(p), n(7.0)]); + close_tol(dbv(1.0), 186083.3333, 1e-9); + close_tol(dbv(2.0), 259639.4167, 1e-9); + close_tol(dbv(3.0), 176814.4428, 1e-9); + close_tol(dbv(7.0), 15845.09847, 1e-9); + is_err(dbv(8.0), CellError::Num); + close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(3650.0), n(1.0)]), 1.315068493, 1e-8); + close(ev("DDB", vec![n(2400.0), n(300.0), n(120.0), n(1.0), n(2.0)]), 40.0); + close(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(1.0), n(2.0)]), 480.0); + close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(2.0), n(1.5)]), 306.0, 1e-12); + close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(10.0)]), 22.1225472, 1e-8); + is_err(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(11.0)]), CellError::Num); + close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(3650.0), n(0.0), n(1.0)]), 1.315068493, 1e-8); + close(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(0.0), n(1.0)]), 40.0); + close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(1.0)]), 480.0); + close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(6.0), n(18.0)]), 396.3060533, 1e-8); + close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(6.0), n(18.0), n(1.5)]), 311.8089367, 1e-8); + close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(0.875), n(1.5)]), 315.0); + // Over the whole life the total depreciation is cost - salvage. + close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(10.0)]), 2100.0); + is_err(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(5.0), n(4.0)]), CellError::Num); + close_tol(ev("AMORLINC", vec![n(2400.0), d(2008, 8, 19), d(2008, 12, 31), n(300.0), n(1.0), n(0.15), n(1.0)]), 360.0, 1e-9); + close(ev("AMORDEGRC", vec![n(2400.0), d(2008, 8, 19), d(2008, 12, 31), n(300.0), n(1.0), n(0.15), n(1.0)]), 776.0); +} + +#[test] +fn coupons() { + let args = |f: &str| ev(f, vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(1.0)]); + close(args("COUPDAYBS"), 71.0); + close(args("COUPDAYS"), 181.0); + close(args("COUPDAYSNC"), 110.0); + close(args("COUPNCD"), 40678.0); + close(args("COUPPCD"), 40497.0); + close(args("COUPNUM"), 2.0); + close(ev("COUPDAYS", vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(0.0)]), 180.0); + close(ev("COUPDAYS", vec![d(2011, 1, 25), d(2011, 11, 15), n(4.0), n(3.0)]), 91.25); + close(ev("COUPNUM", vec![d(2007, 1, 25), d(2008, 11, 15), n(2.0), n(1.0)]), 4.0); + is_err(ev("COUPNUM", vec![d(2012, 1, 25), d(2011, 11, 15), n(2.0)]), CellError::Num); + is_err(ev("COUPNUM", vec![d(2011, 1, 25), d(2011, 11, 15), n(3.0)]), CellError::Num); + is_err(ev("COUPNUM", vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(5.0)]), CellError::Num); + // End-of-month maturity keeps month-end coupon dates. + close(ev("COUPPCD", vec![d(2011, 3, 15), d(2011, 11, 30), n(2.0)]), serial_from_ymd(DateSystem::D1900, 2010, 11, 30).unwrap()); + close(ev("COUPNCD", vec![d(2011, 3, 15), d(2011, 11, 30), n(2.0)]), serial_from_ymd(DateSystem::D1900, 2011, 5, 31).unwrap()); +} + +#[test] +fn securities() { + close_tol(ev("DISC", vec![d(2007, 1, 25), d(2007, 6, 15), n(97.975), n(100.0), n(1.0)]), 0.052420213, 1e-8); + close_tol(ev("PRICEDISC", vec![d(2008, 2, 16), d(2008, 3, 1), n(0.0525), n(100.0), n(2.0)]), 99.79583333, 1e-9); + close_tol(ev("YIELDDISC", vec![d(2008, 2, 16), d(2008, 3, 1), n(99.795), n(100.0), n(2.0)]), 0.052823, 1e-5); + close_tol(ev("INTRATE", vec![d(2008, 2, 15), d(2008, 5, 15), n(1000000.0), n(1014420.0), n(2.0)]), 0.05768, 1e-9); + close_tol(ev("RECEIVED", vec![d(2008, 2, 15), d(2008, 5, 15), n(1000000.0), n(0.0575), n(2.0)]), 1014584.654, 1e-9); + is_err(ev("DISC", vec![d(2008, 2, 15), d(2008, 1, 15), n(97.0), n(100.0)]), CellError::Num); + close_tol(ev("TBILLPRICE", vec![d(2008, 3, 31), d(2008, 6, 1), n(0.09)]), 98.45, 1e-12); + close_tol(ev("TBILLYIELD", vec![d(2008, 3, 31), d(2008, 6, 1), n(98.45)]), 0.091417, 1e-6); + close_tol(ev("TBILLEQ", vec![d(2008, 3, 31), d(2008, 6, 1), n(0.0914)]), 0.094151, 1e-6); + is_err(ev("TBILLPRICE", vec![d(2008, 3, 31), d(2009, 6, 1), n(0.09)]), CellError::Num); + close_tol(ev("ACCRINTM", vec![d(2008, 4, 1), d(2008, 6, 15), n(0.1), n(1000.0), n(3.0)]), 20.54794521, 1e-9); + is_err(ev("ACCRINTM", vec![d(2008, 6, 15), d(2008, 4, 1), n(0.1)]), CellError::Num); + close_tol(ev("ACCRINT", vec![d(2008, 3, 1), d(2008, 8, 31), d(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(0.0)]), 16.66666667, 1e-9); + close_tol(ev("ACCRINT", vec![d(2008, 3, 5), d(2008, 8, 31), d(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(0.0), b(false)]), 15.55555556, 1e-9); + close_tol(ev("PRICEMAT", vec![d(2008, 2, 15), d(2008, 4, 13), d(2007, 11, 11), n(0.061), n(0.061), n(0.0)]), 99.98449888, 1e-9); + close_tol(ev("YIELDMAT", vec![d(2008, 3, 15), d(2008, 11, 3), d(2007, 11, 8), n(0.0625), n(100.0123), n(0.0)]), 0.060954, 1e-6); + close_tol(ev("PRICE", vec![d(2008, 2, 15), d(2017, 11, 15), n(0.0575), n(0.065), n(100.0), n(2.0), n(0.0)]), 94.63436, 1e-7); + close_tol(ev("YIELD", vec![d(2008, 2, 15), d(2016, 11, 15), n(0.0575), n(95.04287), n(100.0), n(2.0), n(0.0)]), 0.065, 1e-6); + is_err(ev("PRICE", vec![d(2008, 2, 15), d(2017, 11, 15), n(-0.01), n(0.065), n(100.0), n(2.0)]), CellError::Num); + close_tol(ev("DURATION", vec![d(2018, 7, 1), d(2048, 1, 1), n(0.08), n(0.09), n(2.0), n(1.0)]), 10.9191453, 1e-8); + close_tol(ev("MDURATION", vec![d(2008, 1, 1), d(2016, 1, 1), n(0.08), n(0.09), n(2.0), n(1.0)]), 5.73567, 1e-6); + // A one-coupon bond uses the closed form for yield; PRICE and YIELD invert each other. + let p = ev("PRICE", vec![d(2008, 2, 15), d(2008, 6, 15), n(0.05), n(0.04), n(100.0), n(2.0)]); + let pv = match p { + Value::Number(x) => x, + _ => panic!(), + }; + close_tol(ev("YIELD", vec![d(2008, 2, 15), d(2008, 6, 15), n(0.05), n(pv), n(100.0), n(2.0)]), 0.04, 1e-9); +} + +fn number(v: Value) -> f64 { + match v { + Value::Number(x) => x, + other => panic!("expected a number, got {other:?}"), + } +} + +#[test] +fn odd_periods() { + // The worked examples on Microsoft's function pages (results as printed there). + let first = |x: f64, rest: &[f64]| { + let mut a = vec![d(2008, 11, 11), d(2021, 3, 1), d(2008, 10, 15), d(2009, 3, 1), n(x)]; + a.extend(rest.iter().map(|v| n(*v))); + a + }; + close_tol(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 1.0])), 113.60, 0.005 / 113.6); + close_tol(ev("ODDFYIELD", first(0.0575, &[84.50, 100.0, 2.0, 0.0])), 0.0772, 0.00005); + let lp = vec![d(2008, 2, 7), d(2008, 6, 15), d(2007, 10, 15), n(0.0375), n(0.0405), n(100.0), n(2.0), n(0.0)]; + close_tol(ev("ODDLPRICE", lp), 99.88, 0.005 / 99.88); + let ly = vec![d(2008, 4, 20), d(2008, 6, 15), d(2007, 12, 24), n(0.0375), n(99.875), n(100.0), n(2.0), n(0.0)]; + close_tol(ev("ODDLYIELD", ly), 0.04519, 0.000005); + + // Yield → price → yield, for a short and a long odd first period and a long odd last period. + for (issue, basis) in [(d(2008, 10, 15), 0.0), (d(2007, 1, 20), 1.0), (d(2008, 1, 31), 3.0)] { + let args = |x: f64| vec![d(2008, 11, 11), d(2021, 3, 1), issue.clone(), d(2009, 3, 1), n(0.06), n(x), n(100.0), n(2.0), n(basis)]; + let p = number(ev("ODDFPRICE", args(0.05))); + close_tol(ev("ODDFYIELD", args(p)), 0.05, 1e-9); + } + let last = |x: f64| vec![d(2009, 2, 1), d(2009, 12, 15), d(2008, 3, 15), n(0.04), n(x), n(100.0), n(4.0), n(1.0)]; + let p = number(ev("ODDLPRICE", last(0.07))); + close_tol(ev("ODDLYIELD", last(p)), 0.07, 1e-9); + + // Argument checks: frequency, basis, date order, negative yield, non-positive price. + is_err(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 3.0, 1.0])), CellError::Num); + is_err(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 5.0])), CellError::Num); + is_err(ev("ODDFPRICE", first(0.0785, &[-0.01, 100.0, 2.0, 1.0])), CellError::Num); + is_err(ev("ODDFYIELD", first(0.0575, &[0.0, 100.0, 2.0, 0.0])), CellError::Num); + is_err(ev("ODDFPRICE", vec![d(2009, 4, 1), d(2021, 3, 1), d(2008, 10, 15), d(2009, 3, 1), n(0.07), n(0.06), n(100.0), n(2.0)]), CellError::Num); + is_err(ev("ODDLPRICE", vec![d(2007, 10, 1), d(2008, 6, 15), d(2007, 10, 15), n(0.0375), n(0.0405), n(100.0), n(2.0)]), CellError::Num); + is_err(ev("ODDLYIELD", vec![d(2008, 4, 20), d(2008, 6, 15), d(2007, 12, 24), n(0.0375), n(-1.0), n(100.0), n(2.0)]), CellError::Num); + is_err( + ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 1.0]).into_iter().take(4).chain([t("x"), n(0.06), n(100.0), n(2.0)]).collect()), + CellError::Value, + ); +} + +#[test] +fn odd_periods_survive_hostile_input() { + // Quarterly quasi-periods across the whole date range, huge rates and yields: a value or + // an error, never a panic or a hang. + let wide = |x: f64, y: f64| vec![n(2_957_000.0), n(2_958_465.0), n(400.0), n(2_957_900.0), n(x), n(y), n(1e300), n(4.0), n(1.0)]; + for (x, y) in [(0.05, 0.05), (1e300, 1e300), (0.0, 1e-300)] { + let _ = ev("ODDFPRICE", wide(x, y)); + let _ = ev("ODDFYIELD", wide(x, y)); + } + // The long walk itself works: settled early in an odd first period of ~32,000 quarters, + // a small yield round-trips. + let long = |y: f64| vec![n(500.0), n(2_958_465.0), n(400.0), n(2_957_900.0), n(0.05), n(y), n(100.0), n(4.0), n(1.0)]; + let p = number(ev("ODDFPRICE", long(0.0001))); + close_tol(ev("ODDFYIELD", long(p)), 0.0001, 1e-9); + let last = |x: f64, y: f64| vec![n(2_958_000.0), n(2_958_465.0), n(400.0), n(x), n(y), n(1e300), n(4.0), n(1.0)]; + for (x, y) in [(0.05, 0.05), (1e300, 1e300), (0.0, f64::MAX)] { + let _ = ev("ODDLPRICE", last(x, y)); + let _ = ev("ODDLYIELD", last(x, y)); + } + is_err(ev("ODDFPRICE", vec![n(-5.0), n(1e20), n(0.0), n(1e10), n(0.05), n(0.05), n(100.0), n(2.0)]), CellError::Num); +} + +#[test] +fn euroconvert() { + // The examples on Microsoft's EUROCONVERT page. + let ec = |x: f64, s: &str, tg: &str, rest: Vec| { + let mut a = vec![n(x), t(s), t(tg)]; + a.extend(rest); + ev("EUROCONVERT", a) + }; + close(ec(1.2, "DEM", "EUR", vec![]), 0.61); + close(ec(1.0, "FRF", "EUR", vec![b(true), n(3.0)]), 0.152); + close(ec(1.0, "FRF", "EUR", vec![b(false), n(3.0)]), 0.15); + close(ec(1.0, "FRF", "DEM", vec![b(true), n(3.0)]), 0.29728616); + // Euro to a member currency rounds to the target's calculation precision (lira: 0). + close(ec(10.0, "eur", "ITL", vec![]), 19363.0); + close(ec(100.0, "EUR", "IEP", vec![b(true)]), 78.7564); + // Same currency returns the number untouched. + close(ec(1.23456, "DEM", "DEM", vec![]), 1.23456); + // Unknown codes and a triangulation precision below 3 are #VALUE!. + is_err(ec(1.0, "USD", "EUR", vec![]), CellError::Value); + is_err(ec(1.0, "FRF", "DEM", vec![b(true), n(2.0)]), CellError::Value); + // Hostile magnitudes give a number or #NUM!, never a panic. + is_err(ec(f64::MAX, "EUR", "ITL", vec![]), CellError::Num); + close(ec(1.0, "FRF", "DEM", vec![b(true), n(1e300)]), 1.95583 / 6.55957); +} + +#[test] +fn lifting() { + let v = ev("SLN", vec![av(row(&[100.0, 200.0])), n(0.0), n(10.0)]); + assert_eq!(rows_of(&v), vec![vec![nv(10.0), nv(20.0)]]); + is_err(ev("PMT", vec![t("abc"), n(1.0), n(1.0)]), CellError::Value); + is_err(ev("PMT", vec![e(CellError::NA), n(1.0), n(1.0)]), CellError::NA); +} + +#[test] +fn securities_read_dates_in_the_1904_system() { + let d4 = |y, m, dd| n(serial_from_ymd(DateSystem::D1904, y, m, dd).unwrap()); + let ev4 = |name: &str, args: Vec| { + let mut ctx = TestCtx { sys: DateSystem::D1904, ..TestCtx::default() }; + crate::call(crate::lookup(name).unwrap(), &args, &mut ctx) + }; + let (s, m) = (d4(2011, 3, 15), d4(2011, 11, 30)); + close(ev4("COUPNCD", vec![s.clone(), m.clone(), n(2.0)]), 39232.0); + close(ev4("COUPPCD", vec![s.clone(), m.clone(), n(4.0), n(0.0)]), 39140.0); + close(ev4("COUPDAYBS", vec![s.clone(), m.clone(), n(4.0), n(0.0)]), 15.0); + let accrint = vec![d4(2008, 3, 1), d4(2008, 8, 31), d4(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(1.0)]; + close_tol(ev4("ACCRINT", accrint), 16.576086956522, 1e-9); + // Results that already matched Excel keep matching. + let semi = |name| ev4(name, vec![s.clone(), m.clone(), n(2.0), n(0.0)]); + close(semi("COUPPCD"), 39050.0); + close(semi("COUPDAYS"), 180.0); + close(semi("COUPDAYBS"), 105.0); + close(semi("COUPDAYSNC"), 75.0); + close(semi("COUPNUM"), 2.0); + let price = vec![s.clone(), m.clone(), n(0.0575), n(0.065), n(100.0), n(2.0), n(0.0)]; + close_tol(ev4("PRICE", price), 99.47768286339101, 1e-9); + close_tol(ev4("DURATION", vec![s, m, n(0.08), n(0.09), n(2.0), n(0.0)]), 0.6890136809022, 1e-9); + let odd = vec![d4(2008, 11, 11), d4(2021, 3, 1), d4(2008, 10, 15), d4(2009, 3, 1), n(0.0785), n(0.0625), n(100.0), n(2.0), n(1.0)]; + close_tol(ev4("ODDFPRICE", odd), 113.597717474079, 1e-9); +} diff --git a/crates/functions/src/financial/tvm.rs b/crates/functions/src/financial/tvm.rs new file mode 100644 index 0000000000..a62d29bb59 --- /dev/null +++ b/crates/functions/src/financial/tvm.rs @@ -0,0 +1,415 @@ +use super::*; + +// --------------------------------------------------------------------------------------------- +// Time value of money +// --------------------------------------------------------------------------------------------- + +/// The payment per period. `(1 + rate)^nper` comes from `nper * ln_1p(rate)` and `p - 1` from +/// `exp_m1`: rounding `1 + rate` first throws away the low bits of a small rate, which left PMT up to +/// about 1000 ULPs from Excel (observed over 400 synthetic probes; this form is within 4). A loan +/// schedule's last `IF(p - SUM(principal) > 0, ...)` turns on those bits. Excel gives #NUM! for a +/// rate of -1 or less, and -pv*rate (no divide) once `(1 + rate)^nper` overflows. +fn pmt_calc(rate: f64, nper: f64, pv: f64, fv: f64, typ: f64) -> f64 { + if rate == 0.0 { + return -(pv + fv) / nper; + } + if rate <= -1.0 { + return f64::NAN; + } + let l = nper * rate.ln_1p(); + let m = l.exp_m1(); + let k = 1.0 + rate * typ; + if m > 0.0 { + // (fv + pv*p) / (p - 1) = pv + (pv + fv) / (p - 1), without the cancellation in p - 1. + // rate / m (about 1 / nper for a tiny rate) is taken first so (pv + fv) / m can't overflow. + -(pv * rate + (pv + fv) * (rate / m)) / k + } else if m > -0.5 { + // p near 1 from below: fv + pv*p = (pv + fv) + pv*m keeps the small terms that rounding p would drop. + -(rate * (pv + fv + pv * m)) / (k * m) + } else { + // p well below 1: m nears -1 and pv*m would swallow pv + fv, so use p itself. + -(rate * (fv + pv * l.exp())) / (k * m) + } +} + +fn fv_calc(rate: f64, nper: f64, pmt: f64, pv: f64, typ: f64) -> f64 { + if rate == 0.0 { + return -(pv + pmt * nper); + } + let p = (1.0 + rate).powf(nper); + -(pv * p + pmt * (1.0 + rate * typ) * (p - 1.0) / rate) +} + +fn pv_calc(rate: f64, nper: f64, pmt: f64, fv: f64, typ: f64) -> f64 { + if rate == 0.0 { + return -(fv + pmt * nper); + } + let p = (1.0 + rate).powf(nper); + -(fv + pmt * (1.0 + rate * typ) * (p - 1.0) / rate) / p +} + +pub(super) fn pmt(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (rate, nper, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + let (fv, typ) = (opt_num(a, 3, 0.0)?, type_arg(a, 4)?); + if nper == 0.0 { + return Err(CellError::Num); + } + num_val(pmt_calc(rate, nper, pv, fv, typ)) +} + +pub(super) fn fv(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (rate, nper, pmt) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + num_val(fv_calc(rate, nper, pmt, opt_num(a, 3, 0.0)?, type_arg(a, 4)?)) +} + +pub(super) fn pv(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (rate, nper, pmt) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + num_val(pv_calc(rate, nper, pmt, opt_num(a, 3, 0.0)?, type_arg(a, 4)?)) +} + +pub(super) fn nper(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (rate, pmt, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + let (fv, typ) = (opt_num(a, 3, 0.0)?, type_arg(a, 4)?); + if rate == 0.0 { + if pmt == 0.0 { + return Err(CellError::Num); + } + return num_val(-(pv + fv) / pmt); + } + let numer = pmt * (1.0 + rate * typ) - fv * rate; + let denom = pv * rate + pmt * (1.0 + rate * typ); + if denom == 0.0 { + return Err(CellError::Num); + } + let q = numer / denom; + if q <= 0.0 || rate <= -1.0 { + return Err(CellError::Num); + } + num_val(q.ln() / (1.0 + rate).ln()) +} + +fn ipmt_calc(rate: f64, per: f64, nper: f64, pv: f64, fv: f64, typ: f64) -> f64 { + let p = pmt_calc(rate, nper, pv, fv, typ); + if typ == 1.0 && per == 1.0 { + return 0.0; + } + let bal = fv_calc(rate, per - 1.0, p, pv, typ); + let i = bal * rate; + if typ == 1.0 { i / (1.0 + rate) } else { i } +} + +pub(super) fn ipmt(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (rate, per, np, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?); + let (fv, typ) = (opt_num(a, 4, 0.0)?, type_arg(a, 5)?); + if per < 1.0 || per > np || np == 0.0 { + return Err(CellError::Num); + } + num_val(ipmt_calc(rate, per, np, pv, fv, typ)) +} + +pub(super) fn ppmt(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (rate, per, np, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?); + let (fv, typ) = (opt_num(a, 4, 0.0)?, type_arg(a, 5)?); + if per < 1.0 || per > np || np == 0.0 { + return Err(CellError::Num); + } + num_val(pmt_calc(rate, np, pv, fv, typ) - ipmt_calc(rate, per, np, pv, fv, typ)) +} + +fn cumulative(a: &[Arg], principal: bool) -> R { + let (rate, np, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + let (start, end, typ) = (num(a, 3)?.trunc(), num(a, 4)?.trunc(), num(a, 5)?); + let np_i = np.trunc(); + if rate <= 0.0 || np_i <= 0.0 || pv <= 0.0 || start < 1.0 || end < start || end > np_i || (typ != 0.0 && typ != 1.0) { + return Err(CellError::Num); + } + if end - start > 1e7 || np_i > 1e15 { + return Err(CellError::Num); + } + let p = pmt_calc(rate, np_i, pv, 0.0, typ); + let mut sum = 0.0; + let mut per = start; + while per <= end { + let i = ipmt_calc(rate, per, np_i, pv, 0.0, typ); + sum += if principal { p - i } else { i }; + per += 1.0; + } + num_val(sum) +} + +pub(super) fn cumipmt(a: &[Arg], _c: &mut dyn Ctx) -> R { + cumulative(a, false) +} + +pub(super) fn cumprinc(a: &[Arg], _c: &mut dyn Ctx) -> R { + cumulative(a, true) +} + +/// Newton iteration with a numeric derivative, keeping the rate above -1. +fn newton(f: impl Fn(f64) -> f64, guess: f64) -> Option { + let mut x = guess; + for _ in 0..200 { + let y = f(x); + if !y.is_finite() { + return None; + } + let h = 1e-6 * x.abs().max(1e-3); + let d = (f(x + h) - f(x - h)) / (2.0 * h); + if d == 0.0 || !d.is_finite() { + return None; + } + let mut nx = x - y / d; + if nx <= -1.0 { + nx = (x - 1.0) / 2.0; + if nx <= -1.0 { + nx = -0.999_999_999; + } + } + if (nx - x).abs() < 1e-12 * nx.abs().max(1.0) { + return Some(nx); + } + x = nx; + } + None +} + +/// Tries Newton from the guess and a few fallback starting points; then bisection over a +/// bracket if one can be found. +fn solve_rate(f: impl Fn(f64) -> f64, guess: f64) -> R { + let check = |r: f64| f(r).is_finite() && f(r).abs() < 1e-7 * (1.0 + f(guess).abs().min(1e12)); + for g in [guess, 0.1, 0.01, 0.5, -0.5, 1.0, 0.001, 5.0, -0.9, 10.0] { + if let Some(r) = newton(&f, g) + && r > -1.0 + && check(r) + { + return Ok(r); + } + } + // Scan for a sign change and bisect. + let grid: Vec = [-0.999_999, -0.99, -0.9, -0.5, -0.2, 0.0, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 100.0, 1000.0].to_vec(); + for w in grid.windows(2) { + let (mut lo, mut hi) = match w { + [a, b] => (*a, *b), + _ => continue, + }; + let (flo, fhi) = (f(lo), f(hi)); + if !(flo.is_finite() && fhi.is_finite()) || flo * fhi > 0.0 { + continue; + } + let lo_neg = flo < 0.0; + for _ in 0..200 { + let mid = 0.5 * (lo + hi); + let fm = f(mid); + if (fm < 0.0) == lo_neg { + lo = mid; + } else { + hi = mid; + } + } + return Ok(0.5 * (lo + hi)); + } + Err(CellError::Num) +} + +pub(super) fn rate(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (np, pmt, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + let (fv, typ, guess) = (opt_num(a, 3, 0.0)?, type_arg(a, 4)?, opt_num(a, 5, 0.1)?); + if np <= 0.0 { + return Err(CellError::Num); + } + let f = |r: f64| { + if r.abs() < 1e-12 { + pv + pmt * np + fv + } else { + let p = (1.0 + r).powf(np); + pv * p + pmt * (1.0 + r * typ) * (p - 1.0) / r + fv + } + }; + num_val(solve_rate(f, guess)?) +} + +pub(super) fn npv(a: &[Arg], _c: &mut dyn Ctx) -> R { + let rate = num(a, 0)?; + if rate == -1.0 { + return Err(CellError::Div0); + } + let vals = numbers(a.get(1..).unwrap_or(&[]))?; + let mut sum = 0.0; + let mut d = 1.0; + for v in vals { + d *= 1.0 + rate; + sum += v / d; + } + num_val(sum) +} + +/// Values and dates for XNPV/XIRR: both must be numeric and of equal length. +fn dated_flows(values: &Value, dates: &Value) -> R<(Vec, Vec)> { + let va = as_array(values); + let da = as_array(dates); + if va.data.len() != da.data.len() { + return Err(CellError::Num); + } + let mut v = Vec::with_capacity(va.data.len()); + let mut d = Vec::with_capacity(da.data.len()); + for (x, y) in va.data.iter().zip(da.data.iter()) { + match x { + Value::Number(n) => v.push(*n), + Value::Error(e) => return Err(*e), + _ => return Err(CellError::Value), + } + match y { + Value::Number(n) => d.push(n.trunc()), + Value::Error(e) => return Err(*e), + Value::Text(_) => d.push(y.to_number()?.trunc()), + _ => return Err(CellError::Value), + } + } + let d0 = *d.first().ok_or(CellError::Num)?; + if d.iter().any(|&x| x < d0 || x < 0.0) { + return Err(CellError::Num); + } + Ok((v, d)) +} + +fn xnpv_calc(rate: f64, v: &[f64], d: &[f64]) -> f64 { + let d0 = d.first().copied().unwrap_or(0.0); + v.iter().zip(d).map(|(x, t)| x / (1.0 + rate).powf((t - d0) / 365.0)).sum() +} + +pub(super) fn xnpv(a: &[Arg], _c: &mut dyn Ctx) -> R { + let rate = num(a, 0)?; + if rate <= -1.0 { + return Err(CellError::Num); + } + let (v, d) = dated_flows(&a.get(1).ok_or(CellError::Value)?.value, &a.get(2).ok_or(CellError::Value)?.value)?; + num_val(xnpv_calc(rate, &v, &d)) +} + +pub(super) fn xirr(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (v, d) = dated_flows(&a.first().ok_or(CellError::Value)?.value, &a.get(1).ok_or(CellError::Value)?.value)?; + let guess = opt_num(a, 2, 0.1)?; + if !v.iter().any(|x| *x > 0.0) || !v.iter().any(|x| *x < 0.0) { + return Err(CellError::Num); + } + num_val(solve_rate(|r| xnpv_calc(r, &v, &d), guess)?) +} + +pub(super) fn irr(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = array_numbers(&a.first().ok_or(CellError::Value)?.value)?; + let guess = opt_num(a, 1, 0.1)?; + if !v.iter().any(|x| *x > 0.0) || !v.iter().any(|x| *x < 0.0) { + return Err(CellError::Num); + } + let f = |r: f64| { + let mut s = 0.0; + let mut d = 1.0; + for x in &v { + s += x / d; + d *= 1.0 + r; + } + s + }; + num_val(solve_rate(f, guess)?) +} + +pub(super) fn mirr(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = array_numbers(&a.first().ok_or(CellError::Value)?.value)?; + let (fr, rr) = (num(a, 1)?, num(a, 2)?); + let n = v.len() as f64; + if v.len() < 2 { + return Err(CellError::Div0); + } + let (mut pos, mut neg) = (0.0, 0.0); + for (i, x) in v.iter().enumerate() { + if *x > 0.0 { + pos += x / (1.0 + rr).powi(i as i32); + } else if *x < 0.0 { + neg += x / (1.0 + fr).powi(i as i32); + } + } + // No positive flow is not an error in Excel: the formula below gives -1. + if neg == 0.0 { + return Err(CellError::Div0); + } + let r = (-pos * (1.0 + rr).powf(n - 1.0) / neg).powf(1.0 / (n - 1.0)) - 1.0; + num_val(r) +} + +pub(super) fn fvschedule(a: &[Arg], _c: &mut dyn Ctx) -> R { + let mut p = num(a, 0)?; + for v in flatten(a.get(1..2).unwrap_or(&[])) { + let r = match v { + Value::Number(n) => n, + Value::Empty => 0.0, + Value::Error(e) => return Err(e), + _ => return Err(CellError::Value), + }; + p *= 1.0 + r; + } + num_val(p) +} + +pub(super) fn effect(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (nom, n) = (num(a, 0)?, num(a, 1)?.trunc()); + if nom <= 0.0 || n < 1.0 { + return Err(CellError::Num); + } + num_val((1.0 + nom / n).powf(n) - 1.0) +} + +pub(super) fn nominal(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (eff, n) = (num(a, 0)?, num(a, 1)?.trunc()); + if eff <= 0.0 || n < 1.0 { + return Err(CellError::Num); + } + num_val(n * ((1.0 + eff).powf(1.0 / n) - 1.0)) +} + +pub(super) fn ispmt(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (rate, per, np, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, num(a, 3)?); + if np == 0.0 { + return Err(CellError::Div0); + } + num_val(pv * rate * (per / np - 1.0)) +} + +pub(super) fn pduration(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (rate, pv, fv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + if rate <= 0.0 || pv <= 0.0 || fv <= 0.0 { + return Err(CellError::Num); + } + num_val((fv.ln() - pv.ln()) / (1.0 + rate).ln()) +} + +pub(super) fn rri(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (np, pv, fv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + if np <= 0.0 || pv == 0.0 { + return Err(CellError::Num); + } + num_val((fv / pv).powf(1.0 / np) - 1.0) +} + +fn dollar_digits(fraction: f64) -> R { + if fraction < 0.0 { + return Err(CellError::Num); + } + if fraction < 1.0 { + return Err(CellError::Div0); + } + Ok(10f64.powf(fraction.log10().ceil())) +} + +pub(super) fn dollarde(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (x, f) = (num(a, 0)?, num(a, 1)?.trunc()); + let p = dollar_digits(f)?; + let int = x.trunc(); + num_val(int + (x - int) * p / f) +} + +pub(super) fn dollarfr(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (x, f) = (num(a, 0)?, num(a, 1)?.trunc()); + let p = dollar_digits(f)?; + let int = x.trunc(); + num_val(int + (x - int) * f / p) +} diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 9824da7bf2..6d4cdcf58c 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -11,6 +11,14 @@ use crate::util::{ }; use crate::{Arg, Ctx, FnSpec, VAR}; +mod criteria; +mod matrix; +mod roman_base; + +use criteria::{averageif, averageifs, countif, countifs, maxifs, minifs, sumif, sumifs}; +use matrix::{mdeterm, minverse, mmult, munit}; +use roman_base::{arabic, base, decimal, roman}; + // --------------------------------------------------------------------------------------------- // Lifting tables @@ -635,307 +643,6 @@ fn radians(a: &[Arg], _c: &mut dyn Ctx) -> R { unary(a, |x| Ok(x * PI / 180.0)) } -// --------------------------------------------------------------------------------------------- -// Number systems - -const NUMERALS: [(char, u32); 7] = [('I', 1), ('V', 5), ('X', 10), ('L', 50), ('C', 100), ('D', 500), ('M', 1000)]; - -/// Tokens (text, value) for a ROMAN form, largest value first. Form 0 is classic; each higher -/// form widens one of two limits on the subtracted numeral's ratio to the larger one, alternating: -/// the limit for I/X/C (10, 10, 100, 100, 1000) and for V/L (none, 20, 20, 200, 200). So form 2 -/// allows IC and XM but not VD, and form 3 adds VD and VM but not ID. -fn roman_tokens(form: u32) -> Vec<(String, u32)> { - let (pow10_max, five_max) = [(10, 0), (10, 20), (100, 20), (100, 200), (1000, 200)].get(form as usize).copied().unwrap_or((10, 0)); - let mut toks: Vec<(String, u32)> = NUMERALS.iter().map(|(c, v)| (c.to_string(), *v)).collect(); - for (i, (sc, sv)) in NUMERALS.iter().enumerate() { - for (lc, lv) in NUMERALS.iter().skip(i + 1) { - let ratio = lv / sv; - let pow10 = matches!(sc, 'I' | 'X' | 'C'); - let ok = ratio <= if pow10 { pow10_max } else { five_max }; - let value = lv - sv; - // Skip pairs that equal a single numeral (VX = V) – they are never shorter. - if ok && !NUMERALS.iter().any(|(_, v)| *v == value) { - toks.push((format!("{sc}{lc}"), value)); - } - } - } - // Largest value first; on ties prefer the shorter / classic token. - toks.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.len().cmp(&b.0.len()))); - toks -} - -fn roman(a: &[Arg], _c: &mut dyn Ctx) -> R { - let n = num(a, 0)?.trunc(); - let form = match a.get(1).map(|x| x.value.scalar()) { - None => 0.0, - Some(Value::Bool(true)) => 0.0, - Some(Value::Bool(false)) => 4.0, - Some(_) => num(a, 1)?.trunc(), - }; - if !(0.0..=3999.0).contains(&n) || !(0.0..=4.0).contains(&form) { - return Err(CellError::Value); - } - let toks = roman_tokens(form as u32); - let mut rem = n as u32; - let mut out = String::new(); - while rem > 0 { - let Some((t, v)) = toks.iter().find(|(_, v)| *v <= rem) else { break }; - out.push_str(t); - rem -= v; - } - text_val(out) -} - -fn arabic(a: &[Arg], _c: &mut dyn Ctx) -> R { - let s = text(a, 0)?; - let s = s.trim(); - if s.chars().count() > 255 { - return Err(CellError::Value); - } - let (neg, body) = match s.strip_prefix('-') { - Some(r) => (true, r), - None => (false, s), - }; - let mut vals = Vec::new(); - for ch in body.chars() { - let u = ch.to_ascii_uppercase(); - let Some((_, v)) = NUMERALS.iter().find(|(c, _)| *c == u) else { return Err(CellError::Value) }; - vals.push(*v as i64); - } - let mut total = 0i64; - for (i, v) in vals.iter().enumerate() { - if vals.get(i + 1).is_some_and(|next| next > v) { - total -= v; - } else { - total += v; - } - } - num_val(if neg { -total as f64 } else { total as f64 }) -} - -fn base(a: &[Arg], _c: &mut dyn Ctx) -> R { - let n = num(a, 0)?.trunc(); - let radix = num(a, 1)?.trunc(); - let min_len = opt_num(a, 2, 0.0)?.trunc(); - if !(0.0..9.007_199_254_740_992e15).contains(&n) || !(2.0..=36.0).contains(&radix) || !(0.0..=255.0).contains(&min_len) { - return Err(CellError::Num); - } - let radix = radix as u64; - let mut v = n as u64; - let mut digits = Vec::new(); - loop { - let d = (v % radix) as u32; - digits.push(char::from_digit(d, radix as u32).unwrap_or('0').to_ascii_uppercase()); - v /= radix; - if v == 0 { - break; - } - } - while digits.len() < min_len as usize { - digits.push('0'); - } - text_val(digits.iter().rev().collect::()) -} - -fn decimal(a: &[Arg], _c: &mut dyn Ctx) -> R { - let s = text(a, 0)?; - let radix = num(a, 1)?.trunc(); - if !(2.0..=36.0).contains(&radix) || s.chars().count() > 255 { - return Err(CellError::Num); - } - let radix = radix as u32; - let mut total = 0f64; - for ch in s.trim().chars() { - let Some(d) = ch.to_digit(radix) else { return Err(CellError::Num) }; - total = total * radix as f64 + d as f64; - } - if total >= 9.007_199_254_740_992e15 { - return Err(CellError::Num); - } - num_val(total) -} - -// --------------------------------------------------------------------------------------------- -// Matrices - -/// A numeric matrix (every element must be a number, else `#VALUE!`). -fn matrix(v: &Value) -> R<(usize, usize, Vec)> { - let arr = as_array(v); - let mut out = Vec::with_capacity(arr.data.len()); - for x in arr.iter() { - match x { - Value::Number(n) => out.push(*n), - Value::Error(e) => return Err(*e), - _ => return Err(CellError::Value), - } - } - Ok((arr.rows, arr.cols, out)) -} - -fn to_value_array(rows: usize, cols: usize, data: Vec) -> R { - let vals = data.into_iter().map(|x| if x.is_finite() { Value::Number(x) } else { Value::Error(CellError::Num) }).collect(); - array_val(rows, cols, vals) -} - -fn mmult(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (ar, ac, ad) = matrix(&crate::util::arg(a, 0)?.value)?; - let (br, bc, bd) = matrix(&crate::util::arg(a, 1)?.value)?; - if ac != br { - return Err(CellError::Value); - } - if ar.saturating_mul(bc) > MAX_CELLS || ar.saturating_mul(bc).saturating_mul(ac) > 2_000_000_000 { - return Err(CellError::Num); - } - let mut out = vec![0.0; ar * bc]; - for i in 0..ar { - for k in 0..ac { - let x = ad.get(i * ac + k).copied().unwrap_or(0.0); - if x == 0.0 { - continue; - } - for j in 0..bc { - if let (Some(o), Some(y)) = (out.get_mut(i * bc + j), bd.get(k * bc + j)) { - *o += x * y; - } - } - } - } - to_value_array(ar, bc, out) -} - -/// LU decomposition with partial pivoting; returns (lu, perm, sign) or None when singular. -fn square(a: &[Arg]) -> R<(usize, Vec)> { - let (r, c, d) = matrix(&crate::util::arg(a, 0)?.value)?; - if r != c { - return Err(CellError::Value); - } - if r > 2000 { - return Err(CellError::Num); - } - Ok((r, d)) -} - -fn determinant(n: usize, mut m: Vec) -> f64 { - let mut det = 1.0; - for col in 0..n { - let mut piv = col; - let mut best = 0.0; - for row in col..n { - let v = m.get(row * n + col).copied().unwrap_or(0.0).abs(); - if v > best { - best = v; - piv = row; - } - } - if best == 0.0 { - return 0.0; - } - if piv != col { - for k in 0..n { - m.swap(piv * n + k, col * n + k); - } - det = -det; - } - let p = m.get(col * n + col).copied().unwrap_or(0.0); - det *= p; - for row in col + 1..n { - let f = m.get(row * n + col).copied().unwrap_or(0.0) / p; - if f == 0.0 { - continue; - } - for k in col..n { - let v = m.get(col * n + k).copied().unwrap_or(0.0); - if let Some(x) = m.get_mut(row * n + k) { - *x -= f * v; - } - } - } - } - det -} - -fn mdeterm(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (n, m) = square(a)?; - let d = determinant(n, m); - // Tidy results that should be integers (e.g. 0.9999999999999998 → 1). - num_val(if d.abs() > 1e-300 { to_sig_digits(d, 15) } else { d }) -} - -fn minverse(a: &[Arg], _c: &mut dyn Ctx) -> R { - let (n, mut m) = square(a)?; - let mut inv = vec![0.0; n * n]; - for i in 0..n { - if let Some(x) = inv.get_mut(i * n + i) { - *x = 1.0; - } - } - let scale = m.iter().fold(0.0f64, |acc, x| acc.max(x.abs())).max(1e-300); - for col in 0..n { - let mut piv = col; - let mut best = 0.0; - for row in col..n { - let v = m.get(row * n + col).copied().unwrap_or(0.0).abs(); - if v > best { - best = v; - piv = row; - } - } - if best <= scale * 1e-15 { - return Err(CellError::Num); - } - if piv != col { - for k in 0..n { - m.swap(piv * n + k, col * n + k); - inv.swap(piv * n + k, col * n + k); - } - } - let p = m.get(col * n + col).copied().unwrap_or(1.0); - for k in 0..n { - if let Some(x) = m.get_mut(col * n + k) { - *x /= p; - } - if let Some(x) = inv.get_mut(col * n + k) { - *x /= p; - } - } - for row in 0..n { - if row == col { - continue; - } - let f = m.get(row * n + col).copied().unwrap_or(0.0); - if f == 0.0 { - continue; - } - for k in 0..n { - let mv = m.get(col * n + k).copied().unwrap_or(0.0); - let iv = inv.get(col * n + k).copied().unwrap_or(0.0); - if let Some(x) = m.get_mut(row * n + k) { - *x -= f * mv; - } - if let Some(x) = inv.get_mut(row * n + k) { - *x -= f * iv; - } - } - } - } - // Rounding noise in the inverse scales with 1/scale of the input matrix. - let noise = 1e-15 / scale; - let inv = inv.into_iter().map(|x| if x.abs() < noise { 0.0 } else { x }).collect(); - to_value_array(n, n, inv) -} - -fn munit(a: &[Arg], _c: &mut dyn Ctx) -> R { - let n = num(a, 0)?.trunc(); - let (r, _) = check_size(n, n)?; - let mut data = vec![Value::Number(0.0); r * r]; - for i in 0..r { - if let Some(x) = data.get_mut(i * r + i) { - *x = Value::Number(1.0); - } - } - array_val(r, r, data) -} - fn seriessum(a: &[Arg], _c: &mut dyn Ctx) -> R { let (x, n, m) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); let coeffs = as_array(&crate::util::arg(a, 3)?.value); @@ -951,128 +658,6 @@ fn seriessum(a: &[Arg], _c: &mut dyn Ctx) -> R { num_val(total) } -// --------------------------------------------------------------------------------------------- -// Criteria aggregates - -/// Values at the matched positions of `target` for every (range, criteria) pair. -fn criteria_mask(a: &[Arg], first_pair: usize, rows: usize, cols: usize) -> R> { - let rest = a.get(first_pair..).unwrap_or(&[]); - if rest.is_empty() || rest.len() % 2 != 0 { - return Err(CellError::Value); - } - // A text criterion over 255 characters is `#VALUE!` only when there is a single criteria - // pair. With two or more pairs Excel matches its first 255 characters instead. - let single_pair = rest.len() == 2; - let mut mask = vec![true; rows.saturating_mul(cols)]; - for pair in rest.chunks(2) { - let (Some(range), Some(crit)) = (pair.first(), pair.get(1)) else { return Err(CellError::Value) }; - let r = as_array(&range.value); - if r.rows != rows || r.cols != cols { - return Err(CellError::Value); - } - let scalar = crit.value.scalar(); - let c = if single_pair { Criterion::parse_arg(&scalar)? } else { Criterion::parse_arg_truncated(&scalar) }; - for (m, v) in mask.iter_mut().zip(r.iter()) { - if *m && !c.matches(v) { - *m = false; - } - } - } - Ok(mask) -} - -/// Numbers in `target` where `mask` is set (errors at matched positions propagate). -fn masked_numbers(target: &Array, mask: &[bool]) -> R> { - let mut out = Vec::new(); - for (v, m) in target.iter().zip(mask.iter()) { - if !*m { - continue; - } - match v { - Value::Number(n) => out.push(*n), - Value::Error(e) => return Err(*e), - _ => {} - } - } - Ok(out) -} - -fn countifs(a: &[Arg], _c: &mut dyn Ctx) -> R { - let first = as_array(&crate::util::arg(a, 0)?.value); - let mask = criteria_mask(a, 0, first.rows, first.cols)?; - num_val(mask.iter().filter(|m| **m).count() as f64) -} - -/// The values of the `*IFS` target range with the mask from its pairs. -fn ifs_numbers(a: &[Arg]) -> R> { - let target = as_array(&crate::util::arg(a, 0)?.value); - let mask = criteria_mask(a, 1, target.rows, target.cols)?; - masked_numbers(&target, &mask) -} - -fn sumifs(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(ifs_numbers(a)?.iter().sum()) -} -fn averageifs(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = ifs_numbers(a)?; - if v.is_empty() { - return Err(CellError::Div0); - } - num_val(v.iter().sum::() / v.len() as f64) -} -fn maxifs(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = ifs_numbers(a)?; - num_val(v.iter().copied().reduce(f64::max).unwrap_or(0.0)) -} -fn minifs(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = ifs_numbers(a)?; - num_val(v.iter().copied().reduce(f64::min).unwrap_or(0.0)) -} - -fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R { - let range = as_array(&crate::util::arg(a, 0)?.value); - let c = Criterion::parse_arg(&crate::util::arg(a, 1)?.value.scalar())?; - num_val(range.iter().filter(|v| c.matches(v)).count() as f64) -} - -/// SUMIF / AVERAGEIF: matched numbers from the sum range, read at the criteria range's positions. -/// Excel sizes a sum range reference like the criteria range, anchored at its own top-left cell; -/// the evaluator does that resizing (it has the references), so cells line up one to one here. -fn if_numbers(a: &[Arg]) -> R> { - let range = as_array(&crate::util::arg(a, 0)?.value); - let c = Criterion::parse_arg(&crate::util::arg(a, 1)?.value.scalar())?; - let sum_range = if has(a, 2) { Some(as_array(&crate::util::arg(a, 2)?.value)) } else { None }; - let mut out = Vec::new(); - for r in 0..range.rows { - for col in 0..range.cols { - let Some(v) = range.get(r, col) else { continue }; - if !c.matches(v) { - continue; - } - let target = match &sum_range { - Some(s) => s.get(r, col), - None => Some(v), - }; - match target { - Some(Value::Number(n)) => out.push(*n), - Some(Value::Error(e)) => return Err(*e), - _ => {} - } - } - } - Ok(out) -} -fn sumif(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(if_numbers(a)?.iter().sum()) -} -fn averageif(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = if_numbers(a)?; - if v.is_empty() { - return Err(CellError::Div0); - } - num_val(v.iter().sum::() / v.len() as f64) -} - // --------------------------------------------------------------------------------------------- // Registry @@ -1305,497 +890,4 @@ pub(crate) fn specs() -> Vec { const NUM_RANGE_REV: &[bool] = &[false, true]; #[cfg(test)] -mod tests { - use super::*; - use crate::util::testutil::*; - use CellError::{Div0, NA, Num}; - - fn ev1(name: &str, x: f64) -> Value { - ev(name, vec![n(x)]) - } - fn ev2(name: &str, x: f64, y: f64) -> Value { - ev(name, vec![n(x), n(y)]) - } - - #[test] - fn percentof() { - // SUM(subset) / SUM(all): text, booleans and blanks inside ranges are skipped like SUM. - let all = arr(vec![vec![nv(10.0)], vec![nv(30.0)], vec![tv("x")], vec![Value::Empty], vec![nv(60.0)]]); - let part = arr(vec![vec![nv(10.0)], vec![nv(30.0)]]); - close(ev("PERCENTOF", vec![rf(part), rf(all.clone())]), 0.4); - close(ev("PERCENTOF", vec![n(25.0), n(200.0)]), 0.125); - is_err(ev("PERCENTOF", vec![n(1.0), rf(arr(vec![vec![nv(5.0), nv(-5.0)]]))]), Div0); - is_err(ev("PERCENTOF", vec![rf(arr(vec![vec![Value::Error(NA)]])), rf(all)]), NA); - is_err(ev("PERCENTOF", vec![t("abc"), n(1.0)]), CellError::Value); - } - - #[test] - fn sums() { - close(ev("SUM", vec![n(1.0), n(2.0), t("3"), b(true)]), 7.0); - let r = arr(vec![vec![nv(1.0), tv("x"), Value::Bool(true), Value::Empty, nv(4.0)]]); - close(ev("SUM", vec![rf(r.clone())]), 5.0); - is_err(ev("SUM", vec![t("abc")]), CellError::Value); - is_err(ev("SUM", vec![rf(arr(vec![vec![nv(1.0), Value::Error(Div0)]]))]), Div0); - close(ev("SUM", vec![rf(tv("abc"))]), 0.0); - close(ev("SUMSQ", vec![n(3.0), n(4.0)]), 25.0); - close(ev("PRODUCT", vec![rf(r), n(2.0)]), 8.0); - close(ev("PRODUCT", vec![rf(arr(vec![vec![tv("a")]]))]), 0.0); - let a1 = arr(vec![vec![nv(3.0), nv(4.0)], vec![nv(8.0), nv(6.0)], vec![nv(1.0), nv(9.0)]]); - let a2 = arr(vec![vec![nv(2.0), nv(7.0)], vec![nv(6.0), nv(7.0)], vec![nv(5.0), nv(3.0)]]); - close(ev("SUMPRODUCT", vec![rf(a1.clone()), rf(a2)]), 156.0); - close(ev("SUMPRODUCT", vec![rf(a1.clone())]), 31.0); - is_err(ev("SUMPRODUCT", vec![rf(a1), rf(row(&[1.0, 2.0]))]), CellError::Value); - close(ev("SUMPRODUCT", vec![av(row(&[1.0, 2.0])), av(arr(vec![vec![tv("x"), nv(3.0)]]))]), 6.0); - let x = row(&[2.0, 3.0, 9.0, 1.0, 8.0, 7.0, 5.0]); - let y = row(&[6.0, 5.0, 11.0, 7.0, 5.0, 4.0, 4.0]); - close(ev("SUMX2MY2", vec![av(x.clone()), av(y.clone())]), -55.0); - close(ev("SUMX2PY2", vec![av(x.clone()), av(y.clone())]), 521.0); - close(ev("SUMXMY2", vec![av(x.clone()), av(y)]), 79.0); - is_err(ev("SUMXMY2", vec![av(x), av(row(&[1.0]))]), NA); - } - - /// SUMPRODUCT cases observed in Excel (FormulaEvalTestData, row "Sumproduct"): scalars and - /// single-cell references multiply in, text in a scalar argument is `#VALUE!`, sizes must match. - #[test] - fn sumproduct_scalar_arguments() { - let cases: Vec<(Vec, Result)> = vec![ - (vec![n(3.0), rf(nv(534.0)), rf(nv(1.1))], Ok(1762.2)), - (vec![rf(nv(1.1)), n(3.0), rf(nv(534.0)), rf(nv(1.1))], Ok(1938.42)), - (vec![n(2.0), rf(col(&[1.00001, 2.99999]))], Err(CellError::Value)), - (vec![rf(tv("4")), rf(nv(2.0))], Err(CellError::Value)), - (vec![t("4"), n(2.0)], Err(CellError::Value)), - (vec![rf(nv(3.0)), rf(Value::Error(CellError::Ref))], Err(CellError::Ref)), - (vec![av(arr(vec![vec![tv("4")]])), n(2.0)], Ok(0.0)), - ]; - for (args, want) in cases { - match want { - Ok(x) => close(ev("SUMPRODUCT", args), x), - Err(err) => is_err(ev("SUMPRODUCT", args), err), - } - } - } - - #[test] - fn rounding() { - close(ev2("ROUND", 2.675, 2.0), 2.68); - close(ev2("ROUND", -1.5, 0.0), -2.0); - close(ev2("ROUND", 1234.567, -2.0), 1200.0); - close(ev2("ROUND", 2.15, 1.0), 2.2); - close(ev2("ROUNDUP", 3.2, 0.0), 4.0); - close(ev2("ROUNDUP", -3.14259, 1.0), -3.2); - close(ev2("ROUNDUP", 31415.92654, -2.0), 31500.0); - close(ev2("ROUNDDOWN", -3.9, 0.0), -3.0); - close(ev2("ROUNDDOWN", 2.71989, 3.0), 2.719); - close(ev2("ROUNDDOWN", 31415.92654, -2.0), 31400.0); - for digits in [-308.0, -309.0, -400.0] { - for x in [1234.0, -1234.0, 0.5] { - close(ev2("ROUNDDOWN", x, digits), 0.0); - close(ev2("TRUNC", x, digits), 0.0); - } - } - assert!(matches!(ev2("ROUNDUP", 1234.0, -309.0), Value::Error(CellError::Num))); - close(ev2("ROUNDUP", 0.1 + 0.2, 1.0), 0.3); - close(ev2("TRUNC", 8.987, 2.0), 8.98); - close(ev1("TRUNC", -8.9), -8.0); - close(ev1("INT", -8.9), -9.0); - close(ev1("INT", 8.9), 8.0); - close(ev2("MROUND", 10.0, 3.0), 9.0); - close(ev2("MROUND", -10.0, -3.0), -9.0); - close(ev2("MROUND", 1.3, 0.2), 1.4); - is_err(ev2("MROUND", 5.0, -2.0), Num); - close(ev2("CEILING", 2.5, 1.0), 3.0); - close(ev2("CEILING", -2.5, -2.0), -4.0); - close(ev2("CEILING", -2.5, 2.0), -2.0); - close(ev2("CEILING", 1.5, 0.1), 1.5); - close(ev2("CEILING", 0.234, 0.01), 0.24); - is_err(ev2("CEILING", 2.5, -1.0), Num); - close(ev2("FLOOR", 3.7, 2.0), 2.0); - close(ev2("FLOOR", -2.5, -2.0), -2.0); - close(ev2("FLOOR", -2.5, 2.0), -4.0); - is_err(ev2("FLOOR", 2.5, -2.0), Num); - is_err(ev2("FLOOR", 2.5, 0.0), Div0); - close(ev2("FLOOR", 1.58, 0.1), 1.5); - close(ev2("FLOOR", 0.234, 0.01), 0.23); - close(ev2("CEILING.MATH", 24.3, 5.0), 25.0); - close(ev1("CEILING.MATH", 6.7), 7.0); - close(ev2("CEILING.MATH", -8.1, 2.0), -8.0); - close(ev("CEILING.MATH", vec![n(-5.5), n(2.0), n(-1.0)]), -6.0); - close(ev2("FLOOR.MATH", 24.3, 5.0), 20.0); - close(ev1("FLOOR.MATH", 6.7), 6.0); - close(ev2("FLOOR.MATH", -8.1, 2.0), -10.0); - close(ev("FLOOR.MATH", vec![n(-5.5), n(2.0), n(-1.0)]), -4.0); - close(ev2("CEILING.PRECISE", -4.6, 2.0), -4.0); - close(ev2("CEILING.PRECISE", 4.3, -2.0), 6.0); - close(ev2("ISO.CEILING", 4.3, 0.0), 0.0); - close(ev2("FLOOR.PRECISE", -3.2, -1.0), -4.0); - close(ev1("FLOOR.PRECISE", 3.2), 3.0); - close(ev1("EVEN", 1.5), 2.0); - close(ev1("EVEN", 3.0), 4.0); - close(ev1("EVEN", 2.0), 2.0); - close(ev1("EVEN", -1.0), -2.0); - close(ev1("EVEN", 0.0), 0.0); - close(ev1("ODD", 1.5), 3.0); - close(ev1("ODD", 3.0), 3.0); - close(ev1("ODD", 2.0), 3.0); - close(ev1("ODD", -1.0), -1.0); - close(ev1("ODD", -2.0), -3.0); - close(ev1("ODD", 0.0), 1.0); - } - - #[test] - fn mod_tiny_divisor_stays_in_range() { - for (x, d) in [(3.0, 1e-300), (-1.0, 1e-300), (3.0, -1e-300), (-1.0, -1e-300)] { - match ev2("MOD", x, d) { - Value::Number(r) => { - assert!(r == 0.0 || (r < 0.0) == (d < 0.0), "MOD({x}, {d}) = {r}"); - assert!(r.abs() < d.abs(), "MOD({x}, {d}) = {r}"); - } - other => panic!("MOD({x}, {d}) = {other:?}"), - } - } - } - - #[test] - fn arithmetic() { - close(ev2("MOD", 3.0, 2.0), 1.0); - close(ev2("MOD", -3.0, 2.0), 1.0); - close(ev2("MOD", 3.0, -2.0), -1.0); - close(ev2("MOD", -3.0, -2.0), -1.0); - close(ev2("MOD", 5.5, 1.0), 0.5); - is_err(ev2("MOD", 1.0, 0.0), Div0); - close(ev2("QUOTIENT", 5.0, 2.0), 2.0); - close(ev2("QUOTIENT", -10.0, 3.0), -3.0); - is_err(ev2("QUOTIENT", 1.0, 0.0), Div0); - close(ev1("ABS", -4.0), 4.0); - close(ev1("SIGN", -0.0001), -1.0); - close(ev1("SIGN", 0.0), 0.0); - close(ev1("SQRT", 16.0), 4.0); - is_err(ev1("SQRT", -16.0), Num); - close(ev1("SQRTPI", 1.0), 1.772_453_850_905_516); - close(ev2("POWER", 5.0, 2.0), 25.0); - close(ev2("POWER", 4.0, 1.25), 5.656_854_249_492_381); - close(ev2("POWER", -2.0, 3.0), -8.0); - is_err(ev2("POWER", 0.0, 0.0), Num); - is_err(ev2("POWER", 0.0, -1.0), Div0); - is_err(ev2("POWER", -8.0, 1.0 / 3.0), Num); - is_err(ev2("POWER", 10.0, 400.0), Num); - close(ev1("EXP", 1.0), std::f64::consts::E); - close(ev2("LOG", 8.0, 2.0), 3.0); - close(ev1("LOG", 10.0), 1.0); - close(ev2("LOG", 81.0, 3.0), 4.0); - is_err(ev1("LN", 0.0), Num); - is_err(ev2("LOG", 10.0, 1.0), Div0); - is_err(ev1("LOG10", -1.0), Num); - close(ev1("LN", std::f64::consts::E), 1.0); - close(ev1("LOG10", 1e5), 5.0); - close(ev("PI", vec![]), PI); - close(ev("SQRT", vec![t("16")]), 4.0); - is_err(ev("SQRT", vec![t("x")]), CellError::Value); - } - - #[test] - fn combinatorics() { - close(ev1("FACT", 5.0), 120.0); - close(ev1("FACT", 1.9), 1.0); - close(ev1("FACT", 0.0), 1.0); - is_err(ev1("FACT", -1.0), Num); - is_err(ev1("FACT", 171.0), Num); - close(ev1("FACTDOUBLE", 6.0), 48.0); - close(ev1("FACTDOUBLE", 7.0), 105.0); - close(ev1("FACTDOUBLE", -1.0), 1.0); - is_err(ev1("FACTDOUBLE", -2.0), Num); - close(ev2("COMBIN", 8.0, 2.0), 28.0); - close(ev2("COMBIN", 8.9, 2.9), 28.0); - is_err(ev2("COMBIN", 2.0, 3.0), Num); - close(ev2("COMBIN", 60.0, 30.0), 118_264_581_564_861_424.0); - close(ev2("COMBINA", 4.0, 3.0), 20.0); - close(ev2("COMBINA", 10.0, 3.0), 220.0); - close(ev2("PERMUT", 100.0, 3.0), 970_200.0); - close(ev2("PERMUT", 3.0, 2.0), 6.0); - is_err(ev2("PERMUT", 2.0, 3.0), Num); - close(ev2("PERMUTATIONA", 3.0, 2.0), 9.0); - close(ev2("GCD", 24.0, 36.0), 12.0); - close(ev2("GCD", 5.0, 0.0), 5.0); - close(ev2("GCD", 7.0, 1.0), 1.0); - is_err(ev2("GCD", -1.0, 5.0), Num); - close(ev("GCD", vec![av(row(&[12.0, 18.0, 30.0]))]), 6.0); - close(ev2("LCM", 24.0, 36.0), 72.0); - close(ev2("LCM", 5.0, 2.0), 10.0); - close(ev2("LCM", 5.0, 0.0), 0.0); - close(ev("MULTINOMIAL", vec![n(2.0), n(3.0), n(4.0)]), 1260.0); - is_err(ev("MULTINOMIAL", vec![n(-1.0), n(2.0)]), Num); - } - - #[test] - fn trig() { - close(ev1("SIN", PI / 2.0), 1.0); - close(ev1("COS", 0.0), 1.0); - close(ev1("TAN", PI / 4.0), 1.0); - is_err(ev1("SIN", 1e10), Num); - close(ev1("ASIN", 1.0), PI / 2.0); - is_err(ev1("ACOS", 1.5), Num); - close(ev1("ACOT", 2.0), 0.463_647_609_000_806); - close(ev1("ACOTH", 6.0), 0.168_236_118_310_606); - is_err(ev1("ACOTH", 0.5), Num); - close(ev1("COT", 30.0), -0.156_119_952_161_659); - is_err(ev1("COT", 0.0), Div0); - close(ev1("COTH", 2.0), 1.037_314_720_727_548); - close(ev1("CSC", 15.0), 1.537_780_562_208_209); - close(ev1("CSCH", 1.5), 0.469_642_440_595_653); - close(ev1("SEC", 45.0), 1.903_594_407_014_6); - close_tol(ev1("SECH", 45.0), 5.725_037_161_098_787e-20, 1e-9); - close(ev2("ATAN2", 1.0, 1.0), PI / 4.0); - close(ev2("ATAN2", -1.0, -1.0), -3.0 * PI / 4.0); - is_err(ev2("ATAN2", 0.0, 0.0), Div0); - close(ev1("ASINH", -2.5), -1.647_231_146_371_096); - close(ev1("ACOSH", 10.0), 2.993_222_846_126_381); - is_err(ev1("ACOSH", 0.5), Num); - close(ev1("ATANH", 0.5), 0.549_306_144_334_055); - is_err(ev1("ATANH", 1.0), Num); - close(ev1("SINH", 1.0), 1.175_201_193_643_801); - close(ev1("DEGREES", PI), 180.0); - close(ev1("RADIANS", 270.0), 4.712_388_980_384_69); - } - - #[test] - fn number_systems() { - is_text(ev1("ROMAN", 499.0), "CDXCIX"); - is_text(ev2("ROMAN", 499.0, 1.0), "LDVLIV"); - is_text(ev2("ROMAN", 499.0, 2.0), "XDIX"); - is_text(ev2("ROMAN", 499.0, 3.0), "VDIV"); - is_text(ev2("ROMAN", 499.0, 4.0), "ID"); - is_text(ev("ROMAN", vec![n(499.0), b(false)]), "ID"); - is_text(ev1("ROMAN", 1999.0), "MCMXCIX"); - is_text(ev1("ROMAN", 3999.0), "MMMCMXCIX"); - is_text(ev1("ROMAN", 0.0), ""); - is_err(ev1("ROMAN", 4000.0), CellError::Value); - is_err(ev1("ROMAN", -1.0), CellError::Value); - close(ev("ARABIC", vec![t("LVII")]), 57.0); - close(ev("ARABIC", vec![t("mcmxii")]), 1912.0); - close(ev("ARABIC", vec![t("")]), 0.0); - close(ev("ARABIC", vec![t("-XIV")]), -14.0); - is_err(ev("ARABIC", vec![t("ABC")]), CellError::Value); - is_text(ev2("BASE", 7.0, 2.0), "111"); - is_text(ev2("BASE", 100.0, 16.0), "64"); - is_text(ev("BASE", vec![n(15.0), n(2.0), n(10.0)]), "0000001111"); - is_text(ev2("BASE", 0.0, 36.0), "0"); - is_err(ev2("BASE", 7.0, 1.0), Num); - is_err(ev2("BASE", -7.0, 2.0), Num); - close(ev("DECIMAL", vec![t("FF"), n(16.0)]), 255.0); - close(ev("DECIMAL", vec![t("111"), n(2.0)]), 7.0); - close(ev("DECIMAL", vec![t("zap"), n(36.0)]), 45745.0); - is_err(ev("DECIMAL", vec![t("12"), n(2.0)]), Num); - } - - /// Every number where Excel's forms 2 and 3 differ from a plain ratio limit (issue #733): - /// form 2 subtracts I from C and X from M, form 3 also V from M. - #[test] - fn roman_forms_two_and_three_match_excel() { - const HUNDREDS: [&str; 10] = ["", "C", "CC", "CCC", "CD", "D", "DC", "DCC", "DCCC", "CM"]; - const ONES: [&str; 10] = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"]; - let mut cases = Vec::new(); - for thousands in 0..4usize { - let m = "M".repeat(thousands); - for (h, hundreds) in HUNDREDS.iter().enumerate() { - if h != 4 && h != 9 { - cases.push((thousands * 1000 + h * 100 + 99, 2.0, format!("{m}{hundreds}IC"))); - } - } - for (o, ones) in ONES.iter().enumerate() { - cases.push((thousands * 1000 + 990 + o, 2.0, format!("{m}XM{ones}"))); - } - for (o, ones) in ONES.iter().enumerate().take(5) { - cases.push((thousands * 1000 + 995 + o, 3.0, format!("{m}VM{ones}"))); - } - } - assert_eq!(cases.len(), 92); - for (number, form, want) in &cases { - is_text(ev2("ROMAN", *number as f64, *form), want); - } - // Neighbours that already matched: form 2 still has no VD or ID, form 3 no ID. - is_text(ev2("ROMAN", 495.0, 2.0), "XDV"); - is_text(ev2("ROMAN", 495.0, 3.0), "VD"); - is_text(ev2("ROMAN", 1499.0, 2.0), "MXDIX"); - is_text(ev2("ROMAN", 999.0, 0.0), "CMXCIX"); - is_text(ev2("ROMAN", 999.0, 1.0), "LMVLIV"); - is_text(ev2("ROMAN", 999.0, 4.0), "IM"); - is_text(ev2("ROMAN", 995.0, 4.0), "VM"); - is_text(ev2("ROMAN", 99.0, 1.0), "VCIV"); - is_text(ev2("ROMAN", 99.0, 4.0), "IC"); - } - - #[test] - fn minverse_scale_relative() { - let big = ev("MINVERSE", vec![av(arr(vec![vec![nv(1e20), nv(0.0)], vec![nv(0.0), nv(1e20)]]))]); - let r = rows_of(&big); - close_tol(r[0][0].clone(), 1e-20, 1e-25); - close_tol(r[1][1].clone(), 1e-20, 1e-25); - let small = ev("MINVERSE", vec![av(arr(vec![vec![nv(1e-20), nv(0.0)], vec![nv(0.0), nv(1e-20)]]))]); - let r = rows_of(&small); - close_tol(r[0][0].clone(), 1e20, 1e10); - } - - #[test] - fn matrices() { - let a = arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(3.0), nv(4.0)]]); - let b2 = arr(vec![vec![nv(5.0), nv(6.0)], vec![nv(7.0), nv(8.0)]]); - assert_eq!(rows_of(&ev("MMULT", vec![av(a.clone()), av(b2)])), vec![vec![nv(19.0), nv(22.0)], vec![nv(43.0), nv(50.0)]]); - is_err(ev("MMULT", vec![av(a.clone()), av(row(&[1.0, 2.0]))]), CellError::Value); - is_err(ev("MMULT", vec![av(arr(vec![vec![tv("x")]])), av(row(&[1.0]))]), CellError::Value); - close(ev("MDETERM", vec![av(a.clone())]), -2.0); - let m3 = arr(vec![vec![nv(3.0), nv(6.0), nv(1.0)], vec![nv(1.0), nv(1.0), nv(0.0)], vec![nv(3.0), nv(10.0), nv(2.0)]]); - close(ev("MDETERM", vec![av(m3)]), 1.0); - let m4 = arr(vec![ - vec![nv(1.0), nv(3.0), nv(8.0), nv(5.0)], - vec![nv(1.0), nv(3.0), nv(6.0), nv(1.0)], - vec![nv(1.0), nv(1.0), nv(1.0), nv(0.0)], - vec![nv(7.0), nv(3.0), nv(10.0), nv(2.0)], - ]); - close(ev("MDETERM", vec![av(m4)]), 88.0); - is_err(ev("MDETERM", vec![av(row(&[1.0, 2.0]))]), CellError::Value); - let inv = ev("MINVERSE", vec![av(arr(vec![vec![nv(4.0), nv(-1.0)], vec![nv(2.0), nv(0.0)]]))]); - let r = rows_of(&inv); - close(r[0][0].clone(), 0.0); - close(r[0][1].clone(), 0.5); - close(r[1][0].clone(), -1.0); - close(r[1][1].clone(), 2.0); - is_err(ev("MINVERSE", vec![av(arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(2.0), nv(4.0)]]))]), Num); - assert_eq!(rows_of(&ev1("MUNIT", 2.0)), vec![vec![nv(1.0), nv(0.0)], vec![nv(0.0), nv(1.0)]]); - is_err(ev1("MUNIT", 0.0), CellError::Value); - is_err(ev1("MUNIT", 1e6), Num); - let coeffs = row(&[1.0, -0.5, 1.0 / 24.0, -1.0 / 720.0]); - close_tol(ev("SERIESSUM", vec![n(PI / 4.0), n(0.0), n(2.0), av(coeffs)]), 0.707_103_214_823, 1e-9); - } - - #[test] - fn random() { - let mut ctx = TestCtx::default(); - let spec = crate::lookup("RAND").unwrap(); - assert!(spec.volatile); - for _ in 0..50 { - let v = crate::call(spec, &[], &mut ctx); - let x = v.as_f64().unwrap(); - assert!((0.0..1.0).contains(&x)); - } - let spec = crate::lookup("RANDBETWEEN").unwrap(); - for _ in 0..50 { - let x = crate::call(spec, &[n(1.0), n(6.0)], &mut ctx).as_f64().unwrap(); - assert!((1.0..=6.0).contains(&x) && x.fract() == 0.0); - } - is_err(ev2("RANDBETWEEN", 5.0, 1.0), Num); - } - - #[test] - fn lifting() { - assert_eq!(rows_of(&ev("ABS", vec![av(row(&[-1.0, 2.0, -3.0]))])), vec![vec![nv(1.0), nv(2.0), nv(3.0)]]); - let v = ev("POWER", vec![av(col(&[1.0, 2.0])), av(row(&[2.0, 3.0]))]); - assert_eq!(rows_of(&v), vec![vec![nv(1.0), nv(1.0)], vec![nv(4.0), nv(8.0)]]); - close(ev("ROUND", vec![av(arr(vec![vec![nv(1.25)]])), n(1.0)]), 1.3); - } - - fn data() -> (Value, Value) { - let names = arr(vec![vec![tv("apple")], vec![tv("banana")], vec![tv("Apple")], vec![Value::Empty], vec![tv("cherry")]]); - let qty = col(&[10.0, 20.0, 30.0, 40.0, 50.0]); - (names, qty) - } - - #[test] - fn criteria_functions() { - let (names, qty) = data(); - close(ev("COUNTIF", vec![rf(names.clone()), t("apple")]), 2.0); - close(ev("COUNTIF", vec![rf(names.clone()), t("a*")]), 2.0); - close(ev("COUNTIF", vec![rf(names.clone()), t("<>apple")]), 3.0); - close(ev("COUNTIF", vec![rf(names.clone()), t("")]), 1.0); - close(ev("COUNTIF", vec![rf(qty.clone()), t(">20")]), 3.0); - close(ev("COUNTIF", vec![rf(qty.clone()), n(30.0)]), 1.0); - let multi = ev("COUNTIF", vec![rf(names.clone()), av(arr(vec![vec![tv("apple"), tv("cherry")]]))]); - assert_eq!(rows_of(&multi), vec![vec![nv(2.0), nv(1.0)]]); - close(ev("SUMIF", vec![rf(names.clone()), t("apple"), rf(qty.clone())]), 40.0); - close(ev("SUMIF", vec![rf(qty.clone()), t(">=30")]), 120.0); - close(ev("SUMIF", vec![rf(names.clone()), t("?????"), rf(col(&[1.0]))]), 1.0); - close(ev("AVERAGEIF", vec![rf(qty.clone()), t(">20")]), 40.0); - is_err(ev("AVERAGEIF", vec![rf(qty.clone()), t(">100")]), Div0); - close(ev("SUMIFS", vec![rf(qty.clone()), rf(names.clone()), t("apple"), rf(qty.clone()), t(">15")]), 30.0); - close(ev("COUNTIFS", vec![rf(names.clone()), t("*a*"), rf(qty.clone()), t("<25")]), 2.0); - close(ev("AVERAGEIFS", vec![rf(qty.clone()), rf(names.clone()), t("<>banana")]), 32.5); - close(ev("MAXIFS", vec![rf(qty.clone()), rf(names.clone()), t("apple")]), 30.0); - close(ev("MINIFS", vec![rf(qty.clone()), rf(names.clone()), t("apple")]), 10.0); - close(ev("MAXIFS", vec![rf(qty.clone()), rf(names.clone()), t("zzz")]), 0.0); - is_err(ev("SUMIFS", vec![rf(qty.clone()), rf(row(&[1.0, 2.0])), t("1")]), CellError::Value); - is_err(ev("COUNTIFS", vec![rf(qty.clone()), t(">1"), rf(qty)]), CellError::Value); - is_err(ev("SUMIF", vec![rf(col(&[1.0, 2.0])), t(">0"), rf(arr(vec![vec![Value::Error(NA)], vec![nv(1.0)]]))]), NA); - } - - #[test] - fn criteria_over_255_chars_are_value_errors_with_one_pair_and_truncated_with_more() { - let ok = "x".repeat(255); - let long = "x".repeat(256); - let cells = arr(vec![vec![tv(&ok)], vec![tv(&long)]]); - let qty = col(&[1.0, 2.0]); - close(ev("COUNTIF", vec![rf(cells.clone()), t(&ok)]), 1.0); - is_err(ev("COUNTIF", vec![rf(cells.clone()), t(&long)]), CellError::Value); - close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&ok)]), 1.0); - is_err(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long)]), CellError::Value); - is_err(ev("COUNTIFS", vec![rf(cells.clone()), t(&long)]), CellError::Value); - // With two or more pairs Excel matches the first 255 characters: the 255-character cell - // (quantity 1), not the 256-character one (quantity 2). Checked on Excel 2016 and 365. - let over = format!("{ok}y"); - close(ev("COUNTIFS", vec![rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); - close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); - close(ev("SUMIFS", vec![rf(qty.clone()), rf(qty.clone()), t(">0"), rf(cells.clone()), t(&over)]), 1.0); - close(ev("AVERAGEIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); - close(ev("MAXIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&"x".repeat(300)), rf(qty), t(">0")]), 1.0); - } - - #[test] - fn criteria_over_255_utf16_units_follow_excel() { - // The tester's sheet, identical on Excel 2016 and Microsoft 365: A1:A6 hold these - // strings, B is the row number, and each row uses its own string as the criterion. - let e = "😀"; - let strings = - [e.repeat(127), e.repeat(128), format!("{}{e}", "a".repeat(254)), format!("{}{e}", "a".repeat(253)), e.repeat(200), "я".repeat(256)]; - let cells = arr(strings.iter().map(|s| vec![tv(s)]).collect()); - let rows = col(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); - // COUNTIF, SUMIF, COUNTIFS with one pair, SUMIFS with a second pair `B,">0"`. - let expected: [Option<[f64; 4]>; 6] = [Some([1.0, 1.0, 1.0, 1.0]), None, None, Some([1.0, 4.0, 1.0, 4.0]), None, None]; - for (s, want) in strings.iter().zip(expected) { - let countif = ev("COUNTIF", vec![rf(cells.clone()), t(s)]); - let sumif = ev("SUMIF", vec![rf(cells.clone()), t(s), rf(rows.clone())]); - let countifs = ev("COUNTIFS", vec![rf(cells.clone()), t(s)]); - let sumifs = ev("SUMIFS", vec![rf(rows.clone()), rf(cells.clone()), t(s), rf(rows.clone()), t(">0")]); - match want { - Some([a, b, c, d]) => { - close(countif, a); - close(sumif, b); - close(countifs, c); - close(sumifs, d); - } - // Over 255 units: `#VALUE!` with one pair; cut to 255 units with two, which - // matches no cell (for emoji it splits a surrogate pair). - None => { - is_err(countif, CellError::Value); - is_err(sumif, CellError::Value); - is_err(countifs, CellError::Value); - close(sumifs, 0.0); - } - } - } - } - - #[test] - fn criteria_cut_inside_a_surrogate_pair() { - let e = "😀"; - let cells = arr(vec![vec![tv(&e.repeat(127))], vec![tv(&e.repeat(128))], vec![tv("zzz")], vec![Value::Empty]]); - let rows = col(&[1.0, 2.0, 3.0, 4.0]); - let sumifs = |crit: String| ev("SUMIFS", vec![rf(rows.clone()), rf(cells.clone()), t(&crit), rf(rows.clone()), t(">0")]); - // "=" + 😀×128 is 257 units: the cut after 255 keeps "=" and 127 whole emoji. - close(sumifs(format!("={}", e.repeat(128))), 1.0); - // With a two-unit prefix the cut keeps 126 emoji and half of the 127th. Such a criterion - // equals no cell text: "=" matches nothing and "<>" everything, blanks included. - close(sumifs(format!("=a{}", e.repeat(128))), 0.0); - close(sumifs(format!("<>{}", e.repeat(128))), 10.0); - // Comparisons treat it as sorting just above the 126 kept emoji. - close(sumifs(format!(">={}", e.repeat(128))), 1.0 + 2.0); - close(sumifs(format!("<={}", e.repeat(128))), 3.0); - } -} +mod tests; diff --git a/crates/functions/src/math/criteria.rs b/crates/functions/src/math/criteria.rs new file mode 100644 index 0000000000..fd77aed344 --- /dev/null +++ b/crates/functions/src/math/criteria.rs @@ -0,0 +1,128 @@ +use super::*; + +// --------------------------------------------------------------------------------------------- +// Criteria aggregates + +/// Values at the matched positions of `target` for every (range, criteria) pair. +fn criteria_mask(a: &[Arg], first_pair: usize, rows: usize, cols: usize) -> R> { + let rest = a.get(first_pair..).unwrap_or(&[]); + if rest.is_empty() || rest.len() % 2 != 0 { + return Err(CellError::Value); + } + // A text criterion over 255 characters is `#VALUE!` only when there is a single criteria + // pair. With two or more pairs Excel matches its first 255 characters instead. + let single_pair = rest.len() == 2; + let mut mask = vec![true; rows.saturating_mul(cols)]; + for pair in rest.chunks(2) { + let (Some(range), Some(crit)) = (pair.first(), pair.get(1)) else { return Err(CellError::Value) }; + let r = as_array(&range.value); + if r.rows != rows || r.cols != cols { + return Err(CellError::Value); + } + let scalar = crit.value.scalar(); + let c = if single_pair { Criterion::parse_arg(&scalar)? } else { Criterion::parse_arg_truncated(&scalar) }; + for (m, v) in mask.iter_mut().zip(r.iter()) { + if *m && !c.matches(v) { + *m = false; + } + } + } + Ok(mask) +} + +/// Numbers in `target` where `mask` is set (errors at matched positions propagate). +fn masked_numbers(target: &Array, mask: &[bool]) -> R> { + let mut out = Vec::new(); + for (v, m) in target.iter().zip(mask.iter()) { + if !*m { + continue; + } + match v { + Value::Number(n) => out.push(*n), + Value::Error(e) => return Err(*e), + _ => {} + } + } + Ok(out) +} + +pub(super) fn countifs(a: &[Arg], _c: &mut dyn Ctx) -> R { + let first = as_array(&crate::util::arg(a, 0)?.value); + let mask = criteria_mask(a, 0, first.rows, first.cols)?; + num_val(mask.iter().filter(|m| **m).count() as f64) +} + +/// The values of the `*IFS` target range with the mask from its pairs. +fn ifs_numbers(a: &[Arg]) -> R> { + let target = as_array(&crate::util::arg(a, 0)?.value); + let mask = criteria_mask(a, 1, target.rows, target.cols)?; + masked_numbers(&target, &mask) +} + +pub(super) fn sumifs(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(ifs_numbers(a)?.iter().sum()) +} + +pub(super) fn averageifs(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = ifs_numbers(a)?; + if v.is_empty() { + return Err(CellError::Div0); + } + num_val(v.iter().sum::() / v.len() as f64) +} + +pub(super) fn maxifs(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = ifs_numbers(a)?; + num_val(v.iter().copied().reduce(f64::max).unwrap_or(0.0)) +} + +pub(super) fn minifs(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = ifs_numbers(a)?; + num_val(v.iter().copied().reduce(f64::min).unwrap_or(0.0)) +} + +pub(super) fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R { + let range = as_array(&crate::util::arg(a, 0)?.value); + let c = Criterion::parse_arg(&crate::util::arg(a, 1)?.value.scalar())?; + num_val(range.iter().filter(|v| c.matches(v)).count() as f64) +} + +/// SUMIF / AVERAGEIF: matched numbers from the sum range, read at the criteria range's positions. +/// Excel sizes a sum range reference like the criteria range, anchored at its own top-left cell; +/// the evaluator does that resizing (it has the references), so cells line up one to one here. +fn if_numbers(a: &[Arg]) -> R> { + let range = as_array(&crate::util::arg(a, 0)?.value); + let c = Criterion::parse_arg(&crate::util::arg(a, 1)?.value.scalar())?; + let sum_range = if has(a, 2) { Some(as_array(&crate::util::arg(a, 2)?.value)) } else { None }; + let mut out = Vec::new(); + for r in 0..range.rows { + for col in 0..range.cols { + let Some(v) = range.get(r, col) else { continue }; + if !c.matches(v) { + continue; + } + let target = match &sum_range { + Some(s) => s.get(r, col), + None => Some(v), + }; + match target { + Some(Value::Number(n)) => out.push(*n), + Some(Value::Error(e)) => return Err(*e), + _ => {} + } + } + } + Ok(out) +} + +pub(super) fn sumif(a: &[Arg], _c: &mut dyn Ctx) -> R { + num_val(if_numbers(a)?.iter().sum()) +} + +pub(super) fn averageif(a: &[Arg], _c: &mut dyn Ctx) -> R { + let v = if_numbers(a)?; + if v.is_empty() { + return Err(CellError::Div0); + } + num_val(v.iter().sum::() / v.len() as f64) +} diff --git a/crates/functions/src/math/matrix.rs b/crates/functions/src/math/matrix.rs new file mode 100644 index 0000000000..9bfb75c53e --- /dev/null +++ b/crates/functions/src/math/matrix.rs @@ -0,0 +1,182 @@ +use super::*; + +// --------------------------------------------------------------------------------------------- +// Matrices + +/// A numeric matrix (every element must be a number, else `#VALUE!`). +fn matrix(v: &Value) -> R<(usize, usize, Vec)> { + let arr = as_array(v); + let mut out = Vec::with_capacity(arr.data.len()); + for x in arr.iter() { + match x { + Value::Number(n) => out.push(*n), + Value::Error(e) => return Err(*e), + _ => return Err(CellError::Value), + } + } + Ok((arr.rows, arr.cols, out)) +} + +fn to_value_array(rows: usize, cols: usize, data: Vec) -> R { + let vals = data.into_iter().map(|x| if x.is_finite() { Value::Number(x) } else { Value::Error(CellError::Num) }).collect(); + array_val(rows, cols, vals) +} + +pub(super) fn mmult(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (ar, ac, ad) = matrix(&crate::util::arg(a, 0)?.value)?; + let (br, bc, bd) = matrix(&crate::util::arg(a, 1)?.value)?; + if ac != br { + return Err(CellError::Value); + } + if ar.saturating_mul(bc) > MAX_CELLS || ar.saturating_mul(bc).saturating_mul(ac) > 2_000_000_000 { + return Err(CellError::Num); + } + let mut out = vec![0.0; ar * bc]; + for i in 0..ar { + for k in 0..ac { + let x = ad.get(i * ac + k).copied().unwrap_or(0.0); + if x == 0.0 { + continue; + } + for j in 0..bc { + if let (Some(o), Some(y)) = (out.get_mut(i * bc + j), bd.get(k * bc + j)) { + *o += x * y; + } + } + } + } + to_value_array(ar, bc, out) +} + +/// LU decomposition with partial pivoting; returns (lu, perm, sign) or None when singular. +fn square(a: &[Arg]) -> R<(usize, Vec)> { + let (r, c, d) = matrix(&crate::util::arg(a, 0)?.value)?; + if r != c { + return Err(CellError::Value); + } + if r > 2000 { + return Err(CellError::Num); + } + Ok((r, d)) +} + +fn determinant(n: usize, mut m: Vec) -> f64 { + let mut det = 1.0; + for col in 0..n { + let mut piv = col; + let mut best = 0.0; + for row in col..n { + let v = m.get(row * n + col).copied().unwrap_or(0.0).abs(); + if v > best { + best = v; + piv = row; + } + } + if best == 0.0 { + return 0.0; + } + if piv != col { + for k in 0..n { + m.swap(piv * n + k, col * n + k); + } + det = -det; + } + let p = m.get(col * n + col).copied().unwrap_or(0.0); + det *= p; + for row in col + 1..n { + let f = m.get(row * n + col).copied().unwrap_or(0.0) / p; + if f == 0.0 { + continue; + } + for k in col..n { + let v = m.get(col * n + k).copied().unwrap_or(0.0); + if let Some(x) = m.get_mut(row * n + k) { + *x -= f * v; + } + } + } + } + det +} + +pub(super) fn mdeterm(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (n, m) = square(a)?; + let d = determinant(n, m); + // Tidy results that should be integers (e.g. 0.9999999999999998 → 1). + num_val(if d.abs() > 1e-300 { to_sig_digits(d, 15) } else { d }) +} + +pub(super) fn minverse(a: &[Arg], _c: &mut dyn Ctx) -> R { + let (n, mut m) = square(a)?; + let mut inv = vec![0.0; n * n]; + for i in 0..n { + if let Some(x) = inv.get_mut(i * n + i) { + *x = 1.0; + } + } + let scale = m.iter().fold(0.0f64, |acc, x| acc.max(x.abs())).max(1e-300); + for col in 0..n { + let mut piv = col; + let mut best = 0.0; + for row in col..n { + let v = m.get(row * n + col).copied().unwrap_or(0.0).abs(); + if v > best { + best = v; + piv = row; + } + } + if best <= scale * 1e-15 { + return Err(CellError::Num); + } + if piv != col { + for k in 0..n { + m.swap(piv * n + k, col * n + k); + inv.swap(piv * n + k, col * n + k); + } + } + let p = m.get(col * n + col).copied().unwrap_or(1.0); + for k in 0..n { + if let Some(x) = m.get_mut(col * n + k) { + *x /= p; + } + if let Some(x) = inv.get_mut(col * n + k) { + *x /= p; + } + } + for row in 0..n { + if row == col { + continue; + } + let f = m.get(row * n + col).copied().unwrap_or(0.0); + if f == 0.0 { + continue; + } + for k in 0..n { + let mv = m.get(col * n + k).copied().unwrap_or(0.0); + let iv = inv.get(col * n + k).copied().unwrap_or(0.0); + if let Some(x) = m.get_mut(row * n + k) { + *x -= f * mv; + } + if let Some(x) = inv.get_mut(row * n + k) { + *x -= f * iv; + } + } + } + } + // Rounding noise in the inverse scales with 1/scale of the input matrix. + let noise = 1e-15 / scale; + let inv = inv.into_iter().map(|x| if x.abs() < noise { 0.0 } else { x }).collect(); + to_value_array(n, n, inv) +} + +pub(super) fn munit(a: &[Arg], _c: &mut dyn Ctx) -> R { + let n = num(a, 0)?.trunc(); + let (r, _) = check_size(n, n)?; + let mut data = vec![Value::Number(0.0); r * r]; + for i in 0..r { + if let Some(x) = data.get_mut(i * r + i) { + *x = Value::Number(1.0); + } + } + array_val(r, r, data) +} diff --git a/crates/functions/src/math/roman_base.rs b/crates/functions/src/math/roman_base.rs new file mode 100644 index 0000000000..931552e938 --- /dev/null +++ b/crates/functions/src/math/roman_base.rs @@ -0,0 +1,121 @@ +use super::*; + +// --------------------------------------------------------------------------------------------- +// Number systems + +const NUMERALS: [(char, u32); 7] = [('I', 1), ('V', 5), ('X', 10), ('L', 50), ('C', 100), ('D', 500), ('M', 1000)]; + +/// Tokens (text, value) for a ROMAN form, largest value first. Form 0 is classic; each higher +/// form widens one of two limits on the subtracted numeral's ratio to the larger one, alternating: +/// the limit for I/X/C (10, 10, 100, 100, 1000) and for V/L (none, 20, 20, 200, 200). So form 2 +/// allows IC and XM but not VD, and form 3 adds VD and VM but not ID. +fn roman_tokens(form: u32) -> Vec<(String, u32)> { + let (pow10_max, five_max) = [(10, 0), (10, 20), (100, 20), (100, 200), (1000, 200)].get(form as usize).copied().unwrap_or((10, 0)); + let mut toks: Vec<(String, u32)> = NUMERALS.iter().map(|(c, v)| (c.to_string(), *v)).collect(); + for (i, (sc, sv)) in NUMERALS.iter().enumerate() { + for (lc, lv) in NUMERALS.iter().skip(i + 1) { + let ratio = lv / sv; + let pow10 = matches!(sc, 'I' | 'X' | 'C'); + let ok = ratio <= if pow10 { pow10_max } else { five_max }; + let value = lv - sv; + // Skip pairs that equal a single numeral (VX = V) – they are never shorter. + if ok && !NUMERALS.iter().any(|(_, v)| *v == value) { + toks.push((format!("{sc}{lc}"), value)); + } + } + } + // Largest value first; on ties prefer the shorter / classic token. + toks.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.len().cmp(&b.0.len()))); + toks +} + +pub(super) fn roman(a: &[Arg], _c: &mut dyn Ctx) -> R { + let n = num(a, 0)?.trunc(); + let form = match a.get(1).map(|x| x.value.scalar()) { + None => 0.0, + Some(Value::Bool(true)) => 0.0, + Some(Value::Bool(false)) => 4.0, + Some(_) => num(a, 1)?.trunc(), + }; + if !(0.0..=3999.0).contains(&n) || !(0.0..=4.0).contains(&form) { + return Err(CellError::Value); + } + let toks = roman_tokens(form as u32); + let mut rem = n as u32; + let mut out = String::new(); + while rem > 0 { + let Some((t, v)) = toks.iter().find(|(_, v)| *v <= rem) else { break }; + out.push_str(t); + rem -= v; + } + text_val(out) +} + +pub(super) fn arabic(a: &[Arg], _c: &mut dyn Ctx) -> R { + let s = text(a, 0)?; + let s = s.trim(); + if s.chars().count() > 255 { + return Err(CellError::Value); + } + let (neg, body) = match s.strip_prefix('-') { + Some(r) => (true, r), + None => (false, s), + }; + let mut vals = Vec::new(); + for ch in body.chars() { + let u = ch.to_ascii_uppercase(); + let Some((_, v)) = NUMERALS.iter().find(|(c, _)| *c == u) else { return Err(CellError::Value) }; + vals.push(*v as i64); + } + let mut total = 0i64; + for (i, v) in vals.iter().enumerate() { + if vals.get(i + 1).is_some_and(|next| next > v) { + total -= v; + } else { + total += v; + } + } + num_val(if neg { -total as f64 } else { total as f64 }) +} + +pub(super) fn base(a: &[Arg], _c: &mut dyn Ctx) -> R { + let n = num(a, 0)?.trunc(); + let radix = num(a, 1)?.trunc(); + let min_len = opt_num(a, 2, 0.0)?.trunc(); + if !(0.0..9.007_199_254_740_992e15).contains(&n) || !(2.0..=36.0).contains(&radix) || !(0.0..=255.0).contains(&min_len) { + return Err(CellError::Num); + } + let radix = radix as u64; + let mut v = n as u64; + let mut digits = Vec::new(); + loop { + let d = (v % radix) as u32; + digits.push(char::from_digit(d, radix as u32).unwrap_or('0').to_ascii_uppercase()); + v /= radix; + if v == 0 { + break; + } + } + while digits.len() < min_len as usize { + digits.push('0'); + } + text_val(digits.iter().rev().collect::()) +} + +pub(super) fn decimal(a: &[Arg], _c: &mut dyn Ctx) -> R { + let s = text(a, 0)?; + let radix = num(a, 1)?.trunc(); + if !(2.0..=36.0).contains(&radix) || s.chars().count() > 255 { + return Err(CellError::Num); + } + let radix = radix as u32; + let mut total = 0f64; + for ch in s.trim().chars() { + let Some(d) = ch.to_digit(radix) else { return Err(CellError::Num) }; + total = total * radix as f64 + d as f64; + } + if total >= 9.007_199_254_740_992e15 { + return Err(CellError::Num); + } + num_val(total) +} diff --git a/crates/functions/src/math/tests.rs b/crates/functions/src/math/tests.rs new file mode 100644 index 0000000000..14f72d1e10 --- /dev/null +++ b/crates/functions/src/math/tests.rs @@ -0,0 +1,492 @@ +use super::*; +use crate::util::testutil::*; +use CellError::{Div0, NA, Num}; + +fn ev1(name: &str, x: f64) -> Value { + ev(name, vec![n(x)]) +} +fn ev2(name: &str, x: f64, y: f64) -> Value { + ev(name, vec![n(x), n(y)]) +} + +#[test] +fn percentof() { + // SUM(subset) / SUM(all): text, booleans and blanks inside ranges are skipped like SUM. + let all = arr(vec![vec![nv(10.0)], vec![nv(30.0)], vec![tv("x")], vec![Value::Empty], vec![nv(60.0)]]); + let part = arr(vec![vec![nv(10.0)], vec![nv(30.0)]]); + close(ev("PERCENTOF", vec![rf(part), rf(all.clone())]), 0.4); + close(ev("PERCENTOF", vec![n(25.0), n(200.0)]), 0.125); + is_err(ev("PERCENTOF", vec![n(1.0), rf(arr(vec![vec![nv(5.0), nv(-5.0)]]))]), Div0); + is_err(ev("PERCENTOF", vec![rf(arr(vec![vec![Value::Error(NA)]])), rf(all)]), NA); + is_err(ev("PERCENTOF", vec![t("abc"), n(1.0)]), CellError::Value); +} + +#[test] +fn sums() { + close(ev("SUM", vec![n(1.0), n(2.0), t("3"), b(true)]), 7.0); + let r = arr(vec![vec![nv(1.0), tv("x"), Value::Bool(true), Value::Empty, nv(4.0)]]); + close(ev("SUM", vec![rf(r.clone())]), 5.0); + is_err(ev("SUM", vec![t("abc")]), CellError::Value); + is_err(ev("SUM", vec![rf(arr(vec![vec![nv(1.0), Value::Error(Div0)]]))]), Div0); + close(ev("SUM", vec![rf(tv("abc"))]), 0.0); + close(ev("SUMSQ", vec![n(3.0), n(4.0)]), 25.0); + close(ev("PRODUCT", vec![rf(r), n(2.0)]), 8.0); + close(ev("PRODUCT", vec![rf(arr(vec![vec![tv("a")]]))]), 0.0); + let a1 = arr(vec![vec![nv(3.0), nv(4.0)], vec![nv(8.0), nv(6.0)], vec![nv(1.0), nv(9.0)]]); + let a2 = arr(vec![vec![nv(2.0), nv(7.0)], vec![nv(6.0), nv(7.0)], vec![nv(5.0), nv(3.0)]]); + close(ev("SUMPRODUCT", vec![rf(a1.clone()), rf(a2)]), 156.0); + close(ev("SUMPRODUCT", vec![rf(a1.clone())]), 31.0); + is_err(ev("SUMPRODUCT", vec![rf(a1), rf(row(&[1.0, 2.0]))]), CellError::Value); + close(ev("SUMPRODUCT", vec![av(row(&[1.0, 2.0])), av(arr(vec![vec![tv("x"), nv(3.0)]]))]), 6.0); + let x = row(&[2.0, 3.0, 9.0, 1.0, 8.0, 7.0, 5.0]); + let y = row(&[6.0, 5.0, 11.0, 7.0, 5.0, 4.0, 4.0]); + close(ev("SUMX2MY2", vec![av(x.clone()), av(y.clone())]), -55.0); + close(ev("SUMX2PY2", vec![av(x.clone()), av(y.clone())]), 521.0); + close(ev("SUMXMY2", vec![av(x.clone()), av(y)]), 79.0); + is_err(ev("SUMXMY2", vec![av(x), av(row(&[1.0]))]), NA); +} + +/// SUMPRODUCT cases observed in Excel (FormulaEvalTestData, row "Sumproduct"): scalars and +/// single-cell references multiply in, text in a scalar argument is `#VALUE!`, sizes must match. +#[test] +fn sumproduct_scalar_arguments() { + let cases: Vec<(Vec, Result)> = vec![ + (vec![n(3.0), rf(nv(534.0)), rf(nv(1.1))], Ok(1762.2)), + (vec![rf(nv(1.1)), n(3.0), rf(nv(534.0)), rf(nv(1.1))], Ok(1938.42)), + (vec![n(2.0), rf(col(&[1.00001, 2.99999]))], Err(CellError::Value)), + (vec![rf(tv("4")), rf(nv(2.0))], Err(CellError::Value)), + (vec![t("4"), n(2.0)], Err(CellError::Value)), + (vec![rf(nv(3.0)), rf(Value::Error(CellError::Ref))], Err(CellError::Ref)), + (vec![av(arr(vec![vec![tv("4")]])), n(2.0)], Ok(0.0)), + ]; + for (args, want) in cases { + match want { + Ok(x) => close(ev("SUMPRODUCT", args), x), + Err(err) => is_err(ev("SUMPRODUCT", args), err), + } + } +} + +#[test] +fn rounding() { + close(ev2("ROUND", 2.675, 2.0), 2.68); + close(ev2("ROUND", -1.5, 0.0), -2.0); + close(ev2("ROUND", 1234.567, -2.0), 1200.0); + close(ev2("ROUND", 2.15, 1.0), 2.2); + close(ev2("ROUNDUP", 3.2, 0.0), 4.0); + close(ev2("ROUNDUP", -3.14259, 1.0), -3.2); + close(ev2("ROUNDUP", 31415.92654, -2.0), 31500.0); + close(ev2("ROUNDDOWN", -3.9, 0.0), -3.0); + close(ev2("ROUNDDOWN", 2.71989, 3.0), 2.719); + close(ev2("ROUNDDOWN", 31415.92654, -2.0), 31400.0); + for digits in [-308.0, -309.0, -400.0] { + for x in [1234.0, -1234.0, 0.5] { + close(ev2("ROUNDDOWN", x, digits), 0.0); + close(ev2("TRUNC", x, digits), 0.0); + } + } + assert!(matches!(ev2("ROUNDUP", 1234.0, -309.0), Value::Error(CellError::Num))); + close(ev2("ROUNDUP", 0.1 + 0.2, 1.0), 0.3); + close(ev2("TRUNC", 8.987, 2.0), 8.98); + close(ev1("TRUNC", -8.9), -8.0); + close(ev1("INT", -8.9), -9.0); + close(ev1("INT", 8.9), 8.0); + close(ev2("MROUND", 10.0, 3.0), 9.0); + close(ev2("MROUND", -10.0, -3.0), -9.0); + close(ev2("MROUND", 1.3, 0.2), 1.4); + is_err(ev2("MROUND", 5.0, -2.0), Num); + close(ev2("CEILING", 2.5, 1.0), 3.0); + close(ev2("CEILING", -2.5, -2.0), -4.0); + close(ev2("CEILING", -2.5, 2.0), -2.0); + close(ev2("CEILING", 1.5, 0.1), 1.5); + close(ev2("CEILING", 0.234, 0.01), 0.24); + is_err(ev2("CEILING", 2.5, -1.0), Num); + close(ev2("FLOOR", 3.7, 2.0), 2.0); + close(ev2("FLOOR", -2.5, -2.0), -2.0); + close(ev2("FLOOR", -2.5, 2.0), -4.0); + is_err(ev2("FLOOR", 2.5, -2.0), Num); + is_err(ev2("FLOOR", 2.5, 0.0), Div0); + close(ev2("FLOOR", 1.58, 0.1), 1.5); + close(ev2("FLOOR", 0.234, 0.01), 0.23); + close(ev2("CEILING.MATH", 24.3, 5.0), 25.0); + close(ev1("CEILING.MATH", 6.7), 7.0); + close(ev2("CEILING.MATH", -8.1, 2.0), -8.0); + close(ev("CEILING.MATH", vec![n(-5.5), n(2.0), n(-1.0)]), -6.0); + close(ev2("FLOOR.MATH", 24.3, 5.0), 20.0); + close(ev1("FLOOR.MATH", 6.7), 6.0); + close(ev2("FLOOR.MATH", -8.1, 2.0), -10.0); + close(ev("FLOOR.MATH", vec![n(-5.5), n(2.0), n(-1.0)]), -4.0); + close(ev2("CEILING.PRECISE", -4.6, 2.0), -4.0); + close(ev2("CEILING.PRECISE", 4.3, -2.0), 6.0); + close(ev2("ISO.CEILING", 4.3, 0.0), 0.0); + close(ev2("FLOOR.PRECISE", -3.2, -1.0), -4.0); + close(ev1("FLOOR.PRECISE", 3.2), 3.0); + close(ev1("EVEN", 1.5), 2.0); + close(ev1("EVEN", 3.0), 4.0); + close(ev1("EVEN", 2.0), 2.0); + close(ev1("EVEN", -1.0), -2.0); + close(ev1("EVEN", 0.0), 0.0); + close(ev1("ODD", 1.5), 3.0); + close(ev1("ODD", 3.0), 3.0); + close(ev1("ODD", 2.0), 3.0); + close(ev1("ODD", -1.0), -1.0); + close(ev1("ODD", -2.0), -3.0); + close(ev1("ODD", 0.0), 1.0); +} + +#[test] +fn mod_tiny_divisor_stays_in_range() { + for (x, d) in [(3.0, 1e-300), (-1.0, 1e-300), (3.0, -1e-300), (-1.0, -1e-300)] { + match ev2("MOD", x, d) { + Value::Number(r) => { + assert!(r == 0.0 || (r < 0.0) == (d < 0.0), "MOD({x}, {d}) = {r}"); + assert!(r.abs() < d.abs(), "MOD({x}, {d}) = {r}"); + } + other => panic!("MOD({x}, {d}) = {other:?}"), + } + } +} + +#[test] +fn arithmetic() { + close(ev2("MOD", 3.0, 2.0), 1.0); + close(ev2("MOD", -3.0, 2.0), 1.0); + close(ev2("MOD", 3.0, -2.0), -1.0); + close(ev2("MOD", -3.0, -2.0), -1.0); + close(ev2("MOD", 5.5, 1.0), 0.5); + is_err(ev2("MOD", 1.0, 0.0), Div0); + close(ev2("QUOTIENT", 5.0, 2.0), 2.0); + close(ev2("QUOTIENT", -10.0, 3.0), -3.0); + is_err(ev2("QUOTIENT", 1.0, 0.0), Div0); + close(ev1("ABS", -4.0), 4.0); + close(ev1("SIGN", -0.0001), -1.0); + close(ev1("SIGN", 0.0), 0.0); + close(ev1("SQRT", 16.0), 4.0); + is_err(ev1("SQRT", -16.0), Num); + close(ev1("SQRTPI", 1.0), 1.772_453_850_905_516); + close(ev2("POWER", 5.0, 2.0), 25.0); + close(ev2("POWER", 4.0, 1.25), 5.656_854_249_492_381); + close(ev2("POWER", -2.0, 3.0), -8.0); + is_err(ev2("POWER", 0.0, 0.0), Num); + is_err(ev2("POWER", 0.0, -1.0), Div0); + is_err(ev2("POWER", -8.0, 1.0 / 3.0), Num); + is_err(ev2("POWER", 10.0, 400.0), Num); + close(ev1("EXP", 1.0), std::f64::consts::E); + close(ev2("LOG", 8.0, 2.0), 3.0); + close(ev1("LOG", 10.0), 1.0); + close(ev2("LOG", 81.0, 3.0), 4.0); + is_err(ev1("LN", 0.0), Num); + is_err(ev2("LOG", 10.0, 1.0), Div0); + is_err(ev1("LOG10", -1.0), Num); + close(ev1("LN", std::f64::consts::E), 1.0); + close(ev1("LOG10", 1e5), 5.0); + close(ev("PI", vec![]), PI); + close(ev("SQRT", vec![t("16")]), 4.0); + is_err(ev("SQRT", vec![t("x")]), CellError::Value); +} + +#[test] +fn combinatorics() { + close(ev1("FACT", 5.0), 120.0); + close(ev1("FACT", 1.9), 1.0); + close(ev1("FACT", 0.0), 1.0); + is_err(ev1("FACT", -1.0), Num); + is_err(ev1("FACT", 171.0), Num); + close(ev1("FACTDOUBLE", 6.0), 48.0); + close(ev1("FACTDOUBLE", 7.0), 105.0); + close(ev1("FACTDOUBLE", -1.0), 1.0); + is_err(ev1("FACTDOUBLE", -2.0), Num); + close(ev2("COMBIN", 8.0, 2.0), 28.0); + close(ev2("COMBIN", 8.9, 2.9), 28.0); + is_err(ev2("COMBIN", 2.0, 3.0), Num); + close(ev2("COMBIN", 60.0, 30.0), 118_264_581_564_861_424.0); + close(ev2("COMBINA", 4.0, 3.0), 20.0); + close(ev2("COMBINA", 10.0, 3.0), 220.0); + close(ev2("PERMUT", 100.0, 3.0), 970_200.0); + close(ev2("PERMUT", 3.0, 2.0), 6.0); + is_err(ev2("PERMUT", 2.0, 3.0), Num); + close(ev2("PERMUTATIONA", 3.0, 2.0), 9.0); + close(ev2("GCD", 24.0, 36.0), 12.0); + close(ev2("GCD", 5.0, 0.0), 5.0); + close(ev2("GCD", 7.0, 1.0), 1.0); + is_err(ev2("GCD", -1.0, 5.0), Num); + close(ev("GCD", vec![av(row(&[12.0, 18.0, 30.0]))]), 6.0); + close(ev2("LCM", 24.0, 36.0), 72.0); + close(ev2("LCM", 5.0, 2.0), 10.0); + close(ev2("LCM", 5.0, 0.0), 0.0); + close(ev("MULTINOMIAL", vec![n(2.0), n(3.0), n(4.0)]), 1260.0); + is_err(ev("MULTINOMIAL", vec![n(-1.0), n(2.0)]), Num); +} + +#[test] +fn trig() { + close(ev1("SIN", PI / 2.0), 1.0); + close(ev1("COS", 0.0), 1.0); + close(ev1("TAN", PI / 4.0), 1.0); + is_err(ev1("SIN", 1e10), Num); + close(ev1("ASIN", 1.0), PI / 2.0); + is_err(ev1("ACOS", 1.5), Num); + close(ev1("ACOT", 2.0), 0.463_647_609_000_806); + close(ev1("ACOTH", 6.0), 0.168_236_118_310_606); + is_err(ev1("ACOTH", 0.5), Num); + close(ev1("COT", 30.0), -0.156_119_952_161_659); + is_err(ev1("COT", 0.0), Div0); + close(ev1("COTH", 2.0), 1.037_314_720_727_548); + close(ev1("CSC", 15.0), 1.537_780_562_208_209); + close(ev1("CSCH", 1.5), 0.469_642_440_595_653); + close(ev1("SEC", 45.0), 1.903_594_407_014_6); + close_tol(ev1("SECH", 45.0), 5.725_037_161_098_787e-20, 1e-9); + close(ev2("ATAN2", 1.0, 1.0), PI / 4.0); + close(ev2("ATAN2", -1.0, -1.0), -3.0 * PI / 4.0); + is_err(ev2("ATAN2", 0.0, 0.0), Div0); + close(ev1("ASINH", -2.5), -1.647_231_146_371_096); + close(ev1("ACOSH", 10.0), 2.993_222_846_126_381); + is_err(ev1("ACOSH", 0.5), Num); + close(ev1("ATANH", 0.5), 0.549_306_144_334_055); + is_err(ev1("ATANH", 1.0), Num); + close(ev1("SINH", 1.0), 1.175_201_193_643_801); + close(ev1("DEGREES", PI), 180.0); + close(ev1("RADIANS", 270.0), 4.712_388_980_384_69); +} + +#[test] +fn number_systems() { + is_text(ev1("ROMAN", 499.0), "CDXCIX"); + is_text(ev2("ROMAN", 499.0, 1.0), "LDVLIV"); + is_text(ev2("ROMAN", 499.0, 2.0), "XDIX"); + is_text(ev2("ROMAN", 499.0, 3.0), "VDIV"); + is_text(ev2("ROMAN", 499.0, 4.0), "ID"); + is_text(ev("ROMAN", vec![n(499.0), b(false)]), "ID"); + is_text(ev1("ROMAN", 1999.0), "MCMXCIX"); + is_text(ev1("ROMAN", 3999.0), "MMMCMXCIX"); + is_text(ev1("ROMAN", 0.0), ""); + is_err(ev1("ROMAN", 4000.0), CellError::Value); + is_err(ev1("ROMAN", -1.0), CellError::Value); + close(ev("ARABIC", vec![t("LVII")]), 57.0); + close(ev("ARABIC", vec![t("mcmxii")]), 1912.0); + close(ev("ARABIC", vec![t("")]), 0.0); + close(ev("ARABIC", vec![t("-XIV")]), -14.0); + is_err(ev("ARABIC", vec![t("ABC")]), CellError::Value); + is_text(ev2("BASE", 7.0, 2.0), "111"); + is_text(ev2("BASE", 100.0, 16.0), "64"); + is_text(ev("BASE", vec![n(15.0), n(2.0), n(10.0)]), "0000001111"); + is_text(ev2("BASE", 0.0, 36.0), "0"); + is_err(ev2("BASE", 7.0, 1.0), Num); + is_err(ev2("BASE", -7.0, 2.0), Num); + close(ev("DECIMAL", vec![t("FF"), n(16.0)]), 255.0); + close(ev("DECIMAL", vec![t("111"), n(2.0)]), 7.0); + close(ev("DECIMAL", vec![t("zap"), n(36.0)]), 45745.0); + is_err(ev("DECIMAL", vec![t("12"), n(2.0)]), Num); +} + +/// Every number where Excel's forms 2 and 3 differ from a plain ratio limit (issue #733): +/// form 2 subtracts I from C and X from M, form 3 also V from M. +#[test] +fn roman_forms_two_and_three_match_excel() { + const HUNDREDS: [&str; 10] = ["", "C", "CC", "CCC", "CD", "D", "DC", "DCC", "DCCC", "CM"]; + const ONES: [&str; 10] = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"]; + let mut cases = Vec::new(); + for thousands in 0..4usize { + let m = "M".repeat(thousands); + for (h, hundreds) in HUNDREDS.iter().enumerate() { + if h != 4 && h != 9 { + cases.push((thousands * 1000 + h * 100 + 99, 2.0, format!("{m}{hundreds}IC"))); + } + } + for (o, ones) in ONES.iter().enumerate() { + cases.push((thousands * 1000 + 990 + o, 2.0, format!("{m}XM{ones}"))); + } + for (o, ones) in ONES.iter().enumerate().take(5) { + cases.push((thousands * 1000 + 995 + o, 3.0, format!("{m}VM{ones}"))); + } + } + assert_eq!(cases.len(), 92); + for (number, form, want) in &cases { + is_text(ev2("ROMAN", *number as f64, *form), want); + } + // Neighbours that already matched: form 2 still has no VD or ID, form 3 no ID. + is_text(ev2("ROMAN", 495.0, 2.0), "XDV"); + is_text(ev2("ROMAN", 495.0, 3.0), "VD"); + is_text(ev2("ROMAN", 1499.0, 2.0), "MXDIX"); + is_text(ev2("ROMAN", 999.0, 0.0), "CMXCIX"); + is_text(ev2("ROMAN", 999.0, 1.0), "LMVLIV"); + is_text(ev2("ROMAN", 999.0, 4.0), "IM"); + is_text(ev2("ROMAN", 995.0, 4.0), "VM"); + is_text(ev2("ROMAN", 99.0, 1.0), "VCIV"); + is_text(ev2("ROMAN", 99.0, 4.0), "IC"); +} + +#[test] +fn minverse_scale_relative() { + let big = ev("MINVERSE", vec![av(arr(vec![vec![nv(1e20), nv(0.0)], vec![nv(0.0), nv(1e20)]]))]); + let r = rows_of(&big); + close_tol(r[0][0].clone(), 1e-20, 1e-25); + close_tol(r[1][1].clone(), 1e-20, 1e-25); + let small = ev("MINVERSE", vec![av(arr(vec![vec![nv(1e-20), nv(0.0)], vec![nv(0.0), nv(1e-20)]]))]); + let r = rows_of(&small); + close_tol(r[0][0].clone(), 1e20, 1e10); +} + +#[test] +fn matrices() { + let a = arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(3.0), nv(4.0)]]); + let b2 = arr(vec![vec![nv(5.0), nv(6.0)], vec![nv(7.0), nv(8.0)]]); + assert_eq!(rows_of(&ev("MMULT", vec![av(a.clone()), av(b2)])), vec![vec![nv(19.0), nv(22.0)], vec![nv(43.0), nv(50.0)]]); + is_err(ev("MMULT", vec![av(a.clone()), av(row(&[1.0, 2.0]))]), CellError::Value); + is_err(ev("MMULT", vec![av(arr(vec![vec![tv("x")]])), av(row(&[1.0]))]), CellError::Value); + close(ev("MDETERM", vec![av(a.clone())]), -2.0); + let m3 = arr(vec![vec![nv(3.0), nv(6.0), nv(1.0)], vec![nv(1.0), nv(1.0), nv(0.0)], vec![nv(3.0), nv(10.0), nv(2.0)]]); + close(ev("MDETERM", vec![av(m3)]), 1.0); + let m4 = arr(vec![ + vec![nv(1.0), nv(3.0), nv(8.0), nv(5.0)], + vec![nv(1.0), nv(3.0), nv(6.0), nv(1.0)], + vec![nv(1.0), nv(1.0), nv(1.0), nv(0.0)], + vec![nv(7.0), nv(3.0), nv(10.0), nv(2.0)], + ]); + close(ev("MDETERM", vec![av(m4)]), 88.0); + is_err(ev("MDETERM", vec![av(row(&[1.0, 2.0]))]), CellError::Value); + let inv = ev("MINVERSE", vec![av(arr(vec![vec![nv(4.0), nv(-1.0)], vec![nv(2.0), nv(0.0)]]))]); + let r = rows_of(&inv); + close(r[0][0].clone(), 0.0); + close(r[0][1].clone(), 0.5); + close(r[1][0].clone(), -1.0); + close(r[1][1].clone(), 2.0); + is_err(ev("MINVERSE", vec![av(arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(2.0), nv(4.0)]]))]), Num); + assert_eq!(rows_of(&ev1("MUNIT", 2.0)), vec![vec![nv(1.0), nv(0.0)], vec![nv(0.0), nv(1.0)]]); + is_err(ev1("MUNIT", 0.0), CellError::Value); + is_err(ev1("MUNIT", 1e6), Num); + let coeffs = row(&[1.0, -0.5, 1.0 / 24.0, -1.0 / 720.0]); + close_tol(ev("SERIESSUM", vec![n(PI / 4.0), n(0.0), n(2.0), av(coeffs)]), 0.707_103_214_823, 1e-9); +} + +#[test] +fn random() { + let mut ctx = TestCtx::default(); + let spec = crate::lookup("RAND").unwrap(); + assert!(spec.volatile); + for _ in 0..50 { + let v = crate::call(spec, &[], &mut ctx); + let x = v.as_f64().unwrap(); + assert!((0.0..1.0).contains(&x)); + } + let spec = crate::lookup("RANDBETWEEN").unwrap(); + for _ in 0..50 { + let x = crate::call(spec, &[n(1.0), n(6.0)], &mut ctx).as_f64().unwrap(); + assert!((1.0..=6.0).contains(&x) && x.fract() == 0.0); + } + is_err(ev2("RANDBETWEEN", 5.0, 1.0), Num); +} + +#[test] +fn lifting() { + assert_eq!(rows_of(&ev("ABS", vec![av(row(&[-1.0, 2.0, -3.0]))])), vec![vec![nv(1.0), nv(2.0), nv(3.0)]]); + let v = ev("POWER", vec![av(col(&[1.0, 2.0])), av(row(&[2.0, 3.0]))]); + assert_eq!(rows_of(&v), vec![vec![nv(1.0), nv(1.0)], vec![nv(4.0), nv(8.0)]]); + close(ev("ROUND", vec![av(arr(vec![vec![nv(1.25)]])), n(1.0)]), 1.3); +} + +fn data() -> (Value, Value) { + let names = arr(vec![vec![tv("apple")], vec![tv("banana")], vec![tv("Apple")], vec![Value::Empty], vec![tv("cherry")]]); + let qty = col(&[10.0, 20.0, 30.0, 40.0, 50.0]); + (names, qty) +} + +#[test] +fn criteria_functions() { + let (names, qty) = data(); + close(ev("COUNTIF", vec![rf(names.clone()), t("apple")]), 2.0); + close(ev("COUNTIF", vec![rf(names.clone()), t("a*")]), 2.0); + close(ev("COUNTIF", vec![rf(names.clone()), t("<>apple")]), 3.0); + close(ev("COUNTIF", vec![rf(names.clone()), t("")]), 1.0); + close(ev("COUNTIF", vec![rf(qty.clone()), t(">20")]), 3.0); + close(ev("COUNTIF", vec![rf(qty.clone()), n(30.0)]), 1.0); + let multi = ev("COUNTIF", vec![rf(names.clone()), av(arr(vec![vec![tv("apple"), tv("cherry")]]))]); + assert_eq!(rows_of(&multi), vec![vec![nv(2.0), nv(1.0)]]); + close(ev("SUMIF", vec![rf(names.clone()), t("apple"), rf(qty.clone())]), 40.0); + close(ev("SUMIF", vec![rf(qty.clone()), t(">=30")]), 120.0); + close(ev("SUMIF", vec![rf(names.clone()), t("?????"), rf(col(&[1.0]))]), 1.0); + close(ev("AVERAGEIF", vec![rf(qty.clone()), t(">20")]), 40.0); + is_err(ev("AVERAGEIF", vec![rf(qty.clone()), t(">100")]), Div0); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(names.clone()), t("apple"), rf(qty.clone()), t(">15")]), 30.0); + close(ev("COUNTIFS", vec![rf(names.clone()), t("*a*"), rf(qty.clone()), t("<25")]), 2.0); + close(ev("AVERAGEIFS", vec![rf(qty.clone()), rf(names.clone()), t("<>banana")]), 32.5); + close(ev("MAXIFS", vec![rf(qty.clone()), rf(names.clone()), t("apple")]), 30.0); + close(ev("MINIFS", vec![rf(qty.clone()), rf(names.clone()), t("apple")]), 10.0); + close(ev("MAXIFS", vec![rf(qty.clone()), rf(names.clone()), t("zzz")]), 0.0); + is_err(ev("SUMIFS", vec![rf(qty.clone()), rf(row(&[1.0, 2.0])), t("1")]), CellError::Value); + is_err(ev("COUNTIFS", vec![rf(qty.clone()), t(">1"), rf(qty)]), CellError::Value); + is_err(ev("SUMIF", vec![rf(col(&[1.0, 2.0])), t(">0"), rf(arr(vec![vec![Value::Error(NA)], vec![nv(1.0)]]))]), NA); +} + +#[test] +fn criteria_over_255_chars_are_value_errors_with_one_pair_and_truncated_with_more() { + let ok = "x".repeat(255); + let long = "x".repeat(256); + let cells = arr(vec![vec![tv(&ok)], vec![tv(&long)]]); + let qty = col(&[1.0, 2.0]); + close(ev("COUNTIF", vec![rf(cells.clone()), t(&ok)]), 1.0); + is_err(ev("COUNTIF", vec![rf(cells.clone()), t(&long)]), CellError::Value); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&ok)]), 1.0); + is_err(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long)]), CellError::Value); + is_err(ev("COUNTIFS", vec![rf(cells.clone()), t(&long)]), CellError::Value); + // With two or more pairs Excel matches the first 255 characters: the 255-character cell + // (quantity 1), not the 256-character one (quantity 2). Checked on Excel 2016 and 365. + let over = format!("{ok}y"); + close(ev("COUNTIFS", vec![rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(qty.clone()), t(">0"), rf(cells.clone()), t(&over)]), 1.0); + close(ev("AVERAGEIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); + close(ev("MAXIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&"x".repeat(300)), rf(qty), t(">0")]), 1.0); +} + +#[test] +fn criteria_over_255_utf16_units_follow_excel() { + // The tester's sheet, identical on Excel 2016 and Microsoft 365: A1:A6 hold these + // strings, B is the row number, and each row uses its own string as the criterion. + let e = "😀"; + let strings = + [e.repeat(127), e.repeat(128), format!("{}{e}", "a".repeat(254)), format!("{}{e}", "a".repeat(253)), e.repeat(200), "я".repeat(256)]; + let cells = arr(strings.iter().map(|s| vec![tv(s)]).collect()); + let rows = col(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + // COUNTIF, SUMIF, COUNTIFS with one pair, SUMIFS with a second pair `B,">0"`. + let expected: [Option<[f64; 4]>; 6] = [Some([1.0, 1.0, 1.0, 1.0]), None, None, Some([1.0, 4.0, 1.0, 4.0]), None, None]; + for (s, want) in strings.iter().zip(expected) { + let countif = ev("COUNTIF", vec![rf(cells.clone()), t(s)]); + let sumif = ev("SUMIF", vec![rf(cells.clone()), t(s), rf(rows.clone())]); + let countifs = ev("COUNTIFS", vec![rf(cells.clone()), t(s)]); + let sumifs = ev("SUMIFS", vec![rf(rows.clone()), rf(cells.clone()), t(s), rf(rows.clone()), t(">0")]); + match want { + Some([a, b, c, d]) => { + close(countif, a); + close(sumif, b); + close(countifs, c); + close(sumifs, d); + } + // Over 255 units: `#VALUE!` with one pair; cut to 255 units with two, which + // matches no cell (for emoji it splits a surrogate pair). + None => { + is_err(countif, CellError::Value); + is_err(sumif, CellError::Value); + is_err(countifs, CellError::Value); + close(sumifs, 0.0); + } + } + } +} + +#[test] +fn criteria_cut_inside_a_surrogate_pair() { + let e = "😀"; + let cells = arr(vec![vec![tv(&e.repeat(127))], vec![tv(&e.repeat(128))], vec![tv("zzz")], vec![Value::Empty]]); + let rows = col(&[1.0, 2.0, 3.0, 4.0]); + let sumifs = |crit: String| ev("SUMIFS", vec![rf(rows.clone()), rf(cells.clone()), t(&crit), rf(rows.clone()), t(">0")]); + // "=" + 😀×128 is 257 units: the cut after 255 keeps "=" and 127 whole emoji. + close(sumifs(format!("={}", e.repeat(128))), 1.0); + // With a two-unit prefix the cut keeps 126 emoji and half of the 127th. Such a criterion + // equals no cell text: "=" matches nothing and "<>" everything, blanks included. + close(sumifs(format!("=a{}", e.repeat(128))), 0.0); + close(sumifs(format!("<>{}", e.repeat(128))), 10.0); + // Comparisons treat it as sorting just above the 126 kept emoji. + close(sumifs(format!(">={}", e.repeat(128))), 1.0 + 2.0); + close(sumifs(format!("<={}", e.repeat(128))), 3.0); +} diff --git a/crates/functions/src/stat.rs b/crates/functions/src/stat.rs index 344ba9ff8b..270bd23735 100644 --- a/crates/functions/src/stat.rs +++ b/crates/functions/src/stat.rs @@ -7,1592 +7,27 @@ use crate::special::{erf, gamma, invert, ln_beta, ln_gamma, norm_cdf, norm_inv, use crate::util::{A, R, S, arg, array_numbers, array_val, as_array, boolean, has, num, num_val, numbers, numbers_a, opt_num}; use crate::{Arg, Ctx, FnSpec, VAR}; -type C<'a> = &'a mut dyn Ctx; - -// --------------------------------------------------------------------------------------------- -// Basic helpers over number lists -// --------------------------------------------------------------------------------------------- - -fn mean(x: &[f64]) -> R { - if x.is_empty() { - return Err(CellError::Div0); - } - Ok(x.iter().sum::() / x.len() as f64) -} - -fn devsq(x: &[f64]) -> f64 { - if x.is_empty() { - return 0.0; - } - let m = x.iter().sum::() / x.len() as f64; - x.iter().map(|v| (v - m) * (v - m)).sum() -} - -fn var_s(x: &[f64]) -> R { - if x.len() < 2 { - return Err(CellError::Div0); - } - Ok(devsq(x) / (x.len() - 1) as f64) -} - -fn var_p(x: &[f64]) -> R { - if x.is_empty() { - return Err(CellError::Div0); - } - Ok(devsq(x) / x.len() as f64) -} - -fn sorted(mut v: Vec) -> Vec { - v.sort_by(f64::total_cmp); - v -} - -fn median(v: Vec) -> R { - if v.is_empty() { - return Err(CellError::Num); - } - let s = sorted(v); - let n = s.len(); - let hi = s.get(n / 2).copied().unwrap_or(0.0); - if n % 2 == 1 { Ok(hi) } else { Ok(s.get(n / 2 - 1).copied().unwrap_or(0.0).midpoint(hi)) } -} - -/// Values occurring at least twice, ordered by first occurrence, with their (equal, maximal) count. -fn modes(v: &[f64]) -> Vec { - let mut counts: Vec<(f64, usize)> = Vec::new(); - for &x in v { - if let Some(e) = counts.iter_mut().find(|e| e.0 == x) { - e.1 += 1; - } else { - counts.push((x, 1)); - } - } - let best = counts.iter().map(|e| e.1).max().unwrap_or(0); - if best < 2 { - return Vec::new(); - } - counts.into_iter().filter(|e| e.1 == best).map(|e| e.0).collect() -} - -fn mode_sngl(v: &[f64]) -> R { - modes(v).first().copied().ok_or(CellError::NA) -} - -/// Numbers for MODE / MODE.SNGL / MODE.MULT. Inside multi-cell ranges and arrays only numbers -/// count, like [`numbers`]. Scalar arguments differ from SUM: in Excel 365, text and logical -/// values are skipped whether typed or referenced (even numeric text such as `"1"`, so -/// `MODE(1,"1",2,3)` is `#N/A`, and `MODE(1,TRUE,TRUE,2)` is `#N/A`), but a single-cell reference -/// to a blank cell is `#VALUE!` (`MODE(B9,2,D7)` with D7 blank). Arguments are processed left to -/// right, so the first error wins. The results cached in POI's FormulaEvalTestData come from an -/// older Excel that answered `#VALUE!` for typed text; they are not the reference here. -fn mode_numbers(args: &[Arg]) -> R> { - let mut out = Vec::new(); - for a in args { - match &a.value { - Value::Array(arr) => { - for v in arr.iter() { - match v { - Value::Number(n) => out.push(*n), - Value::Error(e) => return Err(*e), - _ => {} - } - } - } - Value::Number(n) => out.push(*n), - Value::Error(e) => return Err(*e), - Value::Text(_) | Value::Bool(_) => {} - Value::Empty => return Err(CellError::Value), - } - } - Ok(out) -} - -fn product(v: &[f64]) -> f64 { - if v.is_empty() { 0.0 } else { v.iter().product() } -} - -fn min_of(v: &[f64]) -> f64 { - if v.is_empty() { 0.0 } else { v.iter().copied().fold(f64::INFINITY, f64::min) } -} - -fn max_of(v: &[f64]) -> f64 { - if v.is_empty() { 0.0 } else { v.iter().copied().fold(f64::NEG_INFINITY, f64::max) } -} - -/// k-th largest (k is rounded up like Excel). -fn kth(v: Vec, k: f64, largest: bool) -> R { - let k = k.ceil(); - if k.is_nan() || k < 1.0 || k > v.len() as f64 { - return Err(CellError::Num); - } - let s = sorted(v); - let k = k as usize; - let idx = if largest { s.len() - k } else { k - 1 }; - s.get(idx).copied().ok_or(CellError::Num) -} - -fn interp(s: &[f64], pos: f64) -> R { - let i = pos.floor(); - let frac = pos - i; - let i = i as usize; - let lo = *s.get(i).ok_or(CellError::Num)?; - match s.get(i + 1) { - Some(hi) if frac > 0.0 => Ok(lo + frac * (hi - lo)), - _ => Ok(lo), - } -} - -fn percentile_inc(v: Vec, k: f64) -> R { - if v.is_empty() || !(0.0..=1.0).contains(&k) { - return Err(CellError::Num); - } - let s = sorted(v); - interp(&s, k * (s.len() - 1) as f64) -} - -fn percentile_exc(v: Vec, k: f64) -> R { - let n = v.len() as f64; - if v.is_empty() || !(k > 0.0 && k < 1.0) { - return Err(CellError::Num); - } - let pos = k * (n + 1.0); - if pos < 1.0 || pos > n { - return Err(CellError::Num); - } - let s = sorted(v); - interp(&s, pos - 1.0) -} - -fn quartile_inc(v: Vec, q: f64) -> R { - let q = q.trunc(); - if !(0.0..=4.0).contains(&q) { - return Err(CellError::Num); - } - percentile_inc(v, q / 4.0) -} - -fn quartile_exc(v: Vec, q: f64) -> R { - let q = q.trunc(); - if !(1.0..=3.0).contains(&q) { - return Err(CellError::Num); - } - percentile_exc(v, q / 4.0) -} - -fn arg_nums(a: &[Arg], i: usize) -> R> { - array_numbers(&arg(a, i)?.value) -} - -/// Pairs of numbers at matching positions of two arrays (pairs with a non-number skipped). -/// Different sizes give `#N/A`; errors propagate. -fn pairs(x: &Value, y: &Value) -> R<(Vec, Vec)> { - let xa = as_array(x); - let ya = as_array(y); - if xa.data.len() != ya.data.len() { - return Err(CellError::NA); - } - let mut xs = Vec::new(); - let mut ys = Vec::new(); - for (a, b) in xa.data.iter().zip(ya.data.iter()) { - if let Value::Error(e) = a { - return Err(*e); - } - if let Value::Error(e) = b { - return Err(*e); - } - if let (Value::Number(p), Value::Number(q)) = (a, b) { - xs.push(*p); - ys.push(*q); - } - } - Ok((xs, ys)) -} - -fn arg_pairs(a: &[Arg], i: usize, j: usize) -> R<(Vec, Vec)> { - pairs(&arg(a, i)?.value, &arg(a, j)?.value) -} - -/// (sum of cross deviations, devsq x, devsq y, mean x, mean y). -fn moments(x: &[f64], y: &[f64]) -> R<(f64, f64, f64, f64, f64)> { - let mx = mean(x)?; - let my = mean(y)?; - let mut sxy = 0.0; - let mut sxx = 0.0; - let mut syy = 0.0; - for (a, b) in x.iter().zip(y) { - sxy += (a - mx) * (b - my); - sxx += (a - mx) * (a - mx); - syy += (b - my) * (b - my); - } - Ok((sxy, sxx, syy, mx, my)) -} - -fn correl(x: &[f64], y: &[f64]) -> R { - if x.len() < 2 { - return Err(CellError::Div0); - } - let (sxy, sxx, syy, _, _) = moments(x, y)?; - if sxx == 0.0 || syy == 0.0 { - return Err(CellError::Div0); - } - Ok(sxy / (sxx * syy).sqrt()) -} - -fn slope(y: &[f64], x: &[f64]) -> R { - let (sxy, sxx, _, _, _) = moments(x, y)?; - if sxx == 0.0 { - return Err(CellError::Div0); - } - Ok(sxy / sxx) -} - -fn intercept(y: &[f64], x: &[f64]) -> R { - let (sxy, sxx, _, mx, my) = moments(x, y)?; - if sxx == 0.0 { - return Err(CellError::Div0); - } - Ok(my - sxy / sxx * mx) -} - -fn trunc_digits(r: f64, sig: f64) -> f64 { - let f = 10f64.powi(sig as i32); - if !f.is_finite() { - return r; - } - ((r * f) + 1e-9).floor() / f -} - -fn col_value(v: Vec) -> R { - let n = v.len(); - array_val(n, 1, v.into_iter().map(Value::Number).collect()) -} - -// --------------------------------------------------------------------------------------------- -// Aggregates -// --------------------------------------------------------------------------------------------- - -fn count_impl(a: &[Arg]) -> f64 { - let mut c = 0usize; - for x in a { - match &x.value { - Value::Array(arr) => c += arr.iter().filter(|v| v.is_number()).count(), - Value::Number(_) => c += 1, - _ if x.from_ref => {} - Value::Bool(_) => c += 1, - Value::Text(t) if gridcraft_core::parse::parse_number_text(t).is_some() => c += 1, - _ => {} - } - } - c as f64 -} - -fn counta_impl(a: &[Arg]) -> f64 { - let mut c = 0usize; - for x in a { - match &x.value { - Value::Array(arr) => c += arr.iter().filter(|v| !v.is_empty()).count(), - Value::Empty if x.from_ref => {} - _ => c += 1, - } - } - c as f64 -} - -fn countblank(a: &[Arg], _: C) -> R { - let v = &arg(a, 0)?.value; - let blank = |x: &Value| matches!(x, Value::Empty) || matches!(x, Value::Text(t) if t.is_empty()); - let c = match v { - Value::Array(arr) => arr.iter().filter(|x| blank(x)).count(), - x => usize::from(blank(x)), - }; - num_val(c as f64) -} - -fn mode_mult(a: &[Arg], _: C) -> R { - let v = mode_numbers(a)?; - let m = modes(&v); - if m.is_empty() { - return Err(CellError::NA); - } - col_value(m) -} - -fn large(a: &[Arg], _: C) -> R { - num_val(kth(arg_nums(a, 0)?, num(a, 1)?, true)?) -} - -fn small(a: &[Arg], _: C) -> R { - num_val(kth(arg_nums(a, 0)?, num(a, 1)?, false)?) -} - -fn rank_impl(a: &[Arg], avg: bool) -> R { - let x = num(a, 0)?; - let v = arg_nums(a, 1)?; - let asc = opt_num(a, 2, 0.0)? != 0.0; - let eq = v.iter().filter(|&&y| y == x).count(); - if eq == 0 { - return Err(CellError::NA); - } - let before = v.iter().filter(|&&y| if asc { y < x } else { y > x }).count(); - let r = before as f64 + 1.0; - num_val(if avg { r + (eq as f64 - 1.0) / 2.0 } else { r }) -} - -fn percentrank_impl(a: &[Arg], exc: bool) -> R { - let s = sorted(arg_nums(a, 0)?); - let x = num(a, 1)?; - let sig = opt_num(a, 2, 3.0)?.trunc(); - if sig < 1.0 || s.is_empty() { - return Err(CellError::Num); - } - let (first, last) = (s.first().copied().unwrap_or(0.0), s.last().copied().unwrap_or(0.0)); - if x < first || x > last { - return Err(CellError::NA); - } - let n = s.len() as f64; - let rank_of = |v: f64| -> f64 { - let less = s.iter().filter(|&&y| y < v).count() as f64; - if exc { - (less + 1.0) / (n + 1.0) - } else if n <= 1.0 { - 1.0 - } else { - less / (n - 1.0) - } - }; - let r = if s.contains(&x) { - rank_of(x) - } else { - let lo = s.iter().copied().filter(|&y| y < x).fold(f64::NEG_INFINITY, f64::max); - let hi = s.iter().copied().filter(|&y| y > x).fold(f64::INFINITY, f64::min); - let (rl, rh) = (rank_of(lo), rank_of(hi)); - rl + (x - lo) / (hi - lo) * (rh - rl) - }; - num_val(trunc_digits(r, sig)) -} - -fn trimmean(a: &[Arg], _: C) -> R { - let v = arg_nums(a, 0)?; - let p = num(a, 1)?; - if !(0.0..1.0).contains(&p) || v.is_empty() { - return Err(CellError::Num); - } - let n = v.len(); - let per_side = ((n as f64 * p) / 2.0).floor() as usize; - let s = sorted(v); - let kept = s.get(per_side..n - per_side).unwrap_or(&[]); - num_val(mean(kept)?) -} - -fn kurt(v: &[f64]) -> R { - let n = v.len() as f64; - if v.len() < 4 { - return Err(CellError::Div0); - } - let m = mean(v)?; - let s = var_s(v)?.sqrt(); - if s == 0.0 { - return Err(CellError::Div0); - } - let sum4: f64 = v.iter().map(|x| ((x - m) / s).powi(4)).sum(); - Ok(n * (n + 1.0) / ((n - 1.0) * (n - 2.0) * (n - 3.0)) * sum4 - 3.0 * (n - 1.0).powi(2) / ((n - 2.0) * (n - 3.0))) -} - -fn skew(v: &[f64]) -> R { - let n = v.len() as f64; - if v.len() < 3 { - return Err(CellError::Div0); - } - let m = mean(v)?; - let s = var_s(v)?.sqrt(); - if s == 0.0 { - return Err(CellError::Div0); - } - let sum3: f64 = v.iter().map(|x| ((x - m) / s).powi(3)).sum(); - Ok(n / ((n - 1.0) * (n - 2.0)) * sum3) -} - -fn skew_p(v: &[f64]) -> R { - let m = mean(v)?; - let s = var_p(v)?.sqrt(); - if s == 0.0 { - return Err(CellError::Div0); - } - Ok(v.iter().map(|x| ((x - m) / s).powi(3)).sum::() / v.len() as f64) -} - -fn geomean(v: &[f64]) -> R { - if v.is_empty() || v.iter().any(|&x| x <= 0.0) { - return Err(CellError::Num); - } - Ok((v.iter().map(|x| x.ln()).sum::() / v.len() as f64).exp()) -} - -fn harmean(v: &[f64]) -> R { - if v.is_empty() || v.iter().any(|&x| x <= 0.0) { - return Err(CellError::Num); - } - Ok(v.len() as f64 / v.iter().map(|x| 1.0 / x).sum::()) -} - -fn avedev(v: &[f64]) -> R { - if v.is_empty() { - return Err(CellError::Num); - } - let m = mean(v)?; - Ok(v.iter().map(|x| (x - m).abs()).sum::() / v.len() as f64) -} - -fn frequency(a: &[Arg], _: C) -> R { - let data = arg_nums(a, 0)?; - let bins = arg_nums(a, 1)?; - if bins.is_empty() { - return col_value(vec![data.len() as f64]); - } - let mut order: Vec = (0..bins.len()).collect(); - order.sort_by(|&i, &j| bins.get(i).copied().unwrap_or(0.0).total_cmp(&bins.get(j).copied().unwrap_or(0.0))); - let mut counts = vec![0.0; bins.len() + 1]; - for x in data { - let slot = order.iter().find(|&&i| x <= bins.get(i).copied().unwrap_or(f64::INFINITY)).copied().unwrap_or(bins.len()); - if let Some(c) = counts.get_mut(slot) { - *c += 1.0; - } - } - col_value(counts) -} - -fn prob(a: &[Arg], _: C) -> R { - let (xs, ps) = arg_pairs(a, 0, 1)?; - let lo = num(a, 2)?; - let hi = if has(a, 3) { num(a, 3)? } else { lo }; - if ps.iter().any(|&p| p <= 0.0 || p > 1.0) { - return Err(CellError::Num); - } - let total: f64 = ps.iter().sum(); - if (total - 1.0).abs() > 1e-7 { - return Err(CellError::Num); - } - num_val(xs.iter().zip(&ps).filter(|(x, _)| **x >= lo && **x <= hi).map(|(_, p)| p).sum()) -} - -fn standardize(a: &[Arg], _: C) -> R { - let (x, m, sd) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - if sd <= 0.0 { - return Err(CellError::Num); - } - num_val((x - m) / sd) -} - -fn forecast(a: &[Arg], _: C) -> R { - let x = num(a, 0)?; - let (ys, xs) = arg_pairs(a, 1, 2)?; - if ys.is_empty() { - return Err(CellError::Div0); - } - num_val(intercept(&ys, &xs)? + slope(&ys, &xs)? * x) -} - -fn steyx(a: &[Arg], _: C) -> R { - let (ys, xs) = arg_pairs(a, 0, 1)?; - let n = ys.len() as f64; - if ys.len() < 3 { - return Err(CellError::Div0); - } - let (sxy, sxx, syy, _, _) = moments(&xs, &ys)?; - if sxx == 0.0 { - return Err(CellError::Div0); - } - num_val(((syy - sxy * sxy / sxx) / (n - 2.0)).max(0.0).sqrt()) -} - -fn covariance(a: &[Arg], sample: bool) -> R { - let (xs, ys) = arg_pairs(a, 0, 1)?; - let n = xs.len(); - if n == 0 || (sample && n < 2) { - return Err(CellError::Div0); - } - let (sxy, ..) = moments(&xs, &ys)?; - num_val(sxy / if sample { (n - 1) as f64 } else { n as f64 }) -} - -// --------------------------------------------------------------------------------------------- -// Regression (LINEST, LOGEST, TREND, GROWTH) -// --------------------------------------------------------------------------------------------- - -struct Xy { - y: Vec, - /// n rows of k predictor values. - x: Vec>, - k: usize, - y_is_row: bool, - y_rows: usize, - y_cols: usize, -} - -fn strict_num(v: &Value) -> R { - match v { - Value::Number(n) => Ok(*n), - Value::Error(e) => Err(*e), - _ => Err(CellError::Value), - } -} - -/// Most predictor columns LINEST, LOGEST, TREND and GROWTH accept. The normal equations are a -/// k×k matrix inverted in O(k³), so an unbounded k (one `SEQUENCE(1,16384)` argument) allocated -/// gigabytes and could abort the process. -const MAX_REGRESSORS: usize = 255; -/// Most multiply-adds (rows × k²) a regression may spend building its normal equations. -const MAX_REGRESSION_WORK: usize = 1_000_000_000; - -/// Rejects regressions too large to fit: more than [`MAX_REGRESSORS`] predictors is `#VALUE!`, -/// more than [`MAX_REGRESSION_WORK`] multiply-adds is `#NUM!`. -fn check_regression_size(n: usize, k: usize) -> R<()> { - if k > MAX_REGRESSORS { - return Err(CellError::Value); - } - match n.checked_mul(k).and_then(|nk| nk.checked_mul(k)) { - Some(work) if work <= MAX_REGRESSION_WORK => Ok(()), - _ => Err(CellError::Num), - } -} - -fn parse_xy(y: &Value, x: Option<&Value>) -> R { - let ya = as_array(y); - let n = ya.data.len(); - let y_is_row = ya.rows == 1 && ya.cols > 1; - let yv: Vec = ya.data.iter().map(strict_num).collect::>()?; - let (xs, k) = match x { - None => ((1..=n).map(|i| vec![i as f64]).collect(), 1), - Some(xv) => { - let xa = as_array(xv); - if xa.rows == ya.rows && xa.cols == ya.cols { - (xa.data.iter().map(|v| strict_num(v).map(|f| vec![f])).collect::>>()?, 1) - } else if !y_is_row && xa.rows == n { - check_regression_size(n, xa.cols)?; - let mut rows = Vec::with_capacity(n); - for r in 0..n { - rows.push((0..xa.cols).map(|c| strict_num(xa.get(r, c).unwrap_or(&Value::Empty))).collect::>>()?); - } - (rows, xa.cols) - } else if y_is_row && xa.cols == n { - check_regression_size(n, xa.rows)?; - let mut rows = Vec::with_capacity(n); - for c in 0..n { - rows.push((0..xa.rows).map(|r| strict_num(xa.get(r, c).unwrap_or(&Value::Empty))).collect::>>()?); - } - (rows, xa.rows) - } else { - return Err(CellError::Ref); - } - } - }; - Ok(Xy { y: yv, x: xs, k, y_is_row, y_rows: ya.rows, y_cols: ya.cols }) -} - -struct Fit { - coef: Vec, - b: f64, - se: Vec, - se_b: Option, - r2: R, - sey: R, - f: R, - df: f64, - ssreg: f64, - ssresid: f64, -} - -/// Inverts a symmetric positive semi-definite matrix by Gauss–Jordan on the diagonal, dropping -/// (zeroing) collinear variables. Returns the inverse and the dropped flags. -fn sym_inverse(m: &[Vec]) -> (Vec>, Vec) { - let p = m.len(); - let mut a: Vec> = m.to_vec(); - let mut inv: Vec> = (0..p).map(|i| (0..p).map(|j| if i == j { 1.0 } else { 0.0 }).collect()).collect(); - let mut dropped = vec![false; p]; - let diag: Vec = (0..p).map(|i| m.get(i).and_then(|r| r.get(i)).copied().unwrap_or(0.0).abs()).collect(); - for j in 0..p { - let piv = a.get(j).and_then(|r| r.get(j)).copied().unwrap_or(0.0); - let scale = diag.get(j).copied().unwrap_or(0.0); - if piv.abs() <= 1e-12 * scale || piv.abs() < 1e-300 { - if let Some(d) = dropped.get_mut(j) { - *d = true; - } - for row in a.iter_mut() { - if let Some(v) = row.get_mut(j) { - *v = 0.0; - } - } - if let Some(r) = a.get_mut(j) { - r.iter_mut().for_each(|v| *v = 0.0); - } - if let Some(r) = inv.get_mut(j) { - r.iter_mut().for_each(|v| *v = 0.0); - } - continue; - } - let arow: Vec = a.get(j).map(|r| r.iter().map(|v| v / piv).collect()).unwrap_or_default(); - let irow: Vec = inv.get(j).map(|r| r.iter().map(|v| v / piv).collect()).unwrap_or_default(); - for i in 0..p { - if i == j { - continue; - } - let f = a.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0); - if f == 0.0 { - continue; - } - if let Some(r) = a.get_mut(i) { - r.iter_mut().zip(&arow).for_each(|(v, w)| *v -= f * w); - } - if let Some(r) = inv.get_mut(i) { - r.iter_mut().zip(&irow).for_each(|(v, w)| *v -= f * w); - } - } - if let Some(r) = a.get_mut(j) { - *r = arow; - } - if let Some(r) = inv.get_mut(j) { - *r = irow; - } - } - (inv, dropped) -} - -fn regress(y: &[f64], x: &[Vec], k: usize, constant: bool) -> R { - let n = y.len(); - if n == 0 || x.len() != n { - return Err(CellError::Value); - } - check_regression_size(n, k)?; - let nf = n as f64; - let (xm, ym) = if constant { - let xm: Vec = (0..k).map(|j| x.iter().map(|r| r.get(j).copied().unwrap_or(0.0)).sum::() / nf).collect(); - (xm, y.iter().sum::() / nf) - } else { - (vec![0.0; k], 0.0) - }; - // Normal equations on (centred) data. - let mut xtx = vec![vec![0.0; k]; k]; - let mut xty = vec![0.0; k]; - for (row, yv) in x.iter().zip(y) { - let d: Vec = (0..k).map(|j| row.get(j).copied().unwrap_or(0.0) - xm.get(j).copied().unwrap_or(0.0)).collect(); - let yd = yv - ym; - for (i, di) in d.iter().enumerate() { - if let Some(t) = xty.get_mut(i) { - *t += di * yd; - } - if let Some(r) = xtx.get_mut(i) { - for (j, dj) in d.iter().enumerate() { - if let Some(v) = r.get_mut(j) { - *v += di * dj; - } - } - } - } - } - let (inv, dropped) = sym_inverse(&xtx); - let coef: Vec = (0..k).map(|i| inv.get(i).map(|r| r.iter().zip(&xty).map(|(a, b)| a * b).sum()).unwrap_or(0.0)).collect(); - let b = if constant { ym - coef.iter().zip(&xm).map(|(c, m)| c * m).sum::() } else { 0.0 }; - let mut ssresid = 0.0; - let mut sstot = 0.0; - for (row, yv) in x.iter().zip(y) { - let pred = b + coef.iter().zip(row).map(|(c, v)| c * v).sum::(); - ssresid += (yv - pred).powi(2); - sstot += if constant { (yv - ym).powi(2) } else { yv * yv }; - } - let used = dropped.iter().filter(|d| !**d).count() + usize::from(constant); - let df = nf - used as f64; - let ssreg = sstot - ssresid; - let r2 = if sstot == 0.0 { Err(CellError::Num) } else { Ok(ssreg / sstot) }; - let sey2 = if df > 0.0 { Ok(ssresid / df) } else { Err(CellError::Num) }; - let sey = sey2.map(f64::sqrt); - let v1 = if constant { nf - df - 1.0 } else { nf - df }; - let f = match sey2 { - Ok(s2) if s2 > 0.0 && v1 > 0.0 => Ok((ssreg / v1) / s2), - _ => Err(CellError::Num), - }; - let s2 = sey2.unwrap_or(f64::NAN); - let se: Vec = (0..k) - .map(|i| if dropped.get(i).copied().unwrap_or(true) { 0.0 } else { (inv.get(i).and_then(|r| r.get(i)).copied().unwrap_or(0.0) * s2).sqrt() }) - .collect(); - let se_b = if constant { - let mut q = 0.0; - for (i, mi) in xm.iter().enumerate() { - for (j, mj) in xm.iter().enumerate() { - q += mi * mj * inv.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0); - } - } - Some((s2 * (1.0 / nf + q)).sqrt()) - } else { - None - }; - Ok(Fit { coef, b, se, se_b, r2, sey, f, df, ssreg, ssresid }) -} - -fn const_arg(a: &[Arg], i: usize) -> R { - if has(a, i) { boolean(a, i) } else { Ok(true) } -} - -fn opt_value(a: &[Arg], i: usize) -> Option<&Value> { - if has(a, i) { a.get(i).map(|x| &x.value) } else { None } -} - -fn linest_impl(a: &[Arg], log: bool) -> R { - let mut xy = parse_xy(&arg(a, 0)?.value, opt_value(a, 1))?; - if log { - if xy.y.iter().any(|&v| v <= 0.0) { - return Err(CellError::Num); - } - xy.y.iter_mut().for_each(|v| *v = v.ln()); - } - let constant = const_arg(a, 2)?; - let stats = if has(a, 3) { boolean(a, 3)? } else { false }; - let fit = regress(&xy.y, &xy.x, xy.k, constant)?; - let tr = |v: f64| if log { v.exp() } else { v }; - let cols = xy.k + 1; - let mut data: Vec = Vec::new(); - for c in fit.coef.iter().rev() { - data.push(Value::number(tr(*c))); - } - data.push(Value::number(tr(fit.b))); - if stats { - let rv = |r: R| r.map(Value::number).unwrap_or_else(Value::Error); - let na = Value::Error(CellError::NA); - for s in fit.se.iter().rev() { - data.push(Value::number(*s)); - } - data.push(fit.se_b.map(Value::number).unwrap_or(na.clone())); - let rows: [[Value; 2]; 3] = - [[rv(fit.r2), rv(fit.sey)], [rv(fit.f), Value::number(fit.df)], [Value::number(fit.ssreg), Value::number(fit.ssresid)]]; - for [p, q] in rows { - data.push(p); - if cols > 1 { - data.push(q); - } - for _ in 2..cols { - data.push(na.clone()); - } - } - return array_val(5, cols, data); - } - array_val(1, cols, data) -} - -fn trend_impl(a: &[Arg], log: bool) -> R { - let mut xy = parse_xy(&arg(a, 0)?.value, opt_value(a, 1))?; - if log { - if xy.y.iter().any(|&v| v <= 0.0) { - return Err(CellError::Num); - } - xy.y.iter_mut().for_each(|v| *v = v.ln()); - } - let constant = const_arg(a, 3)?; - let fit = regress(&xy.y, &xy.x, xy.k, constant)?; - let predict = |row: &[f64]| -> Value { - let v = fit.b + fit.coef.iter().zip(row).map(|(c, x)| c * x).sum::(); - Value::number(if log { v.exp() } else { v }) - }; - match opt_value(a, 2) { - None => { - let data: Vec = xy.x.iter().map(|r| predict(r)).collect(); - array_val(xy.y_rows, xy.y_cols, data) - } - Some(nv) => { - let na = as_array(nv); - if xy.k == 1 { - let data = na.data.iter().map(|v| strict_num(v).map(|x| predict(&[x]))).collect::>>()?; - array_val(na.rows, na.cols, data) - } else if !xy.y_is_row && na.cols == xy.k { - let mut data = Vec::with_capacity(na.rows); - for r in 0..na.rows { - let row = (0..na.cols).map(|c| strict_num(na.get(r, c).unwrap_or(&Value::Empty))).collect::>>()?; - data.push(predict(&row)); - } - array_val(na.rows, 1, data) - } else if xy.y_is_row && na.rows == xy.k { - let mut data = Vec::with_capacity(na.cols); - for c in 0..na.cols { - let row = (0..na.rows).map(|r| strict_num(na.get(r, c).unwrap_or(&Value::Empty))).collect::>>()?; - data.push(predict(&row)); - } - array_val(1, na.cols, data) - } else { - Err(CellError::Ref) - } - } - } -} - -// --------------------------------------------------------------------------------------------- -// Distributions -// --------------------------------------------------------------------------------------------- - -fn ln_comb(n: f64, k: f64) -> f64 { - ln_gamma(n + 1.0) - ln_gamma(k + 1.0) - ln_gamma(n - k + 1.0) -} - -fn t_upper(t: f64, v: f64) -> f64 { - // P(T > t) for t ≥ 0. - 0.5 * reg_inc_beta(v / (v + t * t), v / 2.0, 0.5) -} - -fn t_cdf(t: f64, v: f64) -> f64 { - if t >= 0.0 { 1.0 - t_upper(t, v) } else { t_upper(-t, v) } -} - -fn t_pdf(t: f64, v: f64) -> f64 { - (ln_gamma((v + 1.0) / 2.0) - ln_gamma(v / 2.0) - 0.5 * (v * std::f64::consts::PI).ln() - (v + 1.0) / 2.0 * (1.0 + t * t / v).ln()).exp() -} - -/// t ≥ 0 with P(T > t) = q, q in (0, 0.5]. -fn t_inv_upper(q: f64, v: f64) -> f64 { - if q >= 0.5 { - return 0.0; - } - invert(|t| t_upper(t, v), q, 0.0, 1.0, 1e60) -} - -fn chi_inv(p: f64, v: f64) -> f64 { - if p <= 0.0 { - return 0.0; - } - invert(|x| reg_gamma_p(v / 2.0, x / 2.0), p, 0.0, v.max(1.0), 1e60) -} - -fn chi_inv_rt(q: f64, v: f64) -> f64 { - if q >= 1.0 { - return 0.0; - } - invert(|x| reg_gamma_q(v / 2.0, x / 2.0), q, 0.0, v.max(1.0), 1e60) -} - -fn f_cdf(x: f64, d1: f64, d2: f64) -> f64 { - if x <= 0.0 { - return 0.0; - } - reg_inc_beta(d1 * x / (d1 * x + d2), d1 / 2.0, d2 / 2.0) -} - -fn f_upper(x: f64, d1: f64, d2: f64) -> f64 { - if x <= 0.0 { - return 1.0; - } - reg_inc_beta(d2 / (d2 + d1 * x), d2 / 2.0, d1 / 2.0) -} - -fn f_pdf(x: f64, d1: f64, d2: f64) -> f64 { - if x == 0.0 { - return if d1 < 2.0 { - f64::INFINITY - } else if d1 == 2.0 { - 1.0 - } else { - 0.0 - }; - } - ((d1 / 2.0) * (d1 / d2).ln() + (d1 / 2.0 - 1.0) * x.ln() - ((d1 + d2) / 2.0) * (1.0 + d1 * x / d2).ln() - ln_beta(d1 / 2.0, d2 / 2.0)).exp() -} - -fn binom_pmf(k: f64, n: f64, p: f64) -> f64 { - if k < 0.0 || k > n { - return 0.0; - } - if p == 0.0 { - return if k == 0.0 { 1.0 } else { 0.0 }; - } - if p == 1.0 { - return if k == n { 1.0 } else { 0.0 }; - } - (ln_comb(n, k) + k * p.ln() + (n - k) * (1.0 - p).ln()).exp() -} - -fn binom_cdf(k: f64, n: f64, p: f64) -> f64 { - if k < 0.0 { - return 0.0; - } - if k >= n { - return 1.0; - } - if n <= 10_000.0 { - let mut s = 0.0; - let mut i = 0.0; - while i <= k { - s += binom_pmf(i, n, p); - i += 1.0; - } - return s.min(1.0); - } - if p == 0.0 { - return 1.0; - } - if p == 1.0 { - return 0.0; - } - reg_inc_beta(1.0 - p, n - k, k + 1.0) -} - -fn poisson_pmf(x: f64, m: f64) -> f64 { - if m == 0.0 { - return if x == 0.0 { 1.0 } else { 0.0 }; - } - (-m + x * m.ln() - ln_gamma(x + 1.0)).exp() -} - -fn poisson_cdf(x: f64, m: f64) -> f64 { - if m == 0.0 { - return 1.0; - } - if x <= 1000.0 { - let mut s = 0.0; - let mut i = 0.0; - while i <= x { - s += poisson_pmf(i, m); - i += 1.0; - } - return s.min(1.0); - } - reg_gamma_q(x + 1.0, m) -} - -fn norm_dist(a: &[Arg], _: C) -> R { - let (x, m, sd, cum) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, boolean(a, 3)?); - if sd <= 0.0 { - return Err(CellError::Num); - } - let z = (x - m) / sd; - num_val(if cum { norm_cdf(z) } else { norm_pdf(z) / sd }) -} - -fn norm_inv_fn(a: &[Arg], _: C) -> R { - let (p, m, sd) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - if !(p > 0.0 && p < 1.0) || sd <= 0.0 { - return Err(CellError::Num); - } - num_val(m + sd * norm_inv(p)) -} - -fn norm_s_dist(a: &[Arg], _: C) -> R { - let z = num(a, 0)?; - let cum = if a.len() > 1 { boolean(a, 1)? } else { true }; - num_val(if cum { norm_cdf(z) } else { norm_pdf(z) }) -} - -fn norm_s_inv(a: &[Arg], _: C) -> R { - let p = num(a, 0)?; - if !(p > 0.0 && p < 1.0) { - return Err(CellError::Num); - } - num_val(norm_inv(p)) -} - -fn df_arg(a: &[Arg], i: usize) -> R { - let v = num(a, i)?.trunc(); - if v < 1.0 { - return Err(CellError::Num); - } - Ok(v) -} - -fn t_dist(a: &[Arg], _: C) -> R { - let x = num(a, 0)?; - let v = df_arg(a, 1)?; - let cum = boolean(a, 2)?; - num_val(if cum { t_cdf(x, v) } else { t_pdf(x, v) }) -} - -fn t_dist_2t(a: &[Arg], _: C) -> R { - let x = num(a, 0)?; - let v = df_arg(a, 1)?; - if x < 0.0 { - return Err(CellError::Num); - } - num_val(2.0 * t_upper(x, v)) -} - -fn t_dist_rt(a: &[Arg], _: C) -> R { - let x = num(a, 0)?; - let v = df_arg(a, 1)?; - num_val(1.0 - t_cdf(x, v)) -} - -fn tdist_legacy(a: &[Arg], _: C) -> R { - let x = num(a, 0)?; - let v = df_arg(a, 1)?; - let tails = num(a, 2)?.trunc(); - if x < 0.0 || !(tails == 1.0 || tails == 2.0) { - return Err(CellError::Num); - } - num_val(tails * t_upper(x, v)) -} - -fn t_inv(a: &[Arg], _: C) -> R { - let p = num(a, 0)?; - let v = df_arg(a, 1)?; - if !(p > 0.0 && p < 1.0) { - return Err(CellError::Num); - } - num_val(if p < 0.5 { -t_inv_upper(p, v) } else { t_inv_upper(1.0 - p, v) }) -} - -fn t_inv_2t(a: &[Arg], _: C) -> R { - let p = num(a, 0)?; - let v = df_arg(a, 1)?; - if !(p > 0.0 && p <= 1.0) { - return Err(CellError::Num); - } - num_val(t_inv_upper(p / 2.0, v)) -} - -fn t_test(a: &[Arg], _: C) -> R { - let tails = num(a, 2)?.trunc(); - let ty = num(a, 3)?.trunc(); - if !(tails == 1.0 || tails == 2.0) || !(1.0..=3.0).contains(&ty) { - return Err(CellError::Num); - } - let (t, v) = if ty == 1.0 { - let (x, y) = arg_pairs(a, 0, 1)?; - let d: Vec = x.iter().zip(&y).map(|(p, q)| p - q).collect(); - let n = d.len() as f64; - let sd = var_s(&d)?.sqrt(); - if sd == 0.0 { - return Err(CellError::Div0); - } - (mean(&d)? / (sd / n.sqrt()), n - 1.0) - } else { - let x = arg_nums(a, 0)?; - let y = arg_nums(a, 1)?; - let (n1, n2) = (x.len() as f64, y.len() as f64); - let (v1, v2) = (var_s(&x)?, var_s(&y)?); - let diff = mean(&x)? - mean(&y)?; - if ty == 2.0 { - let df = n1 + n2 - 2.0; - let sp = ((n1 - 1.0) * v1 + (n2 - 1.0) * v2) / df; - let se = (sp * (1.0 / n1 + 1.0 / n2)).sqrt(); - if se == 0.0 { - return Err(CellError::Div0); - } - (diff / se, df) - } else { - let (a1, a2) = (v1 / n1, v2 / n2); - let se = (a1 + a2).sqrt(); - if se == 0.0 { - return Err(CellError::Div0); - } - let df = (a1 + a2).powi(2) / (a1 * a1 / (n1 - 1.0) + a2 * a2 / (n2 - 1.0)); - (diff / se, df) - } - }; - num_val(tails * t_upper(t.abs(), v)) -} - -fn chisq_dist(a: &[Arg], _: C) -> R { - let x = num(a, 0)?; - let v = df_arg(a, 1)?; - let cum = boolean(a, 2)?; - if x < 0.0 || v > 1e10 { - return Err(CellError::Num); - } - if cum { - return num_val(reg_gamma_p(v / 2.0, x / 2.0)); - } - if x == 0.0 { - return if v == 2.0 { - num_val(0.5) - } else if v < 2.0 { - Err(CellError::Num) - } else { - num_val(0.0) - }; - } - num_val(((v / 2.0 - 1.0) * x.ln() - x / 2.0 - (v / 2.0) * 2f64.ln() - ln_gamma(v / 2.0)).exp()) -} - -fn chisq_dist_rt(a: &[Arg], _: C) -> R { - let x = num(a, 0)?; - let v = df_arg(a, 1)?; - if x < 0.0 || v > 1e10 { - return Err(CellError::Num); - } - num_val(reg_gamma_q(v / 2.0, x / 2.0)) -} - -fn chisq_inv(a: &[Arg], _: C) -> R { - let p = num(a, 0)?; - let v = df_arg(a, 1)?; - if !(0.0..1.0).contains(&p) || v > 1e10 { - return Err(CellError::Num); - } - num_val(chi_inv(p, v)) -} - -fn chisq_inv_rt(a: &[Arg], _: C) -> R { - let p = num(a, 0)?; - let v = df_arg(a, 1)?; - if !(p > 0.0 && p <= 1.0) || v > 1e10 { - return Err(CellError::Num); - } - num_val(chi_inv_rt(p, v)) -} - -fn chisq_test(a: &[Arg], _: C) -> R { - let act = as_array(&arg(a, 0)?.value).into_owned(); - let exp = as_array(&arg(a, 1)?.value).into_owned(); - if act.rows != exp.rows || act.cols != exp.cols { - return Err(CellError::NA); - } - let mut x2 = 0.0; - for (o, e) in act.data.iter().zip(&exp.data) { - if let Value::Error(err) = o { - return Err(*err); - } - if let Value::Error(err) = e { - return Err(*err); - } - if let (Value::Number(o), Value::Number(e)) = (o, e) { - if *e == 0.0 { - return Err(CellError::Div0); - } - x2 += (o - e).powi(2) / e; - } - } - let df = if act.rows > 1 && act.cols > 1 { ((act.rows - 1) * (act.cols - 1)) as f64 } else { (act.data.len().saturating_sub(1)) as f64 }; - if df < 1.0 { - return Err(CellError::NA); - } - num_val(reg_gamma_q(df / 2.0, x2 / 2.0)) -} - -fn f_args(a: &[Arg]) -> R<(f64, f64, f64)> { - let x = num(a, 0)?; - let d1 = num(a, 1)?.trunc(); - let d2 = num(a, 2)?.trunc(); - if d1 < 1.0 || d2 < 1.0 || d1 >= 1e10 || d2 >= 1e10 { - return Err(CellError::Num); - } - Ok((x, d1, d2)) -} - -fn f_dist(a: &[Arg], _: C) -> R { - let (x, d1, d2) = f_args(a)?; - let cum = boolean(a, 3)?; - if x < 0.0 { - return Err(CellError::Num); - } - num_val(if cum { f_cdf(x, d1, d2) } else { f_pdf(x, d1, d2) }) -} - -fn f_dist_rt(a: &[Arg], _: C) -> R { - let (x, d1, d2) = f_args(a)?; - if x < 0.0 { - return Err(CellError::Num); - } - num_val(f_upper(x, d1, d2)) -} - -fn f_inv(a: &[Arg], _: C) -> R { - let (p, d1, d2) = f_args(a)?; - if !(0.0..1.0).contains(&p) { - return Err(CellError::Num); - } - if p == 0.0 { - return num_val(0.0); - } - num_val(invert(|x| f_cdf(x, d1, d2), p, 0.0, 1.0, 1e60)) -} - -fn f_inv_rt(a: &[Arg], _: C) -> R { - let (p, d1, d2) = f_args(a)?; - if !(p > 0.0 && p <= 1.0) { - return Err(CellError::Num); - } - if p == 1.0 { - return num_val(0.0); - } - num_val(invert(|x| f_upper(x, d1, d2), p, 0.0, 1.0, 1e60)) -} - -fn f_test(a: &[Arg], _: C) -> R { - let x = arg_nums(a, 0)?; - let y = arg_nums(a, 1)?; - let (v1, v2) = (var_s(&x)?, var_s(&y)?); - if v1 == 0.0 || v2 == 0.0 { - return Err(CellError::Div0); - } - let f = v1 / v2; - let (d1, d2) = ((x.len() - 1) as f64, (y.len() - 1) as f64); - let c = f_cdf(f, d1, d2); - num_val((2.0 * c.min(1.0 - c)).min(1.0)) -} - -fn binom_dist(a: &[Arg], _: C) -> R { - let k = num(a, 0)?.trunc(); - let n = num(a, 1)?.trunc(); - let p = num(a, 2)?; - let cum = boolean(a, 3)?; - if k < 0.0 || k > n || !(0.0..=1.0).contains(&p) { - return Err(CellError::Num); - } - num_val(if cum { binom_cdf(k, n, p) } else { binom_pmf(k, n, p) }) -} - -fn binom_dist_range(a: &[Arg], _: C) -> R { - let n = num(a, 0)?.trunc(); - let p = num(a, 1)?; - let s1 = num(a, 2)?.trunc(); - let s2 = if has(a, 3) { num(a, 3)?.trunc() } else { s1 }; - if !(0.0..=1e15).contains(&n) || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n { - return Err(CellError::Num); - } - if s2 - s1 > 10_000.0 { - return num_val(binom_cdf(s2, n, p) - binom_cdf(s1 - 1.0, n, p)); - } - let mut s = 0.0; - let mut k = s1; - while k <= s2 { - s += binom_pmf(k, n, p); - k += 1.0; - } - num_val(s) -} - -fn binom_inv(a: &[Arg], _: C) -> R { - let n = num(a, 0)?.trunc(); - let p = num(a, 1)?; - let alpha = num(a, 2)?; - if n < 0.0 || !(0.0..=1.0).contains(&p) || !(0.0..=1.0).contains(&alpha) { - return Err(CellError::Num); - } - // Smallest k with cdf(k) ≥ alpha, by bisection on the integer k. - let (mut lo, mut hi) = (0.0f64, n); - if binom_cdf(0.0, n, p) >= alpha { - return num_val(0.0); - } - while hi - lo > 1.0 { - let mid = ((lo + hi) / 2.0).floor(); - // Above 2^53 adjacent f64 values are more than 1 apart, so the gap can stop shrinking. - if mid <= lo || mid >= hi { - break; - } - if binom_cdf(mid, n, p) >= alpha { - hi = mid; - } else { - lo = mid; - } - } - num_val(hi) -} - -fn poisson_dist(a: &[Arg], _: C) -> R { - let x = num(a, 0)?.trunc(); - let m = num(a, 1)?; - let cum = boolean(a, 2)?; - if x < 0.0 || m < 0.0 { - return Err(CellError::Num); - } - num_val(if cum { poisson_cdf(x, m) } else { poisson_pmf(x, m) }) -} - -fn expon_dist(a: &[Arg], _: C) -> R { - let (x, l, cum) = (num(a, 0)?, num(a, 1)?, boolean(a, 2)?); - if x < 0.0 || l <= 0.0 { - return Err(CellError::Num); - } - num_val(if cum { -(-l * x).exp_m1() } else { l * (-l * x).exp() }) -} - -fn gamma_fn(a: &[Arg], _: C) -> R { - let x = num(a, 0)?; - let g = gamma(x); - if !g.is_finite() { - return Err(CellError::Num); - } - num_val(g) -} - -fn gamma_dist(a: &[Arg], _: C) -> R { - let (x, al, be, cum) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, boolean(a, 3)?); - if x < 0.0 || al <= 0.0 || be <= 0.0 { - return Err(CellError::Num); - } - if cum { - return num_val(reg_gamma_p(al, x / be)); - } - if x == 0.0 { - return if al < 1.0 { - Err(CellError::Num) - } else if al == 1.0 { - num_val(1.0 / be) - } else { - num_val(0.0) - }; - } - num_val(((al - 1.0) * x.ln() - x / be - al * be.ln() - ln_gamma(al)).exp()) -} - -fn gamma_inv(a: &[Arg], _: C) -> R { - let (p, al, be) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - if !(0.0..1.0).contains(&p) || al <= 0.0 || be <= 0.0 { - return Err(CellError::Num); - } - if p == 0.0 { - return num_val(0.0); - } - num_val(invert(|x| reg_gamma_p(al, x / be), p, 0.0, (al * be).max(1e-3), 1e60)) -} - -fn gammaln(a: &[Arg], _: C) -> R { - let x = num(a, 0)?; - if x <= 0.0 { - return Err(CellError::Num); - } - num_val(ln_gamma(x)) -} - -fn beta_bounds(a: &[Arg], i: usize) -> R<(f64, f64)> { - let lo = if has(a, i) { num(a, i)? } else { 0.0 }; - let hi = if has(a, i + 1) { num(a, i + 1)? } else { 1.0 }; - if lo >= hi { - return Err(CellError::Num); - } - Ok((lo, hi)) -} - -fn beta_dist(a: &[Arg], _: C) -> R { - let (x, al, be, cum) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, boolean(a, 3)?); - let (lo, hi) = beta_bounds(a, 4)?; - if al <= 0.0 || be <= 0.0 || x < lo || x > hi { - return Err(CellError::Num); - } - let z = (x - lo) / (hi - lo); - if cum { - return num_val(reg_inc_beta(z, al, be)); - } - if (z == 0.0 && al < 1.0) || (z == 1.0 && be < 1.0) { - return Err(CellError::Num); - } - let lp = if z == 0.0 || z == 1.0 { - let base = if z == 0.0 { al } else { be }; - if base == 1.0 { -ln_beta(al, be) } else { f64::NEG_INFINITY } - } else { - (al - 1.0) * z.ln() + (be - 1.0) * (1.0 - z).ln() - ln_beta(al, be) - }; - num_val(lp.exp() / (hi - lo)) -} - -fn betadist_legacy(a: &[Arg], _: C) -> R { - let (x, al, be) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - let (lo, hi) = beta_bounds(a, 3)?; - if al <= 0.0 || be <= 0.0 || x < lo || x > hi { - return Err(CellError::Num); - } - num_val(reg_inc_beta((x - lo) / (hi - lo), al, be)) -} - -fn beta_inv(a: &[Arg], _: C) -> R { - let (p, al, be) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - let (lo, hi) = beta_bounds(a, 3)?; - if al <= 0.0 || be <= 0.0 || !(0.0..=1.0).contains(&p) { - return Err(CellError::Num); - } - let z = if p == 0.0 { - 0.0 - } else if p == 1.0 { - 1.0 - } else { - invert(|x| reg_inc_beta(x, al, be), p, 0.0, 1.0, 1.0) - }; - num_val(lo + z * (hi - lo)) -} - -fn lognorm_dist(a: &[Arg], _: C) -> R { - let (x, m, sd) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - let cum = if a.len() > 3 { boolean(a, 3)? } else { true }; - if x <= 0.0 || sd <= 0.0 { - return Err(CellError::Num); - } - let z = (x.ln() - m) / sd; - num_val(if cum { norm_cdf(z) } else { norm_pdf(z) / (x * sd) }) -} - -fn lognorm_inv(a: &[Arg], _: C) -> R { - let (p, m, sd) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); - if !(p > 0.0 && p < 1.0) || sd <= 0.0 { - return Err(CellError::Num); - } - num_val((m + sd * norm_inv(p)).exp()) -} - -fn weibull_dist(a: &[Arg], _: C) -> R { - let (x, al, be, cum) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, boolean(a, 3)?); - if x < 0.0 || al <= 0.0 || be <= 0.0 { - return Err(CellError::Num); - } - let t = (x / be).powf(al); - num_val(if cum { -(-t).exp_m1() } else { al / be.powf(al) * x.powf(al - 1.0) * (-t).exp() }) -} - -fn hypgeom_dist(a: &[Arg], _: C) -> R { - let k = num(a, 0)?.trunc(); - let n = num(a, 1)?.trunc(); - let kk = num(a, 2)?.trunc(); - let nn = num(a, 3)?.trunc(); - let cum = if a.len() > 4 { boolean(a, 4)? } else { false }; - if k < 0.0 || n <= 0.0 || kk <= 0.0 || nn <= 0.0 || n > nn || kk > nn || k > n.min(kk) || k < (n - nn + kk).max(0.0) { - return Err(CellError::Num); - } - let pmf = |i: f64| (ln_comb(kk, i) + ln_comb(nn - kk, n - i) - ln_comb(nn, n)).exp(); - if !cum { - return num_val(pmf(k)); - } - let mut s = 0.0; - let mut i = (n - nn + kk).max(0.0); - if k - i > 1e6 || i + 1.0 == i { - return Err(CellError::Num); - } - while i <= k { - s += pmf(i); - i += 1.0; - } - num_val(s.min(1.0)) -} - -fn negbinom_dist(a: &[Arg], _: C) -> R { - let f = num(a, 0)?.trunc(); - let s = num(a, 1)?.trunc(); - let p = num(a, 2)?; - let cum = if a.len() > 3 { boolean(a, 3)? } else { false }; - if f < 0.0 || s < 1.0 || !(0.0..=1.0).contains(&p) { - return Err(CellError::Num); - } - if cum { - if p == 0.0 { - return num_val(0.0); - } - return num_val(reg_inc_beta(p, s, f + 1.0)); - } - if p == 0.0 || p == 1.0 { - return num_val(if p == 1.0 && f == 0.0 { 1.0 } else { 0.0 }); - } - num_val((ln_comb(f + s - 1.0, s - 1.0) + s * p.ln() + f * (1.0 - p).ln()).exp()) -} - -fn confidence_norm(a: &[Arg], _: C) -> R { - let (al, sd, n) = (num(a, 0)?, num(a, 1)?, num(a, 2)?.trunc()); - if !(al > 0.0 && al < 1.0) || sd <= 0.0 || n < 1.0 { - return Err(CellError::Num); - } - num_val(norm_inv(1.0 - al / 2.0) * sd / n.sqrt()) -} - -fn confidence_t(a: &[Arg], _: C) -> R { - let (al, sd, n) = (num(a, 0)?, num(a, 1)?, num(a, 2)?.trunc()); - if !(al > 0.0 && al < 1.0) || sd <= 0.0 || n < 1.0 { - return Err(CellError::Num); - } - if n == 1.0 { - return Err(CellError::Div0); - } - num_val(t_inv_upper(al / 2.0, n - 1.0) * sd / n.sqrt()) -} - -fn z_test(a: &[Arg], _: C) -> R { - let v = arg_nums(a, 0)?; - let x = num(a, 1)?; - if v.is_empty() { - return Err(CellError::NA); - } - let sd = if has(a, 2) { num(a, 2)? } else { var_s(&v)?.sqrt() }; - if sd <= 0.0 { - return Err(if has(a, 2) { CellError::Num } else { CellError::Div0 }); - } - let z = (mean(&v)? - x) / (sd / (v.len() as f64).sqrt()); - num_val(1.0 - norm_cdf(z)) -} - -fn fisher(a: &[Arg], _: C) -> R { - let x = num(a, 0)?; - if x <= -1.0 || x >= 1.0 { - return Err(CellError::Num); - } - num_val(0.5 * ((1.0 + x) / (1.0 - x)).ln()) -} - -fn gauss(a: &[Arg], _: C) -> R { - let z = num(a, 0)?; - // Φ(z) − 0.5 = erf(z/√2)/2, accurate near zero. - num_val(0.5 * erf(z / std::f64::consts::SQRT_2)) -} - -// --------------------------------------------------------------------------------------------- -// SUBTOTAL / AGGREGATE support -// --------------------------------------------------------------------------------------------- - -/// Evaluates SUBTOTAL/AGGREGATE function `function_num` over already-filtered cell values. -/// -/// Codes 1–11 (and 101–111) are AVERAGE, COUNT, COUNTA, MAX, MIN, PRODUCT, STDEV.S, STDEV.P, -/// SUM, VAR.S, VAR.P; 12 = MEDIAN, 13 = MODE.SNGL. Codes 14–19 need `k`: use -/// [`aggregate_values_k`] (they give `#VALUE!` here). Values that are not numbers are ignored -/// (COUNTA counts non-empty values); the first error value present is returned. -pub fn aggregate_values(function_num: u32, values: &[Value]) -> Value { - crate::util::finish(aggregate_impl(function_num, values, None)) -} - -/// AGGREGATE codes 14 = LARGE, 15 = SMALL, 16 = PERCENTILE.INC, 17 = QUARTILE.INC, -/// 18 = PERCENTILE.EXC, 19 = QUARTILE.EXC with their `k` argument; codes 1–13 delegate to -/// [`aggregate_values`]. -pub fn aggregate_values_k(function_num: u32, values: &[Value], k: &Value) -> Value { - crate::util::finish(aggregate_impl(function_num, values, Some(k))) -} +mod descriptive; +mod distributions; +mod regression; + +pub use descriptive::{aggregate_values, aggregate_values_k}; +use descriptive::{ + arg_nums, arg_pairs, avedev, correl, count_impl, counta_impl, countblank, devsq, frequency, geomean, harmean, intercept, kurt, large, max_of, + mean, median, min_of, mode_mult, mode_numbers, mode_sngl, percentile_exc, percentile_inc, percentrank_impl, prob, quartile_exc, quartile_inc, + rank_impl, skew, skew_p, slope, small, standardize, trimmean, var_p, var_s, +}; +use distributions::{ + beta_dist, beta_inv, betadist_legacy, binom_dist, binom_dist_range, binom_inv, chisq_dist, chisq_dist_rt, chisq_inv, chisq_inv_rt, chisq_test, + confidence_norm, confidence_t, expon_dist, f_dist, f_dist_rt, f_inv, f_inv_rt, f_test, fisher, gamma_dist, gamma_fn, gamma_inv, gammaln, gauss, + hypgeom_dist, lognorm_dist, lognorm_inv, negbinom_dist, norm_dist, norm_inv_fn, norm_s_dist, norm_s_inv, poisson_dist, t_dist, t_dist_2t, + t_dist_rt, t_inv, t_inv_2t, t_test, tdist_legacy, weibull_dist, z_test, +}; +#[cfg(test)] +use regression::{MAX_REGRESSORS, check_regression_size}; +use regression::{covariance, forecast, linest_impl, steyx, trend_impl}; -fn aggregate_impl(function_num: u32, values: &[Value], k: Option<&Value>) -> R { - let code = if (101..=111).contains(&function_num) { function_num - 100 } else { function_num }; - let mut xs = Vec::new(); - let mut nonempty = 0usize; - let mut push = |v: &Value| -> R<()> { - match v { - Value::Number(n) => xs.push(*n), - Value::Error(e) => return Err(*e), - _ => {} - } - if !v.is_empty() { - nonempty += 1; - } - Ok(()) - }; - for v in values { - match v { - Value::Array(arr) => arr.iter().try_for_each(&mut push)?, - other => push(other)?, - } - } - let k = match (code, k) { - (14..=19, Some(k)) => k.scalar().to_number()?, - (14..=19, None) => return Err(CellError::Value), - _ => 0.0, - }; - let r = match code { - 1 => mean(&xs)?, - 2 => xs.len() as f64, - 3 => nonempty as f64, - 4 => max_of(&xs), - 5 => min_of(&xs), - 6 => product(&xs), - 7 => var_s(&xs)?.sqrt(), - 8 => var_p(&xs)?.sqrt(), - 9 => xs.iter().sum(), - 10 => var_s(&xs)?, - 11 => var_p(&xs)?, - 12 => median(xs)?, - 13 => mode_sngl(&xs)?, - 14 => kth(xs, k, true)?, - 15 => kth(xs, k, false)?, - 16 => percentile_inc(xs, k)?, - 17 => quartile_inc(xs, k)?, - 18 => percentile_exc(xs, k)?, - 19 => quartile_exc(xs, k)?, - _ => return Err(CellError::Value), - }; - num_val(r) -} +type C<'a> = &'a mut dyn Ctx; // --------------------------------------------------------------------------------------------- // Registration @@ -2273,513 +708,4 @@ pub(crate) fn specs() -> Vec { } #[cfg(test)] -mod tests { - use super::{MAX_REGRESSORS, aggregate_values, aggregate_values_k, check_regression_size}; - use crate::util::testutil::*; - use gridcraft_core::{CellError, Value}; - - const D7: f64 = 1e-6; - - fn c(xs: &[f64]) -> crate::Arg { - av(col(xs)) - } - - // The two middle values are finite and their midpoint is representable even when - // adding them first would overflow. Exercise both callers of the shared helper. - #[test] - fn median_finite_midpoint_does_not_overflow() { - for (values, expected) in [ - (vec![1e308, 1e308], 1e308), - (vec![-1e308, -1e308], -1e308), - (vec![1.6e308, 1e308], 1.3e308), - (vec![-1e308, -1.6e308], -1.3e308), - (vec![f64::MAX, f64::MAX], f64::MAX), - (vec![-f64::MAX, -f64::MAX], -f64::MAX), - (vec![-f64::MAX, f64::MAX], 0.0), - (vec![1.7e308, -1.7e308, 1e308, 1.6e308], 1.3e308), - ] { - close(ev("MEDIAN", values.iter().copied().map(n).collect()), expected); - close(ev("MEDIAN", vec![rf(col(&values))]), expected); - close(aggregate_values(12, &values.iter().copied().map(nv).collect::>()), expected); - } - } - - #[test] - fn median_midpoint_keeps_small_values_and_argument_semantics() { - // Halving each operand unconditionally can lose the smallest numbers. Use exact - // equality here: the usual aggregate-test tolerance would accept zero. - for value in [f64::MIN_POSITIVE, -f64::MIN_POSITIVE, f64::from_bits(1), -f64::from_bits(1)] { - assert_eq!(ev("MEDIAN", vec![n(value), n(value)]), nv(value)); - assert_eq!(aggregate_values(12, &[nv(value), nv(value)]), nv(value)); - } - close(ev("MEDIAN", vec![c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0])]), 3.5); - close(ev("MEDIAN", vec![c(&[1e308, -1e308, 3.0])]), 3.0); - close(ev("MEDIAN", vec![n(1e308)]), 1e308); - close(ev("MEDIAN", vec![t("4"), b(true)]), 2.5); - close(ev("MEDIAN", vec![rf(arr(vec![vec![nv(1.0), tv("ignored"), Value::Empty, Value::Bool(true), nv(6.0)]]))]), 3.5); - is_err(ev("MEDIAN", vec![t("invalid")]), CellError::Value); - is_err(ev("MEDIAN", vec![e(CellError::NA), n(1e308)]), CellError::NA); - is_err(ev("MEDIAN", vec![rf(arr(vec![vec![tv("ignored"), Value::Empty]]))]), CellError::Num); - is_err(aggregate_values(12, &[]), CellError::Num); - is_err(aggregate_values(12, &[nv(1e308), Value::Error(CellError::Div0)]), CellError::Div0); - } - - #[test] - fn averages_and_counts() { - close(ev("AVERAGE", vec![n(1.0), n(2.0), n(6.0)]), 3.0); - is_err(ev("AVERAGE", vec![rf(arr(vec![vec![tv("a")]]))]), CellError::Div0); - let mixed = arr(vec![vec![nv(1.0), tv("x"), Value::Bool(true), Value::Empty, nv(3.0)]]); - close(ev("AVERAGE", vec![rf(mixed.clone())]), 2.0); - close(ev("AVERAGEA", vec![rf(mixed.clone())]), 1.25); - close(ev("AVERAGE", vec![t("4"), b(true)]), 2.5); - is_err(ev("AVERAGE", vec![t("abc")]), CellError::Value); - is_err(ev("AVERAGE", vec![rf(arr(vec![vec![nv(1.0), Value::Error(CellError::NA)]]))]), CellError::NA); - close(ev("COUNT", vec![rf(mixed.clone()), n(4.0), t("5"), t("x")]), 4.0); - close(ev("COUNT", vec![e(CellError::Div0)]), 0.0); - close(ev("COUNTA", vec![rf(mixed.clone())]), 4.0); - close(ev("COUNTA", vec![n(1.0), t("")]), 2.0); - let blanks = arr(vec![vec![Value::Empty, tv(""), nv(0.0), tv(" ")]]); - close(ev("COUNTBLANK", vec![rf(blanks)]), 2.0); - close(ev("MEDIAN", vec![c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0])]), 3.5); - close(ev("MEDIAN", vec![c(&[3.0, 1.0, 2.0])]), 2.0); - is_err(ev("MEDIAN", vec![rf(arr(vec![vec![tv("a")]]))]), CellError::Num); - close(ev("MODE.SNGL", vec![c(&[5.6, 4.0, 4.0, 3.0, 2.0, 4.0])]), 4.0); - close(ev("MODE", vec![c(&[1.0, 2.0, 2.0, 1.0])]), 1.0); - is_err(ev("MODE", vec![c(&[1.0, 2.0, 3.0])]), CellError::NA); - assert_eq!(rows_of(&ev("MODE.MULT", vec![c(&[1.0, 2.0, 3.0, 4.0, 3.0, 2.0, 1.0, 2.0, 3.0])])), vec![vec![nv(2.0)], vec![nv(3.0)]]); - } - - /// MODE argument semantics in Excel 365, on the inputs of FormulaEvalTestData (row "Mode"): - /// text is skipped, typed or referenced, but a single-cell reference to a blank cell is - /// `#VALUE!`; the first error from the left wins. The values cached in POI's file come from an - /// older Excel and say `#VALUE!` for `MODE(C9,E16,"1",B8)`, `MODE(B8,"1",2,3)` and - /// `MODE("1",1,#DIV/0!,2,3)`; Excel 365 gives `#N/A`, `#N/A` and `#DIV/0!`. - #[test] - fn mode_scalar_arguments() { - let data = arr(vec![vec![tv(""), nv(0.0), nv(1.0)], vec![tv("x"), nv(1.0), nv(1.001)], vec![tv("y"), nv(9e9), nv(2.3456)]]); - let cases: Vec<(Vec, Result)> = vec![ - (vec![n(1.0), n(1.0), n(2.0), n(3.0)], Ok(1.0)), - (vec![rf(data)], Ok(1.0)), - (vec![rf(arr(vec![vec![nv(-1.1), Value::Empty, tv("1.1")]])), n(-1.1)], Ok(-1.1)), - // MODE(C9,E16,"1",B8): E16 holds the text "1.1". - (vec![rf(nv(1.1)), rf(tv("1.1")), t("1"), rf(nv(1.0))], Err(CellError::NA)), - (vec![rf(nv(1.0)), t("1"), n(2.0), n(3.0)], Err(CellError::NA)), - (vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Div0)), - // MODE(B9,2,D7) with D7 blank. - (vec![rf(nv(2.0)), n(2.0), rf(Value::Empty)], Err(CellError::Value)), - (vec![n(1.0), n(1.0), e(CellError::Div0), t("1")], Err(CellError::Div0)), - ]; - for name in ["MODE", "MODE.SNGL"] { - for (args, want) in &cases { - match want { - Ok(x) => close(ev(name, args.clone()), *x), - Err(err) => is_err(ev(name, args.clone()), *err), - } - } - } - is_err(ev("MODE.MULT", vec![rf(nv(1.0)), t("1"), rf(Value::Empty)]), CellError::Value); - } - - /// Text and logical values typed as arguments are skipped, not coerced (Excel 365, #736). - #[test] - fn mode_skips_typed_text_and_logicals() { - let cases: Vec<(Vec, Result)> = vec![ - (vec![n(1.0), n(1.0), t("x")], Ok(1.0)), - (vec![n(1.0), n(2.0), t("x")], Err(CellError::NA)), - (vec![n(1.0), t("1"), n(2.0), n(3.0)], Err(CellError::NA)), - (vec![t("1"), n(1.0), n(2.0), n(3.0)], Err(CellError::NA)), - (vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Div0)), - (vec![n(1.0), b(true), b(true), n(2.0)], Err(CellError::NA)), - (vec![n(1.0), n(1.0), b(true)], Ok(1.0)), - ]; - for name in ["MODE", "MODE.SNGL"] { - for (args, want) in &cases { - match want { - Ok(x) => close(ev(name, args.clone()), *x), - Err(err) => is_err(ev(name, args.clone()), *err), - } - } - } - assert_eq!(rows_of(&ev("MODE.MULT", vec![n(1.0), t("1"), n(1.0), t("x")])), vec![vec![nv(1.0)]]); - is_err(ev("MODE.MULT", vec![n(1.0), b(true), b(true), n(2.0)]), CellError::NA); - } - - #[test] - fn spread() { - let d = [1345.0, 1301.0, 1368.0, 1322.0, 1310.0, 1370.0, 1318.0, 1350.0, 1303.0, 1299.0]; - close_tol(ev("STDEV.S", vec![c(&d)]), 27.46391572, 1e-9); - close_tol(ev("STDEV", vec![c(&d)]), 27.46391572, 1e-9); - close_tol(ev("STDEV.P", vec![c(&d)]), 26.05455814, 1e-9); - close_tol(ev("STDEVP", vec![c(&d)]), 26.05455814, 1e-9); - close_tol(ev("VAR.S", vec![c(&d)]), 754.2666667, 1e-9); - close(ev("VAR.P", vec![c(&d)]), 678.84); - close(ev("VARP", vec![c(&d)]), 678.84); - is_err(ev("STDEV.S", vec![n(1.0)]), CellError::Div0); - close(ev("STDEV.P", vec![n(1.0)]), 0.0); - let mixed = arr(vec![vec![nv(2.0), Value::Bool(true), tv("x")]]); - close(ev("VARA", vec![rf(mixed.clone())]), 1.0); - close(ev("VARPA", vec![rf(mixed.clone())]), 2.0 / 3.0); - close(ev("STDEVA", vec![rf(mixed.clone())]), 1.0); - close(ev("STDEVPA", vec![rf(mixed)]), (2.0f64 / 3.0).sqrt()); - let s = [4.0, 5.0, 6.0, 7.0, 5.0, 4.0, 3.0]; - close(ev("DEVSQ", vec![c(&[4.0, 5.0, 8.0, 7.0, 11.0, 4.0, 3.0])]), 48.0); - close_tol(ev("AVEDEV", vec![c(&s)]), 1.020408163, 1e-9); - let g = [4.0, 5.0, 8.0, 7.0, 11.0, 4.0, 3.0]; - close_tol(ev("GEOMEAN", vec![c(&g)]), 5.476986969, 1e-9); - close_tol(ev("HARMEAN", vec![c(&g)]), 5.028375962, 1e-9); - is_err(ev("GEOMEAN", vec![c(&[1.0, 0.0])]), CellError::Num); - close_tol(ev("TRIMMEAN", vec![c(&[4.0, 5.0, 6.0, 7.0, 2.0, 3.0, 4.0, 5.0, 1.0, 2.0, 3.0]), n(0.2)]), 3.777777778, 1e-9); - is_err(ev("TRIMMEAN", vec![c(&[1.0]), n(1.0)]), CellError::Num); - let k = [3.0, 4.0, 5.0, 2.0, 3.0, 4.0, 5.0, 6.0, 4.0, 7.0]; - close_tol(ev("KURT", vec![c(&k)]), -0.151799637, 1e-8); - close_tol(ev("SKEW", vec![c(&k)]), 0.359543071, 1e-8); - close_tol(ev("SKEW.P", vec![c(&k)]), 0.303193339, 1e-8); - is_err(ev("KURT", vec![c(&[1.0, 2.0, 3.0])]), CellError::Div0); - close_tol(ev("STANDARDIZE", vec![n(42.0), n(40.0), n(1.5)]), 1.333333333, 1e-9); - is_err(ev("STANDARDIZE", vec![n(42.0), n(40.0), n(0.0)]), CellError::Num); - } - - #[test] - fn min_max_order() { - close(ev("MIN", vec![c(&[3.0, -1.0, 2.0]), n(0.0)]), -1.0); - close(ev("MAX", vec![c(&[3.0, -1.0, 2.0]), n(10.0)]), 10.0); - close(ev("MIN", vec![]), 0.0); - close(ev("MAX", vec![rf(arr(vec![vec![tv("a")]]))]), 0.0); - close(ev("MAXA", vec![rf(arr(vec![vec![nv(-1.0), Value::Bool(true)]]))]), 1.0); - close(ev("MINA", vec![rf(arr(vec![vec![nv(1.0), tv("x")]]))]), 0.0); - let d = c(&[3.0, 5.0, 3.0, 5.0, 4.0, 4.0, 2.0, 4.0, 6.0, 7.0]); - close(ev("LARGE", vec![d.clone(), n(3.0)]), 5.0); - close(ev("SMALL", vec![d.clone(), n(4.0)]), 4.0); - is_err(ev("LARGE", vec![d.clone(), n(0.0)]), CellError::Num); - is_err(ev("SMALL", vec![d.clone(), n(11.0)]), CellError::Num); - assert_eq!(rows_of(&ev("LARGE", vec![d, av(row(&[1.0, 2.0]))])), vec![vec![nv(7.0), nv(6.0)]]); - let r = c(&[89.0, 88.0, 92.0, 101.0, 94.0, 97.0, 95.0]); - close(ev("RANK.AVG", vec![n(94.0), r.clone()]), 4.0); - close(ev("RANK.EQ", vec![n(101.0), r.clone()]), 1.0); - is_err(ev("RANK", vec![n(1.0), r]), CellError::NA); - let r2 = c(&[7.0, 3.5, 3.5, 1.0, 2.0]); - close(ev("RANK.EQ", vec![n(7.0), r2.clone(), n(1.0)]), 5.0); - close(ev("RANK.EQ", vec![n(3.5), r2.clone(), n(1.0)]), 3.0); - close(ev("RANK.AVG", vec![n(3.5), r2, n(1.0)]), 3.5); - } - - #[test] - fn percentiles() { - close(ev("PERCENTILE.INC", vec![c(&[1.0, 3.0, 2.0, 4.0]), n(0.3)]), 1.9); - close(ev("PERCENTILE", vec![c(&[1.0, 3.0, 2.0, 4.0]), n(1.0)]), 4.0); - is_err(ev("PERCENTILE.INC", vec![c(&[1.0]), n(1.5)]), CellError::Num); - let e9 = c(&[1.0, 2.0, 3.0, 6.0, 6.0, 6.0, 7.0, 8.0, 9.0]); - close(ev("PERCENTILE.EXC", vec![e9.clone(), n(0.25)]), 2.5); - is_err(ev("PERCENTILE.EXC", vec![e9.clone(), n(0.0)]), CellError::Num); - is_err(ev("PERCENTILE.EXC", vec![e9.clone(), n(0.01)]), CellError::Num); - close(ev("QUARTILE.INC", vec![c(&[1.0, 2.0, 4.0, 7.0, 8.0, 9.0, 10.0, 12.0]), n(1.0)]), 3.5); - close(ev("QUARTILE", vec![c(&[1.0, 2.0, 4.0, 7.0, 8.0, 9.0, 10.0, 12.0]), n(4.0)]), 12.0); - let q = c(&[6.0, 7.0, 15.0, 36.0, 39.0, 40.0, 41.0, 42.0, 43.0, 47.0, 49.0]); - close(ev("QUARTILE.EXC", vec![q.clone(), n(1.0)]), 15.0); - close(ev("QUARTILE.EXC", vec![q.clone(), n(3.0)]), 43.0); - is_err(ev("QUARTILE.EXC", vec![q, n(4.0)]), CellError::Num); - let p = c(&[13.0, 12.0, 11.0, 8.0, 4.0, 3.0, 2.0, 1.0, 1.0, 1.0]); - close(ev("PERCENTRANK.INC", vec![p.clone(), n(2.0)]), 0.333); - close(ev("PERCENTRANK.INC", vec![p.clone(), n(4.0)]), 0.555); - close(ev("PERCENTRANK.INC", vec![p.clone(), n(8.0)]), 0.666); - close(ev("PERCENTRANK", vec![p.clone(), n(5.0)]), 0.583); - close(ev("PERCENTRANK.INC", vec![p.clone(), n(5.0), n(1.0)]), 0.5); - is_err(ev("PERCENTRANK.INC", vec![p.clone(), n(0.5)]), CellError::NA); - is_err(ev("PERCENTRANK.INC", vec![p, n(5.0), n(0.0)]), CellError::Num); - close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(7.0)]), 0.7); - close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(5.43)]), 0.381); - close(ev("PERCENTRANK.EXC", vec![e9, n(5.43), n(1.0)]), 0.3); - let t = c(&[1.0, 2.0, 3.0]); - close(ev("PERCENTRANK", vec![t.clone(), n(2.0), n(400.0)]), 0.5); - close(ev("PERCENTRANK.INC", vec![t.clone(), n(2.0), n(400.0)]), 0.5); - close(ev("PERCENTRANK.INC", vec![t.clone(), n(1.0), n(400.0)]), 0.0); - close(ev("PERCENTRANK.EXC", vec![t, n(2.0), n(400.0)]), 0.5); - } - - #[test] - fn pairs_and_regression() { - close_tol(ev("CORREL", vec![c(&[3.0, 2.0, 4.0, 5.0, 6.0]), c(&[9.0, 7.0, 12.0, 15.0, 17.0])]), 0.997054486, 1e-9); - close_tol(ev("PEARSON", vec![c(&[9.0, 7.0, 5.0, 3.0, 1.0]), c(&[10.0, 6.0, 1.0, 5.0, 3.0])]), 0.699379, D7); - is_err(ev("CORREL", vec![c(&[1.0, 2.0]), c(&[1.0, 2.0, 3.0])]), CellError::NA); - is_err(ev("CORREL", vec![c(&[1.0, 1.0]), c(&[1.0, 2.0])]), CellError::Div0); - let y = c(&[2.0, 3.0, 9.0, 1.0, 8.0, 7.0, 5.0]); - let x = c(&[6.0, 5.0, 11.0, 7.0, 5.0, 4.0, 4.0]); - close_tol(ev("SLOPE", vec![y.clone(), x.clone()]), 0.305555556, 1e-8); - close_tol(ev("RSQ", vec![y.clone(), x.clone()]), 0.057950192, 1e-8); - close_tol(ev("STEYX", vec![y.clone(), x.clone()]), 3.305718950, 1e-8); - close_tol(ev("INTERCEPT", vec![c(&[2.0, 3.0, 9.0, 1.0, 8.0]), c(&[6.0, 5.0, 11.0, 7.0, 5.0])]), 0.048387097, 1e-8); - close_tol(ev("FORECAST", vec![n(30.0), c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 10.607253, D7); - close_tol(ev("FORECAST.LINEAR", vec![n(30.0), c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 10.607253, D7); - close(ev("COVARIANCE.P", vec![c(&[3.0, 2.0, 4.0, 5.0, 6.0]), c(&[9.0, 7.0, 12.0, 15.0, 17.0])]), 5.2); - close(ev("COVAR", vec![c(&[3.0, 2.0, 4.0, 5.0, 6.0]), c(&[9.0, 7.0, 12.0, 15.0, 17.0])]), 5.2); - close(ev("COVARIANCE.S", vec![c(&[2.0, 4.0, 8.0]), c(&[5.0, 11.0, 12.0])]), 29.0 / 3.0); - // Pairs with text are skipped. - let xs = arr(vec![vec![nv(1.0)], vec![tv("x")], vec![nv(3.0)]]); - close(ev("SLOPE", vec![c(&[2.0, 100.0, 6.0]), rf(xs)]), 2.0); - } - - #[test] - fn linest_family() { - let r = rows_of(&ev("LINEST", vec![c(&[1.0, 9.0, 5.0, 7.0]), c(&[0.0, 4.0, 2.0, 3.0])])); - assert_eq!(r.len(), 1); - close(r[0][0].clone(), 2.0); - close(r[0][1].clone(), 1.0); - // Statistics for a simple fit: y = 1,2,4 on x = 1,2,3. - let s = rows_of(&ev("LINEST", vec![c(&[1.0, 2.0, 4.0]), c(&[1.0, 2.0, 3.0]), b(true), b(true)])); - assert_eq!((s.len(), s[0].len()), (5, 2)); - close(s[0][0].clone(), 1.5); - close(s[0][1].clone(), -2.0 / 3.0); - close(s[2][0].clone(), 0.9642857142857143); // r² - close(s[3][1].clone(), 1.0); // df - close(s[4][0].clone(), 4.5); // ssreg - close(s[4][1].clone(), 1.0 / 6.0); // ssresid - close(s[2][1].clone(), (1.0f64 / 6.0).sqrt()); // sey - close(s[3][0].clone(), 27.0); // F - close(s[1][0].clone(), (1.0f64 / 6.0 / 2.0).sqrt()); // se slope - // No constant. - let z = rows_of(&ev("LINEST", vec![c(&[2.0, 4.0, 6.0]), c(&[1.0, 2.0, 3.0]), b(false)])); - close(z[0][0].clone(), 2.0); - close(z[0][1].clone(), 0.0); - // Two predictors: y = 1 + 2a + 3b. - let xs = arr(vec![vec![nv(1.0), nv(0.0)], vec![nv(0.0), nv(1.0)], vec![nv(1.0), nv(1.0)], vec![nv(2.0), nv(1.0)], vec![nv(3.0), nv(5.0)]]); - let ys: Vec = [(1.0, 0.0), (0.0, 1.0), (1.0, 1.0), (2.0, 1.0), (3.0, 5.0)].iter().map(|(a, b)| 1.0 + 2.0 * a + 3.0 * b).collect(); - let m = rows_of(&ev("LINEST", vec![c(&ys), av(xs)])); - close(m[0][0].clone(), 3.0); - close(m[0][1].clone(), 2.0); - close(m[0][2].clone(), 1.0); - let gy = [33100.0, 47300.0, 69000.0, 102000.0, 150000.0, 220000.0]; - let gx = [11.0, 12.0, 13.0, 14.0, 15.0, 16.0]; - let l = rows_of(&ev("LOGEST", vec![c(&gy), c(&gx)])); - close_tol(l[0][0].clone(), 1.463275628, 1e-9); - close_tol(l[0][1].clone(), 495.3047702, 1e-9); - let g = rows_of(&ev("GROWTH", vec![c(&gy), c(&gx), c(&[17.0, 18.0])])); - close_tol(g[0][0].clone(), 320196.7184, 1e-9); - close_tol(g[1][0].clone(), 468536.0539, 1e-9); - let ty = [133890.0, 135000.0, 135790.0, 137300.0, 138130.0, 139100.0, 139900.0, 141120.0, 141890.0, 143230.0, 144000.0, 145290.0]; - let tx: Vec = (1..=12).map(f64::from).collect(); - let t1 = ev("TREND", vec![c(&ty), c(&tx), n(13.0)]); - close_tol(Value::Number(rows_of(&t1)[0][0].as_f64().unwrap().round()), 146172.0, 1e-9); - let fitted = rows_of(&ev("TREND", vec![c(&[1.0, 9.0, 5.0, 7.0]), c(&[0.0, 4.0, 2.0, 3.0])])); - close(fitted[1][0].clone(), 9.0); - is_err(ev("LOGEST", vec![c(&[1.0, -2.0])]), CellError::Num); - is_err(ev("LINEST", vec![c(&[1.0, 2.0]), c(&[1.0, 2.0, 3.0])]), CellError::Ref); - } - - // #2177: one predictor row of 16,384 columns built three 16,384² matrices (6 GB) and an O(k³) - // inversion; a wider TOROW aborted the process. It is now refused before allocating. - #[test] - fn linest_family_caps_regressor_count() { - let wide = |k: usize| av(arr(vec![(0..k).map(|i| nv(i as f64)).collect()])); - let tall = |k: usize| av(arr((0..k).map(|i| vec![nv(i as f64), nv(1.0)]).collect())); - for f in ["LINEST", "LOGEST", "TREND", "GROWTH"] { - // y is one cell, so every column of x is a predictor. - is_err(ev(f, vec![n(1.0), wide(16_384)]), CellError::Value); - // y is a row, so every row of x is a predictor. - is_err(ev(f, vec![av(arr(vec![vec![nv(1.0), nv(2.0)]])), tall(16_384)]), CellError::Value); - } - is_err(ev("LINEST", vec![n(1.0), wide(MAX_REGRESSORS + 1)]), CellError::Value); - // At the cap the fit still runs (one observation: coefficients are zero, not an error). - assert_eq!(rows_of(&ev("LINEST", vec![n(1.0), wide(MAX_REGRESSORS)]))[0].len(), MAX_REGRESSORS + 1); - // The rows × k² work bound. - assert_eq!(check_regression_size(1_000, MAX_REGRESSORS), Ok(())); - assert_eq!(check_regression_size(1_000_000, 100), Err(CellError::Num)); - assert_eq!(check_regression_size(usize::MAX, 2), Err(CellError::Num)); - assert_eq!(check_regression_size(10_000_000, 1), Ok(())); - } - - #[test] - fn frequency_prob() { - let f = ev("FREQUENCY", vec![c(&[79.0, 85.0, 78.0, 85.0, 50.0, 81.0, 95.0, 88.0, 97.0]), c(&[70.0, 79.0, 89.0])]); - assert_eq!(rows_of(&f), vec![vec![nv(1.0)], vec![nv(2.0)], vec![nv(4.0)], vec![nv(2.0)]]); - let x = c(&[0.0, 1.0, 2.0, 3.0]); - let p = c(&[0.2, 0.3, 0.1, 0.4]); - close(ev("PROB", vec![x.clone(), p.clone(), n(2.0)]), 0.1); - close(ev("PROB", vec![x.clone(), p, n(1.0), n(3.0)]), 0.8); - is_err(ev("PROB", vec![x.clone(), c(&[0.5, 0.5, 0.5, 0.5]), n(1.0)]), CellError::Num); - // Excel: any probability <= 0 is #NUM!, even when it sums to one and lies outside the limits. - is_err(ev("PROB", vec![x.clone(), c(&[0.5, 0.0, 0.25, 0.25]), n(0.0), n(3.0)]), CellError::Num); - is_err(ev("PROB", vec![x, c(&[0.5, 0.0, 0.25, 0.25]), n(2.0), n(3.0)]), CellError::Num); - } - - #[test] - fn normal() { - close_tol(ev("NORM.DIST", vec![n(42.0), n(40.0), n(1.5), b(true)]), 0.9087888, D7); - close_tol(ev("NORM.DIST", vec![n(42.0), n(40.0), n(1.5), b(false)]), 0.10934005, D7); - close_tol(ev("NORMDIST", vec![n(42.0), n(40.0), n(1.5), b(true)]), 0.9087888, D7); - is_err(ev("NORM.DIST", vec![n(42.0), n(40.0), n(0.0), b(true)]), CellError::Num); - close_tol(ev("NORM.INV", vec![n(0.908789), n(40.0), n(1.5)]), 42.000002, D7); - close_tol(ev("NORMINV", vec![n(0.908789), n(40.0), n(1.5)]), 42.000002, D7); - is_err(ev("NORM.INV", vec![n(1.0), n(0.0), n(1.0)]), CellError::Num); - close_tol(ev("NORM.S.DIST", vec![n(1.333333), b(true)]), 0.908788726, 1e-8); - close_tol(ev("NORM.S.DIST", vec![n(0.0), b(false)]), 0.398942280, 1e-8); - close_tol(ev("NORMSDIST", vec![n(1.333333)]), 0.908788726, 1e-8); - close_tol(ev("NORM.S.INV", vec![n(0.908789)]), 1.3333347, D7); - close_tol(ev("NORMSINV", vec![n(0.975)]), 1.959963985, 1e-9); - close_tol(ev("PHI", vec![n(0.75)]), 0.301137432, 1e-8); - close_tol(ev("GAUSS", vec![n(2.0)]), 0.477249868, 1e-8); - close_tol(ev("FISHER", vec![n(0.75)]), 0.972955075, 1e-8); - close_tol(ev("FISHERINV", vec![n(0.972955)]), 0.75, D7); - is_err(ev("FISHER", vec![n(1.0)]), CellError::Num); - close_tol(ev("CONFIDENCE.NORM", vec![n(0.05), n(2.5), n(50.0)]), 0.692951912, 1e-8); - close_tol(ev("CONFIDENCE", vec![n(0.05), n(2.5), n(50.0)]), 0.692951912, 1e-8); - close_tol(ev("CONFIDENCE.T", vec![n(0.05), n(1.0), n(50.0)]), 0.284196855, 1e-8); - is_err(ev("CONFIDENCE.T", vec![n(0.05), n(1.0), n(1.0)]), CellError::Div0); - let z = c(&[3.0, 6.0, 7.0, 8.0, 6.0, 5.0, 4.0, 2.0, 1.0, 9.0]); - close_tol(ev("Z.TEST", vec![z.clone(), n(4.0)]), 0.090574, 1e-5); - close_tol(ev("ZTEST", vec![z, n(6.0)]), 0.863043, 1e-5); - close_tol(ev("LOGNORM.DIST", vec![n(4.0), n(3.5), n(1.2), b(true)]), 0.0390836, D7); - close_tol(ev("LOGNORM.DIST", vec![n(4.0), n(3.5), n(1.2), b(false)]), 0.0176176, D7); - close_tol(ev("LOGNORMDIST", vec![n(4.0), n(3.5), n(1.2)]), 0.0390836, D7); - close_tol(ev("LOGNORM.INV", vec![n(0.039084), n(3.5), n(1.2)]), 4.0000252, D7); - close_tol(ev("LOGINV", vec![n(0.039084), n(3.5), n(1.2)]), 4.0000252, D7); - } - - #[test] - fn t_chi_f() { - close_tol(ev("T.DIST", vec![n(60.0), n(1.0), b(true)]), 0.99469533, D7); - close_tol(ev("T.DIST", vec![n(8.0), n(3.0), b(false)]), 0.00073691, 1e-5); - close_tol(ev("T.DIST.2T", vec![n(1.959999998), n(60.0)]), 0.054644930, D7); - close_tol(ev("T.DIST.RT", vec![n(1.959999998), n(60.0)]), 0.027322465, D7); - close_tol(ev("TDIST", vec![n(1.959999998), n(60.0), n(2.0)]), 0.054644930, D7); - is_err(ev("TDIST", vec![n(-1.0), n(60.0), n(2.0)]), CellError::Num); - is_err(ev("T.DIST", vec![n(1.0), n(0.0), b(true)]), CellError::Num); - close_tol(ev("T.INV", vec![n(0.75), n(2.0)]), 0.8164966, D7); - close_tol(ev("T.INV", vec![n(0.25), n(2.0)]), -0.8164966, D7); - close_tol(ev("T.INV.2T", vec![n(0.546449), n(60.0)]), 0.606533, D7); - close_tol(ev("T.INV.2T", vec![n(0.05), n(10.0)]), 2.228138852, 1e-9); - close_tol(ev("TINV", vec![n(0.05), n(10.0)]), 2.228138852, 1e-9); - let a1 = c(&[3.0, 4.0, 5.0, 8.0, 9.0, 1.0, 2.0, 4.0, 5.0]); - let a2 = c(&[6.0, 19.0, 3.0, 2.0, 14.0, 4.0, 5.0, 17.0, 1.0]); - close_tol(ev("T.TEST", vec![a1.clone(), a2.clone(), n(2.0), n(1.0)]), 0.196016, 1e-5); - close_tol(ev("TTEST", vec![a1.clone(), a2.clone(), n(2.0), n(1.0)]), 0.196016, 1e-5); - close_tol(ev("T.TEST", vec![a1.clone(), a2.clone(), n(2.0), n(2.0)]), 0.191995887, 1e-6); - close_tol(ev("T.TEST", vec![a1.clone(), a2.clone(), n(1.0), n(3.0)]), 0.1012, 1e-3); - is_err(ev("T.TEST", vec![a1, a2, n(3.0), n(1.0)]), CellError::Num); - close_tol(ev("CHISQ.DIST", vec![n(0.5), n(1.0), b(true)]), 0.52049988, D7); - close_tol(ev("CHISQ.DIST", vec![n(2.0), n(3.0), b(false)]), 0.20755375, D7); - close_tol(ev("CHISQ.DIST.RT", vec![n(18.307), n(10.0)]), 0.0500006, 1e-5); - close_tol(ev("CHIDIST", vec![n(18.307), n(10.0)]), 0.0500006, 1e-5); - close_tol(ev("CHISQ.INV", vec![n(0.93), n(1.0)]), 3.283020287, 1e-8); - close_tol(ev("CHISQ.INV", vec![n(0.6), n(2.0)]), 1.832581464, 1e-8); - close_tol(ev("CHISQ.INV.RT", vec![n(0.050001), n(10.0)]), 18.30697, D7); - close_tol(ev("CHIINV", vec![n(0.050001), n(10.0)]), 18.30697, D7); - is_err(ev("CHISQ.DIST", vec![n(-1.0), n(1.0), b(true)]), CellError::Num); - let act = arr(vec![vec![nv(58.0), nv(35.0)], vec![nv(11.0), nv(25.0)], vec![nv(10.0), nv(23.0)]]); - let exp = arr(vec![vec![nv(45.35), nv(47.65)], vec![nv(17.56), nv(18.44)], vec![nv(16.09), nv(16.91)]]); - close_tol(ev("CHISQ.TEST", vec![av(act.clone()), av(exp.clone())]), 0.0003082, 1e-3); - close_tol(ev("CHITEST", vec![av(act), av(exp)]), 0.0003082, 1e-3); - close_tol(ev("F.DIST", vec![n(15.2069), n(6.0), n(4.0), b(true)]), 0.99, 1e-6); - close_tol(ev("F.DIST", vec![n(15.2069), n(6.0), n(4.0), b(false)]), 0.0012238, 1e-5); - close_tol(ev("F.DIST.RT", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6); - close_tol(ev("FDIST", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6); - close_tol(ev("F.INV", vec![n(0.01), n(6.0), n(4.0)]), 0.10930991, D7); - close_tol(ev("F.INV.RT", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7); - close_tol(ev("FINV", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7); - close_tol(ev("F.TEST", vec![c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 0.64831785, D7); - close_tol(ev("FTEST", vec![c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 0.64831785, D7); - is_err(ev("F.DIST", vec![n(1.0), n(0.0), n(4.0), b(true)]), CellError::Num); - } - - #[test] - fn binom_inv_huge_trials_returns() { - // Above 2^53 neighbouring f64 values are more than 1 apart; the bisection must still end. - let cases: [(&str, f64, f64, f64); 3] = [("BINOM.INV", 1e17, 0.5, 0.5), ("CRITBINOM", 1e300, 1.0, 0.5), ("BINOM.INV", 1e300, 0.5, 0.5)]; - for (name, trials, p, alpha) in cases { - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let r = ev(name, vec![n(trials), n(p), n(alpha)]); - let _ = tx.send(r); - }); - match rx.recv_timeout(std::time::Duration::from_secs(5)) { - Ok(Value::Number(_)) | Ok(Value::Error(CellError::Num)) => {} - Ok(other) => panic!("{name}({trials},{p},{alpha}) returned {other:?}"), - Err(_) => panic!("{name}({trials},{p},{alpha}) did not return within 5 s"), - } - } - } - - #[test] - fn discrete() { - close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12); - close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(true)]), 0.828125, 1e-12); - close_tol(ev("BINOMDIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12); - is_err(ev("BINOM.DIST", vec![n(11.0), n(10.0), n(0.5), b(false)]), CellError::Num); - close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(48.0)]), 0.083974967, 1e-6); - close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(45.0), n(50.0)]), 0.523629793, 1e-6); - close(ev("BINOM.INV", vec![n(6.0), n(0.5), n(0.75)]), 4.0); - close(ev("CRITBINOM", vec![n(6.0), n(0.5), n(0.75)]), 4.0); - close(ev("BINOM.INV", vec![n(1e9), n(0.5), n(0.5)]), 5e8); - close_tol(ev("POISSON.DIST", vec![n(2.0), n(5.0), b(true)]), 0.124652, 1e-5); - close_tol(ev("POISSON.DIST", vec![n(2.0), n(5.0), b(false)]), 0.084224, 1e-5); - close_tol(ev("POISSON", vec![n(2.0), n(5.0), b(false)]), 0.084224, 1e-5); - is_err(ev("POISSON.DIST", vec![n(-1.0), n(5.0), b(false)]), CellError::Num); - close_tol(ev("HYPGEOM.DIST", vec![n(1.0), n(4.0), n(8.0), n(20.0), b(true)]), 0.4654, 1e-4); - close_tol(ev("HYPGEOM.DIST", vec![n(1.0), n(4.0), n(8.0), n(20.0), b(false)]), 0.3633, 1e-4); - close_tol(ev("HYPGEOMDIST", vec![n(1.0), n(4.0), n(8.0), n(20.0)]), 0.363261094, 1e-8); - is_err(ev("HYPGEOMDIST", vec![n(5.0), n(4.0), n(8.0), n(20.0)]), CellError::Num); - close_tol(ev("NEGBINOM.DIST", vec![n(10.0), n(5.0), n(0.25), b(true)]), 0.3135141, D7); - close_tol(ev("NEGBINOM.DIST", vec![n(10.0), n(5.0), n(0.25), b(false)]), 0.0550487, D7); - close_tol(ev("NEGBINOMDIST", vec![n(10.0), n(5.0), n(0.25)]), 0.0550487, D7); - } - - #[test] - fn continuous() { - close_tol(ev("EXPON.DIST", vec![n(0.2), n(10.0), b(true)]), 0.86466472, D7); - close_tol(ev("EXPON.DIST", vec![n(0.2), n(10.0), b(false)]), 1.35335283, D7); - close_tol(ev("EXPONDIST", vec![n(0.2), n(10.0), b(true)]), 0.86466472, D7); - is_err(ev("EXPON.DIST", vec![n(0.2), n(0.0), b(true)]), CellError::Num); - close_tol(ev("GAMMA.DIST", vec![n(10.00001131), n(9.0), n(2.0), b(false)]), 0.032639, 1e-5); - close_tol(ev("GAMMA.DIST", vec![n(10.00001131), n(9.0), n(2.0), b(true)]), 0.068094, 1e-5); - close_tol(ev("GAMMADIST", vec![n(10.00001131), n(9.0), n(2.0), b(true)]), 0.068094, 1e-5); - close_tol(ev("GAMMA.INV", vec![n(0.068094), n(9.0), n(2.0)]), 10.0000112, 1e-6); - close_tol(ev("GAMMAINV", vec![n(0.068094), n(9.0), n(2.0)]), 10.0000112, 1e-6); - close_tol(ev("GAMMALN", vec![n(4.0)]), 1.791759469, 1e-9); - close_tol(ev("GAMMALN.PRECISE", vec![n(4.5)]), 2.453736571, 1e-9); - is_err(ev("GAMMALN", vec![n(0.0)]), CellError::Num); - close_tol(ev("GAMMA", vec![n(2.5)]), 1.329340388, 1e-9); - close_tol(ev("GAMMA", vec![n(-0.75)]), -4.834146544, 1e-8); - is_err(ev("GAMMA", vec![n(-1.0)]), CellError::Num); - is_err(ev("GAMMA", vec![n(0.0)]), CellError::Num); - close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(true), n(1.0), n(3.0)]), 0.6854706, D7); - close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(false), n(1.0), n(3.0)]), 1.4837646, D7); - close_tol(ev("BETADIST", vec![n(2.0), n(8.0), n(10.0), n(1.0), n(3.0)]), 0.6854706, D7); - close_tol(ev("BETA.INV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7); - close_tol(ev("BETAINV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7); - is_err(ev("BETA.DIST", vec![n(4.0), n(8.0), n(10.0), b(true), n(1.0), n(3.0)]), CellError::Num); - close_tol(ev("WEIBULL.DIST", vec![n(105.0), n(20.0), n(100.0), b(true)]), 0.929581, 1e-5); - close_tol(ev("WEIBULL.DIST", vec![n(105.0), n(20.0), n(100.0), b(false)]), 0.035589, 1e-4); - close_tol(ev("WEIBULL", vec![n(105.0), n(20.0), n(100.0), b(true)]), 0.929581, 1e-5); - // Lifting over an array of x values. - let r = rows_of(&ev("NORM.S.DIST", vec![av(row(&[0.0, 1.0])), b(true)])); - close(r[0][0].clone(), 0.5); - close_tol(r[0][1].clone(), 0.841344746, 1e-8); - } - - #[test] - fn aggregate_helper() { - let vals = vec![nv(1.0), nv(2.0), tv("x"), Value::Empty, Value::Bool(true), nv(6.0)]; - close(aggregate_values(1, &vals), 3.0); - close(aggregate_values(2, &vals), 3.0); - close(aggregate_values(3, &vals), 5.0); - close(aggregate_values(4, &vals), 6.0); - close(aggregate_values(105, &vals), 1.0); - close(aggregate_values(6, &vals), 12.0); - close(aggregate_values(9, &vals), 9.0); - close(aggregate_values(109, &vals), 9.0); - close(aggregate_values(11, &vals), 14.0 / 3.0); - close(aggregate_values(10, &vals), 7.0); - close(aggregate_values(12, &vals), 2.0); - is_err(aggregate_values(13, &vals), CellError::NA); - is_err(aggregate_values(14, &vals), CellError::Value); - is_err(aggregate_values(99, &vals), CellError::Value); - is_err(aggregate_values(9, &[nv(1.0), Value::Error(CellError::Div0)]), CellError::Div0); - close(aggregate_values_k(14, &vals, &nv(1.0)), 6.0); - close(aggregate_values_k(15, &vals, &nv(2.0)), 2.0); - close(aggregate_values_k(16, &vals, &nv(0.5)), 2.0); - close(aggregate_values_k(17, &vals, &nv(4.0)), 6.0); - close(aggregate_values_k(18, &vals, &nv(0.5)), 2.0); - close(aggregate_values_k(19, &vals, &nv(2.0)), 2.0); - close(aggregate_values_k(9, &vals, &nv(2.0)), 9.0); - is_err(aggregate_values_k(14, &vals, &nv(10.0)), CellError::Num); - } -} +mod tests; diff --git a/crates/functions/src/stat/descriptive.rs b/crates/functions/src/stat/descriptive.rs new file mode 100644 index 0000000000..af4c536076 --- /dev/null +++ b/crates/functions/src/stat/descriptive.rs @@ -0,0 +1,547 @@ +use super::*; + +// --------------------------------------------------------------------------------------------- +// Basic helpers over number lists +// --------------------------------------------------------------------------------------------- + +pub(super) fn mean(x: &[f64]) -> R { + if x.is_empty() { + return Err(CellError::Div0); + } + Ok(x.iter().sum::() / x.len() as f64) +} + +pub(super) fn devsq(x: &[f64]) -> f64 { + if x.is_empty() { + return 0.0; + } + let m = x.iter().sum::() / x.len() as f64; + x.iter().map(|v| (v - m) * (v - m)).sum() +} + +pub(super) fn var_s(x: &[f64]) -> R { + if x.len() < 2 { + return Err(CellError::Div0); + } + Ok(devsq(x) / (x.len() - 1) as f64) +} + +pub(super) fn var_p(x: &[f64]) -> R { + if x.is_empty() { + return Err(CellError::Div0); + } + Ok(devsq(x) / x.len() as f64) +} + +fn sorted(mut v: Vec) -> Vec { + v.sort_by(f64::total_cmp); + v +} + +pub(super) fn median(v: Vec) -> R { + if v.is_empty() { + return Err(CellError::Num); + } + let s = sorted(v); + let n = s.len(); + let hi = s.get(n / 2).copied().unwrap_or(0.0); + if n % 2 == 1 { Ok(hi) } else { Ok(s.get(n / 2 - 1).copied().unwrap_or(0.0).midpoint(hi)) } +} + +/// Values occurring at least twice, ordered by first occurrence, with their (equal, maximal) count. +fn modes(v: &[f64]) -> Vec { + let mut counts: Vec<(f64, usize)> = Vec::new(); + for &x in v { + if let Some(e) = counts.iter_mut().find(|e| e.0 == x) { + e.1 += 1; + } else { + counts.push((x, 1)); + } + } + let best = counts.iter().map(|e| e.1).max().unwrap_or(0); + if best < 2 { + return Vec::new(); + } + counts.into_iter().filter(|e| e.1 == best).map(|e| e.0).collect() +} + +pub(super) fn mode_sngl(v: &[f64]) -> R { + modes(v).first().copied().ok_or(CellError::NA) +} + +/// Numbers for MODE / MODE.SNGL / MODE.MULT. Inside multi-cell ranges and arrays only numbers +/// count, like [`numbers`]. Scalar arguments differ from SUM: in Excel 365, text and logical +/// values are skipped whether typed or referenced (even numeric text such as `"1"`, so +/// `MODE(1,"1",2,3)` is `#N/A`, and `MODE(1,TRUE,TRUE,2)` is `#N/A`), but a single-cell reference +/// to a blank cell is `#VALUE!` (`MODE(B9,2,D7)` with D7 blank). Arguments are processed left to +/// right, so the first error wins. The results cached in POI's FormulaEvalTestData come from an +/// older Excel that answered `#VALUE!` for typed text; they are not the reference here. +pub(super) fn mode_numbers(args: &[Arg]) -> R> { + let mut out = Vec::new(); + for a in args { + match &a.value { + Value::Array(arr) => { + for v in arr.iter() { + match v { + Value::Number(n) => out.push(*n), + Value::Error(e) => return Err(*e), + _ => {} + } + } + } + Value::Number(n) => out.push(*n), + Value::Error(e) => return Err(*e), + Value::Text(_) | Value::Bool(_) => {} + Value::Empty => return Err(CellError::Value), + } + } + Ok(out) +} + +fn product(v: &[f64]) -> f64 { + if v.is_empty() { 0.0 } else { v.iter().product() } +} + +pub(super) fn min_of(v: &[f64]) -> f64 { + if v.is_empty() { 0.0 } else { v.iter().copied().fold(f64::INFINITY, f64::min) } +} + +pub(super) fn max_of(v: &[f64]) -> f64 { + if v.is_empty() { 0.0 } else { v.iter().copied().fold(f64::NEG_INFINITY, f64::max) } +} + +/// k-th largest (k is rounded up like Excel). +fn kth(v: Vec, k: f64, largest: bool) -> R { + let k = k.ceil(); + if k.is_nan() || k < 1.0 || k > v.len() as f64 { + return Err(CellError::Num); + } + let s = sorted(v); + let k = k as usize; + let idx = if largest { s.len() - k } else { k - 1 }; + s.get(idx).copied().ok_or(CellError::Num) +} + +fn interp(s: &[f64], pos: f64) -> R { + let i = pos.floor(); + let frac = pos - i; + let i = i as usize; + let lo = *s.get(i).ok_or(CellError::Num)?; + match s.get(i + 1) { + Some(hi) if frac > 0.0 => Ok(lo + frac * (hi - lo)), + _ => Ok(lo), + } +} + +pub(super) fn percentile_inc(v: Vec, k: f64) -> R { + if v.is_empty() || !(0.0..=1.0).contains(&k) { + return Err(CellError::Num); + } + let s = sorted(v); + interp(&s, k * (s.len() - 1) as f64) +} + +pub(super) fn percentile_exc(v: Vec, k: f64) -> R { + let n = v.len() as f64; + if v.is_empty() || !(k > 0.0 && k < 1.0) { + return Err(CellError::Num); + } + let pos = k * (n + 1.0); + if pos < 1.0 || pos > n { + return Err(CellError::Num); + } + let s = sorted(v); + interp(&s, pos - 1.0) +} + +pub(super) fn quartile_inc(v: Vec, q: f64) -> R { + let q = q.trunc(); + if !(0.0..=4.0).contains(&q) { + return Err(CellError::Num); + } + percentile_inc(v, q / 4.0) +} + +pub(super) fn quartile_exc(v: Vec, q: f64) -> R { + let q = q.trunc(); + if !(1.0..=3.0).contains(&q) { + return Err(CellError::Num); + } + percentile_exc(v, q / 4.0) +} + +pub(super) fn arg_nums(a: &[Arg], i: usize) -> R> { + array_numbers(&arg(a, i)?.value) +} + +/// Pairs of numbers at matching positions of two arrays (pairs with a non-number skipped). +/// Different sizes give `#N/A`; errors propagate. +fn pairs(x: &Value, y: &Value) -> R<(Vec, Vec)> { + let xa = as_array(x); + let ya = as_array(y); + if xa.data.len() != ya.data.len() { + return Err(CellError::NA); + } + let mut xs = Vec::new(); + let mut ys = Vec::new(); + for (a, b) in xa.data.iter().zip(ya.data.iter()) { + if let Value::Error(e) = a { + return Err(*e); + } + if let Value::Error(e) = b { + return Err(*e); + } + if let (Value::Number(p), Value::Number(q)) = (a, b) { + xs.push(*p); + ys.push(*q); + } + } + Ok((xs, ys)) +} + +pub(super) fn arg_pairs(a: &[Arg], i: usize, j: usize) -> R<(Vec, Vec)> { + pairs(&arg(a, i)?.value, &arg(a, j)?.value) +} + +/// (sum of cross deviations, devsq x, devsq y, mean x, mean y). +pub(super) fn moments(x: &[f64], y: &[f64]) -> R<(f64, f64, f64, f64, f64)> { + let mx = mean(x)?; + let my = mean(y)?; + let mut sxy = 0.0; + let mut sxx = 0.0; + let mut syy = 0.0; + for (a, b) in x.iter().zip(y) { + sxy += (a - mx) * (b - my); + sxx += (a - mx) * (a - mx); + syy += (b - my) * (b - my); + } + Ok((sxy, sxx, syy, mx, my)) +} + +pub(super) fn correl(x: &[f64], y: &[f64]) -> R { + if x.len() < 2 { + return Err(CellError::Div0); + } + let (sxy, sxx, syy, _, _) = moments(x, y)?; + if sxx == 0.0 || syy == 0.0 { + return Err(CellError::Div0); + } + Ok(sxy / (sxx * syy).sqrt()) +} + +pub(super) fn slope(y: &[f64], x: &[f64]) -> R { + let (sxy, sxx, _, _, _) = moments(x, y)?; + if sxx == 0.0 { + return Err(CellError::Div0); + } + Ok(sxy / sxx) +} + +pub(super) fn intercept(y: &[f64], x: &[f64]) -> R { + let (sxy, sxx, _, mx, my) = moments(x, y)?; + if sxx == 0.0 { + return Err(CellError::Div0); + } + Ok(my - sxy / sxx * mx) +} + +fn trunc_digits(r: f64, sig: f64) -> f64 { + let f = 10f64.powi(sig as i32); + if !f.is_finite() { + return r; + } + ((r * f) + 1e-9).floor() / f +} + +fn col_value(v: Vec) -> R { + let n = v.len(); + array_val(n, 1, v.into_iter().map(Value::Number).collect()) +} + +// --------------------------------------------------------------------------------------------- +// Aggregates +// --------------------------------------------------------------------------------------------- + +pub(super) fn count_impl(a: &[Arg]) -> f64 { + let mut c = 0usize; + for x in a { + match &x.value { + Value::Array(arr) => c += arr.iter().filter(|v| v.is_number()).count(), + Value::Number(_) => c += 1, + _ if x.from_ref => {} + Value::Bool(_) => c += 1, + Value::Text(t) if gridcraft_core::parse::parse_number_text(t).is_some() => c += 1, + _ => {} + } + } + c as f64 +} + +pub(super) fn counta_impl(a: &[Arg]) -> f64 { + let mut c = 0usize; + for x in a { + match &x.value { + Value::Array(arr) => c += arr.iter().filter(|v| !v.is_empty()).count(), + Value::Empty if x.from_ref => {} + _ => c += 1, + } + } + c as f64 +} + +pub(super) fn countblank(a: &[Arg], _: C) -> R { + let v = &arg(a, 0)?.value; + let blank = |x: &Value| matches!(x, Value::Empty) || matches!(x, Value::Text(t) if t.is_empty()); + let c = match v { + Value::Array(arr) => arr.iter().filter(|x| blank(x)).count(), + x => usize::from(blank(x)), + }; + num_val(c as f64) +} + +pub(super) fn mode_mult(a: &[Arg], _: C) -> R { + let v = mode_numbers(a)?; + let m = modes(&v); + if m.is_empty() { + return Err(CellError::NA); + } + col_value(m) +} + +pub(super) fn large(a: &[Arg], _: C) -> R { + num_val(kth(arg_nums(a, 0)?, num(a, 1)?, true)?) +} + +pub(super) fn small(a: &[Arg], _: C) -> R { + num_val(kth(arg_nums(a, 0)?, num(a, 1)?, false)?) +} + +pub(super) fn rank_impl(a: &[Arg], avg: bool) -> R { + let x = num(a, 0)?; + let v = arg_nums(a, 1)?; + let asc = opt_num(a, 2, 0.0)? != 0.0; + let eq = v.iter().filter(|&&y| y == x).count(); + if eq == 0 { + return Err(CellError::NA); + } + let before = v.iter().filter(|&&y| if asc { y < x } else { y > x }).count(); + let r = before as f64 + 1.0; + num_val(if avg { r + (eq as f64 - 1.0) / 2.0 } else { r }) +} + +pub(super) fn percentrank_impl(a: &[Arg], exc: bool) -> R { + let s = sorted(arg_nums(a, 0)?); + let x = num(a, 1)?; + let sig = opt_num(a, 2, 3.0)?.trunc(); + if sig < 1.0 || s.is_empty() { + return Err(CellError::Num); + } + let (first, last) = (s.first().copied().unwrap_or(0.0), s.last().copied().unwrap_or(0.0)); + if x < first || x > last { + return Err(CellError::NA); + } + let n = s.len() as f64; + let rank_of = |v: f64| -> f64 { + let less = s.iter().filter(|&&y| y < v).count() as f64; + if exc { + (less + 1.0) / (n + 1.0) + } else if n <= 1.0 { + 1.0 + } else { + less / (n - 1.0) + } + }; + let r = if s.contains(&x) { + rank_of(x) + } else { + let lo = s.iter().copied().filter(|&y| y < x).fold(f64::NEG_INFINITY, f64::max); + let hi = s.iter().copied().filter(|&y| y > x).fold(f64::INFINITY, f64::min); + let (rl, rh) = (rank_of(lo), rank_of(hi)); + rl + (x - lo) / (hi - lo) * (rh - rl) + }; + num_val(trunc_digits(r, sig)) +} + +pub(super) fn trimmean(a: &[Arg], _: C) -> R { + let v = arg_nums(a, 0)?; + let p = num(a, 1)?; + if !(0.0..1.0).contains(&p) || v.is_empty() { + return Err(CellError::Num); + } + let n = v.len(); + let per_side = ((n as f64 * p) / 2.0).floor() as usize; + let s = sorted(v); + let kept = s.get(per_side..n - per_side).unwrap_or(&[]); + num_val(mean(kept)?) +} + +pub(super) fn kurt(v: &[f64]) -> R { + let n = v.len() as f64; + if v.len() < 4 { + return Err(CellError::Div0); + } + let m = mean(v)?; + let s = var_s(v)?.sqrt(); + if s == 0.0 { + return Err(CellError::Div0); + } + let sum4: f64 = v.iter().map(|x| ((x - m) / s).powi(4)).sum(); + Ok(n * (n + 1.0) / ((n - 1.0) * (n - 2.0) * (n - 3.0)) * sum4 - 3.0 * (n - 1.0).powi(2) / ((n - 2.0) * (n - 3.0))) +} + +pub(super) fn skew(v: &[f64]) -> R { + let n = v.len() as f64; + if v.len() < 3 { + return Err(CellError::Div0); + } + let m = mean(v)?; + let s = var_s(v)?.sqrt(); + if s == 0.0 { + return Err(CellError::Div0); + } + let sum3: f64 = v.iter().map(|x| ((x - m) / s).powi(3)).sum(); + Ok(n / ((n - 1.0) * (n - 2.0)) * sum3) +} + +pub(super) fn skew_p(v: &[f64]) -> R { + let m = mean(v)?; + let s = var_p(v)?.sqrt(); + if s == 0.0 { + return Err(CellError::Div0); + } + Ok(v.iter().map(|x| ((x - m) / s).powi(3)).sum::() / v.len() as f64) +} + +pub(super) fn geomean(v: &[f64]) -> R { + if v.is_empty() || v.iter().any(|&x| x <= 0.0) { + return Err(CellError::Num); + } + Ok((v.iter().map(|x| x.ln()).sum::() / v.len() as f64).exp()) +} + +pub(super) fn harmean(v: &[f64]) -> R { + if v.is_empty() || v.iter().any(|&x| x <= 0.0) { + return Err(CellError::Num); + } + Ok(v.len() as f64 / v.iter().map(|x| 1.0 / x).sum::()) +} + +pub(super) fn avedev(v: &[f64]) -> R { + if v.is_empty() { + return Err(CellError::Num); + } + let m = mean(v)?; + Ok(v.iter().map(|x| (x - m).abs()).sum::() / v.len() as f64) +} + +pub(super) fn frequency(a: &[Arg], _: C) -> R { + let data = arg_nums(a, 0)?; + let bins = arg_nums(a, 1)?; + if bins.is_empty() { + return col_value(vec![data.len() as f64]); + } + let mut order: Vec = (0..bins.len()).collect(); + order.sort_by(|&i, &j| bins.get(i).copied().unwrap_or(0.0).total_cmp(&bins.get(j).copied().unwrap_or(0.0))); + let mut counts = vec![0.0; bins.len() + 1]; + for x in data { + let slot = order.iter().find(|&&i| x <= bins.get(i).copied().unwrap_or(f64::INFINITY)).copied().unwrap_or(bins.len()); + if let Some(c) = counts.get_mut(slot) { + *c += 1.0; + } + } + col_value(counts) +} + +pub(super) fn prob(a: &[Arg], _: C) -> R { + let (xs, ps) = arg_pairs(a, 0, 1)?; + let lo = num(a, 2)?; + let hi = if has(a, 3) { num(a, 3)? } else { lo }; + if ps.iter().any(|&p| p <= 0.0 || p > 1.0) { + return Err(CellError::Num); + } + let total: f64 = ps.iter().sum(); + if (total - 1.0).abs() > 1e-7 { + return Err(CellError::Num); + } + num_val(xs.iter().zip(&ps).filter(|(x, _)| **x >= lo && **x <= hi).map(|(_, p)| p).sum()) +} + +pub(super) fn standardize(a: &[Arg], _: C) -> R { + let (x, m, sd) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + if sd <= 0.0 { + return Err(CellError::Num); + } + num_val((x - m) / sd) +} + +// --------------------------------------------------------------------------------------------- +// SUBTOTAL / AGGREGATE support +// --------------------------------------------------------------------------------------------- + +/// Evaluates SUBTOTAL/AGGREGATE function `function_num` over already-filtered cell values. +/// +/// Codes 1–11 (and 101–111) are AVERAGE, COUNT, COUNTA, MAX, MIN, PRODUCT, STDEV.S, STDEV.P, +/// SUM, VAR.S, VAR.P; 12 = MEDIAN, 13 = MODE.SNGL. Codes 14–19 need `k`: use +/// [`aggregate_values_k`] (they give `#VALUE!` here). Values that are not numbers are ignored +/// (COUNTA counts non-empty values); the first error value present is returned. +pub fn aggregate_values(function_num: u32, values: &[Value]) -> Value { + crate::util::finish(aggregate_impl(function_num, values, None)) +} + +/// AGGREGATE codes 14 = LARGE, 15 = SMALL, 16 = PERCENTILE.INC, 17 = QUARTILE.INC, +/// 18 = PERCENTILE.EXC, 19 = QUARTILE.EXC with their `k` argument; codes 1–13 delegate to +/// [`aggregate_values`]. +pub fn aggregate_values_k(function_num: u32, values: &[Value], k: &Value) -> Value { + crate::util::finish(aggregate_impl(function_num, values, Some(k))) +} + +fn aggregate_impl(function_num: u32, values: &[Value], k: Option<&Value>) -> R { + let code = if (101..=111).contains(&function_num) { function_num - 100 } else { function_num }; + let mut xs = Vec::new(); + let mut nonempty = 0usize; + let mut push = |v: &Value| -> R<()> { + match v { + Value::Number(n) => xs.push(*n), + Value::Error(e) => return Err(*e), + _ => {} + } + if !v.is_empty() { + nonempty += 1; + } + Ok(()) + }; + for v in values { + match v { + Value::Array(arr) => arr.iter().try_for_each(&mut push)?, + other => push(other)?, + } + } + let k = match (code, k) { + (14..=19, Some(k)) => k.scalar().to_number()?, + (14..=19, None) => return Err(CellError::Value), + _ => 0.0, + }; + let r = match code { + 1 => mean(&xs)?, + 2 => xs.len() as f64, + 3 => nonempty as f64, + 4 => max_of(&xs), + 5 => min_of(&xs), + 6 => product(&xs), + 7 => var_s(&xs)?.sqrt(), + 8 => var_p(&xs)?.sqrt(), + 9 => xs.iter().sum(), + 10 => var_s(&xs)?, + 11 => var_p(&xs)?, + 12 => median(xs)?, + 13 => mode_sngl(&xs)?, + 14 => kth(xs, k, true)?, + 15 => kth(xs, k, false)?, + 16 => percentile_inc(xs, k)?, + 17 => quartile_inc(xs, k)?, + 18 => percentile_exc(xs, k)?, + 19 => quartile_exc(xs, k)?, + _ => return Err(CellError::Value), + }; + num_val(r) +} diff --git a/crates/functions/src/stat/distributions.rs b/crates/functions/src/stat/distributions.rs new file mode 100644 index 0000000000..cb2f0398ac --- /dev/null +++ b/crates/functions/src/stat/distributions.rs @@ -0,0 +1,702 @@ +use super::descriptive::{arg_nums, arg_pairs, mean, var_s}; +use super::*; + +// --------------------------------------------------------------------------------------------- +// Distributions +// --------------------------------------------------------------------------------------------- + +fn ln_comb(n: f64, k: f64) -> f64 { + ln_gamma(n + 1.0) - ln_gamma(k + 1.0) - ln_gamma(n - k + 1.0) +} + +fn t_upper(t: f64, v: f64) -> f64 { + // P(T > t) for t ≥ 0. + 0.5 * reg_inc_beta(v / (v + t * t), v / 2.0, 0.5) +} + +fn t_cdf(t: f64, v: f64) -> f64 { + if t >= 0.0 { 1.0 - t_upper(t, v) } else { t_upper(-t, v) } +} + +fn t_pdf(t: f64, v: f64) -> f64 { + (ln_gamma((v + 1.0) / 2.0) - ln_gamma(v / 2.0) - 0.5 * (v * std::f64::consts::PI).ln() - (v + 1.0) / 2.0 * (1.0 + t * t / v).ln()).exp() +} + +/// t ≥ 0 with P(T > t) = q, q in (0, 0.5]. +fn t_inv_upper(q: f64, v: f64) -> f64 { + if q >= 0.5 { + return 0.0; + } + invert(|t| t_upper(t, v), q, 0.0, 1.0, 1e60) +} + +fn chi_inv(p: f64, v: f64) -> f64 { + if p <= 0.0 { + return 0.0; + } + invert(|x| reg_gamma_p(v / 2.0, x / 2.0), p, 0.0, v.max(1.0), 1e60) +} + +fn chi_inv_rt(q: f64, v: f64) -> f64 { + if q >= 1.0 { + return 0.0; + } + invert(|x| reg_gamma_q(v / 2.0, x / 2.0), q, 0.0, v.max(1.0), 1e60) +} + +fn f_cdf(x: f64, d1: f64, d2: f64) -> f64 { + if x <= 0.0 { + return 0.0; + } + reg_inc_beta(d1 * x / (d1 * x + d2), d1 / 2.0, d2 / 2.0) +} + +fn f_upper(x: f64, d1: f64, d2: f64) -> f64 { + if x <= 0.0 { + return 1.0; + } + reg_inc_beta(d2 / (d2 + d1 * x), d2 / 2.0, d1 / 2.0) +} + +fn f_pdf(x: f64, d1: f64, d2: f64) -> f64 { + if x == 0.0 { + return if d1 < 2.0 { + f64::INFINITY + } else if d1 == 2.0 { + 1.0 + } else { + 0.0 + }; + } + ((d1 / 2.0) * (d1 / d2).ln() + (d1 / 2.0 - 1.0) * x.ln() - ((d1 + d2) / 2.0) * (1.0 + d1 * x / d2).ln() - ln_beta(d1 / 2.0, d2 / 2.0)).exp() +} + +fn binom_pmf(k: f64, n: f64, p: f64) -> f64 { + if k < 0.0 || k > n { + return 0.0; + } + if p == 0.0 { + return if k == 0.0 { 1.0 } else { 0.0 }; + } + if p == 1.0 { + return if k == n { 1.0 } else { 0.0 }; + } + (ln_comb(n, k) + k * p.ln() + (n - k) * (1.0 - p).ln()).exp() +} + +fn binom_cdf(k: f64, n: f64, p: f64) -> f64 { + if k < 0.0 { + return 0.0; + } + if k >= n { + return 1.0; + } + if n <= 10_000.0 { + let mut s = 0.0; + let mut i = 0.0; + while i <= k { + s += binom_pmf(i, n, p); + i += 1.0; + } + return s.min(1.0); + } + if p == 0.0 { + return 1.0; + } + if p == 1.0 { + return 0.0; + } + reg_inc_beta(1.0 - p, n - k, k + 1.0) +} + +fn poisson_pmf(x: f64, m: f64) -> f64 { + if m == 0.0 { + return if x == 0.0 { 1.0 } else { 0.0 }; + } + (-m + x * m.ln() - ln_gamma(x + 1.0)).exp() +} + +fn poisson_cdf(x: f64, m: f64) -> f64 { + if m == 0.0 { + return 1.0; + } + if x <= 1000.0 { + let mut s = 0.0; + let mut i = 0.0; + while i <= x { + s += poisson_pmf(i, m); + i += 1.0; + } + return s.min(1.0); + } + reg_gamma_q(x + 1.0, m) +} + +pub(super) fn norm_dist(a: &[Arg], _: C) -> R { + let (x, m, sd, cum) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, boolean(a, 3)?); + if sd <= 0.0 { + return Err(CellError::Num); + } + let z = (x - m) / sd; + num_val(if cum { norm_cdf(z) } else { norm_pdf(z) / sd }) +} + +pub(super) fn norm_inv_fn(a: &[Arg], _: C) -> R { + let (p, m, sd) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + if !(p > 0.0 && p < 1.0) || sd <= 0.0 { + return Err(CellError::Num); + } + num_val(m + sd * norm_inv(p)) +} + +pub(super) fn norm_s_dist(a: &[Arg], _: C) -> R { + let z = num(a, 0)?; + let cum = if a.len() > 1 { boolean(a, 1)? } else { true }; + num_val(if cum { norm_cdf(z) } else { norm_pdf(z) }) +} + +pub(super) fn norm_s_inv(a: &[Arg], _: C) -> R { + let p = num(a, 0)?; + if !(p > 0.0 && p < 1.0) { + return Err(CellError::Num); + } + num_val(norm_inv(p)) +} + +fn df_arg(a: &[Arg], i: usize) -> R { + let v = num(a, i)?.trunc(); + if v < 1.0 { + return Err(CellError::Num); + } + Ok(v) +} + +pub(super) fn t_dist(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + let v = df_arg(a, 1)?; + let cum = boolean(a, 2)?; + num_val(if cum { t_cdf(x, v) } else { t_pdf(x, v) }) +} + +pub(super) fn t_dist_2t(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + let v = df_arg(a, 1)?; + if x < 0.0 { + return Err(CellError::Num); + } + num_val(2.0 * t_upper(x, v)) +} + +pub(super) fn t_dist_rt(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + let v = df_arg(a, 1)?; + num_val(1.0 - t_cdf(x, v)) +} + +pub(super) fn tdist_legacy(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + let v = df_arg(a, 1)?; + let tails = num(a, 2)?.trunc(); + if x < 0.0 || !(tails == 1.0 || tails == 2.0) { + return Err(CellError::Num); + } + num_val(tails * t_upper(x, v)) +} + +pub(super) fn t_inv(a: &[Arg], _: C) -> R { + let p = num(a, 0)?; + let v = df_arg(a, 1)?; + if !(p > 0.0 && p < 1.0) { + return Err(CellError::Num); + } + num_val(if p < 0.5 { -t_inv_upper(p, v) } else { t_inv_upper(1.0 - p, v) }) +} + +pub(super) fn t_inv_2t(a: &[Arg], _: C) -> R { + let p = num(a, 0)?; + let v = df_arg(a, 1)?; + if !(p > 0.0 && p <= 1.0) { + return Err(CellError::Num); + } + num_val(t_inv_upper(p / 2.0, v)) +} + +pub(super) fn t_test(a: &[Arg], _: C) -> R { + let tails = num(a, 2)?.trunc(); + let ty = num(a, 3)?.trunc(); + if !(tails == 1.0 || tails == 2.0) || !(1.0..=3.0).contains(&ty) { + return Err(CellError::Num); + } + let (t, v) = if ty == 1.0 { + let (x, y) = arg_pairs(a, 0, 1)?; + let d: Vec = x.iter().zip(&y).map(|(p, q)| p - q).collect(); + let n = d.len() as f64; + let sd = var_s(&d)?.sqrt(); + if sd == 0.0 { + return Err(CellError::Div0); + } + (mean(&d)? / (sd / n.sqrt()), n - 1.0) + } else { + let x = arg_nums(a, 0)?; + let y = arg_nums(a, 1)?; + let (n1, n2) = (x.len() as f64, y.len() as f64); + let (v1, v2) = (var_s(&x)?, var_s(&y)?); + let diff = mean(&x)? - mean(&y)?; + if ty == 2.0 { + let df = n1 + n2 - 2.0; + let sp = ((n1 - 1.0) * v1 + (n2 - 1.0) * v2) / df; + let se = (sp * (1.0 / n1 + 1.0 / n2)).sqrt(); + if se == 0.0 { + return Err(CellError::Div0); + } + (diff / se, df) + } else { + let (a1, a2) = (v1 / n1, v2 / n2); + let se = (a1 + a2).sqrt(); + if se == 0.0 { + return Err(CellError::Div0); + } + let df = (a1 + a2).powi(2) / (a1 * a1 / (n1 - 1.0) + a2 * a2 / (n2 - 1.0)); + (diff / se, df) + } + }; + num_val(tails * t_upper(t.abs(), v)) +} + +pub(super) fn chisq_dist(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + let v = df_arg(a, 1)?; + let cum = boolean(a, 2)?; + if x < 0.0 || v > 1e10 { + return Err(CellError::Num); + } + if cum { + return num_val(reg_gamma_p(v / 2.0, x / 2.0)); + } + if x == 0.0 { + return if v == 2.0 { + num_val(0.5) + } else if v < 2.0 { + Err(CellError::Num) + } else { + num_val(0.0) + }; + } + num_val(((v / 2.0 - 1.0) * x.ln() - x / 2.0 - (v / 2.0) * 2f64.ln() - ln_gamma(v / 2.0)).exp()) +} + +pub(super) fn chisq_dist_rt(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + let v = df_arg(a, 1)?; + if x < 0.0 || v > 1e10 { + return Err(CellError::Num); + } + num_val(reg_gamma_q(v / 2.0, x / 2.0)) +} + +pub(super) fn chisq_inv(a: &[Arg], _: C) -> R { + let p = num(a, 0)?; + let v = df_arg(a, 1)?; + if !(0.0..1.0).contains(&p) || v > 1e10 { + return Err(CellError::Num); + } + num_val(chi_inv(p, v)) +} + +pub(super) fn chisq_inv_rt(a: &[Arg], _: C) -> R { + let p = num(a, 0)?; + let v = df_arg(a, 1)?; + if !(p > 0.0 && p <= 1.0) || v > 1e10 { + return Err(CellError::Num); + } + num_val(chi_inv_rt(p, v)) +} + +pub(super) fn chisq_test(a: &[Arg], _: C) -> R { + let act = as_array(&arg(a, 0)?.value).into_owned(); + let exp = as_array(&arg(a, 1)?.value).into_owned(); + if act.rows != exp.rows || act.cols != exp.cols { + return Err(CellError::NA); + } + let mut x2 = 0.0; + for (o, e) in act.data.iter().zip(&exp.data) { + if let Value::Error(err) = o { + return Err(*err); + } + if let Value::Error(err) = e { + return Err(*err); + } + if let (Value::Number(o), Value::Number(e)) = (o, e) { + if *e == 0.0 { + return Err(CellError::Div0); + } + x2 += (o - e).powi(2) / e; + } + } + let df = if act.rows > 1 && act.cols > 1 { ((act.rows - 1) * (act.cols - 1)) as f64 } else { (act.data.len().saturating_sub(1)) as f64 }; + if df < 1.0 { + return Err(CellError::NA); + } + num_val(reg_gamma_q(df / 2.0, x2 / 2.0)) +} + +fn f_args(a: &[Arg]) -> R<(f64, f64, f64)> { + let x = num(a, 0)?; + let d1 = num(a, 1)?.trunc(); + let d2 = num(a, 2)?.trunc(); + if d1 < 1.0 || d2 < 1.0 || d1 >= 1e10 || d2 >= 1e10 { + return Err(CellError::Num); + } + Ok((x, d1, d2)) +} + +pub(super) fn f_dist(a: &[Arg], _: C) -> R { + let (x, d1, d2) = f_args(a)?; + let cum = boolean(a, 3)?; + if x < 0.0 { + return Err(CellError::Num); + } + num_val(if cum { f_cdf(x, d1, d2) } else { f_pdf(x, d1, d2) }) +} + +pub(super) fn f_dist_rt(a: &[Arg], _: C) -> R { + let (x, d1, d2) = f_args(a)?; + if x < 0.0 { + return Err(CellError::Num); + } + num_val(f_upper(x, d1, d2)) +} + +pub(super) fn f_inv(a: &[Arg], _: C) -> R { + let (p, d1, d2) = f_args(a)?; + if !(0.0..1.0).contains(&p) { + return Err(CellError::Num); + } + if p == 0.0 { + return num_val(0.0); + } + num_val(invert(|x| f_cdf(x, d1, d2), p, 0.0, 1.0, 1e60)) +} + +pub(super) fn f_inv_rt(a: &[Arg], _: C) -> R { + let (p, d1, d2) = f_args(a)?; + if !(p > 0.0 && p <= 1.0) { + return Err(CellError::Num); + } + if p == 1.0 { + return num_val(0.0); + } + num_val(invert(|x| f_upper(x, d1, d2), p, 0.0, 1.0, 1e60)) +} + +pub(super) fn f_test(a: &[Arg], _: C) -> R { + let x = arg_nums(a, 0)?; + let y = arg_nums(a, 1)?; + let (v1, v2) = (var_s(&x)?, var_s(&y)?); + if v1 == 0.0 || v2 == 0.0 { + return Err(CellError::Div0); + } + let f = v1 / v2; + let (d1, d2) = ((x.len() - 1) as f64, (y.len() - 1) as f64); + let c = f_cdf(f, d1, d2); + num_val((2.0 * c.min(1.0 - c)).min(1.0)) +} + +pub(super) fn binom_dist(a: &[Arg], _: C) -> R { + let k = num(a, 0)?.trunc(); + let n = num(a, 1)?.trunc(); + let p = num(a, 2)?; + let cum = boolean(a, 3)?; + if k < 0.0 || k > n || !(0.0..=1.0).contains(&p) { + return Err(CellError::Num); + } + num_val(if cum { binom_cdf(k, n, p) } else { binom_pmf(k, n, p) }) +} + +pub(super) fn binom_dist_range(a: &[Arg], _: C) -> R { + let n = num(a, 0)?.trunc(); + let p = num(a, 1)?; + let s1 = num(a, 2)?.trunc(); + let s2 = if has(a, 3) { num(a, 3)?.trunc() } else { s1 }; + if !(0.0..=1e15).contains(&n) || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n { + return Err(CellError::Num); + } + if s2 - s1 > 10_000.0 { + return num_val(binom_cdf(s2, n, p) - binom_cdf(s1 - 1.0, n, p)); + } + let mut s = 0.0; + let mut k = s1; + while k <= s2 { + s += binom_pmf(k, n, p); + k += 1.0; + } + num_val(s) +} + +pub(super) fn binom_inv(a: &[Arg], _: C) -> R { + let n = num(a, 0)?.trunc(); + let p = num(a, 1)?; + let alpha = num(a, 2)?; + if n < 0.0 || !(0.0..=1.0).contains(&p) || !(0.0..=1.0).contains(&alpha) { + return Err(CellError::Num); + } + // Smallest k with cdf(k) ≥ alpha, by bisection on the integer k. + let (mut lo, mut hi) = (0.0f64, n); + if binom_cdf(0.0, n, p) >= alpha { + return num_val(0.0); + } + while hi - lo > 1.0 { + let mid = ((lo + hi) / 2.0).floor(); + // Above 2^53 adjacent f64 values are more than 1 apart, so the gap can stop shrinking. + if mid <= lo || mid >= hi { + break; + } + if binom_cdf(mid, n, p) >= alpha { + hi = mid; + } else { + lo = mid; + } + } + num_val(hi) +} + +pub(super) fn poisson_dist(a: &[Arg], _: C) -> R { + let x = num(a, 0)?.trunc(); + let m = num(a, 1)?; + let cum = boolean(a, 2)?; + if x < 0.0 || m < 0.0 { + return Err(CellError::Num); + } + num_val(if cum { poisson_cdf(x, m) } else { poisson_pmf(x, m) }) +} + +pub(super) fn expon_dist(a: &[Arg], _: C) -> R { + let (x, l, cum) = (num(a, 0)?, num(a, 1)?, boolean(a, 2)?); + if x < 0.0 || l <= 0.0 { + return Err(CellError::Num); + } + num_val(if cum { -(-l * x).exp_m1() } else { l * (-l * x).exp() }) +} + +pub(super) fn gamma_fn(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + let g = gamma(x); + if !g.is_finite() { + return Err(CellError::Num); + } + num_val(g) +} + +pub(super) fn gamma_dist(a: &[Arg], _: C) -> R { + let (x, al, be, cum) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, boolean(a, 3)?); + if x < 0.0 || al <= 0.0 || be <= 0.0 { + return Err(CellError::Num); + } + if cum { + return num_val(reg_gamma_p(al, x / be)); + } + if x == 0.0 { + return if al < 1.0 { + Err(CellError::Num) + } else if al == 1.0 { + num_val(1.0 / be) + } else { + num_val(0.0) + }; + } + num_val(((al - 1.0) * x.ln() - x / be - al * be.ln() - ln_gamma(al)).exp()) +} + +pub(super) fn gamma_inv(a: &[Arg], _: C) -> R { + let (p, al, be) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + if !(0.0..1.0).contains(&p) || al <= 0.0 || be <= 0.0 { + return Err(CellError::Num); + } + if p == 0.0 { + return num_val(0.0); + } + num_val(invert(|x| reg_gamma_p(al, x / be), p, 0.0, (al * be).max(1e-3), 1e60)) +} + +pub(super) fn gammaln(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + if x <= 0.0 { + return Err(CellError::Num); + } + num_val(ln_gamma(x)) +} + +fn beta_bounds(a: &[Arg], i: usize) -> R<(f64, f64)> { + let lo = if has(a, i) { num(a, i)? } else { 0.0 }; + let hi = if has(a, i + 1) { num(a, i + 1)? } else { 1.0 }; + if lo >= hi { + return Err(CellError::Num); + } + Ok((lo, hi)) +} + +pub(super) fn beta_dist(a: &[Arg], _: C) -> R { + let (x, al, be, cum) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, boolean(a, 3)?); + let (lo, hi) = beta_bounds(a, 4)?; + if al <= 0.0 || be <= 0.0 || x < lo || x > hi { + return Err(CellError::Num); + } + let z = (x - lo) / (hi - lo); + if cum { + return num_val(reg_inc_beta(z, al, be)); + } + if (z == 0.0 && al < 1.0) || (z == 1.0 && be < 1.0) { + return Err(CellError::Num); + } + let lp = if z == 0.0 || z == 1.0 { + let base = if z == 0.0 { al } else { be }; + if base == 1.0 { -ln_beta(al, be) } else { f64::NEG_INFINITY } + } else { + (al - 1.0) * z.ln() + (be - 1.0) * (1.0 - z).ln() - ln_beta(al, be) + }; + num_val(lp.exp() / (hi - lo)) +} + +pub(super) fn betadist_legacy(a: &[Arg], _: C) -> R { + let (x, al, be) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + let (lo, hi) = beta_bounds(a, 3)?; + if al <= 0.0 || be <= 0.0 || x < lo || x > hi { + return Err(CellError::Num); + } + num_val(reg_inc_beta((x - lo) / (hi - lo), al, be)) +} + +pub(super) fn beta_inv(a: &[Arg], _: C) -> R { + let (p, al, be) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + let (lo, hi) = beta_bounds(a, 3)?; + if al <= 0.0 || be <= 0.0 || !(0.0..=1.0).contains(&p) { + return Err(CellError::Num); + } + let z = if p == 0.0 { + 0.0 + } else if p == 1.0 { + 1.0 + } else { + invert(|x| reg_inc_beta(x, al, be), p, 0.0, 1.0, 1.0) + }; + num_val(lo + z * (hi - lo)) +} + +pub(super) fn lognorm_dist(a: &[Arg], _: C) -> R { + let (x, m, sd) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + let cum = if a.len() > 3 { boolean(a, 3)? } else { true }; + if x <= 0.0 || sd <= 0.0 { + return Err(CellError::Num); + } + let z = (x.ln() - m) / sd; + num_val(if cum { norm_cdf(z) } else { norm_pdf(z) / (x * sd) }) +} + +pub(super) fn lognorm_inv(a: &[Arg], _: C) -> R { + let (p, m, sd) = (num(a, 0)?, num(a, 1)?, num(a, 2)?); + if !(p > 0.0 && p < 1.0) || sd <= 0.0 { + return Err(CellError::Num); + } + num_val((m + sd * norm_inv(p)).exp()) +} + +pub(super) fn weibull_dist(a: &[Arg], _: C) -> R { + let (x, al, be, cum) = (num(a, 0)?, num(a, 1)?, num(a, 2)?, boolean(a, 3)?); + if x < 0.0 || al <= 0.0 || be <= 0.0 { + return Err(CellError::Num); + } + let t = (x / be).powf(al); + num_val(if cum { -(-t).exp_m1() } else { al / be.powf(al) * x.powf(al - 1.0) * (-t).exp() }) +} + +pub(super) fn hypgeom_dist(a: &[Arg], _: C) -> R { + let k = num(a, 0)?.trunc(); + let n = num(a, 1)?.trunc(); + let kk = num(a, 2)?.trunc(); + let nn = num(a, 3)?.trunc(); + let cum = if a.len() > 4 { boolean(a, 4)? } else { false }; + if k < 0.0 || n <= 0.0 || kk <= 0.0 || nn <= 0.0 || n > nn || kk > nn || k > n.min(kk) || k < (n - nn + kk).max(0.0) { + return Err(CellError::Num); + } + let pmf = |i: f64| (ln_comb(kk, i) + ln_comb(nn - kk, n - i) - ln_comb(nn, n)).exp(); + if !cum { + return num_val(pmf(k)); + } + let mut s = 0.0; + let mut i = (n - nn + kk).max(0.0); + if k - i > 1e6 || i + 1.0 == i { + return Err(CellError::Num); + } + while i <= k { + s += pmf(i); + i += 1.0; + } + num_val(s.min(1.0)) +} + +pub(super) fn negbinom_dist(a: &[Arg], _: C) -> R { + let f = num(a, 0)?.trunc(); + let s = num(a, 1)?.trunc(); + let p = num(a, 2)?; + let cum = if a.len() > 3 { boolean(a, 3)? } else { false }; + if f < 0.0 || s < 1.0 || !(0.0..=1.0).contains(&p) { + return Err(CellError::Num); + } + if cum { + if p == 0.0 { + return num_val(0.0); + } + return num_val(reg_inc_beta(p, s, f + 1.0)); + } + if p == 0.0 || p == 1.0 { + return num_val(if p == 1.0 && f == 0.0 { 1.0 } else { 0.0 }); + } + num_val((ln_comb(f + s - 1.0, s - 1.0) + s * p.ln() + f * (1.0 - p).ln()).exp()) +} + +pub(super) fn confidence_norm(a: &[Arg], _: C) -> R { + let (al, sd, n) = (num(a, 0)?, num(a, 1)?, num(a, 2)?.trunc()); + if !(al > 0.0 && al < 1.0) || sd <= 0.0 || n < 1.0 { + return Err(CellError::Num); + } + num_val(norm_inv(1.0 - al / 2.0) * sd / n.sqrt()) +} + +pub(super) fn confidence_t(a: &[Arg], _: C) -> R { + let (al, sd, n) = (num(a, 0)?, num(a, 1)?, num(a, 2)?.trunc()); + if !(al > 0.0 && al < 1.0) || sd <= 0.0 || n < 1.0 { + return Err(CellError::Num); + } + if n == 1.0 { + return Err(CellError::Div0); + } + num_val(t_inv_upper(al / 2.0, n - 1.0) * sd / n.sqrt()) +} + +pub(super) fn z_test(a: &[Arg], _: C) -> R { + let v = arg_nums(a, 0)?; + let x = num(a, 1)?; + if v.is_empty() { + return Err(CellError::NA); + } + let sd = if has(a, 2) { num(a, 2)? } else { var_s(&v)?.sqrt() }; + if sd <= 0.0 { + return Err(if has(a, 2) { CellError::Num } else { CellError::Div0 }); + } + let z = (mean(&v)? - x) / (sd / (v.len() as f64).sqrt()); + num_val(1.0 - norm_cdf(z)) +} + +pub(super) fn fisher(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + if x <= -1.0 || x >= 1.0 { + return Err(CellError::Num); + } + num_val(0.5 * ((1.0 + x) / (1.0 - x)).ln()) +} + +pub(super) fn gauss(a: &[Arg], _: C) -> R { + let z = num(a, 0)?; + // Φ(z) − 0.5 = erf(z/√2)/2, accurate near zero. + num_val(0.5 * erf(z / std::f64::consts::SQRT_2)) +} diff --git a/crates/functions/src/stat/regression.rs b/crates/functions/src/stat/regression.rs new file mode 100644 index 0000000000..410b086d48 --- /dev/null +++ b/crates/functions/src/stat/regression.rs @@ -0,0 +1,342 @@ +use super::descriptive::{arg_pairs, intercept, moments, slope}; +use super::*; + +pub(super) fn forecast(a: &[Arg], _: C) -> R { + let x = num(a, 0)?; + let (ys, xs) = arg_pairs(a, 1, 2)?; + if ys.is_empty() { + return Err(CellError::Div0); + } + num_val(intercept(&ys, &xs)? + slope(&ys, &xs)? * x) +} + +pub(super) fn steyx(a: &[Arg], _: C) -> R { + let (ys, xs) = arg_pairs(a, 0, 1)?; + let n = ys.len() as f64; + if ys.len() < 3 { + return Err(CellError::Div0); + } + let (sxy, sxx, syy, _, _) = moments(&xs, &ys)?; + if sxx == 0.0 { + return Err(CellError::Div0); + } + num_val(((syy - sxy * sxy / sxx) / (n - 2.0)).max(0.0).sqrt()) +} + +pub(super) fn covariance(a: &[Arg], sample: bool) -> R { + let (xs, ys) = arg_pairs(a, 0, 1)?; + let n = xs.len(); + if n == 0 || (sample && n < 2) { + return Err(CellError::Div0); + } + let (sxy, ..) = moments(&xs, &ys)?; + num_val(sxy / if sample { (n - 1) as f64 } else { n as f64 }) +} + +// --------------------------------------------------------------------------------------------- +// Regression (LINEST, LOGEST, TREND, GROWTH) +// --------------------------------------------------------------------------------------------- + +struct Xy { + y: Vec, + /// n rows of k predictor values. + x: Vec>, + k: usize, + y_is_row: bool, + y_rows: usize, + y_cols: usize, +} + +fn strict_num(v: &Value) -> R { + match v { + Value::Number(n) => Ok(*n), + Value::Error(e) => Err(*e), + _ => Err(CellError::Value), + } +} + +/// Most predictor columns LINEST, LOGEST, TREND and GROWTH accept. The normal equations are a +/// k×k matrix inverted in O(k³), so an unbounded k (one `SEQUENCE(1,16384)` argument) allocated +/// gigabytes and could abort the process. +pub(super) const MAX_REGRESSORS: usize = 255; + +/// Most multiply-adds (rows × k²) a regression may spend building its normal equations. +const MAX_REGRESSION_WORK: usize = 1_000_000_000; + +/// Rejects regressions too large to fit: more than [`MAX_REGRESSORS`] predictors is `#VALUE!`, +/// more than [`MAX_REGRESSION_WORK`] multiply-adds is `#NUM!`. +pub(super) fn check_regression_size(n: usize, k: usize) -> R<()> { + if k > MAX_REGRESSORS { + return Err(CellError::Value); + } + match n.checked_mul(k).and_then(|nk| nk.checked_mul(k)) { + Some(work) if work <= MAX_REGRESSION_WORK => Ok(()), + _ => Err(CellError::Num), + } +} + +fn parse_xy(y: &Value, x: Option<&Value>) -> R { + let ya = as_array(y); + let n = ya.data.len(); + let y_is_row = ya.rows == 1 && ya.cols > 1; + let yv: Vec = ya.data.iter().map(strict_num).collect::>()?; + let (xs, k) = match x { + None => ((1..=n).map(|i| vec![i as f64]).collect(), 1), + Some(xv) => { + let xa = as_array(xv); + if xa.rows == ya.rows && xa.cols == ya.cols { + (xa.data.iter().map(|v| strict_num(v).map(|f| vec![f])).collect::>>()?, 1) + } else if !y_is_row && xa.rows == n { + check_regression_size(n, xa.cols)?; + let mut rows = Vec::with_capacity(n); + for r in 0..n { + rows.push((0..xa.cols).map(|c| strict_num(xa.get(r, c).unwrap_or(&Value::Empty))).collect::>>()?); + } + (rows, xa.cols) + } else if y_is_row && xa.cols == n { + check_regression_size(n, xa.rows)?; + let mut rows = Vec::with_capacity(n); + for c in 0..n { + rows.push((0..xa.rows).map(|r| strict_num(xa.get(r, c).unwrap_or(&Value::Empty))).collect::>>()?); + } + (rows, xa.rows) + } else { + return Err(CellError::Ref); + } + } + }; + Ok(Xy { y: yv, x: xs, k, y_is_row, y_rows: ya.rows, y_cols: ya.cols }) +} + +struct Fit { + coef: Vec, + b: f64, + se: Vec, + se_b: Option, + r2: R, + sey: R, + f: R, + df: f64, + ssreg: f64, + ssresid: f64, +} + +/// Inverts a symmetric positive semi-definite matrix by Gauss–Jordan on the diagonal, dropping +/// (zeroing) collinear variables. Returns the inverse and the dropped flags. +fn sym_inverse(m: &[Vec]) -> (Vec>, Vec) { + let p = m.len(); + let mut a: Vec> = m.to_vec(); + let mut inv: Vec> = (0..p).map(|i| (0..p).map(|j| if i == j { 1.0 } else { 0.0 }).collect()).collect(); + let mut dropped = vec![false; p]; + let diag: Vec = (0..p).map(|i| m.get(i).and_then(|r| r.get(i)).copied().unwrap_or(0.0).abs()).collect(); + for j in 0..p { + let piv = a.get(j).and_then(|r| r.get(j)).copied().unwrap_or(0.0); + let scale = diag.get(j).copied().unwrap_or(0.0); + if piv.abs() <= 1e-12 * scale || piv.abs() < 1e-300 { + if let Some(d) = dropped.get_mut(j) { + *d = true; + } + for row in a.iter_mut() { + if let Some(v) = row.get_mut(j) { + *v = 0.0; + } + } + if let Some(r) = a.get_mut(j) { + r.iter_mut().for_each(|v| *v = 0.0); + } + if let Some(r) = inv.get_mut(j) { + r.iter_mut().for_each(|v| *v = 0.0); + } + continue; + } + let arow: Vec = a.get(j).map(|r| r.iter().map(|v| v / piv).collect()).unwrap_or_default(); + let irow: Vec = inv.get(j).map(|r| r.iter().map(|v| v / piv).collect()).unwrap_or_default(); + for i in 0..p { + if i == j { + continue; + } + let f = a.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0); + if f == 0.0 { + continue; + } + if let Some(r) = a.get_mut(i) { + r.iter_mut().zip(&arow).for_each(|(v, w)| *v -= f * w); + } + if let Some(r) = inv.get_mut(i) { + r.iter_mut().zip(&irow).for_each(|(v, w)| *v -= f * w); + } + } + if let Some(r) = a.get_mut(j) { + *r = arow; + } + if let Some(r) = inv.get_mut(j) { + *r = irow; + } + } + (inv, dropped) +} + +fn regress(y: &[f64], x: &[Vec], k: usize, constant: bool) -> R { + let n = y.len(); + if n == 0 || x.len() != n { + return Err(CellError::Value); + } + check_regression_size(n, k)?; + let nf = n as f64; + let (xm, ym) = if constant { + let xm: Vec = (0..k).map(|j| x.iter().map(|r| r.get(j).copied().unwrap_or(0.0)).sum::() / nf).collect(); + (xm, y.iter().sum::() / nf) + } else { + (vec![0.0; k], 0.0) + }; + // Normal equations on (centred) data. + let mut xtx = vec![vec![0.0; k]; k]; + let mut xty = vec![0.0; k]; + for (row, yv) in x.iter().zip(y) { + let d: Vec = (0..k).map(|j| row.get(j).copied().unwrap_or(0.0) - xm.get(j).copied().unwrap_or(0.0)).collect(); + let yd = yv - ym; + for (i, di) in d.iter().enumerate() { + if let Some(t) = xty.get_mut(i) { + *t += di * yd; + } + if let Some(r) = xtx.get_mut(i) { + for (j, dj) in d.iter().enumerate() { + if let Some(v) = r.get_mut(j) { + *v += di * dj; + } + } + } + } + } + let (inv, dropped) = sym_inverse(&xtx); + let coef: Vec = (0..k).map(|i| inv.get(i).map(|r| r.iter().zip(&xty).map(|(a, b)| a * b).sum()).unwrap_or(0.0)).collect(); + let b = if constant { ym - coef.iter().zip(&xm).map(|(c, m)| c * m).sum::() } else { 0.0 }; + let mut ssresid = 0.0; + let mut sstot = 0.0; + for (row, yv) in x.iter().zip(y) { + let pred = b + coef.iter().zip(row).map(|(c, v)| c * v).sum::(); + ssresid += (yv - pred).powi(2); + sstot += if constant { (yv - ym).powi(2) } else { yv * yv }; + } + let used = dropped.iter().filter(|d| !**d).count() + usize::from(constant); + let df = nf - used as f64; + let ssreg = sstot - ssresid; + let r2 = if sstot == 0.0 { Err(CellError::Num) } else { Ok(ssreg / sstot) }; + let sey2 = if df > 0.0 { Ok(ssresid / df) } else { Err(CellError::Num) }; + let sey = sey2.map(f64::sqrt); + let v1 = if constant { nf - df - 1.0 } else { nf - df }; + let f = match sey2 { + Ok(s2) if s2 > 0.0 && v1 > 0.0 => Ok((ssreg / v1) / s2), + _ => Err(CellError::Num), + }; + let s2 = sey2.unwrap_or(f64::NAN); + let se: Vec = (0..k) + .map(|i| if dropped.get(i).copied().unwrap_or(true) { 0.0 } else { (inv.get(i).and_then(|r| r.get(i)).copied().unwrap_or(0.0) * s2).sqrt() }) + .collect(); + let se_b = if constant { + let mut q = 0.0; + for (i, mi) in xm.iter().enumerate() { + for (j, mj) in xm.iter().enumerate() { + q += mi * mj * inv.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0); + } + } + Some((s2 * (1.0 / nf + q)).sqrt()) + } else { + None + }; + Ok(Fit { coef, b, se, se_b, r2, sey, f, df, ssreg, ssresid }) +} + +fn const_arg(a: &[Arg], i: usize) -> R { + if has(a, i) { boolean(a, i) } else { Ok(true) } +} + +fn opt_value(a: &[Arg], i: usize) -> Option<&Value> { + if has(a, i) { a.get(i).map(|x| &x.value) } else { None } +} + +pub(super) fn linest_impl(a: &[Arg], log: bool) -> R { + let mut xy = parse_xy(&arg(a, 0)?.value, opt_value(a, 1))?; + if log { + if xy.y.iter().any(|&v| v <= 0.0) { + return Err(CellError::Num); + } + xy.y.iter_mut().for_each(|v| *v = v.ln()); + } + let constant = const_arg(a, 2)?; + let stats = if has(a, 3) { boolean(a, 3)? } else { false }; + let fit = regress(&xy.y, &xy.x, xy.k, constant)?; + let tr = |v: f64| if log { v.exp() } else { v }; + let cols = xy.k + 1; + let mut data: Vec = Vec::new(); + for c in fit.coef.iter().rev() { + data.push(Value::number(tr(*c))); + } + data.push(Value::number(tr(fit.b))); + if stats { + let rv = |r: R| r.map(Value::number).unwrap_or_else(Value::Error); + let na = Value::Error(CellError::NA); + for s in fit.se.iter().rev() { + data.push(Value::number(*s)); + } + data.push(fit.se_b.map(Value::number).unwrap_or(na.clone())); + let rows: [[Value; 2]; 3] = + [[rv(fit.r2), rv(fit.sey)], [rv(fit.f), Value::number(fit.df)], [Value::number(fit.ssreg), Value::number(fit.ssresid)]]; + for [p, q] in rows { + data.push(p); + if cols > 1 { + data.push(q); + } + for _ in 2..cols { + data.push(na.clone()); + } + } + return array_val(5, cols, data); + } + array_val(1, cols, data) +} + +pub(super) fn trend_impl(a: &[Arg], log: bool) -> R { + let mut xy = parse_xy(&arg(a, 0)?.value, opt_value(a, 1))?; + if log { + if xy.y.iter().any(|&v| v <= 0.0) { + return Err(CellError::Num); + } + xy.y.iter_mut().for_each(|v| *v = v.ln()); + } + let constant = const_arg(a, 3)?; + let fit = regress(&xy.y, &xy.x, xy.k, constant)?; + let predict = |row: &[f64]| -> Value { + let v = fit.b + fit.coef.iter().zip(row).map(|(c, x)| c * x).sum::(); + Value::number(if log { v.exp() } else { v }) + }; + match opt_value(a, 2) { + None => { + let data: Vec = xy.x.iter().map(|r| predict(r)).collect(); + array_val(xy.y_rows, xy.y_cols, data) + } + Some(nv) => { + let na = as_array(nv); + if xy.k == 1 { + let data = na.data.iter().map(|v| strict_num(v).map(|x| predict(&[x]))).collect::>>()?; + array_val(na.rows, na.cols, data) + } else if !xy.y_is_row && na.cols == xy.k { + let mut data = Vec::with_capacity(na.rows); + for r in 0..na.rows { + let row = (0..na.cols).map(|c| strict_num(na.get(r, c).unwrap_or(&Value::Empty))).collect::>>()?; + data.push(predict(&row)); + } + array_val(na.rows, 1, data) + } else if xy.y_is_row && na.rows == xy.k { + let mut data = Vec::with_capacity(na.cols); + for c in 0..na.cols { + let row = (0..na.rows).map(|r| strict_num(na.get(r, c).unwrap_or(&Value::Empty))).collect::>>()?; + data.push(predict(&row)); + } + array_val(1, na.cols, data) + } else { + Err(CellError::Ref) + } + } + } +} diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs new file mode 100644 index 0000000000..87ce19fac0 --- /dev/null +++ b/crates/functions/src/stat/tests.rs @@ -0,0 +1,508 @@ +use super::{MAX_REGRESSORS, aggregate_values, aggregate_values_k, check_regression_size}; +use crate::util::testutil::*; +use gridcraft_core::{CellError, Value}; + +const D7: f64 = 1e-6; + +fn c(xs: &[f64]) -> crate::Arg { + av(col(xs)) +} + +// The two middle values are finite and their midpoint is representable even when +// adding them first would overflow. Exercise both callers of the shared helper. +#[test] +fn median_finite_midpoint_does_not_overflow() { + for (values, expected) in [ + (vec![1e308, 1e308], 1e308), + (vec![-1e308, -1e308], -1e308), + (vec![1.6e308, 1e308], 1.3e308), + (vec![-1e308, -1.6e308], -1.3e308), + (vec![f64::MAX, f64::MAX], f64::MAX), + (vec![-f64::MAX, -f64::MAX], -f64::MAX), + (vec![-f64::MAX, f64::MAX], 0.0), + (vec![1.7e308, -1.7e308, 1e308, 1.6e308], 1.3e308), + ] { + close(ev("MEDIAN", values.iter().copied().map(n).collect()), expected); + close(ev("MEDIAN", vec![rf(col(&values))]), expected); + close(aggregate_values(12, &values.iter().copied().map(nv).collect::>()), expected); + } +} + +#[test] +fn median_midpoint_keeps_small_values_and_argument_semantics() { + // Halving each operand unconditionally can lose the smallest numbers. Use exact + // equality here: the usual aggregate-test tolerance would accept zero. + for value in [f64::MIN_POSITIVE, -f64::MIN_POSITIVE, f64::from_bits(1), -f64::from_bits(1)] { + assert_eq!(ev("MEDIAN", vec![n(value), n(value)]), nv(value)); + assert_eq!(aggregate_values(12, &[nv(value), nv(value)]), nv(value)); + } + close(ev("MEDIAN", vec![c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0])]), 3.5); + close(ev("MEDIAN", vec![c(&[1e308, -1e308, 3.0])]), 3.0); + close(ev("MEDIAN", vec![n(1e308)]), 1e308); + close(ev("MEDIAN", vec![t("4"), b(true)]), 2.5); + close(ev("MEDIAN", vec![rf(arr(vec![vec![nv(1.0), tv("ignored"), Value::Empty, Value::Bool(true), nv(6.0)]]))]), 3.5); + is_err(ev("MEDIAN", vec![t("invalid")]), CellError::Value); + is_err(ev("MEDIAN", vec![e(CellError::NA), n(1e308)]), CellError::NA); + is_err(ev("MEDIAN", vec![rf(arr(vec![vec![tv("ignored"), Value::Empty]]))]), CellError::Num); + is_err(aggregate_values(12, &[]), CellError::Num); + is_err(aggregate_values(12, &[nv(1e308), Value::Error(CellError::Div0)]), CellError::Div0); +} + +#[test] +fn averages_and_counts() { + close(ev("AVERAGE", vec![n(1.0), n(2.0), n(6.0)]), 3.0); + is_err(ev("AVERAGE", vec![rf(arr(vec![vec![tv("a")]]))]), CellError::Div0); + let mixed = arr(vec![vec![nv(1.0), tv("x"), Value::Bool(true), Value::Empty, nv(3.0)]]); + close(ev("AVERAGE", vec![rf(mixed.clone())]), 2.0); + close(ev("AVERAGEA", vec![rf(mixed.clone())]), 1.25); + close(ev("AVERAGE", vec![t("4"), b(true)]), 2.5); + is_err(ev("AVERAGE", vec![t("abc")]), CellError::Value); + is_err(ev("AVERAGE", vec![rf(arr(vec![vec![nv(1.0), Value::Error(CellError::NA)]]))]), CellError::NA); + close(ev("COUNT", vec![rf(mixed.clone()), n(4.0), t("5"), t("x")]), 4.0); + close(ev("COUNT", vec![e(CellError::Div0)]), 0.0); + close(ev("COUNTA", vec![rf(mixed.clone())]), 4.0); + close(ev("COUNTA", vec![n(1.0), t("")]), 2.0); + let blanks = arr(vec![vec![Value::Empty, tv(""), nv(0.0), tv(" ")]]); + close(ev("COUNTBLANK", vec![rf(blanks)]), 2.0); + close(ev("MEDIAN", vec![c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0])]), 3.5); + close(ev("MEDIAN", vec![c(&[3.0, 1.0, 2.0])]), 2.0); + is_err(ev("MEDIAN", vec![rf(arr(vec![vec![tv("a")]]))]), CellError::Num); + close(ev("MODE.SNGL", vec![c(&[5.6, 4.0, 4.0, 3.0, 2.0, 4.0])]), 4.0); + close(ev("MODE", vec![c(&[1.0, 2.0, 2.0, 1.0])]), 1.0); + is_err(ev("MODE", vec![c(&[1.0, 2.0, 3.0])]), CellError::NA); + assert_eq!(rows_of(&ev("MODE.MULT", vec![c(&[1.0, 2.0, 3.0, 4.0, 3.0, 2.0, 1.0, 2.0, 3.0])])), vec![vec![nv(2.0)], vec![nv(3.0)]]); +} + +/// MODE argument semantics in Excel 365, on the inputs of FormulaEvalTestData (row "Mode"): +/// text is skipped, typed or referenced, but a single-cell reference to a blank cell is +/// `#VALUE!`; the first error from the left wins. The values cached in POI's file come from an +/// older Excel and say `#VALUE!` for `MODE(C9,E16,"1",B8)`, `MODE(B8,"1",2,3)` and +/// `MODE("1",1,#DIV/0!,2,3)`; Excel 365 gives `#N/A`, `#N/A` and `#DIV/0!`. +#[test] +fn mode_scalar_arguments() { + let data = arr(vec![vec![tv(""), nv(0.0), nv(1.0)], vec![tv("x"), nv(1.0), nv(1.001)], vec![tv("y"), nv(9e9), nv(2.3456)]]); + let cases: Vec<(Vec, Result)> = vec![ + (vec![n(1.0), n(1.0), n(2.0), n(3.0)], Ok(1.0)), + (vec![rf(data)], Ok(1.0)), + (vec![rf(arr(vec![vec![nv(-1.1), Value::Empty, tv("1.1")]])), n(-1.1)], Ok(-1.1)), + // MODE(C9,E16,"1",B8): E16 holds the text "1.1". + (vec![rf(nv(1.1)), rf(tv("1.1")), t("1"), rf(nv(1.0))], Err(CellError::NA)), + (vec![rf(nv(1.0)), t("1"), n(2.0), n(3.0)], Err(CellError::NA)), + (vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Div0)), + // MODE(B9,2,D7) with D7 blank. + (vec![rf(nv(2.0)), n(2.0), rf(Value::Empty)], Err(CellError::Value)), + (vec![n(1.0), n(1.0), e(CellError::Div0), t("1")], Err(CellError::Div0)), + ]; + for name in ["MODE", "MODE.SNGL"] { + for (args, want) in &cases { + match want { + Ok(x) => close(ev(name, args.clone()), *x), + Err(err) => is_err(ev(name, args.clone()), *err), + } + } + } + is_err(ev("MODE.MULT", vec![rf(nv(1.0)), t("1"), rf(Value::Empty)]), CellError::Value); +} + +/// Text and logical values typed as arguments are skipped, not coerced (Excel 365, #736). +#[test] +fn mode_skips_typed_text_and_logicals() { + let cases: Vec<(Vec, Result)> = vec![ + (vec![n(1.0), n(1.0), t("x")], Ok(1.0)), + (vec![n(1.0), n(2.0), t("x")], Err(CellError::NA)), + (vec![n(1.0), t("1"), n(2.0), n(3.0)], Err(CellError::NA)), + (vec![t("1"), n(1.0), n(2.0), n(3.0)], Err(CellError::NA)), + (vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Div0)), + (vec![n(1.0), b(true), b(true), n(2.0)], Err(CellError::NA)), + (vec![n(1.0), n(1.0), b(true)], Ok(1.0)), + ]; + for name in ["MODE", "MODE.SNGL"] { + for (args, want) in &cases { + match want { + Ok(x) => close(ev(name, args.clone()), *x), + Err(err) => is_err(ev(name, args.clone()), *err), + } + } + } + assert_eq!(rows_of(&ev("MODE.MULT", vec![n(1.0), t("1"), n(1.0), t("x")])), vec![vec![nv(1.0)]]); + is_err(ev("MODE.MULT", vec![n(1.0), b(true), b(true), n(2.0)]), CellError::NA); +} + +#[test] +fn spread() { + let d = [1345.0, 1301.0, 1368.0, 1322.0, 1310.0, 1370.0, 1318.0, 1350.0, 1303.0, 1299.0]; + close_tol(ev("STDEV.S", vec![c(&d)]), 27.46391572, 1e-9); + close_tol(ev("STDEV", vec![c(&d)]), 27.46391572, 1e-9); + close_tol(ev("STDEV.P", vec![c(&d)]), 26.05455814, 1e-9); + close_tol(ev("STDEVP", vec![c(&d)]), 26.05455814, 1e-9); + close_tol(ev("VAR.S", vec![c(&d)]), 754.2666667, 1e-9); + close(ev("VAR.P", vec![c(&d)]), 678.84); + close(ev("VARP", vec![c(&d)]), 678.84); + is_err(ev("STDEV.S", vec![n(1.0)]), CellError::Div0); + close(ev("STDEV.P", vec![n(1.0)]), 0.0); + let mixed = arr(vec![vec![nv(2.0), Value::Bool(true), tv("x")]]); + close(ev("VARA", vec![rf(mixed.clone())]), 1.0); + close(ev("VARPA", vec![rf(mixed.clone())]), 2.0 / 3.0); + close(ev("STDEVA", vec![rf(mixed.clone())]), 1.0); + close(ev("STDEVPA", vec![rf(mixed)]), (2.0f64 / 3.0).sqrt()); + let s = [4.0, 5.0, 6.0, 7.0, 5.0, 4.0, 3.0]; + close(ev("DEVSQ", vec![c(&[4.0, 5.0, 8.0, 7.0, 11.0, 4.0, 3.0])]), 48.0); + close_tol(ev("AVEDEV", vec![c(&s)]), 1.020408163, 1e-9); + let g = [4.0, 5.0, 8.0, 7.0, 11.0, 4.0, 3.0]; + close_tol(ev("GEOMEAN", vec![c(&g)]), 5.476986969, 1e-9); + close_tol(ev("HARMEAN", vec![c(&g)]), 5.028375962, 1e-9); + is_err(ev("GEOMEAN", vec![c(&[1.0, 0.0])]), CellError::Num); + close_tol(ev("TRIMMEAN", vec![c(&[4.0, 5.0, 6.0, 7.0, 2.0, 3.0, 4.0, 5.0, 1.0, 2.0, 3.0]), n(0.2)]), 3.777777778, 1e-9); + is_err(ev("TRIMMEAN", vec![c(&[1.0]), n(1.0)]), CellError::Num); + let k = [3.0, 4.0, 5.0, 2.0, 3.0, 4.0, 5.0, 6.0, 4.0, 7.0]; + close_tol(ev("KURT", vec![c(&k)]), -0.151799637, 1e-8); + close_tol(ev("SKEW", vec![c(&k)]), 0.359543071, 1e-8); + close_tol(ev("SKEW.P", vec![c(&k)]), 0.303193339, 1e-8); + is_err(ev("KURT", vec![c(&[1.0, 2.0, 3.0])]), CellError::Div0); + close_tol(ev("STANDARDIZE", vec![n(42.0), n(40.0), n(1.5)]), 1.333333333, 1e-9); + is_err(ev("STANDARDIZE", vec![n(42.0), n(40.0), n(0.0)]), CellError::Num); +} + +#[test] +fn min_max_order() { + close(ev("MIN", vec![c(&[3.0, -1.0, 2.0]), n(0.0)]), -1.0); + close(ev("MAX", vec![c(&[3.0, -1.0, 2.0]), n(10.0)]), 10.0); + close(ev("MIN", vec![]), 0.0); + close(ev("MAX", vec![rf(arr(vec![vec![tv("a")]]))]), 0.0); + close(ev("MAXA", vec![rf(arr(vec![vec![nv(-1.0), Value::Bool(true)]]))]), 1.0); + close(ev("MINA", vec![rf(arr(vec![vec![nv(1.0), tv("x")]]))]), 0.0); + let d = c(&[3.0, 5.0, 3.0, 5.0, 4.0, 4.0, 2.0, 4.0, 6.0, 7.0]); + close(ev("LARGE", vec![d.clone(), n(3.0)]), 5.0); + close(ev("SMALL", vec![d.clone(), n(4.0)]), 4.0); + is_err(ev("LARGE", vec![d.clone(), n(0.0)]), CellError::Num); + is_err(ev("SMALL", vec![d.clone(), n(11.0)]), CellError::Num); + assert_eq!(rows_of(&ev("LARGE", vec![d, av(row(&[1.0, 2.0]))])), vec![vec![nv(7.0), nv(6.0)]]); + let r = c(&[89.0, 88.0, 92.0, 101.0, 94.0, 97.0, 95.0]); + close(ev("RANK.AVG", vec![n(94.0), r.clone()]), 4.0); + close(ev("RANK.EQ", vec![n(101.0), r.clone()]), 1.0); + is_err(ev("RANK", vec![n(1.0), r]), CellError::NA); + let r2 = c(&[7.0, 3.5, 3.5, 1.0, 2.0]); + close(ev("RANK.EQ", vec![n(7.0), r2.clone(), n(1.0)]), 5.0); + close(ev("RANK.EQ", vec![n(3.5), r2.clone(), n(1.0)]), 3.0); + close(ev("RANK.AVG", vec![n(3.5), r2, n(1.0)]), 3.5); +} + +#[test] +fn percentiles() { + close(ev("PERCENTILE.INC", vec![c(&[1.0, 3.0, 2.0, 4.0]), n(0.3)]), 1.9); + close(ev("PERCENTILE", vec![c(&[1.0, 3.0, 2.0, 4.0]), n(1.0)]), 4.0); + is_err(ev("PERCENTILE.INC", vec![c(&[1.0]), n(1.5)]), CellError::Num); + let e9 = c(&[1.0, 2.0, 3.0, 6.0, 6.0, 6.0, 7.0, 8.0, 9.0]); + close(ev("PERCENTILE.EXC", vec![e9.clone(), n(0.25)]), 2.5); + is_err(ev("PERCENTILE.EXC", vec![e9.clone(), n(0.0)]), CellError::Num); + is_err(ev("PERCENTILE.EXC", vec![e9.clone(), n(0.01)]), CellError::Num); + close(ev("QUARTILE.INC", vec![c(&[1.0, 2.0, 4.0, 7.0, 8.0, 9.0, 10.0, 12.0]), n(1.0)]), 3.5); + close(ev("QUARTILE", vec![c(&[1.0, 2.0, 4.0, 7.0, 8.0, 9.0, 10.0, 12.0]), n(4.0)]), 12.0); + let q = c(&[6.0, 7.0, 15.0, 36.0, 39.0, 40.0, 41.0, 42.0, 43.0, 47.0, 49.0]); + close(ev("QUARTILE.EXC", vec![q.clone(), n(1.0)]), 15.0); + close(ev("QUARTILE.EXC", vec![q.clone(), n(3.0)]), 43.0); + is_err(ev("QUARTILE.EXC", vec![q, n(4.0)]), CellError::Num); + let p = c(&[13.0, 12.0, 11.0, 8.0, 4.0, 3.0, 2.0, 1.0, 1.0, 1.0]); + close(ev("PERCENTRANK.INC", vec![p.clone(), n(2.0)]), 0.333); + close(ev("PERCENTRANK.INC", vec![p.clone(), n(4.0)]), 0.555); + close(ev("PERCENTRANK.INC", vec![p.clone(), n(8.0)]), 0.666); + close(ev("PERCENTRANK", vec![p.clone(), n(5.0)]), 0.583); + close(ev("PERCENTRANK.INC", vec![p.clone(), n(5.0), n(1.0)]), 0.5); + is_err(ev("PERCENTRANK.INC", vec![p.clone(), n(0.5)]), CellError::NA); + is_err(ev("PERCENTRANK.INC", vec![p, n(5.0), n(0.0)]), CellError::Num); + close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(7.0)]), 0.7); + close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(5.43)]), 0.381); + close(ev("PERCENTRANK.EXC", vec![e9, n(5.43), n(1.0)]), 0.3); + let t = c(&[1.0, 2.0, 3.0]); + close(ev("PERCENTRANK", vec![t.clone(), n(2.0), n(400.0)]), 0.5); + close(ev("PERCENTRANK.INC", vec![t.clone(), n(2.0), n(400.0)]), 0.5); + close(ev("PERCENTRANK.INC", vec![t.clone(), n(1.0), n(400.0)]), 0.0); + close(ev("PERCENTRANK.EXC", vec![t, n(2.0), n(400.0)]), 0.5); +} + +#[test] +fn pairs_and_regression() { + close_tol(ev("CORREL", vec![c(&[3.0, 2.0, 4.0, 5.0, 6.0]), c(&[9.0, 7.0, 12.0, 15.0, 17.0])]), 0.997054486, 1e-9); + close_tol(ev("PEARSON", vec![c(&[9.0, 7.0, 5.0, 3.0, 1.0]), c(&[10.0, 6.0, 1.0, 5.0, 3.0])]), 0.699379, D7); + is_err(ev("CORREL", vec![c(&[1.0, 2.0]), c(&[1.0, 2.0, 3.0])]), CellError::NA); + is_err(ev("CORREL", vec![c(&[1.0, 1.0]), c(&[1.0, 2.0])]), CellError::Div0); + let y = c(&[2.0, 3.0, 9.0, 1.0, 8.0, 7.0, 5.0]); + let x = c(&[6.0, 5.0, 11.0, 7.0, 5.0, 4.0, 4.0]); + close_tol(ev("SLOPE", vec![y.clone(), x.clone()]), 0.305555556, 1e-8); + close_tol(ev("RSQ", vec![y.clone(), x.clone()]), 0.057950192, 1e-8); + close_tol(ev("STEYX", vec![y.clone(), x.clone()]), 3.305718950, 1e-8); + close_tol(ev("INTERCEPT", vec![c(&[2.0, 3.0, 9.0, 1.0, 8.0]), c(&[6.0, 5.0, 11.0, 7.0, 5.0])]), 0.048387097, 1e-8); + close_tol(ev("FORECAST", vec![n(30.0), c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 10.607253, D7); + close_tol(ev("FORECAST.LINEAR", vec![n(30.0), c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 10.607253, D7); + close(ev("COVARIANCE.P", vec![c(&[3.0, 2.0, 4.0, 5.0, 6.0]), c(&[9.0, 7.0, 12.0, 15.0, 17.0])]), 5.2); + close(ev("COVAR", vec![c(&[3.0, 2.0, 4.0, 5.0, 6.0]), c(&[9.0, 7.0, 12.0, 15.0, 17.0])]), 5.2); + close(ev("COVARIANCE.S", vec![c(&[2.0, 4.0, 8.0]), c(&[5.0, 11.0, 12.0])]), 29.0 / 3.0); + // Pairs with text are skipped. + let xs = arr(vec![vec![nv(1.0)], vec![tv("x")], vec![nv(3.0)]]); + close(ev("SLOPE", vec![c(&[2.0, 100.0, 6.0]), rf(xs)]), 2.0); +} + +#[test] +fn linest_family() { + let r = rows_of(&ev("LINEST", vec![c(&[1.0, 9.0, 5.0, 7.0]), c(&[0.0, 4.0, 2.0, 3.0])])); + assert_eq!(r.len(), 1); + close(r[0][0].clone(), 2.0); + close(r[0][1].clone(), 1.0); + // Statistics for a simple fit: y = 1,2,4 on x = 1,2,3. + let s = rows_of(&ev("LINEST", vec![c(&[1.0, 2.0, 4.0]), c(&[1.0, 2.0, 3.0]), b(true), b(true)])); + assert_eq!((s.len(), s[0].len()), (5, 2)); + close(s[0][0].clone(), 1.5); + close(s[0][1].clone(), -2.0 / 3.0); + close(s[2][0].clone(), 0.9642857142857143); // r² + close(s[3][1].clone(), 1.0); // df + close(s[4][0].clone(), 4.5); // ssreg + close(s[4][1].clone(), 1.0 / 6.0); // ssresid + close(s[2][1].clone(), (1.0f64 / 6.0).sqrt()); // sey + close(s[3][0].clone(), 27.0); // F + close(s[1][0].clone(), (1.0f64 / 6.0 / 2.0).sqrt()); // se slope + // No constant. + let z = rows_of(&ev("LINEST", vec![c(&[2.0, 4.0, 6.0]), c(&[1.0, 2.0, 3.0]), b(false)])); + close(z[0][0].clone(), 2.0); + close(z[0][1].clone(), 0.0); + // Two predictors: y = 1 + 2a + 3b. + let xs = arr(vec![vec![nv(1.0), nv(0.0)], vec![nv(0.0), nv(1.0)], vec![nv(1.0), nv(1.0)], vec![nv(2.0), nv(1.0)], vec![nv(3.0), nv(5.0)]]); + let ys: Vec = [(1.0, 0.0), (0.0, 1.0), (1.0, 1.0), (2.0, 1.0), (3.0, 5.0)].iter().map(|(a, b)| 1.0 + 2.0 * a + 3.0 * b).collect(); + let m = rows_of(&ev("LINEST", vec![c(&ys), av(xs)])); + close(m[0][0].clone(), 3.0); + close(m[0][1].clone(), 2.0); + close(m[0][2].clone(), 1.0); + let gy = [33100.0, 47300.0, 69000.0, 102000.0, 150000.0, 220000.0]; + let gx = [11.0, 12.0, 13.0, 14.0, 15.0, 16.0]; + let l = rows_of(&ev("LOGEST", vec![c(&gy), c(&gx)])); + close_tol(l[0][0].clone(), 1.463275628, 1e-9); + close_tol(l[0][1].clone(), 495.3047702, 1e-9); + let g = rows_of(&ev("GROWTH", vec![c(&gy), c(&gx), c(&[17.0, 18.0])])); + close_tol(g[0][0].clone(), 320196.7184, 1e-9); + close_tol(g[1][0].clone(), 468536.0539, 1e-9); + let ty = [133890.0, 135000.0, 135790.0, 137300.0, 138130.0, 139100.0, 139900.0, 141120.0, 141890.0, 143230.0, 144000.0, 145290.0]; + let tx: Vec = (1..=12).map(f64::from).collect(); + let t1 = ev("TREND", vec![c(&ty), c(&tx), n(13.0)]); + close_tol(Value::Number(rows_of(&t1)[0][0].as_f64().unwrap().round()), 146172.0, 1e-9); + let fitted = rows_of(&ev("TREND", vec![c(&[1.0, 9.0, 5.0, 7.0]), c(&[0.0, 4.0, 2.0, 3.0])])); + close(fitted[1][0].clone(), 9.0); + is_err(ev("LOGEST", vec![c(&[1.0, -2.0])]), CellError::Num); + is_err(ev("LINEST", vec![c(&[1.0, 2.0]), c(&[1.0, 2.0, 3.0])]), CellError::Ref); +} + +// #2177: one predictor row of 16,384 columns built three 16,384² matrices (6 GB) and an O(k³) +// inversion; a wider TOROW aborted the process. It is now refused before allocating. +#[test] +fn linest_family_caps_regressor_count() { + let wide = |k: usize| av(arr(vec![(0..k).map(|i| nv(i as f64)).collect()])); + let tall = |k: usize| av(arr((0..k).map(|i| vec![nv(i as f64), nv(1.0)]).collect())); + for f in ["LINEST", "LOGEST", "TREND", "GROWTH"] { + // y is one cell, so every column of x is a predictor. + is_err(ev(f, vec![n(1.0), wide(16_384)]), CellError::Value); + // y is a row, so every row of x is a predictor. + is_err(ev(f, vec![av(arr(vec![vec![nv(1.0), nv(2.0)]])), tall(16_384)]), CellError::Value); + } + is_err(ev("LINEST", vec![n(1.0), wide(MAX_REGRESSORS + 1)]), CellError::Value); + // At the cap the fit still runs (one observation: coefficients are zero, not an error). + assert_eq!(rows_of(&ev("LINEST", vec![n(1.0), wide(MAX_REGRESSORS)]))[0].len(), MAX_REGRESSORS + 1); + // The rows × k² work bound. + assert_eq!(check_regression_size(1_000, MAX_REGRESSORS), Ok(())); + assert_eq!(check_regression_size(1_000_000, 100), Err(CellError::Num)); + assert_eq!(check_regression_size(usize::MAX, 2), Err(CellError::Num)); + assert_eq!(check_regression_size(10_000_000, 1), Ok(())); +} + +#[test] +fn frequency_prob() { + let f = ev("FREQUENCY", vec![c(&[79.0, 85.0, 78.0, 85.0, 50.0, 81.0, 95.0, 88.0, 97.0]), c(&[70.0, 79.0, 89.0])]); + assert_eq!(rows_of(&f), vec![vec![nv(1.0)], vec![nv(2.0)], vec![nv(4.0)], vec![nv(2.0)]]); + let x = c(&[0.0, 1.0, 2.0, 3.0]); + let p = c(&[0.2, 0.3, 0.1, 0.4]); + close(ev("PROB", vec![x.clone(), p.clone(), n(2.0)]), 0.1); + close(ev("PROB", vec![x.clone(), p, n(1.0), n(3.0)]), 0.8); + is_err(ev("PROB", vec![x.clone(), c(&[0.5, 0.5, 0.5, 0.5]), n(1.0)]), CellError::Num); + // Excel: any probability <= 0 is #NUM!, even when it sums to one and lies outside the limits. + is_err(ev("PROB", vec![x.clone(), c(&[0.5, 0.0, 0.25, 0.25]), n(0.0), n(3.0)]), CellError::Num); + is_err(ev("PROB", vec![x, c(&[0.5, 0.0, 0.25, 0.25]), n(2.0), n(3.0)]), CellError::Num); +} + +#[test] +fn normal() { + close_tol(ev("NORM.DIST", vec![n(42.0), n(40.0), n(1.5), b(true)]), 0.9087888, D7); + close_tol(ev("NORM.DIST", vec![n(42.0), n(40.0), n(1.5), b(false)]), 0.10934005, D7); + close_tol(ev("NORMDIST", vec![n(42.0), n(40.0), n(1.5), b(true)]), 0.9087888, D7); + is_err(ev("NORM.DIST", vec![n(42.0), n(40.0), n(0.0), b(true)]), CellError::Num); + close_tol(ev("NORM.INV", vec![n(0.908789), n(40.0), n(1.5)]), 42.000002, D7); + close_tol(ev("NORMINV", vec![n(0.908789), n(40.0), n(1.5)]), 42.000002, D7); + is_err(ev("NORM.INV", vec![n(1.0), n(0.0), n(1.0)]), CellError::Num); + close_tol(ev("NORM.S.DIST", vec![n(1.333333), b(true)]), 0.908788726, 1e-8); + close_tol(ev("NORM.S.DIST", vec![n(0.0), b(false)]), 0.398942280, 1e-8); + close_tol(ev("NORMSDIST", vec![n(1.333333)]), 0.908788726, 1e-8); + close_tol(ev("NORM.S.INV", vec![n(0.908789)]), 1.3333347, D7); + close_tol(ev("NORMSINV", vec![n(0.975)]), 1.959963985, 1e-9); + close_tol(ev("PHI", vec![n(0.75)]), 0.301137432, 1e-8); + close_tol(ev("GAUSS", vec![n(2.0)]), 0.477249868, 1e-8); + close_tol(ev("FISHER", vec![n(0.75)]), 0.972955075, 1e-8); + close_tol(ev("FISHERINV", vec![n(0.972955)]), 0.75, D7); + is_err(ev("FISHER", vec![n(1.0)]), CellError::Num); + close_tol(ev("CONFIDENCE.NORM", vec![n(0.05), n(2.5), n(50.0)]), 0.692951912, 1e-8); + close_tol(ev("CONFIDENCE", vec![n(0.05), n(2.5), n(50.0)]), 0.692951912, 1e-8); + close_tol(ev("CONFIDENCE.T", vec![n(0.05), n(1.0), n(50.0)]), 0.284196855, 1e-8); + is_err(ev("CONFIDENCE.T", vec![n(0.05), n(1.0), n(1.0)]), CellError::Div0); + let z = c(&[3.0, 6.0, 7.0, 8.0, 6.0, 5.0, 4.0, 2.0, 1.0, 9.0]); + close_tol(ev("Z.TEST", vec![z.clone(), n(4.0)]), 0.090574, 1e-5); + close_tol(ev("ZTEST", vec![z, n(6.0)]), 0.863043, 1e-5); + close_tol(ev("LOGNORM.DIST", vec![n(4.0), n(3.5), n(1.2), b(true)]), 0.0390836, D7); + close_tol(ev("LOGNORM.DIST", vec![n(4.0), n(3.5), n(1.2), b(false)]), 0.0176176, D7); + close_tol(ev("LOGNORMDIST", vec![n(4.0), n(3.5), n(1.2)]), 0.0390836, D7); + close_tol(ev("LOGNORM.INV", vec![n(0.039084), n(3.5), n(1.2)]), 4.0000252, D7); + close_tol(ev("LOGINV", vec![n(0.039084), n(3.5), n(1.2)]), 4.0000252, D7); +} + +#[test] +fn t_chi_f() { + close_tol(ev("T.DIST", vec![n(60.0), n(1.0), b(true)]), 0.99469533, D7); + close_tol(ev("T.DIST", vec![n(8.0), n(3.0), b(false)]), 0.00073691, 1e-5); + close_tol(ev("T.DIST.2T", vec![n(1.959999998), n(60.0)]), 0.054644930, D7); + close_tol(ev("T.DIST.RT", vec![n(1.959999998), n(60.0)]), 0.027322465, D7); + close_tol(ev("TDIST", vec![n(1.959999998), n(60.0), n(2.0)]), 0.054644930, D7); + is_err(ev("TDIST", vec![n(-1.0), n(60.0), n(2.0)]), CellError::Num); + is_err(ev("T.DIST", vec![n(1.0), n(0.0), b(true)]), CellError::Num); + close_tol(ev("T.INV", vec![n(0.75), n(2.0)]), 0.8164966, D7); + close_tol(ev("T.INV", vec![n(0.25), n(2.0)]), -0.8164966, D7); + close_tol(ev("T.INV.2T", vec![n(0.546449), n(60.0)]), 0.606533, D7); + close_tol(ev("T.INV.2T", vec![n(0.05), n(10.0)]), 2.228138852, 1e-9); + close_tol(ev("TINV", vec![n(0.05), n(10.0)]), 2.228138852, 1e-9); + let a1 = c(&[3.0, 4.0, 5.0, 8.0, 9.0, 1.0, 2.0, 4.0, 5.0]); + let a2 = c(&[6.0, 19.0, 3.0, 2.0, 14.0, 4.0, 5.0, 17.0, 1.0]); + close_tol(ev("T.TEST", vec![a1.clone(), a2.clone(), n(2.0), n(1.0)]), 0.196016, 1e-5); + close_tol(ev("TTEST", vec![a1.clone(), a2.clone(), n(2.0), n(1.0)]), 0.196016, 1e-5); + close_tol(ev("T.TEST", vec![a1.clone(), a2.clone(), n(2.0), n(2.0)]), 0.191995887, 1e-6); + close_tol(ev("T.TEST", vec![a1.clone(), a2.clone(), n(1.0), n(3.0)]), 0.1012, 1e-3); + is_err(ev("T.TEST", vec![a1, a2, n(3.0), n(1.0)]), CellError::Num); + close_tol(ev("CHISQ.DIST", vec![n(0.5), n(1.0), b(true)]), 0.52049988, D7); + close_tol(ev("CHISQ.DIST", vec![n(2.0), n(3.0), b(false)]), 0.20755375, D7); + close_tol(ev("CHISQ.DIST.RT", vec![n(18.307), n(10.0)]), 0.0500006, 1e-5); + close_tol(ev("CHIDIST", vec![n(18.307), n(10.0)]), 0.0500006, 1e-5); + close_tol(ev("CHISQ.INV", vec![n(0.93), n(1.0)]), 3.283020287, 1e-8); + close_tol(ev("CHISQ.INV", vec![n(0.6), n(2.0)]), 1.832581464, 1e-8); + close_tol(ev("CHISQ.INV.RT", vec![n(0.050001), n(10.0)]), 18.30697, D7); + close_tol(ev("CHIINV", vec![n(0.050001), n(10.0)]), 18.30697, D7); + is_err(ev("CHISQ.DIST", vec![n(-1.0), n(1.0), b(true)]), CellError::Num); + let act = arr(vec![vec![nv(58.0), nv(35.0)], vec![nv(11.0), nv(25.0)], vec![nv(10.0), nv(23.0)]]); + let exp = arr(vec![vec![nv(45.35), nv(47.65)], vec![nv(17.56), nv(18.44)], vec![nv(16.09), nv(16.91)]]); + close_tol(ev("CHISQ.TEST", vec![av(act.clone()), av(exp.clone())]), 0.0003082, 1e-3); + close_tol(ev("CHITEST", vec![av(act), av(exp)]), 0.0003082, 1e-3); + close_tol(ev("F.DIST", vec![n(15.2069), n(6.0), n(4.0), b(true)]), 0.99, 1e-6); + close_tol(ev("F.DIST", vec![n(15.2069), n(6.0), n(4.0), b(false)]), 0.0012238, 1e-5); + close_tol(ev("F.DIST.RT", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6); + close_tol(ev("FDIST", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6); + close_tol(ev("F.INV", vec![n(0.01), n(6.0), n(4.0)]), 0.10930991, D7); + close_tol(ev("F.INV.RT", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7); + close_tol(ev("FINV", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7); + close_tol(ev("F.TEST", vec![c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 0.64831785, D7); + close_tol(ev("FTEST", vec![c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 0.64831785, D7); + is_err(ev("F.DIST", vec![n(1.0), n(0.0), n(4.0), b(true)]), CellError::Num); +} + +#[test] +fn binom_inv_huge_trials_returns() { + // Above 2^53 neighbouring f64 values are more than 1 apart; the bisection must still end. + let cases: [(&str, f64, f64, f64); 3] = [("BINOM.INV", 1e17, 0.5, 0.5), ("CRITBINOM", 1e300, 1.0, 0.5), ("BINOM.INV", 1e300, 0.5, 0.5)]; + for (name, trials, p, alpha) in cases { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let r = ev(name, vec![n(trials), n(p), n(alpha)]); + let _ = tx.send(r); + }); + match rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(Value::Number(_)) | Ok(Value::Error(CellError::Num)) => {} + Ok(other) => panic!("{name}({trials},{p},{alpha}) returned {other:?}"), + Err(_) => panic!("{name}({trials},{p},{alpha}) did not return within 5 s"), + } + } +} + +#[test] +fn discrete() { + close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12); + close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(true)]), 0.828125, 1e-12); + close_tol(ev("BINOMDIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12); + is_err(ev("BINOM.DIST", vec![n(11.0), n(10.0), n(0.5), b(false)]), CellError::Num); + close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(48.0)]), 0.083974967, 1e-6); + close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(45.0), n(50.0)]), 0.523629793, 1e-6); + close(ev("BINOM.INV", vec![n(6.0), n(0.5), n(0.75)]), 4.0); + close(ev("CRITBINOM", vec![n(6.0), n(0.5), n(0.75)]), 4.0); + close(ev("BINOM.INV", vec![n(1e9), n(0.5), n(0.5)]), 5e8); + close_tol(ev("POISSON.DIST", vec![n(2.0), n(5.0), b(true)]), 0.124652, 1e-5); + close_tol(ev("POISSON.DIST", vec![n(2.0), n(5.0), b(false)]), 0.084224, 1e-5); + close_tol(ev("POISSON", vec![n(2.0), n(5.0), b(false)]), 0.084224, 1e-5); + is_err(ev("POISSON.DIST", vec![n(-1.0), n(5.0), b(false)]), CellError::Num); + close_tol(ev("HYPGEOM.DIST", vec![n(1.0), n(4.0), n(8.0), n(20.0), b(true)]), 0.4654, 1e-4); + close_tol(ev("HYPGEOM.DIST", vec![n(1.0), n(4.0), n(8.0), n(20.0), b(false)]), 0.3633, 1e-4); + close_tol(ev("HYPGEOMDIST", vec![n(1.0), n(4.0), n(8.0), n(20.0)]), 0.363261094, 1e-8); + is_err(ev("HYPGEOMDIST", vec![n(5.0), n(4.0), n(8.0), n(20.0)]), CellError::Num); + close_tol(ev("NEGBINOM.DIST", vec![n(10.0), n(5.0), n(0.25), b(true)]), 0.3135141, D7); + close_tol(ev("NEGBINOM.DIST", vec![n(10.0), n(5.0), n(0.25), b(false)]), 0.0550487, D7); + close_tol(ev("NEGBINOMDIST", vec![n(10.0), n(5.0), n(0.25)]), 0.0550487, D7); +} + +#[test] +fn continuous() { + close_tol(ev("EXPON.DIST", vec![n(0.2), n(10.0), b(true)]), 0.86466472, D7); + close_tol(ev("EXPON.DIST", vec![n(0.2), n(10.0), b(false)]), 1.35335283, D7); + close_tol(ev("EXPONDIST", vec![n(0.2), n(10.0), b(true)]), 0.86466472, D7); + is_err(ev("EXPON.DIST", vec![n(0.2), n(0.0), b(true)]), CellError::Num); + close_tol(ev("GAMMA.DIST", vec![n(10.00001131), n(9.0), n(2.0), b(false)]), 0.032639, 1e-5); + close_tol(ev("GAMMA.DIST", vec![n(10.00001131), n(9.0), n(2.0), b(true)]), 0.068094, 1e-5); + close_tol(ev("GAMMADIST", vec![n(10.00001131), n(9.0), n(2.0), b(true)]), 0.068094, 1e-5); + close_tol(ev("GAMMA.INV", vec![n(0.068094), n(9.0), n(2.0)]), 10.0000112, 1e-6); + close_tol(ev("GAMMAINV", vec![n(0.068094), n(9.0), n(2.0)]), 10.0000112, 1e-6); + close_tol(ev("GAMMALN", vec![n(4.0)]), 1.791759469, 1e-9); + close_tol(ev("GAMMALN.PRECISE", vec![n(4.5)]), 2.453736571, 1e-9); + is_err(ev("GAMMALN", vec![n(0.0)]), CellError::Num); + close_tol(ev("GAMMA", vec![n(2.5)]), 1.329340388, 1e-9); + close_tol(ev("GAMMA", vec![n(-0.75)]), -4.834146544, 1e-8); + is_err(ev("GAMMA", vec![n(-1.0)]), CellError::Num); + is_err(ev("GAMMA", vec![n(0.0)]), CellError::Num); + close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(true), n(1.0), n(3.0)]), 0.6854706, D7); + close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(false), n(1.0), n(3.0)]), 1.4837646, D7); + close_tol(ev("BETADIST", vec![n(2.0), n(8.0), n(10.0), n(1.0), n(3.0)]), 0.6854706, D7); + close_tol(ev("BETA.INV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7); + close_tol(ev("BETAINV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7); + is_err(ev("BETA.DIST", vec![n(4.0), n(8.0), n(10.0), b(true), n(1.0), n(3.0)]), CellError::Num); + close_tol(ev("WEIBULL.DIST", vec![n(105.0), n(20.0), n(100.0), b(true)]), 0.929581, 1e-5); + close_tol(ev("WEIBULL.DIST", vec![n(105.0), n(20.0), n(100.0), b(false)]), 0.035589, 1e-4); + close_tol(ev("WEIBULL", vec![n(105.0), n(20.0), n(100.0), b(true)]), 0.929581, 1e-5); + // Lifting over an array of x values. + let r = rows_of(&ev("NORM.S.DIST", vec![av(row(&[0.0, 1.0])), b(true)])); + close(r[0][0].clone(), 0.5); + close_tol(r[0][1].clone(), 0.841344746, 1e-8); +} + +#[test] +fn aggregate_helper() { + let vals = vec![nv(1.0), nv(2.0), tv("x"), Value::Empty, Value::Bool(true), nv(6.0)]; + close(aggregate_values(1, &vals), 3.0); + close(aggregate_values(2, &vals), 3.0); + close(aggregate_values(3, &vals), 5.0); + close(aggregate_values(4, &vals), 6.0); + close(aggregate_values(105, &vals), 1.0); + close(aggregate_values(6, &vals), 12.0); + close(aggregate_values(9, &vals), 9.0); + close(aggregate_values(109, &vals), 9.0); + close(aggregate_values(11, &vals), 14.0 / 3.0); + close(aggregate_values(10, &vals), 7.0); + close(aggregate_values(12, &vals), 2.0); + is_err(aggregate_values(13, &vals), CellError::NA); + is_err(aggregate_values(14, &vals), CellError::Value); + is_err(aggregate_values(99, &vals), CellError::Value); + is_err(aggregate_values(9, &[nv(1.0), Value::Error(CellError::Div0)]), CellError::Div0); + close(aggregate_values_k(14, &vals, &nv(1.0)), 6.0); + close(aggregate_values_k(15, &vals, &nv(2.0)), 2.0); + close(aggregate_values_k(16, &vals, &nv(0.5)), 2.0); + close(aggregate_values_k(17, &vals, &nv(4.0)), 6.0); + close(aggregate_values_k(18, &vals, &nv(0.5)), 2.0); + close(aggregate_values_k(19, &vals, &nv(2.0)), 2.0); + close(aggregate_values_k(9, &vals, &nv(2.0)), 9.0); + is_err(aggregate_values_k(14, &vals, &nv(10.0)), CellError::Num); +} diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index 792fb6804e..1d8d6e1ff7 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -6,6 +6,12 @@ use serde::{Deserialize, Serialize}; use crate::style::{Color, Style}; +mod pivot; +mod print; + +pub use pivot::{PivotCalcField, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs, PivotSort, PivotTable, PivotValue}; +pub use print::{EditRange, HfPage, LineInfo, Orientation, PrintSettings, SheetProtection, password_hash}; + // ---------------------------------------------------------------- tables #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -736,396 +742,6 @@ pub struct Slicer { pub columns: u32, } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum Orientation { - #[default] - Portrait, - Landscape, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct PrintSettings { - pub orientation: Orientation, - /// Paper size name (`Letter`, `A4`, `Legal`…). - pub paper: String, - /// Margins in inches: left, right, top, bottom, header, footer. - pub margins: [f32; 6], - pub print_area: Option, - pub title_rows: Option<(u32, u32)>, - pub title_cols: Option<(u32, u32)>, - /// Percent scale, or fit to pages (wide, tall). - pub scale: u16, - pub fit_to: Option<(u16, u16)>, - pub gridlines: bool, - pub headings: bool, - pub center_h: bool, - pub center_v: bool, - /// The header and footer of odd pages, and of every page unless the variants below apply. - pub header: String, - pub footer: String, - pub row_breaks: Vec, - pub col_breaks: Vec, - /// Different First Page: the sheet's first printed page uses `first_header`/`first_footer`. - #[serde(default)] - pub different_first: bool, - /// Different Odd & Even Pages: even-numbered pages use `even_header`/`even_footer`. - #[serde(default)] - pub different_odd_even: bool, - #[serde(default)] - pub first_header: String, - #[serde(default)] - pub first_footer: String, - #[serde(default)] - pub even_header: String, - #[serde(default)] - pub even_footer: String, - /// Scale with Document / Align with Page Margins (both on unless a file turned them off). - #[serde(default = "yes")] - pub hf_scale_with_doc: bool, - #[serde(default = "yes")] - pub hf_align_with_margins: bool, -} - -fn yes() -> bool { - true -} - -/// Which header/footer pair a printed page uses. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum HfPage { - Odd, - Even, - First, -} - -impl HfPage { - pub fn id(self) -> &'static str { - match self { - HfPage::Odd => "odd", - HfPage::Even => "even", - HfPage::First => "first", - } - } - pub fn parse(id: &str) -> Option { - match id { - "odd" | "all" => Some(HfPage::Odd), - "even" => Some(HfPage::Even), - "first" => Some(HfPage::First), - _ => None, - } - } -} - -impl PrintSettings { - /// The pair used by page `number` (1-based, as `&P` prints it); `first` is the sheet's first page. - pub fn hf_page(&self, first: bool, number: usize) -> HfPage { - if self.different_first && first { - HfPage::First - } else if self.different_odd_even && number.is_multiple_of(2) { - HfPage::Even - } else { - HfPage::Odd - } - } - /// The header (`footer` false) or footer of a page kind. - pub fn hf(&self, page: HfPage, footer: bool) -> &String { - match (page, footer) { - (HfPage::Odd, false) => &self.header, - (HfPage::Odd, true) => &self.footer, - (HfPage::Even, false) => &self.even_header, - (HfPage::Even, true) => &self.even_footer, - (HfPage::First, false) => &self.first_header, - (HfPage::First, true) => &self.first_footer, - } - } - pub fn hf_mut(&mut self, page: HfPage, footer: bool) -> &mut String { - match (page, footer) { - (HfPage::Odd, false) => &mut self.header, - (HfPage::Odd, true) => &mut self.footer, - (HfPage::Even, false) => &mut self.even_header, - (HfPage::Even, true) => &mut self.even_footer, - (HfPage::First, false) => &mut self.first_header, - (HfPage::First, true) => &mut self.first_footer, - } - } - /// Any header or footer text at all (on any kind of page). - pub fn has_header_footer(&self) -> bool { - [&self.header, &self.footer, &self.first_header, &self.first_footer, &self.even_header, &self.even_footer].iter().any(|t| !t.is_empty()) - } -} - -impl Default for PrintSettings { - fn default() -> Self { - PrintSettings { - orientation: Orientation::Portrait, - paper: "Letter".into(), - margins: [0.7, 0.7, 0.75, 0.75, 0.3, 0.3], - print_area: None, - title_rows: None, - title_cols: None, - scale: 100, - fit_to: None, - gridlines: false, - headings: false, - center_h: false, - center_v: false, - header: String::new(), - footer: String::new(), - row_breaks: vec![], - col_breaks: vec![], - different_first: false, - different_odd_even: false, - first_header: String::new(), - first_footer: String::new(), - even_header: String::new(), - even_footer: String::new(), - hf_scale_with_doc: true, - hf_align_with_margins: true, - } - } -} - -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct SheetProtection { - /// Hash of the password (Excel-compatible, written to XLSX `sheetProtection`; never the - /// password itself). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub password: Option, - /// The internal hash older GridCraft JSON workbooks stored ([`password_hash`]); still - /// verified when unprotecting, never written for new passwords. - #[serde(rename = "password_hash", default, skip_serializing_if = "Option::is_none")] - pub legacy_password_hash: Option, - pub select_locked: bool, - pub select_unlocked: bool, - pub format_cells: bool, - pub format_columns: bool, - pub format_rows: bool, - pub insert_columns: bool, - pub insert_rows: bool, - pub delete_columns: bool, - pub delete_rows: bool, - pub sort: bool, - pub autofilter: bool, -} - -/// Review › Allow Edit Ranges: cells that stay editable on a protected sheet, with or without a -/// password (XLSX ``). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct EditRange { - pub title: String, - pub ranges: Vec, - pub password: Option, - /// Windows permissions (`securityDescriptor`), kept as read from the file. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub security_descriptor: Option, -} - -impl EditRange { - pub fn contains(&self, c: CellRef) -> bool { - self.ranges.iter().any(|r| r.contains(c)) - } -} - -/// The non-cryptographic password hash older GridCraft versions stored for sheet protection -/// (only verified now, see [`SheetProtection::legacy_password_hash`]). -pub fn password_hash(pw: &str) -> u64 { - let mut h: u64 = 0xcbf29ce484222325 ^ 0x5348_4545_5443; - for b in pw.bytes() { - h ^= b as u64; - h = h.wrapping_mul(0x100000001b3); - } - h -} - -/// Outline grouping info for a row or column. -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] -pub struct LineInfo { - /// Size in pixels at 96 dpi; `None` = default (rows auto-fit to content). - pub size: Option, - pub hidden: bool, - pub outline: u8, - pub collapsed: bool, - pub style: Option, - /// The size was set by the user (rows): automatic heights follow the content. - #[serde(default)] - pub custom: bool, -} - -// ---------------------------------------------------------------- pivot tables - -/// How a PivotTable lays out its row fields. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum PivotLayout { - /// All row fields in one column, inner items indented ("Row Labels" header). - #[default] - Compact, - /// One column per row field; each item on its own row. - Outline, - /// One column per row field; inner items start on their parent's row, totals below. - Tabular, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum PivotSort { - #[default] - Asc, - Desc, - /// Source order (first appearance). - None, -} - -/// Grouping of a date field (values are date serials). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum PivotDateGroup { - #[default] - None, - Years, - Quarters, - Months, - Days, -} - -/// A field placed in the Rows or Columns area. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct PivotField { - /// Source column header name. - pub source_col: String, - pub sort: PivotSort, - pub date_group: PivotDateGroup, - /// Item labels whose detail is hidden (collapsed). - pub collapsed_items: Vec, -} - -/// Summarize-values-by function. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum PivotFunc { - #[default] - Sum, - Count, - Average, - Max, - Min, - Product, - CountNumbers, - StdDev, - StdDevP, - Var, - VarP, -} - -/// Show-values-as transform. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum PivotShowAs { - #[default] - Normal, - PercentOfGrandTotal, - PercentOfColumnTotal, - PercentOfRowTotal, - /// Running total in the first row field. - RunningTotal, - /// Rank largest to smallest among sibling row items. - Rank, -} - -/// A field in the Values area. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct PivotValue { - pub source_col: String, - pub func: PivotFunc, - /// Caption, e.g. "Sum of Sales". - pub name: String, - pub show_as: PivotShowAs, - pub number_format: Option, -} - -/// A report filter (or, for a field also in Rows/Columns, its hidden-items filter). -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct PivotFilter { - pub source_col: String, - /// Item labels shown; `None` = all. - pub selected: Option>, -} - -/// A PivotTable: its definition plus where its output was last written. The output itself is -/// static cell values and styles on the sheet, rewritten on every refresh. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct PivotTable { - pub id: u32, - pub name: String, - /// Source: range formula text (`Sheet1!$A$1:$E$200`), a table name or a defined name. - pub source: String, - /// Top-left of the output body on the pivot's sheet. - pub anchor: CellRef, - pub rows: Vec, - pub columns: Vec, - pub values: Vec, - pub filters: Vec, - 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". - pub grand_totals_cols: bool, - pub subtotals: bool, - /// Subtotals at the top of each group (Compact/Outline); otherwise below it. - pub subtotals_top: bool, - pub style: String, - pub show_headers: bool, - /// Area written by the last refresh. - pub last_range: Option, - /// Calculated fields, usable in the Values area by name. - pub calculated_fields: Vec, - /// The Σ Values pseudo-field (it exists with two or more value fields) sits in Rows rather - /// than Columns (Excel's "values on rows"). - pub values_on_rows: bool, - /// Position of Σ Values among the row fields (`None` = after the last). Only used with - /// `values_on_rows`: in Columns Σ Values always comes after the last column field. - pub values_position: Option, -} - -/// A calculated field: a formula over other fields (`=Sales*0.1`, `='Unit Price'*Units`). As in -/// Excel, it is evaluated on the sums of the fields it names for each report cell, not per record. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct PivotCalcField { - pub name: String, - /// Formula text, starting with `=`. - pub formula: String, -} - -impl Default for PivotTable { - fn default() -> Self { - PivotTable { - id: 0, - name: "PivotTable1".into(), - source: String::new(), - anchor: CellRef::default(), - rows: vec![], - columns: vec![], - values: vec![], - filters: vec![], - layout: PivotLayout::Compact, - enable_show_details: true, - grand_totals_rows: true, - grand_totals_cols: true, - subtotals: true, - subtotals_top: true, - style: "PivotStyleLight16".into(), - show_headers: true, - last_range: None, - calculated_fields: vec![], - values_on_rows: false, - values_position: None, - } - } -} - #[cfg(test)] mod trendline_forecast_tests { use super::Trendline; diff --git a/crates/model/src/features/pivot.rs b/crates/model/src/features/pivot.rs new file mode 100644 index 0000000000..368aa1f537 --- /dev/null +++ b/crates/model/src/features/pivot.rs @@ -0,0 +1,176 @@ +use super::*; + +// ---------------------------------------------------------------- pivot tables + +/// How a PivotTable lays out its row fields. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum PivotLayout { + /// All row fields in one column, inner items indented ("Row Labels" header). + #[default] + Compact, + /// One column per row field; each item on its own row. + Outline, + /// One column per row field; inner items start on their parent's row, totals below. + Tabular, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum PivotSort { + #[default] + Asc, + Desc, + /// Source order (first appearance). + None, +} + +/// Grouping of a date field (values are date serials). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum PivotDateGroup { + #[default] + None, + Years, + Quarters, + Months, + Days, +} + +/// A field placed in the Rows or Columns area. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct PivotField { + /// Source column header name. + pub source_col: String, + pub sort: PivotSort, + pub date_group: PivotDateGroup, + /// Item labels whose detail is hidden (collapsed). + pub collapsed_items: Vec, +} + +/// Summarize-values-by function. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum PivotFunc { + #[default] + Sum, + Count, + Average, + Max, + Min, + Product, + CountNumbers, + StdDev, + StdDevP, + Var, + VarP, +} + +/// Show-values-as transform. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum PivotShowAs { + #[default] + Normal, + PercentOfGrandTotal, + PercentOfColumnTotal, + PercentOfRowTotal, + /// Running total in the first row field. + RunningTotal, + /// Rank largest to smallest among sibling row items. + Rank, +} + +/// A field in the Values area. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct PivotValue { + pub source_col: String, + pub func: PivotFunc, + /// Caption, e.g. "Sum of Sales". + pub name: String, + pub show_as: PivotShowAs, + pub number_format: Option, +} + +/// A report filter (or, for a field also in Rows/Columns, its hidden-items filter). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct PivotFilter { + pub source_col: String, + /// Item labels shown; `None` = all. + pub selected: Option>, +} + +/// A PivotTable: its definition plus where its output was last written. The output itself is +/// static cell values and styles on the sheet, rewritten on every refresh. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct PivotTable { + pub id: u32, + pub name: String, + /// Source: range formula text (`Sheet1!$A$1:$E$200`), a table name or a defined name. + pub source: String, + /// Top-left of the output body on the pivot's sheet. + pub anchor: CellRef, + pub rows: Vec, + pub columns: Vec, + pub values: Vec, + pub filters: Vec, + 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". + pub grand_totals_cols: bool, + pub subtotals: bool, + /// Subtotals at the top of each group (Compact/Outline); otherwise below it. + pub subtotals_top: bool, + pub style: String, + pub show_headers: bool, + /// Area written by the last refresh. + pub last_range: Option, + /// Calculated fields, usable in the Values area by name. + pub calculated_fields: Vec, + /// The Σ Values pseudo-field (it exists with two or more value fields) sits in Rows rather + /// than Columns (Excel's "values on rows"). + pub values_on_rows: bool, + /// Position of Σ Values among the row fields (`None` = after the last). Only used with + /// `values_on_rows`: in Columns Σ Values always comes after the last column field. + pub values_position: Option, +} + +/// A calculated field: a formula over other fields (`=Sales*0.1`, `='Unit Price'*Units`). As in +/// Excel, it is evaluated on the sums of the fields it names for each report cell, not per record. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct PivotCalcField { + pub name: String, + /// Formula text, starting with `=`. + pub formula: String, +} + +impl Default for PivotTable { + fn default() -> Self { + PivotTable { + id: 0, + name: "PivotTable1".into(), + source: String::new(), + anchor: CellRef::default(), + rows: vec![], + columns: vec![], + values: vec![], + filters: vec![], + layout: PivotLayout::Compact, + enable_show_details: true, + grand_totals_rows: true, + grand_totals_cols: true, + subtotals: true, + subtotals_top: true, + style: "PivotStyleLight16".into(), + show_headers: true, + last_range: None, + calculated_fields: vec![], + values_on_rows: false, + values_position: None, + } + } +} diff --git a/crates/model/src/features/print.rs b/crates/model/src/features/print.rs new file mode 100644 index 0000000000..6a4ab0be0c --- /dev/null +++ b/crates/model/src/features/print.rs @@ -0,0 +1,216 @@ +use super::*; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Orientation { + #[default] + Portrait, + Landscape, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct PrintSettings { + pub orientation: Orientation, + /// Paper size name (`Letter`, `A4`, `Legal`…). + pub paper: String, + /// Margins in inches: left, right, top, bottom, header, footer. + pub margins: [f32; 6], + pub print_area: Option, + pub title_rows: Option<(u32, u32)>, + pub title_cols: Option<(u32, u32)>, + /// Percent scale, or fit to pages (wide, tall). + pub scale: u16, + pub fit_to: Option<(u16, u16)>, + pub gridlines: bool, + pub headings: bool, + pub center_h: bool, + pub center_v: bool, + /// The header and footer of odd pages, and of every page unless the variants below apply. + pub header: String, + pub footer: String, + pub row_breaks: Vec, + pub col_breaks: Vec, + /// Different First Page: the sheet's first printed page uses `first_header`/`first_footer`. + #[serde(default)] + pub different_first: bool, + /// Different Odd & Even Pages: even-numbered pages use `even_header`/`even_footer`. + #[serde(default)] + pub different_odd_even: bool, + #[serde(default)] + pub first_header: String, + #[serde(default)] + pub first_footer: String, + #[serde(default)] + pub even_header: String, + #[serde(default)] + pub even_footer: String, + /// Scale with Document / Align with Page Margins (both on unless a file turned them off). + #[serde(default = "yes")] + pub hf_scale_with_doc: bool, + #[serde(default = "yes")] + pub hf_align_with_margins: bool, +} + +fn yes() -> bool { + true +} + +/// Which header/footer pair a printed page uses. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HfPage { + Odd, + Even, + First, +} + +impl HfPage { + pub fn id(self) -> &'static str { + match self { + HfPage::Odd => "odd", + HfPage::Even => "even", + HfPage::First => "first", + } + } + pub fn parse(id: &str) -> Option { + match id { + "odd" | "all" => Some(HfPage::Odd), + "even" => Some(HfPage::Even), + "first" => Some(HfPage::First), + _ => None, + } + } +} + +impl PrintSettings { + /// The pair used by page `number` (1-based, as `&P` prints it); `first` is the sheet's first page. + pub fn hf_page(&self, first: bool, number: usize) -> HfPage { + if self.different_first && first { + HfPage::First + } else if self.different_odd_even && number.is_multiple_of(2) { + HfPage::Even + } else { + HfPage::Odd + } + } + /// The header (`footer` false) or footer of a page kind. + pub fn hf(&self, page: HfPage, footer: bool) -> &String { + match (page, footer) { + (HfPage::Odd, false) => &self.header, + (HfPage::Odd, true) => &self.footer, + (HfPage::Even, false) => &self.even_header, + (HfPage::Even, true) => &self.even_footer, + (HfPage::First, false) => &self.first_header, + (HfPage::First, true) => &self.first_footer, + } + } + pub fn hf_mut(&mut self, page: HfPage, footer: bool) -> &mut String { + match (page, footer) { + (HfPage::Odd, false) => &mut self.header, + (HfPage::Odd, true) => &mut self.footer, + (HfPage::Even, false) => &mut self.even_header, + (HfPage::Even, true) => &mut self.even_footer, + (HfPage::First, false) => &mut self.first_header, + (HfPage::First, true) => &mut self.first_footer, + } + } + /// Any header or footer text at all (on any kind of page). + pub fn has_header_footer(&self) -> bool { + [&self.header, &self.footer, &self.first_header, &self.first_footer, &self.even_header, &self.even_footer].iter().any(|t| !t.is_empty()) + } +} + +impl Default for PrintSettings { + fn default() -> Self { + PrintSettings { + orientation: Orientation::Portrait, + paper: "Letter".into(), + margins: [0.7, 0.7, 0.75, 0.75, 0.3, 0.3], + print_area: None, + title_rows: None, + title_cols: None, + scale: 100, + fit_to: None, + gridlines: false, + headings: false, + center_h: false, + center_v: false, + header: String::new(), + footer: String::new(), + row_breaks: vec![], + col_breaks: vec![], + different_first: false, + different_odd_even: false, + first_header: String::new(), + first_footer: String::new(), + even_header: String::new(), + even_footer: String::new(), + hf_scale_with_doc: true, + hf_align_with_margins: true, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct SheetProtection { + /// Hash of the password (Excel-compatible, written to XLSX `sheetProtection`; never the + /// password itself). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub password: Option, + /// The internal hash older GridCraft JSON workbooks stored ([`password_hash`]); still + /// verified when unprotecting, never written for new passwords. + #[serde(rename = "password_hash", default, skip_serializing_if = "Option::is_none")] + pub legacy_password_hash: Option, + pub select_locked: bool, + pub select_unlocked: bool, + pub format_cells: bool, + pub format_columns: bool, + pub format_rows: bool, + pub insert_columns: bool, + pub insert_rows: bool, + pub delete_columns: bool, + pub delete_rows: bool, + pub sort: bool, + pub autofilter: bool, +} + +/// Review › Allow Edit Ranges: cells that stay editable on a protected sheet, with or without a +/// password (XLSX ``). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct EditRange { + pub title: String, + pub ranges: Vec, + pub password: Option, + /// Windows permissions (`securityDescriptor`), kept as read from the file. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub security_descriptor: Option, +} + +impl EditRange { + pub fn contains(&self, c: CellRef) -> bool { + self.ranges.iter().any(|r| r.contains(c)) + } +} + +/// The non-cryptographic password hash older GridCraft versions stored for sheet protection +/// (only verified now, see [`SheetProtection::legacy_password_hash`]). +pub fn password_hash(pw: &str) -> u64 { + let mut h: u64 = 0xcbf29ce484222325 ^ 0x5348_4545_5443; + for b in pw.bytes() { + h ^= b as u64; + h = h.wrapping_mul(0x100000001b3); + } + h +} + +/// Outline grouping info for a row or column. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct LineInfo { + /// Size in pixels at 96 dpi; `None` = default (rows auto-fit to content). + pub size: Option, + pub hidden: bool, + pub outline: u8, + pub collapsed: bool, + pub style: Option, + /// The size was set by the user (rows): automatic heights follow the content. + #[serde(default)] + pub custom: bool, +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs new file mode 100644 index 0000000000..f2671692a5 --- /dev/null +++ b/crates/ui-egui/src/app.rs @@ -0,0 +1,520 @@ +use super::clock::now_ms; +use super::services::{Chrome, Services}; +use super::state::{NoticeKind, OpenWindow, SaveAnswer, SheetView}; +use super::*; + +mod clipboard; +mod commands; +mod editing; +mod files; +mod fonts; +mod input; +mod keys; +mod prefs; +mod print; +mod quit; +mod view; + +impl SheetApp { + /// Constructs a `SheetApp` with default UI, grid, printing, control and screenshot state and the caller's session and services. + pub fn new(session: Session, services: Services) -> SheetApp { + SheetApp { + session, + ui: UiState::default(), + chrome: Chrome::default(), + views: HashMap::new(), + editor: None, + text_box_editor: None, + dialog: None, + backstage: None, + message: None, + toast: None, + notice: None, + services, + printing: Default::default(), + print_feedback_seen: None, + synthetic: Default::default(), + synthetic_pending: false, + synthetic_injected_at_pass: None, + control_rx: None, + grid: grid::GridState { + last_border: "bottom".into(), + last_fill: "#FFFF00".into(), + last_font_color: "#C00000".into(), + ..Default::default() + }, + perf: Perf::default(), + fonts_ready: false, + fonts_set: false, + prepared_fonts: false, + fonts_bound: Vec::new(), + fonts_seen: (0, 0), + fonts_rebind: false, + fonts_han: None, + effective_dark: false, + desktop_theme: desktop_theme::DesktopTheme::default(), + desktop_dark: None, + name_box: None, + keytips: keytips::KeyTips::default(), + shots: control::Shots::default(), + capture_epoch: 0, + capture_geometry: None, + capture_geometry_from_raw: false, + selected_chart: None, + clipboard_text_at_copy: None, + cut_text: None, + tab_run: None, + started: now_ms(), + allow_close: false, + open_switch_windows: false, + ui_window: None, + parked: HashMap::new(), + native_windows: false, + arrange_windows: None, + #[cfg(not(target_arch = "wasm32"))] + updates_rt: Default::default(), + } + } + + /// One-time context setup: start cataloging the machine's fonts, then install the fonts and + /// visuals. Workbook-specific fonts are bound later, once the scan is done (see [`logic`]). + /// + /// [`logic`]: SheetApp::logic + pub fn setup_context(ctx: &egui::Context, dark: bool) { + crate::fonts::start_scan(); + Self::setup_context_for_language(ctx, dark, i18n::Language::En); + } + + /// Sets up fonts and visuals for a specific persisted interface language. + pub fn setup_context_for_language(ctx: &egui::Context, dark: bool, language: i18n::Language) { + ctx.set_fonts(theme::font_definitions_for_language(language)); + theme::apply(ctx, dark); + } + + /// Per-frame logic (control channel, screenshots). Call before `ui`. + pub fn logic(&mut self, ctx: &egui::Context) { + self.ensure_text_width(ctx); + // A keytip control request lives one frame: the ribbon consumes it during `ui`, and + // anything that never reached a control (collapsed ribbon, tab switch) is dropped. + self.ui.keytip_invoke = None; + // Floating pictures' textures go with their workbook, even when no sheet is painted. + let open: Vec = self.session.documents().iter().map(|d| d.uid).collect(); + floating_pictures::forget_closed(ctx, &open); + self.apply_font_state(ctx); + if let Some(answer) = self.desktop_theme.take_change() { + self.desktop_dark = Some(answer); + } + self.sync_theme_preference(ctx); + self.sync_input_options(ctx); + // The geometry captures are bound to. Native frames recorded it from their fresh + // `RawInput` in `raw_input_hook` (including hidden logic-only ticks); the epoch is + // monotonic, so a resize back to the original values is still a new epoch and old pixels + // are never saved with new geometry. + let capture_geometry = control::current_capture_geometry(self, ctx); + control::note_capture_geometry(self, capture_geometry); + // Drain before admission: completed or timed-out captures release their slots and + // reservation first, so a new request is never refused for budget that is about to free. + // Screenshot results, deadlines and geometry validation belong to `logic`; `ui` only + // issues the capture command (after every widget, so a command cannot be missed). + control::drain_screenshots(self, ctx); + control::poll(self, ctx); + // Printing feedback is polled once per frame, here, so hidden windows and logic-only + // frames still see worker completion. `ui` and the desktop app no longer poll it. + self.poll_printing(ctx); + self.guard_window_close(ctx); + #[cfg(not(target_arch = "wasm32"))] + updates_ui::tick(self, ctx); + // Files read asynchronously (web file picker, dropped files). + self.poll_async_opens(); + self.autosave(ctx); + self.update_title(ctx); + self.request_hidden_paint(ctx); + // One window always shows a workbook, unless each workbook has a native window of its own + // (`doc_windows` decides what having none left means). + if self.session.active().is_none() && !self.native_windows { + self.session.new_workbook(); + } + self.follow_active_window(); + } + + /// Window title, next to the close/autosave state: `ViewportCommand::Title` is valid outside a + /// pass and a control-channel rename while hidden updates it on return. + fn update_title(&mut self, ctx: &egui::Context) { + // Native windows get theirs from `doc_windows`. + if self.native_windows { + return; + } + let Some(d) = self.session.active() else { return }; + let title = format!("{}{}", d.display_title(), if d.is_dirty() { " — Edited" } else { "" }); + if self.grid.last_title.as_deref() != Some(title.as_str()) { + ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); + self.grid.last_title = Some(title); + } + } + + /// A queued/settling capture with a capturable size and an unapplied synthetic step both need + /// a real `ui` pass. Once a capture is issued, its `Screenshot` command's own `wants_paint` + /// paints the hidden window; after that a full hidden pass every frame only wastes CPU. A + /// minimized request has no size, so no hidden pass can reach issuance: the restore pass issues + /// it instead. Native eframe honours `RequestPaintWhileHidden`; the web runner does not (hidden + /// tabs only run `run_logic`), so a hidden web tab queues the work until it is shown again. + /// This is a shared eframe web follow-up, not a GridCraft-local workaround, and the control + /// protocol documents the limitation. + fn request_hidden_paint(&self, ctx: &egui::Context) { + if !(self.shots.needs_hidden_paint() || self.synthetic_pending || !self.synthetic.is_empty()) { + return; + } + // The web runner has no `RequestPaintWhileHidden`; sending it there only logs an + // unhandled command. A visible tab still gets the repaint below; a hidden tab keeps the + // work queued (see the shared eframe follow-up in docs/egui-037-local-followups.md). + #[cfg(not(target_arch = "wasm32"))] + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + ctx.request_repaint(); + } + + /// Lays out the whole window. + pub fn ui(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + if !self.fonts_ready || self.fonts_rebind { + ctx.request_repaint(); + return; + } + let t0 = now_ms(); + // A script may have switched windows since the last frame. + self.follow_active_window(); + text_box::before_ui(self, &ctx); + // Another window came forward without going through a command (which settles drafts + // first): the draft goes into its own window instead of floating over this one. If its + // workbook refuses it, its window comes back to the front with the draft open. + if self.draft_elsewhere() { + self.commit_edit(0, 0, false, false); + } + // The language the widgets translate with this frame (see `i18n::current`). + i18n::set_current(&ctx, self.ui.language); + #[cfg(not(target_arch = "wasm32"))] + updates_ui::show(self, &ctx); + let t = theme::Tokens::get(&ctx); + // A host can turn the File tab off while Backstage is already open: close it. + if !self.chrome.file_tab { + self.backstage = None; + } + // Keytips opened the File view (Alt, F): its letters keep working there. + if self.backstage.is_none() || self.keytips.prefix().is_some() { + self.ribbon_keys(&ctx); + } + if self.chrome.title_bar { + ribbon::title_bar(self, ui); + } + if self.backstage.is_some() { + // File tab: the Backstage view replaces everything below the title bar. + backstage::show(self, ui); + } else { + ribbon::show(self, ui); + widgets::notice_bar(self, ui); + if self.session.prefs.formula_bar { + formula_bar::show(self, ui); + } + if self.ui.status_bar { + tabs::status_bar(self, ui); + } + tabs::sheet_tabs(self, ui); + pivot_pane::show(self, ui); + panes::show(self, ui); + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.grid_bg)).show(ui, |ui| { + let g0 = now_ms(); + grid::show(self, ui); + self.perf.grid_ms = now_ms() - g0; + }); + } + dialogs::show(self, &ctx); + widgets::message_box(self, &ctx); + widgets::toast(self, &ctx); + // Requests are admitted in `logic`, for the root viewport, so only its pass issues them. + if ctx.viewport_id() == egui::ViewportId::ROOT { + control::issue_screenshots(self, &ctx); + } + if !ctx.input(|i| i.focused) { + // Nothing else to do. + } + let now = now_ms(); + self.perf.frame_ms = now - t0; + if self.perf.last > 0.0 { + let dt = now - self.perf.last; + if dt > 0.0 { + self.perf.fps = self.perf.fps * 0.9 + 100.0 / dt; + } + } + self.perf.last = now; + // The step is applied only after a completed UI pass. A discarded or repeated pass of the + // same frame must not offer it again (grid sizing calls `ui` more than once per frame); + // provenance is the injected pass number, so only a pass that missed input handling is + // re-offered by `raw_input_hook`. + // Synthetic input reaches the root viewport only (`raw_input_hook`), so its pass consumes it. + if self.synthetic_pending && ctx.viewport_id() == egui::ViewportId::ROOT { + self.synthetic.pop_front(); + self.synthetic_pending = false; + self.synthetic_injected_at_pass = None; + } + } + + /// Current selection's top-left cell text as A1 (for the Name Box). + pub fn name_box_text(&self) -> String { + let Some(d) = self.session.active() else { return String::new() }; + let sel = &d.selection; + let cur = sel.current(); + // A named range that exactly matches shows its name. + let sh = d.wb.active(); + if let Some(sh) = sh { + for n in &d.wb.names { + let target = n.formula.replace('$', ""); + let (sheet, body) = match target.rsplit_once('!') { + Some((s, b)) => (Some(gridcraft_engine::core::unquote_sheet(s)), b.to_string()), + None => (None, target.clone()), + }; + if sheet.as_deref().is_none_or(|s| s.eq_ignore_ascii_case(&sh.name)) && RangeRef::parse(&body) == Some(cur) { + return n.name.clone(); + } + } + } + if self.grid.drag_select && !cur.is_single() { + return format!("{}R x {}C", cur.height(), cur.width()); + } + sel.active.a1() + } +} + +#[cfg(test)] +mod lifecycle_tests { + use super::*; + + /// Builds a key-A press or release event without modifiers. + fn key_event(pressed: bool) -> egui::Event { + egui::Event::Key { key: egui::Key::A, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE } + } + + /// Returns a test app with a new workbook and default services. + fn app() -> SheetApp { + let mut session = Session::new(); + session.new_workbook(); + SheetApp::new(session, Services::default()) + } + + /// One headless pass; a test has no renderer for the texture atlas delta (0.37 panics when it + /// is dropped unapplied). + fn ui_pass(ctx: &egui::Context, raw: egui::RawInput, f: impl FnMut(&mut egui::Ui)) -> egui::FullOutput { + let mut out = ctx.run_ui(raw, f); + out.textures_delta.clear(); + out + } + + #[test] + /// Only one synthetic step is offered, is not duplicated by a hidden logic-only tick, and press/release remain distinct batches. + fn a_hidden_logic_tick_keeps_one_unapplied_synthetic_step() { + let ctx = egui::Context::default(); + let mut app = app(); + app.synthetic.push_back(vec![key_event(true)]); + app.synthetic.push_back(vec![key_event(false)]); + + let mut first = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut first); + assert_eq!(first.events.len(), 1, "only one step is offered"); + assert!(app.synthetic_pending); + assert_eq!(app.synthetic.len(), 2, "a logic-only frame does not consume the step"); + + // eframe carries the unconsumed `RawInput` into the next pass, so the hook offers the step + // only once; a hidden logic-only tick does not duplicate it or offer the release. + let mut second = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut second); + assert!(second.events.is_empty(), "an unapplied step is not injected a second time"); + assert_eq!(app.synthetic.len(), 2, "press and release stay distinct batches"); + } + + /// Returns raw input with a screen rect at the given size. + fn raw_with_size(size: egui::Vec2) -> egui::RawInput { + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() } + } + + #[test] + /// A font-gated early-returning pass does not lose the queued step; the hook offers it again and a later real pass applies it. + fn a_font_gated_pass_offers_the_synthetic_step_again() { + let ctx = egui::Context::default(); + ctx.set_fonts(theme::font_definitions()); + let mut app = app(); + app.fonts_ready = true; + app.fonts_set = true; + // Binds `session.text_width`, as eframe's first frame does. + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + app.synthetic.push_back(vec![key_event(true)]); + + // The font gate returns before input handling; `begin_pass` has still consumed the events. + app.fonts_rebind = true; + let mut raw = raw_with_size(egui::vec2(900.0, 700.0)); + app.raw_input_hook(&ctx, &mut raw); + assert_eq!(raw.events.len(), 1); + let _ = ui_pass(&ctx, raw, |ui| app.ui(ui)); + assert!(app.synthetic_pending, "the early-returning pass did not apply the step"); + assert_eq!(app.synthetic.len(), 1, "the step is still queued"); + + // A completed pass that did not apply it made the hook offer it again for a real pass. + app.fonts_rebind = false; + let mut raw = raw_with_size(egui::vec2(900.0, 700.0)); + app.raw_input_hook(&ctx, &mut raw); + assert_eq!(raw.events.len(), 1, "the step is not lost across the font gate"); + let _ = ui_pass(&ctx, raw, |ui| app.ui(ui)); + assert!(!app.synthetic_pending); + assert!(app.synthetic.is_empty(), "the real UI pass applied the step"); + } + + #[test] + /// A language-driven font rebuild waits while a synthetic step is pending and proceeds once it is clear. + fn a_pending_step_delays_a_font_change() { + let ctx = egui::Context::default(); + let mut app = app(); + app.fonts_ready = true; + app.fonts_han = Some(theme::HanOrder::Chinese); + app.ui.language = i18n::Language::Ja; + app.synthetic.push_back(vec![key_event(true)]); + app.synthetic_pending = true; + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + assert!(app.fonts_ready, "the font rebuild waits for the pass that applies the step"); + app.synthetic.clear(); + app.synthetic_pending = false; + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + assert!(!app.fonts_ready, "without pending input the language rebuild proceeds"); + } + + #[test] + /// Logic ticks see fresh viewport and DPI information; resizing back to the original size still advances the capture epoch. + fn a_hidden_logic_tick_sees_fresh_viewport_info_and_keeps_the_epoch_monotonic() { + /// Runs one logic tick with the requested width and native scale. + fn tick(ctx: &egui::Context, app: &mut SheetApp, width: f32, native_ppp: f32) { + let mut raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))), ..Default::default() }; + if let Some(viewport) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { + viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))); + viewport.native_pixels_per_point = Some(native_ppp); + } + let _ = ctx.run_logic(&raw, |ctx| app.logic(ctx)); + } + let ctx = egui::Context::default(); + let mut app = app(); + tick(&ctx, &mut app, 800.0, 2.0); + let first = app.capture_geometry.expect("the first logic tick records the geometry"); + assert_eq!(first.viewport, Some(egui::vec2(800.0, 400.0))); + assert_eq!(first.pixels_per_point, 2.0, "native scale times the interface zoom"); + tick(&ctx, &mut app, 900.0, 2.0); + let resized = app.capture_geometry.expect("geometry"); + assert_eq!(resized.viewport, Some(egui::vec2(900.0, 400.0)), "run_logic's ViewportInfo is the fresh size"); + let epoch = app.capture_epoch; + tick(&ctx, &mut app, 800.0, 2.0); + assert_eq!(app.capture_geometry.map(|g| g.viewport), Some(Some(egui::vec2(800.0, 400.0)))); + assert!(app.capture_epoch > epoch, "resizing back is still a new epoch: old pixels must not be saved"); + } + + #[test] + /// A resize and a resize back both advance the capture epoch. + fn the_capture_epoch_changes_even_when_the_geometry_returns() { + /// Runs one UI pass at the requested width. + fn tick(ctx: &egui::Context, app: &mut SheetApp, width: f32) { + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))), ..Default::default() }; + let _ = ui_pass(ctx, raw, |ui| app.logic(ui.ctx())); + } + let ctx = egui::Context::default(); + let mut app = app(); + tick(&ctx, &mut app, 800.0); + let base = app.capture_epoch; + tick(&ctx, &mut app, 900.0); + let changed = app.capture_epoch; + assert!(changed > base, "a resize increments the capture epoch"); + tick(&ctx, &mut app, 800.0); + assert!(app.capture_epoch > changed, "resizing back is still a new epoch: old pixels must not be saved"); + } + + /// Extracts title viewport commands from a logic output. + fn titles(out: &egui::LogicOutput) -> Vec { + out.viewport_commands + .values() + .flatten() + .filter_map(|c| match c { + egui::ViewportCommand::Title(title) => Some(title.clone()), + _ => None, + }) + .collect() + } + + #[test] + /// A hidden logic tick saves a dirty file-backed workbook once and clears its dirty state. + fn a_logic_only_tick_autosaves_a_dirty_workbook_once() { + let ctx = egui::Context::default(); + let mut app = app(); + let dir = std::env::temp_dir().join(format!("gridcraft-logic-autosave-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("autosave.xlsx"); + app.run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap(); + app.ui.autosave = true; + app.session.active_mut().unwrap().path = Some(path.to_string_lossy().into_owned()); + app.grid.last_autosave = monotonic_ms() - 3000.0; + + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + assert!(path.exists(), "a hidden logic-only tick saves a dirty workbook with a file"); + assert!(!app.session.active().unwrap().is_dirty(), "the save clears the dirty state"); + let saved_at = app.grid.last_autosave; + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + assert_eq!(app.grid.last_autosave, saved_at, "the same change is not saved a second time"); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + /// The title is sent on the first tick and after a rename, but not on every unchanged frame. + fn a_logic_only_tick_updates_the_title_once_per_change() { + let ctx = egui::Context::default(); + let mut app = app(); + let first = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + let title = app.session.active().unwrap().display_title().to_string(); + let expected = format!("{title}{}", if app.session.active().unwrap().is_dirty() { " — Edited" } else { "" }); + assert_eq!(titles(&first), [expected], "the first logic tick titles the window"); + let second = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + assert!(titles(&second).is_empty(), "an unchanged title is not sent every frame"); + + app.session.active_mut().unwrap().title = "Renamed".into(); + let third = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + let expected = format!("Renamed{}", if app.session.active().unwrap().is_dirty() { " — Edited" } else { "" }); + assert_eq!(titles(&third), [expected], "a rename while hidden updates the title"); + } + + #[test] + /// A hidden tick surfaces a failed print job in the app message. + fn a_logic_only_tick_polls_printing_feedback() { + let ctx = egui::Context::default(); + let mut app = app(); + app.printing.printers.push(crate::printing::Printer { name: "Queue".into(), label: String::new(), default: true }); + app.run("ui.printSettings", serde_json::json!({"printer": "Queue"})).unwrap(); + app.services.print_native = Some(Box::new(|_, _| { + let (sender, receiver) = std::sync::mpsc::channel(); + sender.send(Err("printer is offline".into())).unwrap(); + receiver + })); + app.run("ui.printSubmit", serde_json::json!({})).unwrap(); + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + assert_eq!(app.printing.message, Some(crate::printing::Feedback::Failed("printer is offline".into()))); + assert!(app.message.as_ref().is_some_and(|(_, text)| text.contains("printer is offline")), "logic surfaces the failure"); + } + + #[test] + /// Pending synthetic input makes logic request a real hidden paint. + fn a_pending_synthetic_step_requests_a_hidden_paint() { + let ctx = egui::Context::default(); + let mut app = app(); + app.synthetic.push_back(vec![key_event(true)]); + app.synthetic_pending = true; + let out = ui_pass(&ctx, egui::RawInput::default(), |ui| app.logic(ui.ctx())); + let painted = out + .viewport_output + .get(&ctx.viewport_id()) + .is_some_and(|output| output.commands.iter().any(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden))); + assert!(painted, "logic must ask for a real UI pass while a synthetic step is unapplied"); + } +} diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs new file mode 100644 index 0000000000..4d6e466d4d --- /dev/null +++ b/crates/ui-egui/src/app/clipboard.rs @@ -0,0 +1,77 @@ +use super::*; + +impl SheetApp { + /// Copy or cut the selected cells to the system clipboard. Engine-only commands keep + /// their existing internal clipboard behavior and do not write to the host clipboard. + pub fn copy_to_clipboard(&mut self, ctx: &egui::Context, command: &str) { + // Only ask the engine for HTML when the host can publish it. + let result = match self.run_command(command, json!({"html": self.services.copy_html.is_some()})) { + Ok(result) => result, + Err(e) => return self.alert(e), + }; + let Some(text) = result.get("text").and_then(Json::as_str) else { return }; + // The system clipboard gets this copy, so its text decides what Paste pastes. + self.clipboard_text_at_copy = None; + self.cut_text = (command == "edit.cut").then(|| text.replace("\r\n", "\n")); + if let Some(html) = result.get("html").and_then(Json::as_str) + && let Some(copy_html) = &mut self.services.copy_html + { + match copy_html(html, text) { + Ok(()) => return, + Err(error) => log::warn!("HTML clipboard unavailable; copying plain text: {error}"), + } + } + ctx.copy_text(text.to_string()); + } + + /// Paste text that came from the system clipboard (Cmd/Ctrl+V, or the ribbon and menu Paste), + /// with the clipboard's HTML flavour when the host can read it. + /// + /// Excel pastes a cut once: after GridCraft's cut has been pasted, the system clipboard text + /// that came with it is not pasted again over the moved block; GridCraft's own (empty) + /// clipboard is pasted instead, which reports "Nothing to paste.". Other text means the + /// clipboard has moved on, so the cut's text is forgotten and pastes normally later. + pub fn paste_external(&mut self, text: String) { + if Self::same_clipboard_text(Some(&text), self.cut_text.as_deref()) { + if self.session.clipboard.is_none() { + return self.run_or_alert("edit.paste", json!({})); + } + } else { + self.cut_text = None; + } + let html = self.services.paste_html.as_mut().and_then(|read| read()); + let params = match html { + Some(html) => json!({"text": text, "html": html}), + None => json!({"text": text}), + }; + self.run_or_alert("edit.paste", params); + } + + /// Ribbon and menu Paste: paste the text on the system clipboard, as Cmd/Ctrl+V does. With no + /// text there, no host clipboard (web), or the text that was there when GridCraft last copied + /// without publishing, GridCraft's own clipboard is pasted. + pub fn paste_from_clipboard(&mut self) { + match self.system_clipboard_text() { + Some(text) if self.session.clipboard.is_none() || self.clipboard_text_at_copy.as_ref() != Some(&text) => self.paste_external(text), + _ => self.run_or_alert("edit.paste", json!({})), + } + } + + /// The system clipboard's plain text, with the line endings of egui's `Event::Paste` text. + /// `None` when it holds no text or the host can't read it. + pub(super) fn system_clipboard_text(&mut self) -> Option { + self.services.paste_text.as_mut().and_then(|read| read()).filter(|t| !t.is_empty()).map(|t| t.replace("\r\n", "\n")) + } + + /// Whether two clipboard texts are the same copy: trailing line breaks don't count, as in the + /// engine's `edit.paste`, and CRLF matches LF (egui's `Event::Paste` text may keep the host's). + fn same_clipboard_text(a: Option<&str>, b: Option<&str>) -> bool { + fn chars(t: &str) -> impl Iterator + '_ { + t.trim_end_matches(['\r', '\n']).chars().filter(|c| *c != '\r') + } + match (a, b) { + (Some(a), Some(b)) => chars(a).eq(chars(b)), + _ => false, + } + } +} diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs new file mode 100644 index 0000000000..a3dd9ac37e --- /dev/null +++ b/crates/ui-egui/src/app/commands.rs @@ -0,0 +1,348 @@ +use super::*; + +impl SheetApp { + /// Runs an engine command (or a UI command) and handles UI requests it makes. + pub fn run(&mut self, id: &str, params: Json) -> Result { + let r = self.run_command(id, params); + // This copy leaves the system clipboard alone: note what it holds, so Paste can tell + // that GridCraft's copy is newer while that text is still there. + if matches!(id, "edit.copy" | "edit.cut") && r.is_ok() { + self.clipboard_text_at_copy = self.system_clipboard_text(); + self.cut_text = if id == "edit.cut" { self.clipboard_text_at_copy.clone() } else { None }; + } + r + } + + pub(super) fn run_command(&mut self, id: &str, params: Json) -> Result { + let switches_window = id.starts_with("window.") + || matches!(id, "view.newWindow" | "view.hideWindow" | "view.unhideWindow") + || (id == "view.switchWindows" && ["window", "doc"].iter().any(|k| params.get(*k).is_some_and(|d| !d.is_null()))); + if id.starts_with("file.") || switches_window { + self.settle_drafts()?; + } + if matches!(id, "file.save" | "file.saveAs" | "file.saveBytes") { + self.store_view_state(); + } + if matches!( + id, + "sheet.activate" + | "sheet.next" + | "sheet.previous" + | "sheet.move" + | "sheet.hide" + | "sheet.unhide" + | "home.insertSheet" + | "home.deleteSheet" + | "edit.undo" + | "edit.redo" + | "object.delete" + | "object.move" + ) { + self.commit_text_box_edit()?; + } + if let Some(r) = self.run_ui_command(id, ¶ms) { + return r; + } + let before = self.session.active_window(); + let r = self.session.run(id, params); + if id.starts_with("file.") && self.session.active_window() != before { + // File › New or Open from the Backstage view: the window it was in goes back to its sheet. + self.backstage = None; + } + // Requests the command made (a dialog, a draft) belong to the window it left active. + self.follow_active_window(); + self.after_engine(); + if id == "view.arrangeAll" + && self.native_windows + && let Ok(r) = &r + { + let arrange = r.get("arrange").and_then(Json::as_str).unwrap_or("tiled").to_string(); + let windows = r.get("windows").and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_u64).collect()).unwrap_or_default(); + self.arrange_windows = Some((arrange, windows)); + } + // The new chart is selected, as after inserting any object, so Delete removes it, not its data. + if id == "insert.chart" + && let Ok(r) = &r + && let Some(chart) = r.get("chart").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()) + { + self.selected_chart = Some(chart); + } + if id == "file.exportPdf" + && let Ok(r) = &r + { + self.deliver_pdf(r); + } + r + } + + pub fn run_or_alert(&mut self, id: &str, params: Json) { + // A menu/shortcut opens the settings first; the Print page's button submits explicitly. + if id == "file.print" { + if self.services.print_native.is_some() { + self.open_backstage(backstage::Page::Print); + return; + } + return self.print_now(params); + } + // Menu-style Export as PDF with no file named: ask where to save, like Save As does. + // Programmatic callers use `run` and keep getting the PDF bytes back. + if id == "file.exportPdf" && self.services.pick_save.is_some() && params.get("path").and_then(Json::as_str).is_none_or(str::is_empty) { + return self.export_pdf_with_picker(params); + } + // Recommended Charts with no chart type named (ribbon button, keytip, command search) asks + // which chart to insert; programmatic callers of `run` still get the first recommendation. + if id == "insert.recommendedCharts" && params.get("type").is_none() { + return self.open_dialog("recommendedCharts", json!({})); + } + // Switch Windows with no window named (key tip, command search) opens the View tab's + // list to choose from; programmatic callers of `run` get the list back. + if id == "view.switchWindows" && ["window", "doc"].iter().all(|k| params.get(*k).is_none_or(Json::is_null)) { + self.ui.ribbon_tab = "View".into(); + self.ui.ribbon_collapsed = false; + self.open_switch_windows = true; + return; + } + match self.run(id, params) { + // A refused cell draft already shows its own message (with the rule's title). + Err(e) if self.message.as_ref().is_some_and(|(_, m)| *m == e) => {} + Err(e) => self.alert(e), + Ok(_) => {} + } + } + + /// Shows a command error: a toast for an unavailable command, else a message box. + pub(super) fn alert(&mut self, e: String) { + // Shown in the window the user ends up in, also after a command that switched windows. + self.follow_active_window(); + if e.contains("is not available right now") { + self.toast = Some((e, now_ms())); + } else { + self.message = Some(("GridCraft".into(), clean_error(&e))); + } + } + + /// UI-only commands (dialogs, view toggles that aren't saved in the file). + fn run_ui_command(&mut self, id: &str, p: &Json) -> Option> { + let r = match id { + "app.checkForUpdates" | "help.checkForUpdates" => { + self.check_for_updates(); + Ok(Json::Null) + } + "ui.printSettings" => printing::settings(&mut self.printing, p, self.services.printers.as_ref()), + "ui.printSubmit" => self.submit_print(p.clone()), + "ui.ribbonTab" => { + self.ui.ribbon_tab = p.get("tab").and_then(Json::as_str).unwrap_or("Home").to_string(); + Ok(Json::Null) + } + "view.collapseRibbon" => { + 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); + self.ui.system_theme = false; + Ok(json!({"on": self.ui.dark})) + } + "view.theme" => { + match p.get("mode").and_then(Json::as_str) { + Some("system") => self.ui.system_theme = true, + Some(mode @ ("light" | "dark")) => { + self.ui.dark = mode == "dark"; + self.ui.system_theme = false; + } + _ => return Some(Err("theme mode must be system, light, or dark".into())), + } + Ok(json!({"mode": self.ui.theme_mode()})) + } + "view.animations" => { + self.ui.animations = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.animations); + Ok(json!({"on": self.ui.animations})) + } + "options.autoComplete" => { + self.ui.autocomplete = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.autocomplete); + Ok(json!({"on": self.ui.autocomplete})) + } + "view.interfaceScale" => { + // `{"percent": 125}` or `{"percent": "auto"}`; out-of-range values are clamped. + let percent = match p.get("percent") { + Some(Json::String(s)) if s.eq_ignore_ascii_case("auto") => Some(100), + Some(v) => v.as_f64().and_then(clamp_interface_scale), + None => None, + }; + match percent { + Some(v) => { + self.ui.interface_scale = v; + Ok(json!({"percent": v})) + } + None => Err(format!("missing `percent` ({INTERFACE_SCALE_MIN}–{INTERFACE_SCALE_MAX}, or \"auto\")")), + } + } + "app.language.set" => { + // `{"language": "ja"}`; `code` is accepted as an alias. + let code = p.get("language").or_else(|| p.get("code")).and_then(Json::as_str).unwrap_or(""); + match i18n::Language::parse(code) { + Some(l) => { + self.ui.language = l; + Ok(json!({"language": l})) + } + None => Err(format!("unknown language {code:?} (use one of {})", i18n::Language::code_list(", "))), + } + } + "app.language.english" => { + self.ui.language = i18n::Language::En; + Ok(json!({"language": i18n::Language::En})) + } + "app.language.japanese" => { + self.ui.language = i18n::Language::Ja; + Ok(json!({"language": i18n::Language::Ja})) + } + "ui.backstage" => { + // `{"page": "info"}` opens File › Info; `{"page": "close"}` returns to the sheet. + match p.get("page").and_then(Json::as_str).map(str::trim) { + Some("close" | "none" | "") => self.backstage = None, + Some(id) => match backstage::Page::parse(id) { + Some(page) => self.open_backstage(page), + None => { + return Some(Err(format!( + "unknown File page {id:?} (use home, new, open, info, saveAs, print, export, options or close)" + ))); + } + }, + None => self.open_backstage(backstage::Page::Home), + } + Ok(json!({"page": self.backstage.as_ref().map(|b| b.page.id())})) + } + "ui.dialog" => { + let name = p.get("name").and_then(Json::as_str).unwrap_or(""); + self.open_dialog(name, p.clone()); + Ok(json!({"dialog": self.dialog.as_ref().map(|d| d.name())})) + } + "ui.message.dismiss" => { + self.message = None; + Ok(Json::Null) + } + "ui.notice" => Ok(match &self.notice { + Some(n) => json!({ + "text": n.text, + "kind": match n.kind { NoticeKind::Warning => "warning", NoticeKind::Info => "info" }, + "action": n.action, + }), + None => Json::Null, + }), + "ui.notice.dismiss" => { + self.notice = None; + Ok(Json::Null) + } + _ => return None, + }; + Some(r) + } + + /// Picks up requests commands made (dialogs, edit mode…). + pub fn after_engine(&mut self) { + // The requests belong to the window the command left active. + self.follow_active_window(); + for req in self.session.take_ui_requests() { + match req { + UiRequest::Dialog(name, params) => self.open_dialog(&name, params), + UiRequest::Message(m) => self.message = Some(("GridCraft".into(), m)), + UiRequest::EditCell(text) => { + let text = text.map(|text| { + self.session + .active() + .map(|d| formula_locale::display_template(&text, self.ui.language.formula_locale(), &d.wb, d.wb.active_sheet())) + .unwrap_or(text) + }); + 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); + } + } + } + } + } + + /// Whether the active sheet of the active document is protected (drives the Protect/Unprotect + /// Sheet labels and actions). + pub fn active_sheet_protected(&self) -> bool { + self.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false) + } + + pub fn open_dialog(&mut self, name: &str, params: Json) { + if let Err(e) = self.commit_text_box_edit() { + self.message = Some(("Text Box".into(), clean_error(&e))); + return; + } + // Every Protect Sheet entry point (ribbon menus, keytips, tab menu) becomes Unprotect Sheet + // on a protected sheet. A sheet without a password is unprotected straight away. + let name = if name == "protectSheet" && self.active_sheet_protected() { "unprotectSheet" } else { name }; + if name == "unprotectSheet" + && self + .session + .active() + .and_then(|d| d.wb.active()) + .and_then(|s| s.protection.as_ref()) + .is_some_and(|p| p.password.is_none() && p.legacy_password_hash.is_none()) + { + self.run_or_alert("review.unprotectSheet", json!({})); + return; + } + match name { + "open" => { + if let Some(start) = &self.services.open_async { + start(); + } else if let Some(pick) = &self.services.pick_open + && let Some(path) = pick() + { + self.open_path(&path); + } + } + "saveAs" => { + let suggested = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); + if let Some(pick) = &self.services.pick_save { + if let Some(path) = pick(&suggested) { + let path = if std::path::Path::new(&path).extension().is_none() { format!("{path}.xlsx") } else { path }; + self.run_or_alert("file.save", json!({"path": path})); + } + } else if self.services.download.is_some() + && let Ok(r) = self.run("file.saveBytes", json!({"format": "xlsx"})) + && let Some(dl) = &self.services.download + && let Some(b) = r.get("base64").and_then(Json::as_str).and_then(gridcraft_engine::io::base64_decode) + { + let name = r.get("name").and_then(Json::as_str).unwrap_or("Book.xlsx").to_string(); + dl(&name, &b); + } + } + // File › Options (`file.options`) and the File tab itself open the Backstage view. + "options" => self.open_backstage(backstage::Page::Options), + "backstage" => { + let page = params.get("page").and_then(Json::as_str).and_then(backstage::Page::parse).unwrap_or(backstage::Page::Home); + self.open_backstage(page); + } + other => self.dialog = dialogs::Dialog::open(self, other, params), + } + } + + /// Opens the File tab's Backstage view on `page`, committing a cell still being edited first + /// (File actions work on the whole workbook). A draft that can't be committed keeps the sheet. + pub fn open_backstage(&mut self, page: backstage::Page) { + if !self.chrome.file_tab { + return; + } + if !self.commit_edit(0, 0, false, false) { + return; + } + match &mut self.backstage { + Some(b) => b.page = page, + None => self.backstage = Some(backstage::Backstage::new(page)), + } + } +} diff --git a/crates/ui-egui/src/app/editing.rs b/crates/ui-egui/src/app/editing.rs new file mode 100644 index 0000000000..c9c494fb13 --- /dev/null +++ b/crates/ui-egui/src/app/editing.rs @@ -0,0 +1,211 @@ +use super::*; + +impl SheetApp { + /// Starts editing the active cell. `text` replaces the content (typing) or pre-fills it. + pub fn begin_edit(&mut self, text: Option, from_formula_bar: bool) { + // The source formula is display-only on spill children. Grid replacement keeps its + // existing behavior, but no formula-bar entry path may edit that displayed formula. + if from_formula_bar && formula_bar::active_spill_anchor(self).is_some() { + return; + } + if let Err(e) = self.commit_text_box_edit() { + self.message = Some(("Text Box".into(), clean_error(&e))); + return; + } + let Some(d) = self.session.active() else { return }; + let Some(sh) = d.wb.active() else { return }; + let at = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active); + // A picture has no editable scalar text. Selecting it, F2, or clicking + // the formula bar must not turn its fallback value into a text edit. + // Typing still starts an intentional replacement. + if text.is_none() && sh.cell_pictures.contains_key(&at) { + return; + } + let locale = self.ui.language.formula_locale(); + let current = if sh.cell_pictures.contains_key(&at) { + String::new() + } else { + match sh.cell(at) { + Some(c) if c.formula.is_some() => formula_locale::display(&c.input_text(), locale, &d.wb, d.wb.active_sheet()), + _ => gridcraft_engine::display::edit_text(&d.wb, sh, at), + } + }; + let (text, replace) = match text { + Some(t) => (t, true), + None => (current.clone(), false), + }; + let mut ed = editor::EditState::new(d.uid, d.wb.active_sheet(), at, text, replace, from_formula_bar); + ed.window = self.session.active_window(); + ed.locale = locale; + if replace && self.ui.autocomplete && !ed.is_formula() { + ed.completion = editor::column_completion(sh, at, &ed.text); + } + self.editor = Some(ed); + self.session.mode = if replace { gridcraft_engine::Mode::Enter } else { gridcraft_engine::Mode::Edit }; + } + + /// Commits the edit; `then` moves the selection (dr, dc) afterwards. + /// + /// The draft goes into the window and workbook it was typed in. If another window is + /// showing, that window stays active afterwards; a draft its own workbook refuses + /// (validation, a bad formula) brings its window back to the front with the draft still open. + pub fn commit_edit(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool { + let Some((doc, window)) = self.editor.as_ref().map(|ed| (ed.doc, ed.window)) else { return true }; + if !self.draft_elsewhere() { + return self.commit_edit_here(dr, dc, array, fill_selection); + } + // Its window, or (when that window closed) its workbook's most recent one. + let Some(owner) = window.filter(|w| self.session.window(*w).is_some()).or_else(|| self.session.doc_window(doc)) else { + self.cancel_edit(); + return true; + }; + let back = self.session.active_window(); + self.session.activate_window(owner); + if !self.commit_edit_here(dr, dc, array, fill_selection) { + // Refused: its window stays in front with the draft open and the reason shown. + return false; + } + if let Some(back) = back { + self.session.activate_window(back); + } + true + } + + /// The cell draft belongs to a window or workbook other than the one showing. + pub(super) fn draft_elsewhere(&self) -> bool { + self.editor.as_ref().is_some_and(|ed| { + self.session.active().map(|d| d.uid) != Some(ed.doc) || ed.window.is_some_and(|w| self.session.active_window() != Some(w)) + }) + } + + /// Commits the cell and text-box drafts, as Enter would, before a command that can show + /// another workbook or write this one to a file, so a draft never lands in another workbook. + /// A draft its workbook refuses stays open with the reason shown, and the command doesn't run. + pub(crate) fn settle_drafts(&mut self) -> Result<(), String> { + if !self.commit_edit(0, 0, false, false) { + return Err(self.message.as_ref().map(|(_, m)| m.clone()).unwrap_or_else(|| "The cell being edited can't be entered.".into())); + } + self.commit_text_box_edit() + } + + fn commit_edit_here(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool { + let Some(ed) = self.editor.take() else { return true }; + self.session.mode = gridcraft_engine::Mode::Ready; + let text = match &ed.completion { + Some(full) if full.to_lowercase().starts_with(&ed.text.to_lowercase()) => full.clone(), + _ => ed.text.clone(), + }; + // F2 then Enter (or a file command) on an untouched draft: nothing to write. + if !fill_selection && !array && ed.original.as_ref() == Some(&text) { + if dr != 0 || dc != 0 { + self.move_after_enter(dr, dc); + } + return true; + } + let text = match self.session.active().map(|d| formula_locale::canonical(&text, ed.locale, &d.wb, ed.sheet, ed.cell)) { + Some(Ok(text)) => text, + Some(Err(error)) => { + self.message = Some(("GridCraft".into(), error.to_string())); + self.editor = Some(ed); + self.session.mode = gridcraft_engine::Mode::Edit; + return false; + } + None => text, + }; + // Data validation. + if let Some(d) = self.session.active() + && let Some((dv, msg)) = gridcraft_engine::cmd::data::check_validation(&d.wb, ed.sheet, ed.cell, &text) + { + if dv.error_style == gridcraft_engine::model::ErrorStyle::Stop { + let title = if dv.error_title.is_empty() { "GridCraft".to_string() } else { dv.error_title.clone() }; + self.message = Some((title, msg)); + self.editor = Some(ed); + self.session.mode = gridcraft_engine::Mode::Edit; + return false; + } + self.toast = Some((msg, now_ms())); + } + // Point mode can leave another sheet active; the edit belongs to the sheet it started on. + if self.session.active().is_some_and(|d| d.wb.active_sheet() != ed.sheet) + && let Err(e) = self.session.run("sheet.activate", json!({"sheet": ed.sheet})) + { + self.message = Some(("GridCraft".into(), clean_error(&e))); + self.editor = Some(ed); + self.session.mode = gridcraft_engine::Mode::Edit; + return false; + } + // Committing nothing into an empty cell changes nothing: no undo step (#1855, Backspace has + // already cleared the cell as its own step). + let unchanged = !fill_selection + && !array + && text.is_empty() + && self + .session + .active() + .and_then(|d| d.wb.sheet(ed.sheet)) + .is_some_and(|sh| sh.cell(ed.cell).is_none_or(|c| c.formula.is_none() && c.value == gridcraft_engine::core::Value::Empty)); + let r = if unchanged { + Ok(json!({})) + } else if fill_selection { + self.session.run("range.fill", json!({"input": text})) + } else { + self.session.run("cell.set", json!({"cell": ed.cell.a1(), "input": text, "array": array})) + }; + match r { + Ok(_) => { + if dr != 0 || dc != 0 { + self.move_after_enter(dr, dc); + } + self.after_engine(); + true + } + Err(e) => { + self.message = Some(("GridCraft".into(), clean_error(&e))); + self.editor = Some(ed); + self.session.mode = gridcraft_engine::Mode::Edit; + false + } + } + } + + /// Commits the edit with an arrow key. The arrow is an ordinary move, so it ends a run of Tabs + /// instead of continuing it (Right) or going back to its column like Enter (Down), as in Excel. + pub fn commit_edit_arrow(&mut self, dr: i64, dc: i64) -> bool { + let run = self.tab_run.take(); + let ok = self.commit_edit(dr, dc, false, false); + self.tab_run = if ok { None } else { run }; + ok + } + + pub fn cancel_edit(&mut self) { + self.editor = None; + self.session.mode = gridcraft_engine::Mode::Ready; + } + + /// Enter/Tab move inside a multi-cell selection, otherwise to the next cell. + pub fn move_after_enter(&mut self, dr: i64, dc: i64) { + let multi = self.session.active().is_some_and(|d| !d.selection.is_single_cell()); + // Any other move (arrows, clicks, another sheet) leaves the selection off the cell the + // last Tab landed on, which ends the run. + let here = self.session.active().map(|d| (d.uid, d.wb.active_sheet(), d.selection.active)); + let run = self.tab_run.take().filter(|&(uid, sheet, _, at)| here == Some((uid, sheet, at))); + if multi { + let fwd = dr > 0 || dc > 0; + let _ = self.session.run("selection.next", json!({"forward": fwd, "byRow": dc != 0})); + } else if let (Some((_, _, col, at)), 1, 0) = (run, dr, dc) { + let back = i64::from(col) - i64::from(at.col); + let _ = self.session.run("selection.move", json!({"dr": 1, "dc": back})); + } else { + let _ = self.session.run("selection.move", json!({"dr": dr, "dc": dc})); + if dr == 0 && dc != 0 { + let start = run.map(|r| r.2); + self.tab_run = self.session.active().and_then(|d| { + let (uid, sheet) = (d.uid, d.wb.active_sheet()); + let col = start.or(here.map(|h| h.2.col))?; + Some((uid, sheet, col, d.selection.active)) + }); + } + } + self.grid.ensure_visible = true; + } +} diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs new file mode 100644 index 0000000000..4c7e20913a --- /dev/null +++ b/crates/ui-egui/src/app/files.rs @@ -0,0 +1,127 @@ +use super::*; + +impl SheetApp { + pub fn open_path(&mut self, path: &str) { + match self.run("file.open", json!({"path": path})) { + Ok(r) => self.after_open(Some(path), &r), + // A password-protected workbook: ask for the password; the dialog opens it. + Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => self.open_dialog("openPassword", json!({"path": path})), + Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), + } + self.after_engine(); + } + + /// What follows a successful `file.open`: the workbook shows (not the Backstage), the Recent + /// list (for a file on disk) and the open warnings notice. Shared by File › Open, the + /// password dialog and the control channel. + pub fn after_open(&mut self, path: Option<&str>, r: &Json) { + self.backstage = None; + if let Some(path) = path { + self.ui.recent.retain(|p| p != path); + self.ui.recent.insert(0, path.to_string()); + self.ui.recent.truncate(20); + } + self.show_open_warnings(r); + } + + /// Turns the `warnings` a `file.open` result carries into an info-bar notice, choosing the + /// most important one (a preserved VBA project outranks, say, a skipped optional part). + /// Kept deliberately generic: any open-time warning can reach the user this way. + pub fn show_open_warnings(&mut self, r: &Json) { + // The opened workbook's window shows them, however it was opened (a script may have run + // `file.open` on the session directly). + self.follow_active_window(); + let Some(list) = r.get("warnings").and_then(Json::as_array) else { + self.notice = None; + return; + }; + let msgs: Vec<&str> = list.iter().filter_map(Json::as_str).collect(); + if msgs.is_empty() { + // A clean open clears any notice left over from the previous document. + self.notice = None; + return; + } + let vba: Vec<&str> = msgs.iter().copied().filter(|m| m.to_lowercase().contains("macro") || m.contains("VBA")).collect(); + if !vba.is_empty() { + // The reader says "dropped" when the VBA project was missing or unreadable; never promise to keep it then. + let dropped = vba.iter().any(|m| m.contains("dropped")); + let text = if dropped { + "This workbook's VBA macros could not be loaded and were dropped, so they will not be in the saved file. Keep the original file." + } else { + "This workbook contains VBA macros. GridCraft keeps them when you save as .xlsm, but does not run them." + }; + self.notice = Some(Notice { text: text.into(), kind: NoticeKind::Warning, action: Some(("What this means".into(), "macros".into())) }); + } else { + let text = if msgs.len() == 1 { + msgs[0].to_string() + } else { + format!("{} items in this file were handled differently. First: {}", msgs.len(), msgs[0]) + }; + self.notice = Some(Notice { text, kind: NoticeKind::Info, action: None }); + } + } + + /// The open windows in order, for the Switch Windows lists. + pub fn open_windows(&self) -> Vec { + let active = self.session.active_window(); + self.session + .windows() + .iter() + .filter_map(|w| { + let d = self.session.documents().iter().find(|d| d.uid == w.doc_uid)?; + let title = self.session.window_title(w.id)?; + Some(OpenWindow { window: w.id, doc: d.uid, title, dirty: d.is_dirty(), active: active == Some(w.id) }) + }) + .collect() + } + + /// Opens files that arrived asynchronously (web file picker, dropped files). + pub(super) fn poll_async_opens(&mut self) { + let arrived: Vec<(String, Vec)> = self + .services + .inbox + .as_ref() + .map(|i| std::mem::take(&mut *i.lock().unwrap_or_else(std::sync::PoisonError::into_inner))) + .unwrap_or_default(); + for (name, bytes) in arrived { + let b64 = gridcraft_engine::io::base64_encode(&bytes); + match self.run("file.open", json!({"name": &name, "base64": &b64})) { + // A file picked from File › Open (web) arrives here: show it, not the Backstage. + Ok(r) => { + self.backstage = None; + self.show_open_warnings(&r); + } + Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => { + self.backstage = None; + self.open_dialog("openPassword", json!({"name": name, "base64": b64})); + } + Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), + } + self.after_engine(); + } + } + + /// AutoSave: save workbooks that have a file shortly after they change. This decision lives in + /// `logic` so a hidden or logic-only frame still saves; the monotonic timestamp check keeps a + /// forced hidden UI pass from saving the same change twice. + pub(super) fn autosave(&mut self, ctx: &egui::Context) { + let dirty_xlsx = self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".xlsx"))); + let now = crate::monotonic_ms(); + let autosave_due = + self.ui.autosave && self.editor.is_none() && self.text_box_editor.is_none() && now - self.grid.last_autosave > 2000.0 && dirty_xlsx; + if autosave_due { + self.grid.last_autosave = now; + self.store_view_state(); + let _ = self.session.run("file.save", json!({})); + } else if self.ui.autosave && dirty_xlsx { + // Keep checking even when no input arrives while the window is hidden. + ctx.request_repaint_after(std::time::Duration::from_secs(2)); + } + } + + /// Flip AutoSave (title-bar switch / Options ▸ Save). Persisted with the other UI + /// preferences, so it stays as the user left it across restarts (#1901). + pub fn toggle_autosave(&mut self) { + self.ui.autosave = !self.ui.autosave; + } +} diff --git a/crates/ui-egui/src/app/fonts.rs b/crates/ui-egui/src/app/fonts.rs new file mode 100644 index 0000000000..32e072d93c --- /dev/null +++ b/crates/ui-egui/src/app/fonts.rs @@ -0,0 +1,112 @@ +use super::*; +use crate::fonts; + +impl SheetApp { + /// Test-harness setup: mark a deterministic font set (see + /// [`theme::pinned_font_definitions`]) as already installed, so the first `logic` pass neither + /// starts the host font scan nor replaces the harness's pinned set. Production uses + /// [`Self::setup_context`] instead. + #[doc(hidden)] + pub fn use_prepared_fonts(&mut self) { + self.prepared_fonts = true; + self.fonts_ready = true; + self.fonts_set = true; + self.fonts_rebind = false; + self.fonts_han = Some(theme::HanOrder::of(self.ui.language)); + self.fonts_bound = self.used_fonts(); + self.fonts_seen = self.fonts_key(); + } + + /// The font names the open workbooks use: every style's font plus the theme's heading/body + /// fonts. These are the families [`fonts`] binds an installed file for. Every workbook's, as + /// each may be showing in a window of its own. + pub fn used_fonts(&self) -> Vec { + let mut names: Vec = Vec::new(); + for d in self.session.documents() { + names.extend(d.wb.styles.iter().map(|(_, s)| s.font.name.clone())); + names.push(d.wb.theme.major_font.clone()); + names.push(d.wb.theme.minor_font.clone()); + // Text boxes and shapes name their own typefaces. + names.extend(d.wb.sheets.iter().flat_map(|sh| sh.shapes.iter().filter_map(|s| s.format.font.clone()))); + } + names + } + + /// The open workbooks' (uid, revision), folded: [`Self::used_fonts`] changes only with it. + fn fonts_key(&self) -> (u64, u64) { + self.session + .documents() + .iter() + .fold((0u64, 0u64), |(uid, rev), d| (uid.wrapping_mul(31).wrapping_add(d.uid), rev.wrapping_mul(31).wrapping_add(d.revision))) + } + + /// Installs the text-width closure once, on the first frame. + pub(super) fn ensure_text_width(&mut self, ctx: &egui::Context) { + if self.session.text_width.is_some() { + return; + } + let ctx = ctx.clone(); + self.session.text_width = Some(std::sync::Arc::new(move |text, font, exact| theme::cell_text_width(&ctx, text, font, exact))); + } + + /// Applies the font gates: a language Han-order change, startup/prepared fonts and workbook + /// font binding. Every branch keeps its one-frame paint gate. + pub(super) fn apply_font_state(&mut self, ctx: &egui::Context) { + // A pending synthetic step owns the next real `ui` pass: `ui` returns at the font gate + // before it handles input, so a font change requested now would consume the step without + // applying it. Delay the change one frame; the hidden-paint request keeps it coming. + let defer_font_change = self.synthetic_pending || !self.synthetic.is_empty(); + if !defer_font_change && self.fonts_han.is_some_and(|han| han != theme::HanOrder::of(self.ui.language)) { + self.fonts_ready = false; + self.fonts_set = false; + } + if !self.fonts_ready { + self.start_font_setup(ctx); + } else if self.fonts_rebind { + // A font-set change made last frame is active now. + self.fonts_rebind = false; + } else if !defer_font_change && !self.prepared_fonts && fonts::scan_done() { + self.rebind_workbook_fonts(ctx); + } + } + + /// Applies the pending font set. New fonts apply from the next frame on: paint nothing until + /// then. Startup always proceeds: without it a queued step would be re-offered forever. + fn start_font_setup(&mut self, ctx: &egui::Context) { + if self.fonts_set { + self.fonts_ready = true; + return; + } + if self.prepared_fonts { + ctx.set_fonts(theme::pinned_font_definitions()); + theme::apply(ctx, self.ui.dark); + } else { + SheetApp::setup_context_for_language(ctx, self.ui.dark, self.ui.language); + } + self.fonts_set = true; + // The rebuilt set has no workbook fonts yet: bind them again once it is active. + self.fonts_bound.clear(); + self.fonts_seen = (0, 0); + self.fonts_han = Some(theme::HanOrder::of(self.ui.language)); + ctx.request_repaint(); + } + + /// Binds the workbook's installed fonts once the background scan has finished. Adding fonts + /// re-applies the font set, which egui activates at the next pass, so skip this frame's paint + /// (same one-frame cost as startup). Only when an open workbook changed. + fn rebind_workbook_fonts(&mut self, ctx: &egui::Context) { + let seen = self.fonts_key(); + if seen == self.fonts_seen { + return; + } + let used = self.used_fonts(); + self.fonts_seen = seen; + if used == self.fonts_bound { + return; + } + ctx.set_fonts(theme::font_definitions_with(self.ui.language, &used)); + self.fonts_bound = used; + self.fonts_rebind = true; + ctx.request_repaint(); + } +} diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs new file mode 100644 index 0000000000..fb8f31bfa2 --- /dev/null +++ b/crates/ui-egui/src/app/input.rs @@ -0,0 +1,33 @@ +use super::*; + +impl SheetApp { + /// Records capture geometry from the fresh raw frame, then offers exactly one pending synthetic batch and tracks whether it was applied. + pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + // Capture geometry from this frame's real input before any synthetic event is added. This + // runs on every native frame, including hidden/minimized logic-only ticks, so a resize, + // DPI/zoom change, Wayland frame without `inner_rect`, or minimize/restore is recorded + // even though egui's last UI pass still describes the old layout. + let geometry = control::CaptureGeometry::from_raw_input(raw, ctx); + control::note_capture_geometry(self, geometry); + self.capture_geometry_from_raw = true; + // One unapplied gesture step at a time. eframe retains an unconsumed `RawInput` for the + // next pass and for a hidden logic-only tick, so the batch is offered once and survives + // until a `ui` pass applies it (which pops it). Press and release stay distinct batches. + // + // A completed egui pass may have consumed the injected events without applying them: the + // font gate returns before input handling, but `begin_pass` has already taken the events. + // `cumulative_pass_nr` only advances when a pass ends, so a newer value with the batch + // still pending means exactly that, and the step is offered again for the next real pass. + if self.synthetic_pending { + let consumed_by_an_unapplied_pass = self.synthetic_injected_at_pass.is_some_and(|pass| ctx.cumulative_pass_nr() > pass); + if !consumed_by_an_unapplied_pass { + return; + } + } + if let Some(batch) = self.synthetic.front() { + raw.events.extend(batch.iter().cloned()); + self.synthetic_pending = true; + self.synthetic_injected_at_pass = Some(ctx.cumulative_pass_nr()); + } + } +} diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs new file mode 100644 index 0000000000..913cdbeb6b --- /dev/null +++ b/crates/ui-egui/src/app/keys.rs @@ -0,0 +1,66 @@ +use super::*; + +impl SheetApp { + pub(super) fn ribbon_keys(&mut self, ctx: &egui::Context) { + let before = self.keytips.prefix().map(str::to_string); + let enabled = self.editor.is_none() + && self.dialog.is_none() + && self.message.is_none() + && self.name_box.is_none() + && !ctx.text_edit_focused() + && self.grid.context_menu.is_none() + && self.grid.object_menu.is_none() + && self.grid.header_menu.is_none() + && self.grid.fill_menu.is_none() + && self.grid.filter_menu.is_none() + && self.grid.list_picker.is_none() + && self.grid.renaming_tab.is_none() + && (before.is_some() || !egui::Popup::is_any_open(ctx)); + let actions = self.keytips.process(ctx, enabled); + // Backing out of F to the tab letters, or finishing an F sequence, closes the File view + // that Alt, F opened: the command runs on the sheet, as it did without the view. + if self.backstage.is_some() && before.is_some() && (self.keytips.prefix() == Some("") || !actions.is_empty()) { + self.backstage = None; + } + if before.is_some() && before.as_deref() != self.keytips.prefix() && !ctx.input(|i| i.pointer.any_pressed()) { + // Leave pointer cancellation to the popup so clicking a menu item still works. + egui::Popup::close_all(ctx); + } + for action in actions { + match action { + keytips::Action::Tab("File") => self.open_backstage(backstage::Page::Home), + keytips::Action::Tab(tab) => { + self.run_or_alert("ui.ribbonTab", json!({"tab": tab})); + self.ui.ribbon_collapsed = false; + } + keytips::Action::Command(id @ ("edit.copy" | "edit.cut")) => self.copy_to_clipboard(ctx, id), + keytips::Action::Command("edit.paste") => self.paste_from_clipboard(), + keytips::Action::Command(id) => self.run_or_alert(id, json!({})), + keytips::Action::CommandWith(id, params) => { + let params: Json = serde_json::from_str(params).unwrap_or_else(|_| json!({})); + // A Borders preset picked by key tip becomes the split button's face, as a + // click on the same menu item does. + if id == "home.borders" + && let Some(preset) = params.get("preset").and_then(Json::as_str) + { + self.grid.last_border = preset.to_string(); + } + self.run_or_alert(id, params); + } + keytips::Action::Dialog(name) => self.open_dialog(name, json!({})), + keytips::Action::DialogWith(name, params) => self.open_dialog(name, serde_json::from_str(params).unwrap_or_else(|_| json!({}))), + // Reserved sequence: fires silently while its command is missing. When the + // command lands in the catalog it goes live with no table edit. + keytips::Action::Pending(id) => { + if gridcraft_engine::find_command(id).is_some() { + self.run_or_alert(id, json!({})); + } + } + // Route to the ribbon control that owns this keytip: it opens its dropdown or + // runs its click handler with the right params (the engine command alone would + // be missing them). + keytips::Action::Control(id) => self.ui.keytip_invoke = Some(id), + } + } + } +} diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs new file mode 100644 index 0000000000..bd80b36e39 --- /dev/null +++ b/crates/ui-egui/src/app/prefs.rs @@ -0,0 +1,65 @@ +use super::*; + +impl SheetApp { + /// Runs a command and shows its error in a message box (for menu/ribbon clicks). + /// Follow the desktop's light/dark choice while the app runs (Linux; the desktop app calls + /// this once — tests and embedders don't start the watcher thread). + pub fn watch_desktop_theme(&mut self) { + self.desktop_theme = desktop_theme::DesktopTheme::start(); + } + + /// Follows the desktop/system theme preference and records the effective theme. + pub(super) fn sync_theme_preference(&mut self, ctx: &egui::Context) { + let preference = match ( + self.ui.system_theme, + ctx.system_theme().or_else(|| self.desktop_dark.map(|d| if d { egui::Theme::Dark } else { egui::Theme::Light })), + ) { + (true, Some(egui::Theme::Dark)) => egui::ThemePreference::Dark, + (true, Some(egui::Theme::Light)) => egui::ThemePreference::Light, + _ => self.ui.theme_preference(), + }; + if ctx.options(|o| o.theme_preference) != preference { + ctx.set_theme(preference); + ctx.request_repaint(); + } + self.effective_dark = ctx.theme() == egui::Theme::Dark; + } + + /// Keeps egui's own keyboard zoom and quit shortcuts off and applies GridCraft's zoom factor. + pub(super) fn sync_input_options(&mut self, ctx: &egui::Context) { + // Interface size: the preference owns the zoom factor, so egui's own Ctrl/Cmd +/-/0 zoom + // keys stay off (they would also swallow GridCraft's shortcuts). + if ctx.options(|o| o.zoom_with_keyboard) { + ctx.options_mut(|o| o.zoom_with_keyboard = false); + } + // egui closes the window on Ctrl+Q (and with Shift or Alt held too) without asking to save + // (#1443). Excel for Windows has no such key (Ctrl+Q is Quick Analysis). With a native + // window per workbook egui's Cmd+Q would close only the window it was typed in: the menu + // bar's Quit item has the key instead (it asks about unsaved workbooks first). + if (ctx.os() != egui::os::OperatingSystem::Mac || self.native_windows) && ctx.options(|o| !o.quit_shortcuts.is_empty()) { + ctx.options_mut(|o| o.quit_shortcuts.clear()); + } + let zoom = self.ui.zoom_factor(); + if (ctx.zoom_factor() - zoom).abs() > 0.001 { + ctx.set_zoom_factor(zoom); + ctx.request_repaint(); + } + } + + /// Look for a newer release once, in the background, as the desktop app starts (a no-op on the + /// web). A found release opens the update prompt; nothing is shown when GridCraft is current. + pub fn start_update_check(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + updates_ui::start(self, false); + } + + /// Help ▸ Check for Updates…: look now, and say so when GridCraft is up to date or the check fails. + pub fn check_for_updates(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + updates_ui::start(self, true); + #[cfg(target_arch = "wasm32")] + { + self.message = Some(("GridCraft".into(), "The web version is always the latest release.".into())); + } + } +} diff --git a/crates/ui-egui/src/app/print.rs b/crates/ui-egui/src/app/print.rs new file mode 100644 index 0000000000..7c683f4ba3 --- /dev/null +++ b/crates/ui-egui/src/app/print.rs @@ -0,0 +1,89 @@ +use super::*; + +impl SheetApp { + /// Web: `file.exportPdf` without a path returns the PDF as base64; hand it to the browser as a download. + pub(super) fn deliver_pdf(&self, result: &Json) { + if result.get("path").is_some() { + return; + } + if let Some(dl) = &self.services.download + && let Some(b) = result.get("base64").and_then(Json::as_str).and_then(gridcraft_engine::io::base64_decode) + { + let title = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); + dl(&std::path::Path::new(&title).with_extension("pdf").to_string_lossy(), &b); + } + } + + /// Asks for a `.pdf` path with the save picker, then re-runs `file.exportPdf` with it. Other + /// params (range, sheets, fitToPage) are kept. A cancelled picker does nothing. + pub(super) fn export_pdf_with_picker(&mut self, mut params: Json) { + if !self.commit_edit(0, 0, false, false) { + return; + } + let title = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); + let stem = std::path::Path::new(&title).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book1").to_string(); + let Some(pick) = &self.services.pick_save else { return }; + let Some(path) = pick(&format!("{stem}.pdf")) else { return }; + // The picker may suggest a workbook extension; the export writes a PDF. + let path = std::path::Path::new(&path).with_extension("pdf").to_string_lossy().into_owned(); + match &mut params { + Json::Object(map) => { + map.insert("path".into(), Json::String(path)); + } + other => *other = json!({"path": path}), + } + if let Err(e) = self.run("file.exportPdf", params) { + self.alert(e); + } + } + + /// Builds the selected sheets' print-ready PDF and hands it to the desktop worker or the + /// browser. Rendering errors use the usual alert; asynchronous spooler errors stay visible + /// on the Print page. The engine command still returns PDF bytes without submitting. + pub fn print_now(&mut self, params: Json) { + if let Err(error) = self.submit_print(params) { + self.alert(error); + } + } + + pub(super) fn submit_print(&mut self, params: Json) -> Result { + if self.services.print_native.is_some() && !self.printing.ready() { + return Err("Select an available printer in File > Print before submitting.".into()); + } + let pdf = self.run("file.print", params)?; + let encoded = pdf.get("base64").and_then(Json::as_str).ok_or("The print command did not produce a PDF.")?; + let bytes = gridcraft_engine::io::base64_decode(encoded).ok_or("Couldn't decode the generated PDF.")?; + let len = bytes.len(); + if let Some(submit) = &self.services.print_native { + self.printing.begin(submit(bytes, self.printing.settings.clone())).map_err(|_| "A print job is already being submitted.".to_owned())?; + self.print_feedback_seen = None; + } else if let Some(print) = &self.services.print { + print(&bytes); + } else { + return Err("Printing is unavailable here; export a PDF instead.".into()); + } + Ok(json!({"printing": true, "bytes": len, "pending": self.services.print_native.is_some()})) + } + + /// Terminal feedback also reaches the normal window when printing starts through control. + /// Polls printing feedback. Called once per frame from [`Self::logic`]; the direct calls in + /// `tests/native_print.rs` deliberately bypass the lifecycle to test this helper alone. + pub fn poll_printing(&mut self, ctx: &egui::Context) { + self.printing.poll(ctx); + if self.printing.message == self.print_feedback_seen { + return; + } + self.print_feedback_seen = self.printing.message.clone(); + let Some(message @ (printing::Feedback::Submitted(_) | printing::Feedback::Failed(_) | printing::Feedback::WorkerStopped)) = + self.printing.message.clone() + else { + return; + }; + let text = printing::feedback(&message, self.ui.language, |label| self.ui.language.tr(label).to_owned()); + if matches!(message, printing::Feedback::Submitted(_)) { + self.toast = Some((text, now_ms())); + } else { + self.alert(text); + } + } +} diff --git a/crates/ui-egui/src/app/quit.rs b/crates/ui-egui/src/app/quit.rs new file mode 100644 index 0000000000..e70928eee1 --- /dev/null +++ b/crates/ui-egui/src/app/quit.rs @@ -0,0 +1,96 @@ +use super::*; + +impl SheetApp { + /// Holds back a window close (the close button, Alt+F4, the OS quitting the app) while a + /// workbook has unsaved changes, and asks about them first. + pub(super) fn guard_window_close(&mut self, ctx: &egui::Context) { + if self.native_windows || self.allow_close || !ctx.input(|i| i.viewport().close_requested()) { + return; + } + // A cell still being edited is part of the workbook, as for File › Close. + if !self.commit_edit(0, 0, false, false) { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + return; + } + if !self.session.documents().iter().any(|d| d.is_dirty()) { + return; + } + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + match self.dialog.as_mut() { + // Already asking about a workbook (File › Close): answering it now goes on to quit. + Some(d) if d.name() == "saveChanges" => { + d.values.insert("quit".into(), json!(true)); + } + _ => self.continue_quit(ctx), + } + } + + /// Asks about the next unsaved workbook before quitting, in its window, or closes the app + /// once none is left. + pub(crate) fn continue_quit(&mut self, ctx: &egui::Context) { + let dirty = self.session.documents().iter().find(|d| d.is_dirty()).map(|d| d.uid); + match dirty.and_then(|uid| self.session.doc_window(uid)) { + Some(window) => { + // Its window comes forward (the native window too: see `doc_windows`). + self.enter_window(window); + let title = self.session.active().map(|d| d.display_title()).unwrap_or_default(); + self.open_dialog("saveChanges", json!({"title": title, "quit": true})); + } + None => { + self.allow_close = true; + ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Close); + } + } + } + + /// Answers the Save Changes question about the active workbook (its buttons). While quitting, + /// the next unsaved workbook is asked about once this one is answered; Cancel stops the quit. + /// Returns whether the question is settled: Save keeps it open when the cell being edited + /// can't be entered. + pub fn answer_save_changes(&mut self, answer: SaveAnswer, quitting: bool, ctx: &egui::Context) -> bool { + match answer { + SaveAnswer::Cancel => {} + SaveAnswer::DontSave => { + self.run_or_alert("file.close", json!({"force": true, "document": true})); + if quitting { + self.continue_quit(ctx); + } + } + SaveAnswer::Save => { + // File operations include the cell still being edited, as File › Save does. + if !self.commit_edit(0, 0, false, false) { + return false; + } + self.run_or_alert("file.save", json!({})); + if self.session.active().is_some_and(|x| !x.is_dirty()) { + self.run_or_alert("file.close", json!({"force": true, "document": true})); + if quitting { + self.continue_quit(ctx); + } + } + } + } + true + } + + /// Quit (the macOS app menu, ⌘Q): commits every window's cell draft as Enter would, then asks + /// about each unsaved workbook in its window; Cancel on any of them stops the quit. A draft its + /// workbook refuses stops the quit too, with its window in front and the reason shown. + pub fn request_quit(&mut self, ctx: &egui::Context) { + while let Some(window) = self.parked_window_with_draft() { + self.enter_window(window); + if self.settle_drafts().is_err() { + return; + } + } + if self.settle_drafts().is_err() { + return; + } + if let Some(d) = self.dialog.as_mut().filter(|d| d.name() == "saveChanges") { + // Already asking about a workbook (File › Close): answering it goes on to quit. + d.values.insert("quit".into(), json!(true)); + return; + } + self.continue_quit(ctx); + } +} diff --git a/crates/ui-egui/src/app/view.rs b/crates/ui-egui/src/app/view.rs new file mode 100644 index 0000000000..c74cf15f0c --- /dev/null +++ b/crates/ui-egui/src/app/view.rs @@ -0,0 +1,56 @@ +use super::*; + +impl SheetApp { + /// The active workbook and sheet: what drafts, text boxes and picture targets belong to. + pub fn view_key(&self) -> Option<(u64, usize)> { + self.session.active().map(|d| (d.uid, d.wb.active_sheet())) + } + + /// The active window and sheet: each window scrolls on its own. + fn scroll_key(&self) -> Option<(u64, usize)> { + Some((self.session.active_window()?, self.session.active()?.wb.active_sheet())) + } + + pub fn view(&self) -> SheetView { + self.scroll_key().and_then(|k| self.views.get(&k).copied()).unwrap_or_else(|| self.saved_view()) + } + + pub fn view_mut(&mut self) -> Option<&mut SheetView> { + let k = self.scroll_key()?; + let saved = self.saved_view(); + Some(self.views.entry(k).or_insert(saved)) + } + + /// The view a sheet opens with: scrolled to the file's saved first visible cell (`topLeftCell`). + fn saved_view(&self) -> SheetView { + let Some(sh) = self.session.active().and_then(|d| d.wb.active()) else { return SheetView::default() }; + if sh.view != gridcraft_engine::model::SheetViewMode::Normal { + return SheetView::default(); + } + let (fr, fc) = sh.freeze.unwrap_or((0, 0)); + let top = sh.view_top_left; + let y = sh.row_top(top.row.max(fr)) - sh.row_top(fr); + let x = sh.col_left(top.col.max(fc)) - sh.col_left(fc); + SheetView { scroll: egui::vec2(x.max(0.0) as f32, y.max(0.0) as f32) } + } + + /// Hands the active document's scroll positions (first visible cell per sheet) and active + /// cell to the engine so a save writes them (`sheetView topLeftCell`). Normal view only: + /// Page Layout view scrolls over pages, not cells. + pub fn store_view_state(&mut self) { + let window = self.session.active_window(); + let Some(d) = self.session.active_mut() else { return }; + let mut tops = Vec::new(); + for (&(w, si), v) in &self.views { + let Some(sh) = d.wb.sheets.get(si).filter(|sh| Some(w) == window && sh.view == gridcraft_engine::model::SheetViewMode::Normal) else { + continue; + }; + let (fr, fc) = sh.freeze.unwrap_or((0, 0)); + // A hair past the edge so a row that starts exactly at the scroll offset is the one found. + let row = sh.row_at(sh.row_top(fr) + v.scroll.y.max(0.0) as f64 + 0.01); + let col = sh.col_at(sh.col_left(fc) + v.scroll.x.max(0.0) as f64 + 0.01); + tops.push((si, CellRef::new(row, col))); + } + d.store_view(&tops); + } +} diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index ec097c201e..3f8706dec7 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -20,6 +20,12 @@ use crate::i18n::{self, Language}; use crate::icons::{self, Icon}; use crate::theme::{self, Tokens}; +mod options; +mod print; + +use options::{options, platform}; +use print::print; + /// Width of the navigation column. const NAV_W: f32 = 196.0; /// Width of the content column (pages are left-aligned like a document, not stretched). @@ -704,172 +710,6 @@ fn save_as(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcom dim(ui, lang.tr("A CSV file, web page or PDF is a copy: the workbook keeps its own file.")); } -/// Builds the print scope and fit-to-page parameters, caches `file.printPreview` per document revision/options, and draws page thumbnails with navigation plus the print settings and Print action. -fn print(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) { - let t = Tokens::get(ui.ctx()); - let lang = app.ui.language; - let Some((uid, revision)) = app.session.active().map(|d| (d.uid, d.revision)) else { - dim(ui, lang.tr("No workbook is open.")); - return; - }; - let mut params = json!({"sheets": if bs.print_all { "all" } else { "active" }}); - if bs.fit_to_page { - params["fitToPage"] = json!(true); - } - let key = (uid, revision, bs.print_all, bs.fit_to_page); - if bs.preview.as_ref().is_none_or(|(k, _)| *k != key) { - let preview = app.session.run("file.printPreview", params.clone()).unwrap_or(Json::Null); - bs.preview = Some((key, preview)); - } - let pages: Vec = bs.preview.as_ref().and_then(|(_, p)| p.get("pageList")).and_then(Json::as_array).cloned().unwrap_or_default(); - ui.horizontal_top(|ui| { - ui.vertical(|ui| { - ui.set_width(250.0); - if let Some(discover) = &app.services.printers { - crate::printing::show(&mut app.printing, ui, discover, |label| lang.tr(label).to_owned()); - } - let can_print = app.services.print_native.is_none() || app.printing.ready(); - if ui.add_enabled_ui(can_print, |ui| square_button(ui, Icon::Print, lang.tr("Print"))).inner.clicked() - && app.commit_edit(0, 0, false, false) - { - app.print_now(params.clone()); - out.close = app.services.print_native.is_none(); - } - ui.add_space(16.0); - let scope = if bs.print_all { "Print Entire Workbook" } else { "Print Active Sheets" }; - egui::ComboBox::from_id_salt("backstage_print_scope") - .width(230.0) - .selected_text(lang.tr(scope)) - .show_ui(ui, |ui| { - for (all, label) in [(false, "Print Active Sheets"), (true, "Print Entire Workbook")] { - if ui.selectable_label(bs.print_all == all, lang.tr(label)).clicked() { - bs.print_all = all; - bs.preview_page = 0; - } - } - }) - .response - .accessible_name(lang.tr("Print What")); - ui.add_space(8.0); - if ui.checkbox(&mut bs.fit_to_page, lang.tr("Fit Sheet on One Page")).changed() { - bs.preview_page = 0; - } - ui.add_space(8.0); - if ui.link(lang.tr("Page Setup…")).clicked() { - app.open_dialog("pageSetup", json!({})); - } - }); - ui.add_space(24.0); - ui.vertical(|ui| { - if pages.is_empty() { - dim(ui, lang.tr("Nothing to print.")); - return; - } - let last = pages.len().saturating_sub(1); - bs.preview_page = bs.preview_page.min(last); - let Some(page) = pages.get(bs.preview_page) else { return }; - // Paper proportions from the preview (points); hostile sizes fall back to Letter. - let w = page.get("width").and_then(Json::as_f64).filter(|w| w.is_finite() && *w > 1.0).unwrap_or(612.0) as f32; - let h = page.get("height").and_then(Json::as_f64).filter(|h| h.is_finite() && *h > 1.0).unwrap_or(792.0) as f32; - let scale = (520.0 / h).min(440.0 / w); - let (rect, _) = ui.allocate_exact_size(vec2(w * scale, h * scale), Sense::hover()); - let p = ui.painter(); - p.rect_filled(rect.translate(vec2(2.0, 3.0)), 2.0, t.shadow); - p.rect_filled(rect, 2.0, t.grid_bg); - p.rect_stroke(rect, 2.0, Stroke::new(1.0, t.ribbon_border), StrokeKind::Inside); - if let Some(d) = app.session.active() { - paint_page(p, rect, w, page, &d.wb, &t); - } - let sheet = page.get("sheet").and_then(Json::as_str).unwrap_or(""); - let range = page.get("range").and_then(Json::as_str).unwrap_or(""); - ui.add_space(4.0); - dim(ui, &format!("{sheet} · {range}")); - ui.add_space(8.0); - ui.horizontal(|ui| { - if ui.add_enabled(bs.preview_page > 0, egui::Button::new("◀")).on_hover_text(lang.tr("Previous page")).clicked() { - bs.preview_page = bs.preview_page.saturating_sub(1); - } - let text = lang - .tr("Page {page} of {pages}") - .replace("{page}", &(bs.preview_page + 1).to_string()) - .replace("{pages}", &pages.len().to_string()); - ui.label(text); - if ui.add_enabled(bs.preview_page < last, egui::Button::new("▶")).on_hover_text(lang.tr("Next page")).clicked() { - bs.preview_page = (bs.preview_page + 1).min(last); - } - }); - }); - }); -} - -/// Most rows and columns one preview page draws (a page holds far fewer at any readable scale). -const PREVIEW_ROWS: u32 = 200; -const PREVIEW_COLS: u32 = 60; - -/// Draws one printed page in miniature: the cells' displayed text at the page's print scale, -/// inside the sheet's margins. `paper_w` is the page width in points. -fn paint_page(p: &egui::Painter, rect: Rect, paper_w: f32, page: &Json, wb: &gridcraft_engine::model::Workbook, t: &Tokens) { - let Some(range) = page.get("range").and_then(Json::as_str).and_then(gridcraft_engine::core::RangeRef::parse) else { return }; - let Some(sh) = page.get("sheet").and_then(Json::as_str).and_then(|n| wb.sheet_index(n)).and_then(|i| wb.sheets.get(i)) else { return }; - // Preview pixels per point, and per sheet pixel (sheet geometry is 96 dpi: 0.75 pt per pixel). - let per_pt = rect.width() / paper_w; - let print_scale = - page.get("scale").and_then(Json::as_f64).filter(|s| s.is_finite() && *s > 0.0).map_or(1.0, |s| (s / 100.0) as f32).clamp(0.1, 4.0); - let per_px = per_pt * 0.75 * print_scale; - let margin = - |i: usize, default: f32| sh.print.margins.get(i).copied().filter(|m| m.is_finite()).unwrap_or(default).clamp(0.0, 3.0) * 72.0 * per_pt; - let body = Rect::from_min_max( - pos2(rect.left() + margin(0, 0.7), rect.top() + margin(2, 0.75)), - pos2(rect.right() - margin(1, 0.7), rect.bottom() - margin(3, 0.75)), - ); - if !body.is_positive() { - return; - } - let font = theme::ui_font((11.0 * per_pt * print_scale).clamp(3.0, 14.0)); - let last_row = range.end.row.min(range.start.row.saturating_add(PREVIEW_ROWS)); - let last_col = range.end.col.min(range.start.col.saturating_add(PREVIEW_COLS)); - let mut y = body.top(); - for row in range.start.row..=last_row { - let h = sh.row_height(row) * per_px; - if h <= 0.0 { - continue; - } - if y >= body.bottom() { - break; - } - let mut x = body.left(); - for col in range.start.col..=last_col { - let w = sh.col_width(col) * per_px; - if w <= 0.0 { - continue; - } - if x >= body.right() { - break; - } - let cell = gridcraft_engine::core::CellRef::new(row, col); - let text = gridcraft_engine::display::cell_text(wb, sh, cell); - if !text.is_empty() { - let numeric = sh.value(cell).as_f64().is_some(); - // Text runs on over empty neighbours, as on the sheet; numbers stay in their cell. - let mut span = w; - if !numeric { - let mut next = col.saturating_add(1); - while next <= last_col && x + span < body.right() && sh.value(gridcraft_engine::core::CellRef::new(row, next)).is_empty() { - span += sh.col_width(next) * per_px; - next = next.saturating_add(1); - } - } - let clip = Rect::from_min_size(pos2(x, y), vec2(span, h)).intersect(body); - let (anchor, at) = - if numeric { (Align2::RIGHT_CENTER, pos2(x + w - 1.0, y + h / 2.0)) } else { (Align2::LEFT_CENTER, pos2(x + 1.0, y + h / 2.0)) }; - p.with_clip_rect(clip).text(at, anchor, text, font.clone(), t.cell_text); - } - x += w; - } - y += h; - } -} - fn export(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { let lang = app.ui.language; if action_row(ui, Icon::Paper, lang.tr("Create PDF"), lang.tr("Save the sheet as a PDF document that keeps its page layout.")).clicked() @@ -949,168 +789,6 @@ pub fn export_as(app: &mut SheetApp, fmt: &str) -> bool { } } -/// Excel's File › Options page: General (display theme, interface language and Show formula bar), -/// Editing (AutoComplete) and Save (the autosave checkbox toggling `app.ui.autosave`), plus Formulas -/// (calculation mode and iterative calculation) when a document is active. Theme, language, formula -/// bar, AutoComplete and calculation changes run their commands through `run_or_alert`; calculation -/// settings are saved with the workbook. -fn options(app: &mut SheetApp, ui: &mut Ui) { - let lang = app.ui.language; - section(ui, lang, "Options|General"); - egui::Grid::new("backstage_options_general").num_columns(2).spacing(vec2(18.0, 10.0)).show(ui, |ui| { - let label = ui.label(lang.tr("Display theme")); - let mode = app.ui.theme_mode(); - let current = match mode { - "system" => "System", - "dark" => "Dark", - _ => "Light", - }; - egui::ComboBox::from_id_salt("backstage_theme") - .width(160.0) - .selected_text(lang.tr(current)) - .show_ui(ui, |ui| { - for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] { - if ui.selectable_label(mode == value, lang.tr(label)).clicked() { - app.run_or_alert("view.theme", json!({"mode": value})); - } - } - }) - .response - .labelled_by(label.id); - ui.end_row(); - let label = ui.label(lang.tr("Interface language")); - egui::ComboBox::from_id_salt("backstage_language") - .width(160.0) - .selected_text(lang.name()) - .show_ui(ui, |ui| { - for l in i18n::Language::for_picker() { - if ui.selectable_label(l == lang, l.name()).clicked() { - app.run_or_alert("app.language.set", json!({"language": l.code()})); - } - } - }) - .response - .labelled_by(label.id); - ui.end_row(); - }); - ui.add_space(6.0); - let mut formula_bar = app.session.prefs.formula_bar; - if ui.checkbox(&mut formula_bar, lang.tr("Show formula bar")).changed() { - app.run_or_alert("view.formulaBar", json!({"on": formula_bar})); - } - ui.add_space(18.0); - section(ui, lang, "Options|Editing"); - let mut autocomplete = app.ui.autocomplete; - if ui.checkbox(&mut autocomplete, lang.tr("Enable AutoComplete for cell values")).changed() { - app.run_or_alert("options.autoComplete", json!({"on": autocomplete})); - } - ui.add_space(18.0); - section(ui, lang, "Options|Save"); - let mut autosave = app.ui.autosave; - if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() { - app.toggle_autosave(); - } - let Some(calc) = app.session.active().map(|d| d.wb.calc.clone()) else { return }; - ui.add_space(18.0); - section(ui, lang, "Formulas"); - ui.label(lang.tr("Workbook calculation")); - use gridcraft_engine::model::CalcMode; - for (mode, id, label) in [ - (CalcMode::Automatic, "automatic", "Automatic"), - (CalcMode::AutomaticExceptTables, "automaticExceptTables", "Automatic Except for Data Tables"), - (CalcMode::Manual, "manual", "Manual"), - ] { - if ui.radio(calc.mode == mode, lang.tr(label)).clicked() && calc.mode != mode { - app.run_or_alert("formulas.calculationOptions", json!({"mode": id})); - } - } - ui.add_space(4.0); - let mut iterative = calc.iterative; - if ui.checkbox(&mut iterative, lang.tr("Enable iterative calculation")).changed() { - app.run_or_alert("formulas.calculationOptions", json!({"iterative": iterative})); - } - // Maximum Iterations and Maximum Change apply only while iteration is on, as in Excel. - ui.add_enabled_ui(iterative, |ui| { - ui.horizontal(|ui| { - ui.add_space(24.0); - egui::Grid::new("backstage_options_iteration").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| { - let label = ui.label(lang.tr("Maximum Iterations:")); - let id = ui.make_persistent_id("backstage_max_iterations"); - // Text left in the field when iteration is switched off (it loses focus) is dropped. - let committed = crate::widgets::committed_text(ui, id, &calc.max_iterations.to_string(), Some(80.0)); - if let Some(response) = ui.ctx().read_response(id) { - response.labelled_by(label.id); - } - if let Some(text) = committed.filter(|_| iterative) { - match parse_max_iterations(&text) { - Some(n) if n != calc.max_iterations => { - app.run_or_alert("formulas.calculationOptions", json!({"maxIterations": n})); - } - Some(_) => {} - None => refuse(app, ui, "Maximum Iterations must be a whole number from 1 to 32767."), - } - } - ui.end_row(); - let label = ui.label(lang.tr("Maximum Change:")); - let id = ui.make_persistent_id("backstage_max_change"); - let committed = crate::widgets::committed_text(ui, id, &calc.max_change.to_string(), Some(80.0)); - if let Some(response) = ui.ctx().read_response(id) { - response.labelled_by(label.id); - } - if let Some(text) = committed.filter(|_| iterative) { - match parse_max_change(&text, lang.formula_locale()) { - Some(d) if d != calc.max_change => { - app.run_or_alert("formulas.calculationOptions", json!({"maxChange": d})); - } - Some(_) => {} - None => refuse(app, ui, "Maximum Change must be a number of 0 or more."), - } - } - ui.end_row(); - }); - }); - }); - // TODO: add "Set precision as displayed" here once `formulas.calculationOptions` takes - // `precisionAsDisplayed` (PR #1366); the model already stores it. - ui.add_space(6.0); - dim(ui, lang.tr("Calculation settings are saved with the workbook.")); -} - -/// Refuses a typed setting with a message, leaving the workbook as it was. The Enter that -/// committed the field is consumed so it doesn't also dismiss the message box in this frame. -fn refuse(app: &mut SheetApp, ui: &mut Ui, text: &str) { - ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)); - app.message = Some(("GridCraft".into(), app.ui.language.tr(text).into())); -} - -/// Maximum Iterations as typed: a whole number from 1 to 32767, or `None` (Excel refuses others). -fn parse_max_iterations(text: &str) -> Option { - let n = text.trim().parse::().ok()?; - (1..=gridcraft_engine::model::CalcSettings::MAX_ITERATIONS).contains(&n).then_some(n) -} - -/// Maximum Change as typed: a finite number of 0 or more, or `None`. The interface's decimal -/// separator is accepted as well as a dot. -fn parse_max_change(text: &str, locale: gridcraft_engine::formula::FormulaLocale) -> Option { - let d = text.trim().replace(locale.decimal_separator(), ".").parse::().ok()?; - (d.is_finite() && d >= 0.0).then_some(d) -} - -/// The platform the app was built for, e.g. "macOS (aarch64)"; the web build reports "Web". -fn platform() -> String { - let os = match std::env::consts::OS { - "macos" => "macOS", - "windows" => "Windows", - "linux" => "Linux", - "freebsd" => "FreeBSD", - "openbsd" => "OpenBSD", - "netbsd" => "NetBSD", - _ if cfg!(target_arch = "wasm32") => return "Web".into(), - other => other, - }; - format!("{os} ({})", std::env::consts::ARCH) -} - /// Excel's File › Account: product information and the About window. fn account(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/backstage/options.rs b/crates/ui-egui/src/backstage/options.rs new file mode 100644 index 0000000000..5a772e093e --- /dev/null +++ b/crates/ui-egui/src/backstage/options.rs @@ -0,0 +1,178 @@ +use super::*; + +/// Excel's File › Options page: General (display theme, interface language and Show formula bar), +/// Editing (AutoComplete) and Save (the autosave checkbox toggling `app.ui.autosave`), plus Formulas +/// (calculation mode and iterative calculation) when a document is active. Theme, language, formula +/// bar, AutoComplete and calculation changes run their commands through `run_or_alert`; calculation +/// settings are saved with the workbook. +pub(super) fn options(app: &mut SheetApp, ui: &mut Ui) { + let lang = app.ui.language; + section(ui, lang, "Options|General"); + general_options(app, ui, lang); + ui.add_space(6.0); + let mut formula_bar = app.session.prefs.formula_bar; + if ui.checkbox(&mut formula_bar, lang.tr("Show formula bar")).changed() { + app.run_or_alert("view.formulaBar", json!({"on": formula_bar})); + } + ui.add_space(18.0); + section(ui, lang, "Options|Editing"); + let mut autocomplete = app.ui.autocomplete; + if ui.checkbox(&mut autocomplete, lang.tr("Enable AutoComplete for cell values")).changed() { + app.run_or_alert("options.autoComplete", json!({"on": autocomplete})); + } + ui.add_space(18.0); + section(ui, lang, "Options|Save"); + let mut autosave = app.ui.autosave; + if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() { + app.toggle_autosave(); + } + let Some(calc) = app.session.active().map(|d| d.wb.calc.clone()) else { return }; + ui.add_space(18.0); + section(ui, lang, "Formulas"); + ui.label(lang.tr("Workbook calculation")); + calc_options(app, ui, lang, &calc); + ui.add_space(6.0); + dim(ui, lang.tr("Calculation settings are saved with the workbook.")); +} + +/// The display theme and interface language controls of the General section. +fn general_options(app: &mut SheetApp, ui: &mut Ui, lang: Language) { + egui::Grid::new("backstage_options_general").num_columns(2).spacing(vec2(18.0, 10.0)).show(ui, |ui| { + let label = ui.label(lang.tr("Display theme")); + let mode = app.ui.theme_mode(); + let current = match mode { + "system" => "System", + "dark" => "Dark", + _ => "Light", + }; + egui::ComboBox::from_id_salt("backstage_theme") + .width(160.0) + .selected_text(lang.tr(current)) + .show_ui(ui, |ui| { + for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] { + if ui.selectable_label(mode == value, lang.tr(label)).clicked() { + app.run_or_alert("view.theme", json!({"mode": value})); + } + } + }) + .response + .labelled_by(label.id); + ui.end_row(); + let label = ui.label(lang.tr("Interface language")); + egui::ComboBox::from_id_salt("backstage_language") + .width(160.0) + .selected_text(lang.name()) + .show_ui(ui, |ui| { + for l in i18n::Language::for_picker() { + if ui.selectable_label(l == lang, l.name()).clicked() { + app.run_or_alert("app.language.set", json!({"language": l.code()})); + } + } + }) + .response + .labelled_by(label.id); + ui.end_row(); + }); +} + +/// The calculation mode, and iterative calculation, of the active workbook. +fn calc_options(app: &mut SheetApp, ui: &mut Ui, lang: Language, calc: &gridcraft_engine::model::CalcSettings) { + use gridcraft_engine::model::CalcMode; + for (mode, id, label) in [ + (CalcMode::Automatic, "automatic", "Automatic"), + (CalcMode::AutomaticExceptTables, "automaticExceptTables", "Automatic Except for Data Tables"), + (CalcMode::Manual, "manual", "Manual"), + ] { + if ui.radio(calc.mode == mode, lang.tr(label)).clicked() && calc.mode != mode { + app.run_or_alert("formulas.calculationOptions", json!({"mode": id})); + } + } + ui.add_space(4.0); + let mut iterative = calc.iterative; + if ui.checkbox(&mut iterative, lang.tr("Enable iterative calculation")).changed() { + app.run_or_alert("formulas.calculationOptions", json!({"iterative": iterative})); + } + // Maximum Iterations and Maximum Change apply only while iteration is on, as in Excel. + iteration_options(app, ui, lang, calc, iterative); + // TODO: add "Set precision as displayed" here once `formulas.calculationOptions` takes + // `precisionAsDisplayed` (PR #1366); the model already stores it. +} + +/// Maximum Iterations and Maximum Change, live only while iterative calculation is on. +fn iteration_options(app: &mut SheetApp, ui: &mut Ui, lang: Language, calc: &gridcraft_engine::model::CalcSettings, iterative: bool) { + ui.add_enabled_ui(iterative, |ui| { + ui.horizontal(|ui| { + ui.add_space(24.0); + egui::Grid::new("backstage_options_iteration").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| { + let label = ui.label(lang.tr("Maximum Iterations:")); + let id = ui.make_persistent_id("backstage_max_iterations"); + // Text left in the field when iteration is switched off (it loses focus) is dropped. + let committed = crate::widgets::committed_text(ui, id, &calc.max_iterations.to_string(), Some(80.0)); + if let Some(response) = ui.ctx().read_response(id) { + response.labelled_by(label.id); + } + if let Some(text) = committed.filter(|_| iterative) { + match parse_max_iterations(&text) { + Some(n) if n != calc.max_iterations => { + app.run_or_alert("formulas.calculationOptions", json!({"maxIterations": n})); + } + Some(_) => {} + None => refuse(app, ui, "Maximum Iterations must be a whole number from 1 to 32767."), + } + } + ui.end_row(); + let label = ui.label(lang.tr("Maximum Change:")); + let id = ui.make_persistent_id("backstage_max_change"); + let committed = crate::widgets::committed_text(ui, id, &calc.max_change.to_string(), Some(80.0)); + if let Some(response) = ui.ctx().read_response(id) { + response.labelled_by(label.id); + } + if let Some(text) = committed.filter(|_| iterative) { + match parse_max_change(&text, lang.formula_locale()) { + Some(d) if d != calc.max_change => { + app.run_or_alert("formulas.calculationOptions", json!({"maxChange": d})); + } + Some(_) => {} + None => refuse(app, ui, "Maximum Change must be a number of 0 or more."), + } + } + ui.end_row(); + }); + }); + }); +} + +/// Refuses a typed setting with a message, leaving the workbook as it was. The Enter that +/// committed the field is consumed so it doesn't also dismiss the message box in this frame. +fn refuse(app: &mut SheetApp, ui: &mut Ui, text: &str) { + ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)); + app.message = Some(("GridCraft".into(), app.ui.language.tr(text).into())); +} + +/// Maximum Iterations as typed: a whole number from 1 to 32767, or `None` (Excel refuses others). +fn parse_max_iterations(text: &str) -> Option { + let n = text.trim().parse::().ok()?; + (1..=gridcraft_engine::model::CalcSettings::MAX_ITERATIONS).contains(&n).then_some(n) +} + +/// Maximum Change as typed: a finite number of 0 or more, or `None`. The interface's decimal +/// separator is accepted as well as a dot. +fn parse_max_change(text: &str, locale: gridcraft_engine::formula::FormulaLocale) -> Option { + let d = text.trim().replace(locale.decimal_separator(), ".").parse::().ok()?; + (d.is_finite() && d >= 0.0).then_some(d) +} + +/// The platform the app was built for, e.g. "macOS (aarch64)"; the web build reports "Web". +pub(super) fn platform() -> String { + let os = match std::env::consts::OS { + "macos" => "macOS", + "windows" => "Windows", + "linux" => "Linux", + "freebsd" => "FreeBSD", + "openbsd" => "OpenBSD", + "netbsd" => "NetBSD", + _ if cfg!(target_arch = "wasm32") => return "Web".into(), + other => other, + }; + format!("{os} ({})", std::env::consts::ARCH) +} diff --git a/crates/ui-egui/src/backstage/print.rs b/crates/ui-egui/src/backstage/print.rs new file mode 100644 index 0000000000..ac0b183d60 --- /dev/null +++ b/crates/ui-egui/src/backstage/print.rs @@ -0,0 +1,168 @@ +use super::*; + +/// Builds the print scope and fit-to-page parameters, caches `file.printPreview` per document revision/options, and draws page thumbnails with navigation plus the print settings and Print action. +pub(super) fn print(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language; + let Some((uid, revision)) = app.session.active().map(|d| (d.uid, d.revision)) else { + dim(ui, lang.tr("No workbook is open.")); + return; + }; + let mut params = json!({"sheets": if bs.print_all { "all" } else { "active" }}); + if bs.fit_to_page { + params["fitToPage"] = json!(true); + } + let key = (uid, revision, bs.print_all, bs.fit_to_page); + if bs.preview.as_ref().is_none_or(|(k, _)| *k != key) { + let preview = app.session.run("file.printPreview", params.clone()).unwrap_or(Json::Null); + bs.preview = Some((key, preview)); + } + let pages: Vec = bs.preview.as_ref().and_then(|(_, p)| p.get("pageList")).and_then(Json::as_array).cloned().unwrap_or_default(); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(250.0); + if let Some(discover) = &app.services.printers { + crate::printing::show(&mut app.printing, ui, discover, |label| lang.tr(label).to_owned()); + } + let can_print = app.services.print_native.is_none() || app.printing.ready(); + if ui.add_enabled_ui(can_print, |ui| square_button(ui, Icon::Print, lang.tr("Print"))).inner.clicked() + && app.commit_edit(0, 0, false, false) + { + app.print_now(params.clone()); + out.close = app.services.print_native.is_none(); + } + ui.add_space(16.0); + let scope = if bs.print_all { "Print Entire Workbook" } else { "Print Active Sheets" }; + egui::ComboBox::from_id_salt("backstage_print_scope") + .width(230.0) + .selected_text(lang.tr(scope)) + .show_ui(ui, |ui| { + for (all, label) in [(false, "Print Active Sheets"), (true, "Print Entire Workbook")] { + if ui.selectable_label(bs.print_all == all, lang.tr(label)).clicked() { + bs.print_all = all; + bs.preview_page = 0; + } + } + }) + .response + .accessible_name(lang.tr("Print What")); + ui.add_space(8.0); + if ui.checkbox(&mut bs.fit_to_page, lang.tr("Fit Sheet on One Page")).changed() { + bs.preview_page = 0; + } + ui.add_space(8.0); + if ui.link(lang.tr("Page Setup…")).clicked() { + app.open_dialog("pageSetup", json!({})); + } + }); + ui.add_space(24.0); + ui.vertical(|ui| { + if pages.is_empty() { + dim(ui, lang.tr("Nothing to print.")); + return; + } + let last = pages.len().saturating_sub(1); + bs.preview_page = bs.preview_page.min(last); + let Some(page) = pages.get(bs.preview_page) else { return }; + // Paper proportions from the preview (points); hostile sizes fall back to Letter. + let w = page.get("width").and_then(Json::as_f64).filter(|w| w.is_finite() && *w > 1.0).unwrap_or(612.0) as f32; + let h = page.get("height").and_then(Json::as_f64).filter(|h| h.is_finite() && *h > 1.0).unwrap_or(792.0) as f32; + let scale = (520.0 / h).min(440.0 / w); + let (rect, _) = ui.allocate_exact_size(vec2(w * scale, h * scale), Sense::hover()); + let p = ui.painter(); + p.rect_filled(rect.translate(vec2(2.0, 3.0)), 2.0, t.shadow); + p.rect_filled(rect, 2.0, t.grid_bg); + p.rect_stroke(rect, 2.0, Stroke::new(1.0, t.ribbon_border), StrokeKind::Inside); + if let Some(d) = app.session.active() { + paint_page(p, rect, w, page, &d.wb, &t); + } + let sheet = page.get("sheet").and_then(Json::as_str).unwrap_or(""); + let range = page.get("range").and_then(Json::as_str).unwrap_or(""); + ui.add_space(4.0); + dim(ui, &format!("{sheet} · {range}")); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.add_enabled(bs.preview_page > 0, egui::Button::new("◀")).on_hover_text(lang.tr("Previous page")).clicked() { + bs.preview_page = bs.preview_page.saturating_sub(1); + } + let text = lang + .tr("Page {page} of {pages}") + .replace("{page}", &(bs.preview_page + 1).to_string()) + .replace("{pages}", &pages.len().to_string()); + ui.label(text); + if ui.add_enabled(bs.preview_page < last, egui::Button::new("▶")).on_hover_text(lang.tr("Next page")).clicked() { + bs.preview_page = (bs.preview_page + 1).min(last); + } + }); + }); + }); +} + +/// Most rows and columns one preview page draws (a page holds far fewer at any readable scale). +const PREVIEW_ROWS: u32 = 200; + +const PREVIEW_COLS: u32 = 60; + +/// Draws one printed page in miniature: the cells' displayed text at the page's print scale, +/// inside the sheet's margins. `paper_w` is the page width in points. +fn paint_page(p: &egui::Painter, rect: Rect, paper_w: f32, page: &Json, wb: &gridcraft_engine::model::Workbook, t: &Tokens) { + let Some(range) = page.get("range").and_then(Json::as_str).and_then(gridcraft_engine::core::RangeRef::parse) else { return }; + let Some(sh) = page.get("sheet").and_then(Json::as_str).and_then(|n| wb.sheet_index(n)).and_then(|i| wb.sheets.get(i)) else { return }; + // Preview pixels per point, and per sheet pixel (sheet geometry is 96 dpi: 0.75 pt per pixel). + let per_pt = rect.width() / paper_w; + let print_scale = + page.get("scale").and_then(Json::as_f64).filter(|s| s.is_finite() && *s > 0.0).map_or(1.0, |s| (s / 100.0) as f32).clamp(0.1, 4.0); + let per_px = per_pt * 0.75 * print_scale; + let margin = + |i: usize, default: f32| sh.print.margins.get(i).copied().filter(|m| m.is_finite()).unwrap_or(default).clamp(0.0, 3.0) * 72.0 * per_pt; + let body = Rect::from_min_max( + pos2(rect.left() + margin(0, 0.7), rect.top() + margin(2, 0.75)), + pos2(rect.right() - margin(1, 0.7), rect.bottom() - margin(3, 0.75)), + ); + if !body.is_positive() { + return; + } + let font = theme::ui_font((11.0 * per_pt * print_scale).clamp(3.0, 14.0)); + let last_row = range.end.row.min(range.start.row.saturating_add(PREVIEW_ROWS)); + let last_col = range.end.col.min(range.start.col.saturating_add(PREVIEW_COLS)); + let mut y = body.top(); + for row in range.start.row..=last_row { + let h = sh.row_height(row) * per_px; + if h <= 0.0 { + continue; + } + if y >= body.bottom() { + break; + } + let mut x = body.left(); + for col in range.start.col..=last_col { + let w = sh.col_width(col) * per_px; + if w <= 0.0 { + continue; + } + if x >= body.right() { + break; + } + let cell = gridcraft_engine::core::CellRef::new(row, col); + let text = gridcraft_engine::display::cell_text(wb, sh, cell); + if !text.is_empty() { + let numeric = sh.value(cell).as_f64().is_some(); + // Text runs on over empty neighbours, as on the sheet; numbers stay in their cell. + let mut span = w; + if !numeric { + let mut next = col.saturating_add(1); + while next <= last_col && x + span < body.right() && sh.value(gridcraft_engine::core::CellRef::new(row, next)).is_empty() { + span += sh.col_width(next) * per_px; + next = next.saturating_add(1); + } + } + let clip = Rect::from_min_size(pos2(x, y), vec2(span, h)).intersect(body); + let (anchor, at) = + if numeric { (Align2::RIGHT_CENTER, pos2(x + w - 1.0, y + h / 2.0)) } else { (Align2::LEFT_CENTER, pos2(x + 1.0, y + h / 2.0)) }; + p.with_clip_rect(clip).text(at, anchor, text, font.clone(), t.cell_text); + } + x += w; + } + y += h; + } +} diff --git a/crates/ui-egui/src/clock.rs b/crates/ui-egui/src/clock.rs new file mode 100644 index 0000000000..ca1b577835 --- /dev/null +++ b/crates/ui-egui/src/clock.rs @@ -0,0 +1,25 @@ +/// Wall-clock milliseconds since the Unix epoch, for user-visible timestamps (Backstage's "last +/// changed", toasts). It can jump when the system clock is adjusted, so it must not drive +/// deadlines or pacing; use [`monotonic_ms`] for those. +pub fn now_ms() -> f64 { + gridcraft_engine::core::date::unix_now_ms().unwrap_or(0.0) +} + +/// Monotonic milliseconds since the first call, for screenshot deadlines and AutoSave pacing. +/// +/// Deliberately separate from [`now_ms`]: a wall-clock adjustment must not extend (or expire) a +/// screenshot deadline or save a workbook twice. Native uses [`std::time::Instant`]; wasm uses the +/// browser's `performance.now()`, which is monotonic too. +#[cfg(not(target_arch = "wasm32"))] +#[allow(clippy::disallowed_methods)] // the std clock is read only off wasm +pub fn monotonic_ms() -> f64 { + use std::sync::OnceLock; + static START: OnceLock = OnceLock::new(); + START.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() * 1000.0 +} + +#[cfg(target_arch = "wasm32")] +/// Web monotonic clock: the browser's `performance.now()`, or 0 when it is unavailable. +pub fn monotonic_ms() -> f64 { + web_sys::window().and_then(|window| window.performance()).map_or(0.0, |performance| performance.now()) +} diff --git a/crates/ui-egui/src/code_editor.rs b/crates/ui-egui/src/code_editor.rs index 6f02b4044f..ee65b7ec82 100644 --- a/crates/ui-egui/src/code_editor.rs +++ b/crates/ui-egui/src/code_editor.rs @@ -78,6 +78,32 @@ pub fn highlight(text: &str, lang: Lang) -> Vec<(Range, Token)> { } } +/// End (exclusive, in chars) of the Python string literal opening with quote `c` at char `i`. +fn python_string_end(chars: &[(usize, char)], i: usize, c: char) -> usize { + 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, + } + } + j.min(chars.len()) +} + fn python(text: &str) -> Vec<(Range, 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()); @@ -92,28 +118,7 @@ fn python(text: &str) -> Vec<(Range, Token)> { 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()); + let j = python_string_end(&chars, i, c); 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())) { @@ -565,377 +570,4 @@ pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max } #[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] - /// Shift+Tab removes four leading spaces, while a plain Tab still inserts four spaces at the caret. - 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 { - ui.ctx().set_fonts(theme::pinned_font_definitions()); - return; - } - let id = egui::Id::unique("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 deterministic font set, including the editor's monospace family. - fn install_mono(ctx: &egui::Context) { - ctx.set_fonts(theme::pinned_font_definitions()); - } - - /// Builds a 400x300 code-editor harness with pinned mono fonts, focus, two passes and an accessibility check. - 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::unique("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.check_accessibility(); - 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] - /// Events held by the previous pass are applied once in the next pass; older held events are dropped and cleared. - fn held_back_events_land_in_the_next_pass_only() { - let mut h = editor_harness(""); - let id = egui::Id::unique("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_id(id))).is_none()); - } - - #[test] - /// Held keyboard input does not land in the next focused text field once the code editor has lost focus. - 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::unique("code"), egui::Id::unique("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)).accessible_name("Other field"); - }, - (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] - /// A discard-triggered repeat pass receives held keys with the selection made by the first pass. - 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::unique("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"); - } -} +mod tests; diff --git a/crates/ui-egui/src/code_editor/tests.rs b/crates/ui-egui/src/code_editor/tests.rs new file mode 100644 index 0000000000..ba3c08efa6 --- /dev/null +++ b/crates/ui-egui/src/code_editor/tests.rs @@ -0,0 +1,368 @@ +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] +/// Shift+Tab removes four leading spaces, while a plain Tab still inserts four spaces at the caret. +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 { + ui.ctx().set_fonts(theme::pinned_font_definitions()); + return; + } + let id = egui::Id::unique("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 deterministic font set, including the editor's monospace family. +fn install_mono(ctx: &egui::Context) { + ctx.set_fonts(theme::pinned_font_definitions()); +} + +/// Builds a 400x300 code-editor harness with pinned mono fonts, focus, two passes and an accessibility check. +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::unique("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.check_accessibility(); + 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] +/// Events held by the previous pass are applied once in the next pass; older held events are dropped and cleared. +fn held_back_events_land_in_the_next_pass_only() { + let mut h = editor_harness(""); + let id = egui::Id::unique("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_id(id))).is_none()); +} + +#[test] +/// Held keyboard input does not land in the next focused text field once the code editor has lost focus. +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::unique("code"), egui::Id::unique("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)).accessible_name("Other field"); + }, + (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] +/// A discard-triggered repeat pass receives held keys with the selection made by the first pass. +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::unique("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"); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index a4c226b815..190300c484 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -24,6 +24,21 @@ use serde_json::{Value as Json, json}; use crate::SheetApp; +mod screenshots; +mod synthetic; + +pub use screenshots::{ + CaptureGeometry, SCREENSHOT_DOCUMENT_CHANGED, SCREENSHOT_MAX_AGGREGATE_BYTES, SCREENSHOT_MAX_BASE64_BYTES, SCREENSHOT_MAX_ENCODING_BYTES, + SCREENSHOT_MAX_IMAGE_BYTES, SCREENSHOT_MAX_PEAK_BYTES, SCREENSHOT_MAX_REQUESTS, SCREENSHOT_MAX_RETRIES, SCREENSHOT_VIEWPORT_CHANGED, Shots, + UNKNOWN_CAPTURE_BYTES, drain_screenshots, encoding_working_bytes, issue_screenshots, save_image, +}; +use screenshots::{Shot, check_encoding_budget, check_peak_budget, reject_shot}; +#[cfg(test)] +use screenshots::{base64_len, png_worst_bytes, validate_image}; +pub(crate) use screenshots::{current_capture_geometry, note_capture_geometry}; +pub use synthetic::{MAX_QUEUED_SYNTHETIC_BYTES, MAX_QUEUED_SYNTHETIC_EVENTS, MAX_SYNTHETIC_TEXT_BYTES}; +use synthetic::{cell_center, key_from, modifiers, push_click, push_drag, push_key, push_move, push_text}; + pub type ControlResponse = Json; pub struct ControlRequest { @@ -67,323 +82,6 @@ pub enum Outcome { Screenshot { path: Option }, } -/// Most screenshots one client may have in flight or waiting for its reply at once. -pub const SCREENSHOT_MAX_REQUESTS: usize = 4; -/// Retained bytes for one screenshot image. The renderer's readback buffer is a separate -/// transient allocation (recorded in peak RSS), not part of this reservation. -pub const SCREENSHOT_MAX_IMAGE_BYTES: usize = 128 * 1024 * 1024; -/// Aggregate bytes reserved by every outstanding screenshot request. -pub const SCREENSHOT_MAX_AGGREGATE_BYTES: usize = 256 * 1024 * 1024; -/// Largest transient working set allowed while one capture is encoded. It covers the RGBA copy -/// made from the retained `ColorImage`, the PNG stream and (for base64 replies) the encoded text. -/// Admission checks this before a request is issued, so a large capture cannot make the process -/// peak several times above its retained-image budget. -pub const SCREENSHOT_MAX_ENCODING_BYTES: usize = 256 * 1024 * 1024; -/// Largest base64 reply this client produces. The encoded length is checked before the string is -/// allocated; at most [`SCREENSHOT_MAX_REQUESTS`] such replies can therefore wait in the channel, -/// keeping queued base64 text bounded too. -pub const SCREENSHOT_MAX_BASE64_BYTES: usize = 64 * 1024 * 1024; -/// Hard bound on retained request images plus one capture's encoding working set. Admission, -/// issuance and every retry reject a request that would push the process over this, so the -/// aggregate reservation and the transient encoder peak together stay bounded. -pub const SCREENSHOT_MAX_PEAK_BYTES: usize = 512 * 1024 * 1024; -/// Capture retries allowed inside one request's deadline after a resize/DPI/zoom/surface change. -pub const SCREENSHOT_MAX_RETRIES: u32 = 2; -/// Reported once when the active document changes before a queued capture is issued or completed. -pub const SCREENSHOT_DOCUMENT_CHANGED: &str = "screenshot failed: the document changed while the capture was queued"; -/// Reported once when a capture request or completion is not for the active viewport. -pub const SCREENSHOT_VIEWPORT_CHANGED: &str = "screenshot failed: the capture belongs to another viewport"; - -/// Reserved while the viewport has no capturable size yet (minimized, or not laid out at all): -/// the largest single image the per-image limit allows. The real size replaces it at issuance. -pub const UNKNOWN_CAPTURE_BYTES: usize = SCREENSHOT_MAX_IMAGE_BYTES; - -/// The viewport geometry a capture is admitted, issued and completed against. -/// -/// `SheetApp` keeps a monotonic epoch (see [`crate::SheetApp::logic`]) that increments on any -/// change of these values, so a resize back to the original size is still a mismatch: an image -/// taken in another layout must never be combined with the current geometry. The viewport id, -/// minimized/occluded state and visibility are part of the identity too: a minimize/restore or -/// an occlusion transition at the same pixel size is a new surface generation, not a no-op. -/// -/// The identity deliberately keeps `zoom`, `visible` and `occluded` even though interface zoom -/// does not change the expected physical surface size. A capture is bound to one layout generation, -/// so a zoom or occlusion transition during it is retried within [`SCREENSHOT_MAX_RETRIES`] and -/// then reported as a bounded failure instead of risking pixels from another generation. The common -/// wheel-zoom path is frozen while a capture is in flight; a non-glide zoom or a compositor -/// occlusion flap can still exhaust the retries, which is accepted as a rare, explicit error. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct CaptureGeometry { - /// The viewport this capture belongs to. - pub viewport_id: egui::ViewportId, - /// Logical size of the content that is captured, or `None` when the window currently has no - /// capturable surface (minimized, or not laid out yet). A hidden-but-occluded window still - /// reports a size. - pub viewport: Option, - pub pixels_per_point: f32, - pub zoom: f32, - /// Maximum physical width/height the surface/renderer can capture. - pub max_texture_side: usize, - pub minimized: Option, - pub occluded: Option, - pub visible: Option, -} - -impl CaptureGeometry { - /// The geometry described by a native frame's fresh [`egui::RawInput`]. - /// - /// `raw.screen_rect` is the authoritative content size when `ViewportInfo::inner_rect` is - /// absent (Wayland always omits it, and a minimized window can omit both). The fallback to the - /// last UI pass's rect is deliberately *not* used here: a logic-only tick after a resize or - /// minimize must record the new state (or the explicit unknown state), never the old layout. - pub fn from_raw_input(raw: &egui::RawInput, ctx: &egui::Context) -> Self { - let info = raw.viewports.get(&raw.viewport_id).cloned().unwrap_or_default(); - Self::build(raw.viewport_id, &info, raw.screen_rect, raw.max_texture_side, ctx.zoom_factor(), ctx.pixels_per_point()) - } - - /// Builds a `CaptureGeometry` from fresh viewport information. A minimized window has no size; `inner_rect` falls back to the raw screen rect; native scale is multiplied by interface zoom, and a zero surface limit means uncapped. - fn build( - viewport_id: egui::ViewportId, - info: &egui::ViewportInfo, - screen_rect: Option, - max_texture_side: Option, - zoom: f32, - fallback_pixels_per_point: f32, - ) -> Self { - let minimized = info.minimized; - let visible = info.visible(); - // A minimized window has no surface to capture even if the platform still reports the - // last inner rectangle. Both rectangles missing likewise means "unknown size", not a - // reason to re-use an earlier layout. - let viewport = if minimized == Some(true) { - None - } else { - info.inner_rect.map(|rect| rect.size()).filter(positive_size).or_else(|| screen_rect.map(|rect| rect.size()).filter(positive_size)) - }; - let pixels_per_point = - info.native_pixels_per_point.filter(|p| p.is_finite() && *p > 0.0).map_or(fallback_pixels_per_point, |native| native * zoom); - Self { - viewport_id, - viewport, - pixels_per_point, - zoom, - // Zero means "unreported" (a bare test context); `expected_size` treats it as no cap. - max_texture_side: max_texture_side.unwrap_or(0), - minimized, - occluded: info.occluded, - visible, - } - } - - /// The geometry of egui's current input. This is a fallback for callers that do not run - /// [`crate::SheetApp::raw_input_hook`] (tests and embedders); native eframe always records the - /// fresh [`Self::from_raw_input`] value first, so issuance and drain never fall back here. - pub fn current(ctx: &egui::Context) -> Self { - let (viewport_id, info, screen_rect, max_texture_side, fallback_ppp) = ctx.input(|i| { - let viewport_id = i.raw.viewport_id; - let info = i.raw.viewports.get(&viewport_id).cloned().unwrap_or_default(); - // `screen_rect` from the pass if one supplied it; otherwise egui's retained content - // rect (which is only fresh when this is called from a UI pass, never a logic tick). - let screen_rect = i.raw.screen_rect.or_else(|| Some(i.viewport_rect())); - (viewport_id, info, screen_rect, i.max_texture_side, i.pixels_per_point()) - }); - Self::build(viewport_id, &info, screen_rect, Some(max_texture_side), ctx.zoom_factor(), fallback_ppp) - } - - /// Physical pixel size the screenshot should have, rounded and capped by the surface limit. - /// `None` when the viewport has no positive, finite size (minimized or not laid out yet). - pub fn expected_size(&self) -> Option<[usize; 2]> { - let viewport = self.viewport?; - let ppp = self.pixels_per_point; - if !viewport.x.is_finite() || !viewport.y.is_finite() || viewport.x <= 0.0 || viewport.y <= 0.0 { - return None; - } - if !ppp.is_finite() || ppp <= 0.0 { - return None; - } - // An unreported surface limit (0) must not cap every capture to one pixel. - let max = if self.max_texture_side == 0 { usize::MAX } else { self.max_texture_side }; - let axis = |points: f32| -> Option { - let px = (f64::from(points) * f64::from(ppp)).round(); - if !px.is_finite() || px <= 0.0 || px > usize::MAX as f64 { - return None; - } - Some((px as usize).min(max)) - }; - Some([axis(viewport.x)?, axis(viewport.y)?]) - } - - /// Checked `width * height * 4` bytes for the expected image, capped by the surface limit. - pub fn expected_bytes(&self) -> Option { - let [w, h] = self.expected_size()?; - w.checked_mul(h)?.checked_mul(4) - } - - /// Checked bytes of the largest image [`validate_image`] accepts for this geometry: one - /// physical pixel of rounding per axis. Reservations and the slot's per-image bound both use - /// this, so the aggregate budget is strict for the bytes actually retained. - pub fn retained_bytes(&self) -> Option { - let [w, h] = self.expected_size()?; - let w = w.checked_add(1)?; - let h = h.checked_add(1)?; - w.checked_mul(h)?.checked_mul(4) - } -} - -/// True only when both width and height are finite and positive. -fn positive_size(size: &egui::Vec2) -> bool { - size.x.is_finite() && size.y.is_finite() && size.x > 0.0 && size.y > 0.0 -} - -/// Conservative worst-case size of the PNG stream for a `bytes`-sized RGBA image with `height` -/// rows. PNG adds a filter byte per row and zlib framing; uncompressed blocks add a few bytes per -/// 64 KiB. 1% + 64 KiB plus the row bytes is safely above the real encoder output. -fn png_worst_bytes(bytes: usize, height: usize) -> Option { - bytes.checked_add(bytes / 100)?.checked_add(height.checked_mul(2)?)?.checked_add(64 * 1024) -} - -/// Base64 length (with padding) for `len` bytes. -fn base64_len(len: usize) -> Option { - len.checked_add(2)?.checked_div(3)?.checked_mul(4) -} - -/// Worst transient bytes for encoding one expected capture: the retained `ColorImage` plus the -/// RGBA copy made from it, then (for base64) the PNG stream and the encoded text. `None` on -/// overflow. Admission rejects a request whose working set exceeds -/// [`SCREENSHOT_MAX_ENCODING_BYTES`] before any slot or command exists. -pub fn encoding_working_bytes(expected: [usize; 2], base64: bool) -> Option { - let [w, h] = expected; - let bytes = w.checked_mul(h)?.checked_mul(4)?; - let retained_and_copy = bytes.checked_mul(2)?; - if !base64 { - return Some(retained_and_copy); - } - let png = png_worst_bytes(bytes, h)?; - let encoded = base64_len(png)?; - Some(retained_and_copy.max(bytes.checked_add(png)?.checked_add(encoded)?)) -} - -/// Reject an expected capture whose encoding working set or base64 reply would exceed the -/// explicit encoding limits. Checked before admission and again immediately before issuance. -fn check_encoding_budget(expected: [usize; 2], base64: bool) -> Result<(), String> { - let [w, h] = expected; - let Some(bytes) = w.checked_mul(h).and_then(|p| p.checked_mul(4)) else { - return Err("screenshot failed: the window size overflows the screenshot budget".to_string()); - }; - let Some(working) = encoding_working_bytes(expected, base64) else { - return Err("screenshot failed: the window size overflows the screenshot encoding budget".to_string()); - }; - if working > SCREENSHOT_MAX_ENCODING_BYTES { - return Err(format!( - "screenshot encoding budget exceeded: {working} bytes of transient working memory for one {w}×{h} capture, over the {SCREENSHOT_MAX_ENCODING_BYTES}-byte encoding limit" - )); - } - if base64 { - let Some(png) = png_worst_bytes(bytes, h) else { - return Err("screenshot failed: the window size overflows the screenshot encoding budget".to_string()); - }; - let Some(encoded) = base64_len(png) else { - return Err("screenshot failed: the window size overflows the screenshot encoding budget".to_string()); - }; - if encoded > SCREENSHOT_MAX_BASE64_BYTES { - return Err(format!( - "screenshot base64 budget exceeded: the reply for one {w}×{h} capture could be {encoded} bytes, over the {SCREENSHOT_MAX_BASE64_BYTES}-byte base64 limit; request a path instead" - )); - } - } - Ok(()) -} - -/// Reject a request whose reserved retained bytes plus its encoding working set exceed -/// [`SCREENSHOT_MAX_PEAK_BYTES`]. `reserved` already includes this request's own reservation. -fn check_peak_budget(reserved: usize, expected: [usize; 2], base64: bool) -> Result<(), String> { - let Some(working) = encoding_working_bytes(expected, base64) else { - return Err("screenshot failed: the window size overflows the screenshot encoding budget".to_string()); - }; - let peak = reserved.saturating_add(working); - if peak > SCREENSHOT_MAX_PEAK_BYTES { - return Err(format!( - "screenshot peak-memory budget exceeded: {reserved} bytes retained plus {working} bytes encoding for one capture is over the {SCREENSHOT_MAX_PEAK_BYTES}-byte peak limit" - )); - } - Ok(()) -} - -/// One admitted `ui.screenshot` request. The slot owns the image and its viewport waker; the -/// callback holds only a weak reference to it and never writes a file or sends a reply. -struct Shot { - token: u64, - path: Option, - reply: Sender, - /// Monotonic deadline for the whole request, including retries. - deadline: f64, - /// Settle time before the first issuance (the UI must be still). - ready_at: f64, - /// Real (non-discarded) UI passes counted towards settling. - frames: u32, - /// `cumulative_pass_nr` of the last pass counted in `frames`, so repeated `ui` calls in one - /// pass count once and a settled frame is never counted twice. - settle_pass: Option, - issued: bool, - retries: u32, - /// Geometry at admission, updated immediately before issuance. - geometry: CaptureGeometry, - /// The viewport this request was admitted for. A callback or slot from another viewport must - /// never fill this request. - viewport_id: egui::ViewportId, - /// The active document (`DocState::uid`) at admission. A queued capture is never rebound to a - /// document that became active later. - document: Option, - /// Monotonic geometry epoch at issuance; any later change rejects the image. - epoch: u64, - /// Physical size expected at issuance; `None` while the window has no capturable size. - expected: Option<[usize; 2]>, - /// Bytes reserved from the aggregate budget until the request completes or is dropped. This - /// is the largest image the request may retain (one pixel of rounding per axis included) and - /// is also the slot's per-image bound. - bytes: usize, - /// Created at issuance (and anew for every retry), never at admission: the bound must match - /// the geometry and reservation the command is issued against. - slot: Option, -} - -#[derive(Default)] -pub struct Shots { - token: u64, - requests: Vec, -} - -impl Shots { - /// A screenshot was asked for and hasn't been delivered yet: animations hold still, so the - /// picture shows the settled state. - pub(crate) fn in_flight(&self) -> bool { - !self.requests.is_empty() - } - - /// Whether a hidden window still needs `RequestPaintWhileHidden` to reach issuance. Only a - /// request that is admitted or settling with a capturable size needs a first `ui` pass; once - /// `issue_screenshots` has sent the `Screenshot` command, that command's own `wants_paint` - /// paints the hidden window. A minimized request has no capturable size, so requesting hidden - /// full frames for its whole wait cannot make progress (the restore pass issues it). - pub(crate) fn needs_hidden_paint(&self) -> bool { - self.requests.iter().any(|r| !r.issued && r.expected.is_some()) - } - - /// Bytes currently reserved by admitted requests. - fn reserved(&self) -> usize { - self.requests.iter().fold(0usize, |sum, r| sum.saturating_add(r.bytes)) - } - - /// Drop every outstanding request without a reply (app shutdown): callbacks become inert. - pub fn cancel_all(&mut self) { - for request in self.requests.drain(..) { - if let Some(slot) = request.slot { - slot.cancel(); - } - } - } -} - fn ok(v: Json) -> Outcome { Outcome::Done(json!({"ok": true, "result": v})) } @@ -461,148 +159,6 @@ pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json { }) } -fn key_from(name: &str) -> Option { - egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() { - "enter" | "return" => Some(egui::Key::Enter), - "esc" | "escape" => Some(egui::Key::Escape), - "delete" | "del" => Some(egui::Key::Delete), - "backspace" => Some(egui::Key::Backspace), - "left" => Some(egui::Key::ArrowLeft), - "right" => Some(egui::Key::ArrowRight), - "up" => Some(egui::Key::ArrowUp), - "down" => Some(egui::Key::ArrowDown), - "space" => Some(egui::Key::Space), - "tab" => Some(egui::Key::Tab), - "pageup" => Some(egui::Key::PageUp), - "pagedown" => Some(egui::Key::PageDown), - "home" => Some(egui::Key::Home), - "end" => Some(egui::Key::End), - "f2" => Some(egui::Key::F2), - "f4" => Some(egui::Key::F4), - "f9" => Some(egui::Key::F9), - _ => None, - }) -} - -fn modifiers(p: &Json) -> egui::Modifiers { - let b = |n: &str| p.get(n).and_then(Json::as_bool).unwrap_or(false); - egui::Modifiers { - alt: b("alt"), - ctrl: b("ctrl"), - shift: b("shift"), - mac_cmd: b("cmd") && cfg!(target_os = "macos"), - command: b("cmd") || (b("ctrl") && !cfg!(target_os = "macos")), - } -} - -fn cell_center(app: &SheetApp, c: CellRef) -> Option { - let d = app.session.active()?; - let sh = d.wb.active()?; - let rect = app.grid.rect?; - let geo = crate::grid::Geo::new(sh, rect, app.view().scroll); - Some(geo.screen_cell_rect(sh, c).center()) -} - -/// Most synthetic events (pointer, key, text and move alike) a control client may keep queued at -/// once. The queue offers one batch per UI pass, and a hidden logic-only frame keeps the same -/// batch unapplied, so every producer must honour this budget. -pub const MAX_QUEUED_SYNTHETIC_EVENTS: usize = 10_000; -/// Most bytes of synthetic input queued at once: the `Event` storage plus every text payload. -pub const MAX_QUEUED_SYNTHETIC_BYTES: usize = 8 * 1024 * 1024; -/// Largest single `ui.text` string accepted (well below the control line limit; one paste-sized -/// string is plenty for a spreadsheet cell and keeps a burst from exhausting memory). -pub const MAX_SYNTHETIC_TEXT_BYTES: usize = 1024 * 1024; - -/// Bytes charged to one synthetic event: `size_of::()` plus the payload of a `Text` event. -fn event_bytes(event: &egui::Event) -> usize { - std::mem::size_of::() - + match event { - egui::Event::Text(text) => text.len(), - _ => 0, - } -} - -/// Saturating totals of queued synthetic events and their charged bytes across every pending batch. -fn synthetic_usage(app: &SheetApp) -> (usize, usize) { - app.synthetic - .iter() - .flat_map(|batch| batch.iter()) - .fold((0usize, 0usize), |(events, bytes), event| (events.saturating_add(1), bytes.saturating_add(event_bytes(event)))) -} - -/// Refuses a request that would push the queued synthetic input past either limit; the queue is -/// left unchanged. -fn synthetic_budget(app: &SheetApp, extra_events: usize, extra_bytes: usize) -> Result<(), String> { - let (events, bytes) = synthetic_usage(app); - if events.saturating_add(extra_events) > MAX_QUEUED_SYNTHETIC_EVENTS { - return Err(format!("too many synthetic input events (limit {MAX_QUEUED_SYNTHETIC_EVENTS} queued at once)")); - } - if bytes.saturating_add(extra_bytes) > MAX_QUEUED_SYNTHETIC_BYTES { - return Err(format!("synthetic input exceeds the {MAX_QUEUED_SYNTHETIC_BYTES}-byte queue limit")); - } - Ok(()) -} - -/// Checks the synthetic-event budget, then queues one pointer move followed by `count` press/release pairs (`count` is at least one). -fn push_click(app: &mut SheetApp, pos: egui::Pos2, button: egui::PointerButton, count: u32, m: egui::Modifiers) -> Result<(), String> { - let count = count.max(1) as usize; - let events = count.saturating_mul(2).saturating_add(1); - synthetic_budget(app, events, events.saturating_mul(std::mem::size_of::()))?; - app.synthetic.push_back(vec![egui::Event::PointerMoved(pos)]); - let mut batch = Vec::with_capacity(count * 2); - for _ in 0..count { - batch.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: m }); - batch.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m }); - } - app.synthetic.push_back(batch); - Ok(()) -} - -/// Checks the synthetic-event budget, then queues a move, a press, `steps` interpolated moves and a release. -fn push_drag(app: &mut SheetApp, a: egui::Pos2, b: egui::Pos2, steps: usize, m: egui::Modifiers) -> Result<(), String> { - let steps = steps.max(1); - let events = steps.saturating_add(3); - synthetic_budget(app, events, events.saturating_mul(std::mem::size_of::()))?; - app.synthetic.push_back(vec![egui::Event::PointerMoved(a)]); - app.synthetic.push_back(vec![egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m }]); - for i in 1..=steps { - let t = i as f32 / steps as f32; - app.synthetic.push_back(vec![egui::Event::PointerMoved(a + (b - a) * t)]); - } - app.synthetic.push_back(vec![egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }]); - Ok(()) -} - -/// Checks the synthetic-event budget and queues one key press/release pair. -fn push_key(app: &mut SheetApp, key: egui::Key, m: egui::Modifiers) -> Result<(), String> { - let events = 2; - synthetic_budget(app, events, events * std::mem::size_of::())?; - app.synthetic.push_back(vec![ - egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m }, - egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: m }, - ]); - Ok(()) -} - -/// Rejects text longer than `MAX_SYNTHETIC_TEXT_BYTES`, then queues one `Text` event under the shared queue budget. -fn push_text(app: &mut SheetApp, text: String) -> Result<(), String> { - if text.len() > MAX_SYNTHETIC_TEXT_BYTES { - return Err(format!("text input is {} bytes, over the {MAX_SYNTHETIC_TEXT_BYTES}-byte limit for one event", text.len())); - } - let event = egui::Event::Text(text); - synthetic_budget(app, 1, event_bytes(&event))?; - app.synthetic.push_back(vec![event]); - Ok(()) -} - -/// Queues one pointer-move event under the shared synthetic queue budget. -fn push_move(app: &mut SheetApp, pos: egui::Pos2) -> Result<(), String> { - let event = egui::Event::PointerMoved(pos); - synthetic_budget(app, 1, event_bytes(&event))?; - app.synthetic.push_back(vec![event]); - Ok(()) -} - /// Keeps the commands whose id, label or ribbon path contains `search` (case-insensitive); `None` keeps all. fn filter_commands(commands: Vec, search: Option<&str>) -> Vec { let Some(q) = search.map(str::to_ascii_lowercase) else { return commands }; @@ -883,11 +439,6 @@ pub fn poll(app: &mut SheetApp, ctx: &egui::Context) { } } -/// Sends one screenshot rejection carrying the request token. -fn reject_shot(reply: Sender, token: u64, message: impl std::fmt::Display) { - let _ = reply.send(json!({"ok": false, "error": message.to_string(), "request": token})); -} - /// Put the request token on every screenshot reply (success, rejection, timeout), so a client can /// correlate an asynchronous capture even when the reply order differs from the request order. fn with_token(mut reply: Json, token: u64) -> Json { @@ -897,29 +448,6 @@ fn with_token(mut reply: Json, token: u64) -> Json { reply } -/// The geometry to use for admission, issuance and drain. -/// -/// Native eframe calls [`crate::SheetApp::raw_input_hook`] before every frame (including a -/// logic-only tick), so the recorded value carries fresh `RawInput` geometry even when egui's -/// last UI pass is stale. Tests and embedders that never call the hook fall back to egui's current -/// input; that fallback is only used when no raw frame was seen. -pub(crate) fn current_capture_geometry(app: &SheetApp, ctx: &egui::Context) -> CaptureGeometry { - if app.capture_geometry_from_raw { app.capture_geometry.unwrap_or_else(|| CaptureGeometry::current(ctx)) } else { CaptureGeometry::current(ctx) } -} - -/// Record `geometry` as the layout captures are bound to and bump the epoch when it differs. -/// -/// Issuance and drain use this instead of writing the fields separately: during a hidden -/// logic-only tick the viewport info can be newer than the last UI pass, and the epoch must -/// change together with the geometry or a valid image would be retried against its own epoch. -pub(crate) fn note_capture_geometry(app: &mut SheetApp, geometry: CaptureGeometry) -> u64 { - if app.capture_geometry != Some(geometry) { - app.capture_geometry = Some(geometry); - app.capture_epoch = app.capture_epoch.wrapping_add(1); - } - app.capture_epoch -} - /// Returns the next screenshot token, wrapping and skipping zero. fn next_token(app: &mut SheetApp) -> u64 { let mut token = app.shots.token.wrapping_add(1); @@ -1023,1380 +551,5 @@ fn timeout_reply(message: &str, token: u64) -> Json { json!({"ok": false, "error": message, "request": token}) } -/// The bytes to reserve for one capture of `expected` against `geometry`, after checking the -/// applicable limits. Issuance and retry both use this (admission keeps its own inline checks) so a -/// retry cannot drift from the limits the first issuance applied. `expected` is `None` when the -/// viewport has no capturable size yet: that reserves [`UNKNOWN_CAPTURE_BYTES`] and still checks -/// the aggregate limit, while the per-image, encoding and peak checks only apply once a size is -/// known. -fn capture_bytes(app: &SheetApp, i: usize, geometry: CaptureGeometry, expected: Option<[usize; 2]>, base64: bool) -> Result { - let bytes = match expected { - Some(expected) => { - let bytes = geometry.retained_bytes().ok_or_else(|| "screenshot failed: the window size overflows the screenshot budget".to_string())?; - if bytes > SCREENSHOT_MAX_IMAGE_BYTES { - return Err(format!( - "screenshot budget exceeded: {bytes} bytes for one {}×{} image, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit", - expected[0], expected[1] - )); - } - check_encoding_budget(expected, base64)?; - bytes - } - None => UNKNOWN_CAPTURE_BYTES, - }; - let others = app.shots.requests.iter().enumerate().filter(|(j, _)| *j != i).fold(0usize, |sum, (_, r)| sum.saturating_add(r.bytes)); - if others.saturating_add(bytes) > SCREENSHOT_MAX_AGGREGATE_BYTES { - return Err(format!( - "screenshot budget exceeded: {bytes} bytes for this image would exceed the {SCREENSHOT_MAX_AGGREGATE_BYTES}-byte aggregate limit" - )); - } - if let Some(expected) = expected { - check_peak_budget(others.saturating_add(bytes), expected, base64)?; - } - Ok(bytes) -} - -/// Issued from `ui`: counts one real (non-discarded) settling pass per request, revalidates the -/// geometry and reserves the exact retained bytes, then sends one -/// `ViewportCommand::Screenshot(callback)` per request. -/// -/// The slot is created here, not at admission. Its per-image bound is the same reservation the -/// aggregate budget holds, so an image that would exceed the budget cannot be retained; a retry -/// after a resize creates a fresh slot for the new geometry. -pub fn issue_screenshots(app: &mut SheetApp, ctx: &egui::Context) { - let now = crate::monotonic_ms(); - let pass = ctx.cumulative_pass_nr(); - // A pass whose output is already going to be discarded is not a settling frame and its - // layout is not the one to capture. GridCraft requests no discard after this call. - let discard = ctx.will_discard(); - let active_document = app.session.active().map(|d| d.uid); - let current_viewport = ctx.viewport_id(); - let len = app.shots.requests.len(); - let mut failures: Vec<(usize, String)> = Vec::new(); - for i in 0..len { - let (issued, ready_at, settle_pass) = { - let r = &app.shots.requests[i]; - (r.issued, r.ready_at, r.settle_pass) - }; - if issued || discard || settle_pass == Some(pass) { - continue; - } - // One count per egui pass, however many times this `ui` runs in it. - let frames = { - let r = &mut app.shots.requests[i]; - r.settle_pass = Some(pass); - r.frames = r.frames.saturating_add(1); - r.frames - }; - if now < ready_at || frames < 3 { - continue; - } - // A queued capture belongs to the document and viewport it was admitted for. A switch - // before issuance is reported once and never rebound to the new document. - let (viewport_id, document, base64) = { - let r = &app.shots.requests[i]; - (r.viewport_id, r.document, r.path.is_none()) - }; - if viewport_id != current_viewport { - failures.push((i, SCREENSHOT_VIEWPORT_CHANGED.into())); - continue; - } - if document != active_document { - failures.push((i, SCREENSHOT_DOCUMENT_CHANGED.into())); - continue; - } - let current = current_capture_geometry(app, ctx); - let Some(expected) = current.expected_size() else { - // Still minimized or not laid out: keep the request queued and try again on a later - // real pass; `drain_screenshots` reports the size-specific timeout if none arrives. - app.shots.requests[i].expected = None; - continue; - }; - let bytes = match capture_bytes(app, i, current, Some(expected), base64) { - Ok(bytes) => bytes, - Err(message) => { - failures.push((i, message)); - continue; - } - }; - // Update the geometry and its epoch together, so the drain accepts exactly the layout the - // capture is issued against even when a hidden logic tick already saw the new viewport. - let epoch = note_capture_geometry(app, current); - let r = &mut app.shots.requests[i]; - r.geometry = current; - r.expected = Some(expected); - r.bytes = bytes; - r.epoch = epoch; - if let Some(old) = r.slot.take() { - old.cancel(); - } - let (slot, callback) = CaptureSlot::new(ctx, viewport_id, bytes); - r.slot = Some(slot); - ctx.send_viewport_cmd_to(viewport_id, egui::ViewportCommand::Screenshot(callback)); - r.issued = true; - } - for (i, message) in failures.into_iter().rev() { - reject_shot_at(app, i, message); - } - if app.shots.in_flight() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); - } -} - -/// Checks that the image has the pixel count its dimensions imply, is within one pixel of the expected size on each axis, and keeps the expected aspect ratio within that rounding. -fn validate_image(image: &egui::ColorImage, expected: [usize; 2]) -> Result<(), String> { - let [w, h] = image.size; - let [ew, eh] = expected; - let pixels = w.checked_mul(h).ok_or_else(|| "screenshot dimensions overflow".to_string())?; - if image.pixels.len() != pixels { - return Err(format!("screenshot has {} pixels for its {w}×{h} dimensions", image.pixels.len())); - } - // Allow one physical pixel of rounding per axis, but not independent distortion: an image - // that is one pixel wider and one pixel shorter has a different aspect and cannot be the - // recorded viewport. Rejecting it forces the retry/timeout path instead of saving skewed pixels. - if w.abs_diff(ew) > 1 || h.abs_diff(eh) > 1 { - return Err(format!("screenshot is {w}×{h} pixels, expected {ew}×{eh}: the window changed size while it was captured")); - } - // Keep the returned scale's aspect within the rounding error of the recorded logical viewport. - let cross = w.checked_mul(eh).and_then(|a| h.checked_mul(ew).map(|b| a.abs_diff(b))); - let Some(cross) = cross else { - return Err("screenshot dimensions overflow the aspect check".to_string()); - }; - if cross > ew.max(eh) { - return Err(format!("screenshot is {w}×{h} pixels, expected {ew}×{eh}: the aspect ratio changed while it was captured")); - } - Ok(()) -} - -/// What [`drain_screenshots`] must do with one request after inspecting it. -enum DrainAction { - /// Still waiting; leave the request at the same index. - Keep, - /// Answer the request with this reply and remove it. - Remove(Json), - /// Re-arm the request for a fresh capture within its retry budget. - Restart, -} - -/// Called from `logic`: takes each completed slot once, validates identity, size, geometry/epoch -/// and **deadline** and only then encodes, writes and replies outside the slot. A stale, oversized -/// or late image is dropped without writing a file. -pub fn drain_screenshots(app: &mut SheetApp, ctx: &egui::Context) { - let now = crate::monotonic_ms(); - let current_geometry = current_capture_geometry(app, ctx); - let epoch = note_capture_geometry(app, current_geometry); - let active_document = app.session.active().map(|d| d.uid); - let current_viewport = ctx.viewport_id(); - let mut i = 0; - while i < app.shots.requests.len() { - match drain_action(app, i, now, current_geometry, epoch, active_document, current_viewport) { - DrainAction::Keep => i += 1, - DrainAction::Remove(reply) => finish_shot(app, i, reply), - DrainAction::Restart => match retry_capture(app, i, current_geometry, epoch, now) { - Ok(()) => i += 1, - Err(message) => reject_shot_at(app, i, message), - }, - } - } - if app.shots.in_flight() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); - } -} - -/// Validates one request and decides whether it stays queued, is answered or is re-armed. -fn drain_action( - app: &mut SheetApp, - i: usize, - now: f64, - current_geometry: CaptureGeometry, - epoch: u64, - active_document: Option, - current_viewport: egui::ViewportId, -) -> DrainAction { - let r = &mut app.shots.requests[i]; - // A document or viewport change is a definitive mismatch: cancel once, never retry and - // never write the pixels against the new identity. - if r.viewport_id != current_viewport { - cancel_shot(r); - return DrainAction::Remove(json!({"ok": false, "error": SCREENSHOT_VIEWPORT_CHANGED})); - } - if r.document != active_document { - cancel_shot(r); - return DrainAction::Remove(json!({"ok": false, "error": SCREENSHOT_DOCUMENT_CHANGED})); - } - if !r.issued { - return drain_unissued(r, now); - } - drain_completion(r, now, current_geometry, epoch) -} - -/// A request that never issued a capture: keep waiting, or report the timeout that fits whether -/// the window ever had a capturable size. -fn drain_unissued(r: &mut Shot, now: f64) -> DrainAction { - if now < r.deadline { - return DrainAction::Keep; - } - cancel_shot(r); - let message = if r.expected.is_none() { - "screenshot timeout: the window never reported a capturable size (it may be minimized or not laid out)" - } else { - "no frame was presented before the screenshot timeout" - }; - DrainAction::Remove(timeout_reply(message, r.token)) -} - -/// A request that issued a capture: inspect its slot once, then apply the deadline, validation -/// and geometry checks in the same order as issuance. -fn drain_completion(r: &mut Shot, now: f64, current_geometry: CaptureGeometry, epoch: u64) -> DrainAction { - match r.slot.as_ref().and_then(|slot| slot.take()) { - // A completion that arrives past the deadline is late even if the pixels are valid: the - // client was promised one answer within the deadline, so nothing is written for it. - Some(result) if now >= r.deadline => { - drop(result); - DrainAction::Remove(timeout_reply("screenshot timeout: the image completed after its deadline; no file was written", r.token)) - } - Some(Ok(image)) => drain_image(r, image, now, current_geometry, epoch), - // The slot refused the image before `validate_image` saw it: an image larger than the - // reservation is as much evidence of a resize as a smaller one. Retry within the same - // bounded budget instead of failing only when the window grew. A non-resize fault still - // exhausts the retries and reports the slot's error, so this stays fail-closed. - Some(Err(_)) if retry_allowed(r, now) => DrainAction::Restart, - Some(Err(e)) => DrainAction::Remove(json!({"ok": false, "error": e.to_string()})), - None if now >= r.deadline => { - cancel_shot(r); - DrainAction::Remove(timeout_reply("no frame was presented before the screenshot timeout", r.token)) - } - None => DrainAction::Keep, - } -} - -/// Validates a completed image against the size recorded at issuance and the current -/// geometry/epoch; only a valid image is saved, and only a retry that is still inside the -/// request's deadline and retry budget gets a fresh attempt. -fn drain_image(r: &mut Shot, image: std::sync::Arc, now: f64, current_geometry: CaptureGeometry, epoch: u64) -> DrainAction { - let validated = match r.expected { - Some(expected) => validate_image(&image, expected), - None => Err("screenshot has no expected size".to_string()), - }; - match validated { - Err(_) if retry_allowed(r, now) => { - drop(image); - DrainAction::Restart - } - Err(message) => DrainAction::Remove(json!({"ok": false, "error": message})), - Ok(()) if epoch != r.epoch || current_geometry != r.geometry => { - if !retry_allowed(r, now) { - return DrainAction::Remove(json!({ - "ok": false, - "error": "screenshot geometry changed while it was captured; no image was written" - })); - } - drop(image); - DrainAction::Restart - } - Ok(()) => DrainAction::Remove(save_image(image, r.path.as_deref())), - } -} - -/// Whether this request may take another capture: both limits are fixed for the whole request. -fn retry_allowed(r: &Shot, now: f64) -> bool { - r.retries < SCREENSHOT_MAX_RETRIES && now < r.deadline -} - -/// Takes and cancels the request's capture slot, if it has one. -fn cancel_shot(r: &mut Shot) { - if let Some(slot) = r.slot.take() { - slot.cancel(); - } -} - -/// Remove request `i`, cancel its slot and answer it once with a token-carrying reply. -fn finish_shot(app: &mut SheetApp, i: usize, reply: Json) { - let request = app.shots.requests.remove(i); - if let Some(slot) = request.slot { - slot.cancel(); - } - let _ = request.reply.send(with_token(reply, request.token)); -} - -/// Remove request `i`, cancel its slot and reject it with `message`. -fn reject_shot_at(app: &mut SheetApp, i: usize, message: impl std::fmt::Display) { - let request = app.shots.requests.remove(i); - if let Some(slot) = request.slot { - slot.cancel(); - } - reject_shot(request.reply, request.token, message); -} - -/// Re-arm one request for a fresh issuance against `geometry`: a new reservation (the maximum -/// retained image, or [`UNKNOWN_CAPTURE_BYTES`] while there is no size), a new epoch and no slot. -/// A fresh slot and callback are created by [`issue_screenshots`]; the old image was dropped by -/// the caller. -fn retry_capture(app: &mut SheetApp, i: usize, geometry: CaptureGeometry, epoch: u64, now: f64) -> Result<(), String> { - let expected = geometry.expected_size(); - let base64 = app.shots.requests.get(i).is_some_and(|r| r.path.is_none()); - let bytes = capture_bytes(app, i, geometry, expected, base64)?; - let r = &mut app.shots.requests[i]; - r.retries = r.retries.saturating_add(1); - r.issued = false; - r.frames = 3; - r.settle_pass = None; - r.ready_at = now; - r.expected = expected; - r.bytes = bytes; - r.geometry = geometry; - r.epoch = epoch; - if let Some(slot) = r.slot.take() { - slot.cancel(); - } - Ok(()) -} - -/// Encodes a screenshot as PNG to `path`, or returns it as base64. -/// -/// The retained `ColorImage` is released before encoding, and a path capture streams the PNG -/// straight to the file. A base64 reply is length-checked against -/// [`SCREENSHOT_MAX_BASE64_BYTES`] *before* the string is allocated, so one admitted capture cannot -/// retain an unbounded reply alongside its image. -pub fn save_image(image: std::sync::Arc, path: Option<&str>) -> Json { - use image::ImageEncoder as _; - let [w, h] = image.size; - let Some(capacity) = w.checked_mul(h).and_then(|p| p.checked_mul(4)) else { return json!({"ok": false, "error": "bad image"}) }; - if image.pixels.len().checked_mul(4) != Some(capacity) { - return json!({"ok": false, "error": "bad image"}); - } - let (Ok(width), Ok(height)) = (u32::try_from(w), u32::try_from(h)) else { return json!({"ok": false, "error": "bad image"}) }; - // One RGBA copy is unavoidable with the `image` encoder API. Drop the retained image before - // encoding so the peak is the copy plus encoder buffers, not both images plus the PNG stream. - let mut rgba = Vec::with_capacity(capacity); - for p in &image.pixels { - rgba.extend_from_slice(&p.to_array()); - } - drop(image); - match path { - Some(p) => match std::fs::File::create(p) { - Ok(file) => { - let encoder = image::codecs::png::PngEncoder::new(file); - match encoder.write_image(&rgba, width, height, image::ExtendedColorType::Rgba8) { - Ok(()) => json!({"ok": true, "result": {"path": p, "width": w, "height": h}}), - Err(e) => { - // Do not leave a truncated PNG behind. - let _ = std::fs::remove_file(p); - json!({"ok": false, "error": e.to_string()}) - } - } - } - Err(e) => json!({"ok": false, "error": e.to_string()}), - }, - None => { - let mut png = Vec::new(); - let encoder = image::codecs::png::PngEncoder::new(&mut png); - if let Err(e) = encoder.write_image(&rgba, width, height, image::ExtendedColorType::Rgba8) { - return json!({"ok": false, "error": e.to_string()}); - } - let Some(encoded_len) = base64_len(png.len()) else { - return json!({"ok": false, "error": "screenshot base64 length overflow"}); - }; - if encoded_len > SCREENSHOT_MAX_BASE64_BYTES { - return json!({ - "ok": false, - "error": format!( - "screenshot base64 budget exceeded: the {w}×{h} reply would be {encoded_len} bytes, over the {SCREENSHOT_MAX_BASE64_BYTES}-byte base64 limit; request a path instead" - ) - }); - } - json!({"ok": true, "result": {"base64": gridcraft_engine::io::base64_encode(&png), "width": w, "height": h}}) - } - } -} - #[cfg(test)] -mod tests { - use super::*; - - fn sample() -> Vec { - vec![ - json!({"id": "home.borders", "label": "Borders", "menu": ["Home", "Font"]}), - json!({"id": "home.bold", "label": "Bold", "menu": ["Home", "Font"]}), - json!({"id": "ui.ribbonTab", "label": "Ribbon Tab", "enabled": true, "ui": true}), - ] - } - - fn ids(v: &[Json]) -> Vec<&str> { - v.iter().filter_map(|c| c.get("id").and_then(Json::as_str)).collect() - } - - #[test] - fn search_filters_case_insensitively() { - assert_eq!(ids(&filter_commands(sample(), Some("border"))), ["home.borders"]); - assert_eq!(filter_commands(sample(), Some("BORDER")), filter_commands(sample(), Some("border"))); - assert_eq!(ids(&filter_commands(sample(), Some("ribbon"))), ["ui.ribbonTab"]); - assert_eq!(ids(&filter_commands(sample(), Some("font"))), ["home.borders", "home.bold"]); - assert!(filter_commands(sample(), Some("catalog_no_match_7391")).is_empty()); - assert_eq!(filter_commands(sample(), None).len(), 3); - } - - fn test_app() -> SheetApp { - let mut session = gridcraft_engine::Session::new(); - session.new_workbook(); - SheetApp::new(session, crate::Services::default()) - } - - #[test] - /// Checks that oversized click/drag counts are refused without changing already queued batches, that queued input counts toward the budget, and that normal gestures still queue fully. - fn oversized_pointer_requests_are_refused_and_leave_the_queue_alone() { - let mut app = test_app(); - let p = egui::pos2(20.0, 20.0); - let m = egui::Modifiers::NONE; - let btn = egui::PointerButton::Primary; - assert!(push_click(&mut app, p, btn, 2, m).is_ok()); - assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), 5); - assert!(push_click(&mut app, p, btn, u32::MAX, m).is_err()); - assert!(push_drag(&mut app, p, p, usize::MAX, m).is_err()); - assert!(push_drag(&mut app, p, p, MAX_QUEUED_SYNTHETIC_EVENTS, m).is_err()); - assert_eq!(app.synthetic.len(), 2); - assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), 5); - // Already queued input counts toward the budget. - assert!(push_click(&mut app, p, btn, (MAX_QUEUED_SYNTHETIC_EVENTS / 2) as u32, m).is_err()); - assert_eq!(app.synthetic.len(), 2); - // Normal requests still queue in full. - assert!(push_drag(&mut app, p, p + egui::vec2(40.0, 40.0), 3, m).is_ok()); - assert_eq!(app.synthetic.len(), 8); - } - - #[test] - fn print_command_discovery_documents_settings_and_submission() { - let mut session = gridcraft_engine::Session::new(); - session.new_workbook(); - let mut app = SheetApp::new(session, crate::Services::default()); - let Outcome::Done(reply) = handle(&mut app, &egui::Context::default(), "engine.commands", &json!({"search":"print"})) else { - panic!("command discovery must return immediately"); - }; - assert_eq!(reply["ok"], true); - let commands = reply["result"].as_array().unwrap(); - for (id, expected_docs) in [ - ("ui.printSettings", &["copies", "duplex", "refresh", "discovering", "status", "detail"][..]), - ("ui.printSubmit", &["file.print", "pagination", "pending", "without opening a dialog"][..]), - ] { - let command = commands.iter().find(|command| command["id"] == id).unwrap(); - assert_eq!(command["ui"], true); - let docs = command["params"].as_str().unwrap(); - for expected in expected_docs { - assert!(docs.contains(expected), "{id}: missing {expected}"); - } - } - assert!(!app.printing.busy(), "discovery must not submit a job"); - assert!(app.backstage.is_none(), "discovery must not open settings"); - } - - #[test] - fn language_command_docs_list_every_language() { - let mut session = gridcraft_engine::Session::new(); - session.new_workbook(); - let mut app = SheetApp::new(session, crate::Services::default()); - let Outcome::Done(reply) = handle(&mut app, &egui::Context::default(), "engine.commands", &json!({"search":"language"})) else { - panic!("command discovery must return immediately"); - }; - let commands = reply["result"].as_array().unwrap(); - let docs = commands.iter().find(|c| c["id"] == "app.language.set").unwrap()["params"].as_str().unwrap(); - for l in crate::i18n::Language::ALL { - assert!(docs.contains(&format!("\"{}\"", l.code())), "app.language.set docs miss {:?}: {docs}", l.code()); - } - } - /// One headless UI pass. A test has no renderer to apply the font-atlas delta, so dropping - /// it is intentional (0.37 panics when a `TexturesDelta` is dropped unapplied). - fn ui_pass(ctx: &egui::Context, raw: egui::RawInput, f: impl FnMut(&mut egui::Ui)) -> egui::FullOutput { - let mut out = ctx.run_ui(raw, f); - out.textures_delta.clear(); - out - } - - /// Returns an egui context initialized by one blank pass at `size` with an 8192-pixel texture limit. - fn sized_context(size: egui::Vec2) -> egui::Context { - let ctx = egui::Context::default(); - let raw = egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), - max_texture_side: Some(8192), - ..Default::default() - }; - let _ = ui_pass(&ctx, raw, |_| {}); - ctx - } - - /// Returns blank raw input with a screen rect at `size` and an 8192-pixel texture limit. - fn raw_of(size: egui::Vec2) -> egui::RawInput { - egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), max_texture_side: Some(8192), ..Default::default() } - } - - /// Extracts cloned `ViewportCommand::Screenshot` callbacks from a `FullOutput` for the context's viewport. - fn screenshot_callbacks(out: &egui::FullOutput, ctx: &egui::Context) -> Vec { - out.viewport_output - .get(&ctx.viewport_id()) - .map(|output| { - output - .commands - .iter() - .filter_map(|command| match command { - egui::ViewportCommand::Screenshot(callback) => Some(callback.clone()), - _ => None, - }) - .collect::>() - }) - .unwrap_or_default() - } - - /// Run three settled passes with `raw` and return the callbacks issued in them. - fn issue_with(ctx: &egui::Context, app: &mut SheetApp, raw: egui::RawInput) -> Vec { - let mut callbacks = Vec::new(); - for _ in 0..3 { - let out = ui_pass(ctx, raw.clone(), |ui| issue_screenshots(app, ui.ctx())); - callbacks.extend(screenshot_callbacks(&out, ctx)); - } - callbacks - } - - /// Returns a per-process temporary PNG path after removing any stale file at that path. - fn temp_png(name: &str) -> std::path::PathBuf { - let path = std::env::temp_dir().join(format!("gridcraft-shot-{name}-{}.png", std::process::id())); - let _ = std::fs::remove_file(&path); - path - } - - #[test] - /// Checks rounded physical pixels, expected/retained byte counts, the texture-axis cap, the no-size case and overflow refusal. - fn capture_geometry_reports_checked_capped_pixels() { - let geometry = CaptureGeometry { - viewport_id: egui::ViewportId::ROOT, - viewport: Some(egui::vec2(800.0, 600.0)), - pixels_per_point: 2.0, - zoom: 1.0, - max_texture_side: 4096, - minimized: None, - occluded: None, - visible: None, - }; - assert_eq!(geometry.expected_size(), Some([1600, 1200])); - assert_eq!(geometry.expected_bytes(), Some(1600 * 1200 * 4)); - assert_eq!(geometry.retained_bytes(), Some(1601 * 1201 * 4), "one pixel of rounding per axis is reserved too"); - let capped = CaptureGeometry { max_texture_side: 1024, ..geometry }; - assert_eq!(capped.expected_size(), Some([1024, 1024]), "the surface limit caps both axes"); - let empty = CaptureGeometry { viewport: Some(egui::Vec2::ZERO), ..geometry }; - assert_eq!(empty.expected_size(), None, "a minimized window has no capturable size"); - assert_eq!(empty.retained_bytes(), None); - let huge = CaptureGeometry { viewport: Some(egui::vec2(f32::MAX, f32::MAX)), ..geometry }; - assert_eq!(huge.expected_size(), None, "overflow is refused, not truncated"); - } - - #[test] - /// Checks that one request beyond `SCREENSHOT_MAX_REQUESTS` is rejected with its token while earlier requests stay queued and do not reply early. - fn screenshot_admission_stops_on_the_count_budget_without_evicting() { - let ctx = sized_context(egui::vec2(800.0, 600.0)); - let mut app = test_app(); - let mut replies = Vec::new(); - for _ in 0..SCREENSHOT_MAX_REQUESTS { - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - replies.push(rx); - } - assert_eq!(app.shots.requests.len(), SCREENSHOT_MAX_REQUESTS); - assert!(replies.iter_mut().all(|r| r.try_recv().is_err()), "admitted requests do not reply early"); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - let reply = rx.try_recv().expect("the over-count request is rejected"); - assert_eq!(reply["ok"], false); - assert!(reply["error"].as_str().is_some_and(|e| e.contains("limit 4")), "{reply}"); - assert!(reply["request"].as_u64().is_some(), "a rejection carries its request token: {reply}"); - assert_eq!(app.shots.requests.len(), SCREENSHOT_MAX_REQUESTS, "an earlier request is never evicted"); - } - - #[test] - /// Three roughly 100 MB path captures hit the aggregate byte limit; the third is rejected and only two requests remain. - fn screenshot_admission_stops_on_aggregate_bytes_before_the_count() { - let ctx = sized_context(egui::vec2(5000.0, 5000.0)); - let mut app = test_app(); - let path = temp_png("aggregate").to_string_lossy().into_owned(); - let mut rejected = None; - for _ in 0..3 { - let (tx, rx) = std::sync::mpsc::channel(); - // A path capture (not base64) isolates the retained-image aggregate budget. - admit(&mut app, &ctx, Some(path.clone()), tx); - if let Ok(reply) = rx.try_recv() { - rejected = Some(reply); - } - } - let reply = rejected.expect("three 100 MB captures exceed the 256 MiB aggregate budget"); - assert!(reply["error"].as_str().is_some_and(|e| e.contains("aggregate limit")), "{reply}"); - assert_eq!(app.shots.requests.len(), 2); - } - - #[test] - /// An unknown-size request reserves the placeholder, issues nothing while settling, then replies once on timeout and releases the reservation. - fn requests_without_a_size_stay_queued_and_time_out() { - let ctx = sized_context(egui::Vec2::ZERO); - let mut app = test_app(); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - assert!(rx.try_recv().is_err(), "an unknown size is queued, not refused with an overflow error"); - assert_eq!(app.shots.requests[0].bytes, UNKNOWN_CAPTURE_BYTES, "the largest possible image is reserved meanwhile"); - // Settling passes without a size neither issue nor fail the request. - let callbacks = issue_with(&ctx, &mut app, raw_of(egui::Vec2::ZERO)); - assert!(callbacks.is_empty()); - assert!(!app.shots.requests[0].issued); - assert!(rx.try_recv().is_err()); - app.shots.requests[0].deadline = 0.0; - let _ = ui_pass(&ctx, raw_of(egui::Vec2::ZERO), |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the size-less request times out once"); - assert!(reply["error"].as_str().is_some_and(|e| e.contains("capturable size")), "{reply}"); - assert_eq!(app.shots.reserved(), 0, "the placeholder reservation is released"); - } - - /// Counts `RequestPaintWhileHidden` commands in a logic output. - fn hidden_paints(out: &egui::LogicOutput) -> usize { - out.viewport_commands.values().flatten().filter(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden)).count() - } - - #[test] - /// A queued capture requests a hidden paint; once issued, the screenshot command supplies the paint itself. - fn a_queued_capture_requests_hidden_paint_until_it_is_issued() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let (tx, _rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - let queued = ctx.run_logic(&raw_of(egui::vec2(64.0, 48.0)), |ctx| app.logic(ctx)); - assert_eq!(hidden_paints(&queued), 1, "a queued capture needs a real `ui` pass"); - - app.shots.requests[0].ready_at = 0.0; - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 1); - let issued = ctx.run_logic(&raw_of(egui::vec2(64.0, 48.0)), |ctx| app.logic(ctx)); - assert_eq!(hidden_paints(&issued), 0, "the issued Screenshot command paints the hidden window by itself"); - } - - #[test] - /// A minimized request stays queued without requesting hidden paints, because it cannot issue until restored. - fn a_minimized_capture_does_not_force_hidden_frames_while_it_waits() { - let ctx = egui::Context::default(); - let mut app = test_app(); - let mut raw = raw_of(egui::vec2(800.0, 600.0)); - if let Some(v) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { - v.inner_rect = None; - v.minimized = Some(true); - } - app.raw_input_hook(&ctx, &mut raw); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - assert!(rx.try_recv().is_err()); - let waited = ctx.run_logic(&raw, |ctx| app.logic(ctx)); - assert_eq!(hidden_paints(&waited), 0, "a minimized request cannot issue until it is restored"); - app.shots.cancel_all(); - } - - #[test] - /// The real app harness issues exactly one callback, replies once with the token and dimensions, and releases the reservation. - fn one_admitted_capture_issues_one_callback_and_replies_once() { - use egui_kittest::kittest::Queryable as _; - - let mut app = test_app(); - app.use_prepared_fonts(); - let mut h = egui_kittest::Harness::builder() - .with_size(egui::vec2(900.0, 700.0)) - .with_context_setup(|ctx| { - ctx.set_fonts(crate::theme::pinned_font_definitions()); - crate::theme::apply(ctx, false); - }) - // This test completes the callback itself. Keep the real app/AccessKit tree, but - // do not let kittest's automatic screenshot handling initialize a GPU renderer. - .renderer(egui_kittest::LazyRenderer::Uninitialized { textures_delta: Default::default(), builder: None }) - .build_ui_state( - |ui, app: &mut SheetApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - app, - ); - h.run_steps(4); - assert!(h.query_by_label("Name Box").is_some(), "the capture fixture draws the real app, not an empty tree"); - h.check_accessibility(); - - let ctx = h.ctx.clone(); - let (tx, rx) = std::sync::mpsc::channel(); - admit(h.state_mut(), &ctx, None, tx); - let token = h.state().shots.requests[0].token; - h.state_mut().shots.requests[0].ready_at = 0.0; - let mut callbacks = Vec::new(); - for _ in 0..3 { - h.step(); - callbacks.extend(screenshot_callbacks(h.output(), &ctx)); - } - assert_eq!(callbacks.len(), 1, "the settled request is issued once"); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([900, 700], egui::Color32::RED))); - h.step(); // the real logic drains the callback and the UI repaints - h.check_accessibility(); - assert!(h.query_by_label("Name Box").is_some(), "the app tree survives callback drain and repaint"); - let reply = rx.try_recv().expect("the drain replies once"); - assert_eq!(reply["ok"], true, "{reply}"); - assert_eq!(reply["result"]["width"], 900); - assert_eq!(reply["result"]["height"], 700); - assert_eq!(reply["request"].as_u64(), Some(token), "the reply names its request"); - assert!(rx.try_recv().is_err(), "a completed slot is taken exactly once"); - assert_eq!(h.state().shots.reserved(), 0, "the reservation is released after the reply"); - } - - #[test] - /// Completing callbacks in reverse order replies on each matching token/path and writes each request's own pixels. - fn out_of_order_completions_reply_on_their_own_request() { - let ctx = sized_context(egui::vec2(8.0, 8.0)); - let mut app = test_app(); - let p1 = temp_png("order-1"); - let p2 = temp_png("order-2"); - let (tx1, rx1) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(p1.to_string_lossy().into_owned()), tx1); - let token1 = app.shots.requests[0].token; - let (tx2, rx2) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(p2.to_string_lossy().into_owned()), tx2); - let token2 = app.shots.requests[1].token; - for request in &mut app.shots.requests { - request.ready_at = 0.0; - } - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 2, "one callback per admitted request"); - // Complete the second request first: slots, not callback order, carry the result. - callbacks[1].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([8, 8], egui::Color32::BLUE))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - let second = rx2.try_recv().expect("the second request gets its own reply"); - assert_eq!(second["ok"], true, "{second}"); - assert_eq!(second["request"].as_u64(), Some(token2)); - assert_eq!(second["result"]["path"].as_str(), Some(p2.to_string_lossy().as_ref())); - assert!(!p1.exists(), "the unfinished request has not written anything"); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([8, 8], egui::Color32::RED))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - let first = rx1.try_recv().expect("the first request gets its own reply"); - assert_eq!(first["request"].as_u64(), Some(token1)); - assert_eq!(first["result"]["path"].as_str(), Some(p1.to_string_lossy().as_ref())); - assert_ne!(std::fs::read(&p1).unwrap(), std::fs::read(&p2).unwrap(), "each reply carries its own pixels"); - let _ = std::fs::remove_file(&p1); - let _ = std::fs::remove_file(&p2); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// An expired request replies once with a timeout, writes no file and releases its reservation. - fn a_timed_out_capture_replies_once_and_never_writes() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let path = temp_png("timeout"); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); - app.shots.requests[0].deadline = 0.0; - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the timeout replies"); - assert_eq!(reply["ok"], false); - assert!(reply["error"].as_str().is_some_and(|e| e.contains("timeout")), "{reply}"); - assert!(!path.exists(), "a timed-out capture never writes a file"); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - assert!(rx.try_recv().is_err(), "a timeout is reported once"); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// A valid image that completes after its deadline gets one timeout reply and is not written. - fn a_capture_completed_after_its_deadline_writes_nothing() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let path = temp_png("late"); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); - let token = app.shots.requests[0].token; - app.shots.requests[0].ready_at = 0.0; - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 1); - // The deadline passes while the callback is outstanding; the image arrives afterwards. - app.shots.requests[0].deadline = 0.0; - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the late completion gets one timeout reply"); - assert_eq!(reply["ok"], false, "{reply}"); - assert!(reply["error"].as_str().is_some_and(|e| e.contains("timeout")), "{reply}"); - assert_eq!(reply["request"].as_u64(), Some(token)); - assert!(!path.exists(), "a capture that completed after its deadline never writes a file"); - assert!(rx.try_recv().is_err(), "the late image is reported once"); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// Cancelling a request does not reply; a later callback for it is ignored and writes nothing. - fn a_callback_after_the_request_is_removed_is_inert() { - let ctx = sized_context(egui::vec2(32.0, 24.0)); - let mut app = test_app(); - let path = temp_png("removed"); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); - app.shots.requests[0].ready_at = 0.0; - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 1); - app.shots.cancel_all(); - assert!(rx.try_recv().is_err(), "cancelling a request does not reply"); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::RED))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - assert!(rx.try_recv().is_err(), "a callback for a removed request is inert"); - assert!(!path.exists()); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// The first issuance adopts a new geometry, updates the epoch and reservation, spends no retry, and the capture then succeeds. - fn a_resize_between_admission_and_issuance_does_not_cost_a_retry() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - app.shots.requests[0].ready_at = 0.0; - let admitted_epoch = app.shots.requests[0].epoch; - let resized = raw_of(egui::vec2(32.0, 24.0)); - let callbacks = issue_with(&ctx, &mut app, resized.clone()); - assert_eq!(callbacks.len(), 1); - let request = &app.shots.requests[0]; - assert_eq!(request.retries, 0, "the first issuance adopts the current geometry instead of spending a retry"); - assert_eq!(request.geometry.viewport, Some(egui::vec2(32.0, 24.0))); - assert_eq!(request.expected, Some([32, 24])); - assert_eq!(request.bytes, 33 * 25 * 4); - assert!(request.epoch > admitted_epoch, "issuance records the new epoch together with the geometry"); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::RED))); - let _ = ui_pass(&ctx, resized, |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the once-issued capture replies"); - assert_eq!(reply["ok"], true, "{reply}"); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// The reservation is the largest accepted rounded image (11×9×4 bytes for a 10×8 viewport), and that image is accepted. - fn the_retained_reservation_is_the_slot_bound() { - let ctx = sized_context(egui::vec2(10.0, 8.0)); - let mut app = test_app(); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - // validate_image accepts one pixel of rounding per axis: an 11x9 image is the largest. - assert_eq!(app.shots.requests[0].bytes, 11 * 9 * 4); - app.shots.requests[0].ready_at = 0.0; - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 1); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([11, 9], egui::Color32::RED))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the rounded image is accepted"); - assert_eq!(reply["ok"], true, "{reply}"); - assert_eq!(reply["result"]["width"], 11); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// Oversized images retry within the bounded budget, never write, then report the per-image limit, while the valid peer is written. - fn an_image_beyond_the_reservation_is_released_without_writing() { - let ctx = sized_context(egui::vec2(10.0, 8.0)); - let mut app = test_app(); - let p1 = temp_png("over-1"); - let p2 = temp_png("over-2"); - let (tx1, rx1) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(p1.to_string_lossy().into_owned()), tx1); - let (tx2, rx2) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(p2.to_string_lossy().into_owned()), tx2); - for request in &mut app.shots.requests { - request.ready_at = 0.0; - } - assert_eq!(app.shots.reserved(), 2 * 11 * 9 * 4); - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 2); - // The slot refuses an image over the reservation instead of retaining bytes beyond it. - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([12, 9], egui::Color32::RED))); - // The other request completes with valid pixels. - callbacks[1].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([10, 8], egui::Color32::BLUE))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - // A larger image can be a genuine resize, so it receives the same bounded retries as - // a smaller one. Each oversized callback is released and never written while retrying. - for attempt in 1..=SCREENSHOT_MAX_RETRIES { - assert_eq!(app.shots.requests[0].retries, attempt); - assert!(rx1.try_recv().is_err(), "retry {attempt} has not replied early"); - assert!(!p1.exists(), "oversized pixels are never written on retry {attempt}"); - let callbacks = issue_with(&ctx, &mut app, raw_of(egui::vec2(10.0, 8.0))); - assert_eq!(callbacks.len(), 1, "the other valid request is already complete"); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([12, 9], egui::Color32::RED))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - } - let refused = rx1.try_recv().expect("the oversized image is reported after bounded retries"); - assert_eq!(refused["ok"], false, "{refused}"); - assert!(refused["error"].as_str().is_some_and(|e| e.contains("per-image limit")), "{refused}"); - assert!(!p1.exists()); - let accepted = rx2.try_recv().expect("the valid image is written"); - assert_eq!(accepted["ok"], true, "{accepted}"); - let _ = std::fs::remove_file(&p2); - assert_eq!(app.shots.reserved(), 0, "both reservations are released"); - } - - #[test] - /// Two `ui` calls in one pass count as one settling frame, and a pass that requests a discard is not counted; only its rerun counts. - fn settling_counts_one_real_pass_and_skips_a_discarded_one() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let (tx, _rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - // Two `ui` calls in one pass are one settling frame. - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| { - issue_screenshots(&mut app, ui.ctx()); - issue_screenshots(&mut app, ui.ctx()); - }); - assert_eq!(app.shots.requests[0].frames, 1); - // A pass that already asked to be discarded does not count, even though `run_ui` repeats it. - let mut asked = false; - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| { - if !asked { - asked = true; - ui.ctx().request_discard("settling test"); - } - issue_screenshots(&mut app, ui.ctx()); - }); - assert_eq!(app.shots.requests[0].frames, 2, "only the rerun (real) pass counted"); - } - - #[test] - /// Issuance survives a discard requested after it, the image is written once, and a duplicate callback completion is inert. - fn a_discard_requested_after_issuance_still_issues_and_writes_once() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let path = temp_png("discard-after-issue"); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); - app.shots.requests[0].ready_at = 0.0; - let mut asked = false; - let mut callbacks = Vec::new(); - for _ in 0..3 { - let out = ui_pass(&ctx, egui::RawInput::default(), |ui| { - issue_screenshots(&mut app, ui.ctx()); - if !asked && app.shots.requests.first().is_some_and(|r| r.issued) { - asked = true; - ui.ctx().request_discard("discard after screenshot issuance"); - } - }); - callbacks.extend(screenshot_callbacks(&out, &ctx)); - } - assert!(asked, "the test requested a discard after issuance"); - assert_eq!(callbacks.len(), 1, "issuance happens exactly once across the discard repeat"); - let image = std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED)); - callbacks[0].clone().complete(image.clone()); - callbacks[0].clone().complete(image); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the discarded pass still delivers one reply"); - assert_eq!(reply["ok"], true, "{reply}"); - assert!(path.exists(), "the image is written exactly once"); - assert!(rx.try_recv().is_err(), "a second completion of the same callback is inert"); - let _ = std::fs::remove_file(&path); - } - - #[test] - /// A grow-resize spends one retry, reissues at the new size and then accepts the new image. - fn an_image_larger_than_the_reservation_retries_like_a_smaller_one() { - let ctx = sized_context(egui::vec2(10.0, 8.0)); - let mut app = test_app(); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - app.shots.requests[0].ready_at = 0.0; - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 1); - - // The surface grew by two pixels while the image was outstanding. The slot's reservation - // refuses the larger image before `validate_image`; drain must retry at the new geometry - // instead of failing because the resize went in this direction. - let mut grown = raw_of(egui::vec2(20.0, 16.0)); - app.raw_input_hook(&ctx, &mut grown); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([12, 9], egui::Color32::RED))); - let _ = ctx.run_logic(&grown, |ctx| app.logic(ctx)); - let request = &app.shots.requests[0]; - assert_eq!(request.retries, 1, "the slot's per-image refusal spends one retry, not a failure"); - assert!(!request.issued); - assert_eq!(request.geometry.viewport, Some(egui::vec2(20.0, 16.0))); - assert!(rx.try_recv().is_err(), "a retried capture does not reply yet"); - - let callbacks = issue_with(&ctx, &mut app, raw_of(egui::vec2(20.0, 16.0))); - assert_eq!(callbacks.len(), 1); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([20, 16], egui::Color32::BLUE))); - let _ = ui_pass(&ctx, raw_of(egui::vec2(20.0, 16.0)), |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the retried capture replies"); - assert_eq!(reply["ok"], true, "{reply}"); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// Minimize retries reserve unknown-size bytes; only two fit the aggregate budget and stay queued (the rest are reported), and the survivors write after restore. - fn a_minimize_retry_without_a_size_still_checks_the_aggregate_budget() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let mut paths = Vec::new(); - let mut receivers = Vec::new(); - for n in 0..SCREENSHOT_MAX_REQUESTS { - let path = temp_png(&format!("minimize-retry-{n}")); - let (tx, rx) = std::sync::mpsc::channel(); - // A path capture isolates the retained-image aggregate budget. - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); - paths.push(path); - receivers.push(rx); - } - for request in &mut app.shots.requests { - request.ready_at = 0.0; - } - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), SCREENSHOT_MAX_REQUESTS, "four path captures fit every budget"); - assert_eq!(app.shots.reserved(), SCREENSHOT_MAX_REQUESTS * 65 * 49 * 4); - - // The window is minimized with every capture outstanding. The drain retries each one - // against the new surface generation, which has no capturable size, so every retry - // reserves UNKNOWN_CAPTURE_BYTES. Only two unknown-size reservations fit the aggregate - // limit; the rest must be reported instead of inflating `reserved()` over the budget. - let mut minimized = raw_of(egui::vec2(64.0, 48.0)); - if let Some(viewport) = minimized.viewports.get_mut(&egui::ViewportId::ROOT) { - viewport.inner_rect = None; - viewport.minimized = Some(true); - } - app.raw_input_hook(&ctx, &mut minimized); - let image = std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED)); - for callback in &callbacks { - callback.clone().complete(image.clone()); - } - let _ = ctx.run_logic(&minimized, |ctx| app.logic(ctx)); - - assert!( - app.shots.reserved() <= SCREENSHOT_MAX_AGGREGATE_BYTES, - "size-less retries reserved {} bytes, over the {SCREENSHOT_MAX_AGGREGATE_BYTES}-byte aggregate limit", - app.shots.reserved() - ); - let rejected: Vec = receivers.iter_mut().filter_map(|rx| rx.try_recv().ok()).collect(); - assert_eq!(rejected.len(), 2, "two unknown-size retries no longer fit the aggregate budget"); - assert!(rejected.iter().all(|reply| reply["error"].as_str().is_some_and(|e| e.contains("aggregate limit"))), "{rejected:?}"); - assert_eq!(app.shots.requests.len(), 2, "the two fitting retries stay queued"); - assert!(app.shots.requests.iter().all(|r| !r.issued && r.expected.is_none() && r.bytes == UNKNOWN_CAPTURE_BYTES)); - assert!(receivers.iter_mut().all(|rx| rx.try_recv().is_err()), "the surviving retries wait for the restore"); - - // Restoring re-issues only the surviving requests at the real size; both fit because the - // over-reserved retries were rejected rather than kept. - let mut restored = raw_of(egui::vec2(64.0, 48.0)); - if let Some(viewport) = restored.viewports.get_mut(&egui::ViewportId::ROOT) { - viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(64.0, 48.0))); - viewport.minimized = Some(false); - viewport.occluded = Some(false); - } - app.raw_input_hook(&ctx, &mut restored); - let callbacks = issue_with(&ctx, &mut app, restored.clone()); - assert_eq!(callbacks.len(), 2, "the surviving requests issue on restore"); - for callback in &callbacks { - callback.clone().complete(image.clone()); - } - let _ = ctx.run_logic(&restored, |ctx| app.logic(ctx)); - - let mut delivered = 0; - for rx in &mut receivers { - if let Ok(reply) = rx.try_recv() { - assert_eq!(reply["ok"], true, "{reply}"); - delivered += 1; - } - } - assert_eq!(delivered, 2, "the two surviving captures reply once each"); - let written = paths.iter().filter(|path| path.exists()).count(); - assert_eq!(written, 2, "only the surviving captures write their image"); - for path in &paths { - let _ = std::fs::remove_file(path); - } - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// Fresh hidden viewport geometry invalidates the old image, spends one retry, and the reissued image succeeds. - fn a_hidden_resize_rejects_an_image_issued_before_it_and_reissues() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - app.shots.requests[0].ready_at = 0.0; - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 1); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED))); - - // A hidden tick carries the resize in `RawInput::viewports`; the last UI pass still says - // 64x48, so only the fresh viewport info lets the drain see the change. - let hidden = || { - let mut raw = raw_of(egui::vec2(64.0, 48.0)); - if let Some(viewport) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { - viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(32.0, 400.0))); - viewport.native_pixels_per_point = Some(1.0); - } - raw - }; - let _ = ctx.run_logic(&hidden(), |ctx| app.logic(ctx)); - let request = &app.shots.requests[0]; - assert!(!request.issued, "the image captured before the hidden resize is not accepted"); - assert_eq!(request.retries, 1); - assert_eq!(request.geometry.viewport, Some(egui::vec2(32.0, 400.0))); - assert!(rx.try_recv().is_err(), "a retried capture does not reply yet"); - - // The next real pass issues against the new size and its image is accepted. - let resized = raw_of(egui::vec2(32.0, 24.0)); - let callbacks = issue_with(&ctx, &mut app, resized.clone()); - assert_eq!(callbacks.len(), 1, "the retry is issued once against the current geometry"); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::BLUE))); - let _ = ui_pass(&ctx, resized, |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the retried capture replies"); - assert_eq!(reply["ok"], true, "{reply}"); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// Draining four completed captures replies to all of them and permits a new admission. - fn a_drained_capture_frees_its_slot_for_the_next_admission() { - let ctx = sized_context(egui::vec2(8.0, 8.0)); - let mut app = test_app(); - let mut receivers = Vec::new(); - for _ in 0..SCREENSHOT_MAX_REQUESTS { - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - receivers.push(rx); - } - for request in &mut app.shots.requests { - request.ready_at = 0.0; - } - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - for callback in &callbacks { - callback.clone().complete(std::sync::Arc::new(egui::ColorImage::filled([8, 8], egui::Color32::GREEN))); - } - assert_eq!(app.shots.requests.len(), SCREENSHOT_MAX_REQUESTS); - // Draining before admitting is what `logic` does: completed requests release first. - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - assert_eq!(app.shots.requests.len(), 0); - assert!(receivers.iter_mut().all(|rx| rx.try_recv().is_ok())); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - assert!(rx.try_recv().is_err(), "a new request is admitted once the drained slots are free"); - assert_eq!(app.shots.requests.len(), 1); - } - - #[test] - /// Uses `screen_rect` when `inner_rect` is absent, applies native scale times zoom, and advances the epoch on a hidden resize. - fn raw_input_geometry_uses_screen_rect_when_inner_rect_is_absent() { - // Wayland always omits `inner_rect`; the fresh `RawInput::screen_rect` is the only size. - let ctx = sized_context(egui::vec2(800.0, 600.0)); - let mut app = test_app(); - let mut raw = raw_of(egui::vec2(1280.0, 720.0)); - if let Some(v) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { - v.inner_rect = None; - v.native_pixels_per_point = Some(2.0); - v.minimized = Some(false); - v.occluded = Some(false); - } - app.raw_input_hook(&ctx, &mut raw); - let geo = app.capture_geometry.expect("the raw frame records its geometry"); - assert_eq!(geo.viewport, Some(egui::vec2(1280.0, 720.0))); - assert_eq!(geo.pixels_per_point, 2.0, "native scale times zoom"); - assert_eq!(geo.expected_size(), Some([2560, 1440])); - - let epoch = app.capture_epoch; - // A hidden resize lands on a logic-only tick: no UI pass ran, but the raw frame is fresh. - let mut hidden = raw_of(egui::vec2(640.0, 480.0)); - if let Some(v) = hidden.viewports.get_mut(&egui::ViewportId::ROOT) { - v.inner_rect = None; - v.native_pixels_per_point = Some(2.0); - v.minimized = Some(false); - v.occluded = Some(true); - } - app.raw_input_hook(&ctx, &mut hidden); - let _ = ctx.run_logic(&hidden, |ctx| app.logic(ctx)); - assert!(app.capture_epoch > epoch, "a hidden resize is a new epoch"); - assert_eq!(app.capture_geometry.expect("geometry").viewport, Some(egui::vec2(640.0, 480.0))); - } - - #[test] - /// Minimized geometry has no size and reserves unknown bytes; restoring at the same size still changes the epoch. - fn minimized_geometry_has_no_size_and_restore_bumps_the_epoch() { - let ctx = egui::Context::default(); - let mut app = test_app(); - let mut minimized = raw_of(egui::vec2(800.0, 600.0)); - if let Some(v) = minimized.viewports.get_mut(&egui::ViewportId::ROOT) { - v.inner_rect = None; - v.minimized = Some(true); - } - app.raw_input_hook(&ctx, &mut minimized); - let geo = app.capture_geometry.expect("recorded"); - assert_eq!(geo.viewport, None, "a minimized window has no capturable size"); - assert_eq!(geo.visible, Some(false)); - - // Admission while minimized reserves the unknown-size maximum and waits. - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - assert_eq!(app.shots.requests[0].bytes, UNKNOWN_CAPTURE_BYTES); - assert!(rx.try_recv().is_err()); - let epoch = app.capture_epoch; - - // Restoring at the same logical size is still a new surface generation. - let mut restored = raw_of(egui::vec2(800.0, 600.0)); - if let Some(v) = restored.viewports.get_mut(&egui::ViewportId::ROOT) { - v.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); - v.minimized = Some(false); - v.occluded = Some(false); - } - app.raw_input_hook(&ctx, &mut restored); - assert!(app.capture_epoch > epoch, "minimize/restore at the same size changes the epoch"); - assert_eq!(app.capture_geometry.expect("geometry").viewport, Some(egui::vec2(800.0, 600.0))); - app.shots.cancel_all(); - } - - #[test] - /// Switching documents before issuance cancels the request once with a document-changed error and writes nothing. - fn a_document_switch_before_issuance_cancels_without_writing() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let path = temp_png("doc-queued"); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); - app.shots.requests[0].ready_at = 0.0; - let admitted = app.shots.requests[0].document; - assert!(app.run("file.new", json!({})).is_ok()); - assert_ne!(app.session.active().map(|d| d.uid), admitted, "the test switched documents"); - - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert!(callbacks.is_empty(), "a capture for another document is never issued"); - let reply = rx.try_recv().expect("the queued request is cancelled once"); - assert!(reply["error"].as_str().is_some_and(|e| e.contains("document changed")), "{reply}"); - assert!(!path.exists(), "no pixels for the old document are written to its path"); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// Switching documents after issuance rejects the completed image once and writes nothing. - fn a_document_switch_after_issuance_rejects_the_completed_image() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let path = temp_png("doc-issued"); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); - app.shots.requests[0].ready_at = 0.0; - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 1); - assert!(app.run("file.new", json!({})).is_ok()); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the switched document is reported once"); - assert!(reply["error"].as_str().is_some_and(|e| e.contains("document changed")), "{reply}"); - assert!(!path.exists(), "a completion after a document switch never writes"); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// A request bound to another viewport is rejected once with a viewport error and writes nothing. - fn a_completion_for_another_viewport_is_rejected() { - let ctx = sized_context(egui::vec2(32.0, 24.0)); - let mut app = test_app(); - let path = temp_png("viewport"); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); - app.shots.requests[0].ready_at = 0.0; - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 1); - // A slot/request bound to a different viewport must not fill this one. - app.shots.requests[0].viewport_id = egui::ViewportId::from_hash_of("other-window"); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::RED))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the viewport mismatch is reported once"); - assert!(reply["error"].as_str().is_some_and(|e| e.contains("another viewport")), "{reply}"); - assert!(!path.exists()); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// Accepts one-pixel rounding on a single axis, but rejects opposite-axis deviation, aspect distortion and more than one pixel of error. - fn validate_image_rejects_distortion_but_allows_one_pixel_rounding() { - let image = |w: usize, h: usize| egui::ColorImage::filled([w, h], egui::Color32::RED); - assert!(validate_image(&image(100, 100), [100, 100]).is_ok()); - assert!(validate_image(&image(101, 100), [100, 100]).is_ok(), "one pixel of rounding on one axis is legitimate"); - assert!(validate_image(&image(100, 101), [100, 100]).is_ok()); - assert!(validate_image(&image(101, 99), [100, 100]).is_err(), "independent per-axis tolerance must not accept a distorted image"); - assert!(validate_image(&image(99, 101), [100, 100]).is_err()); - assert!(validate_image(&image(102, 100), [100, 100]).is_err(), "more than one pixel per axis is a different surface"); - } - - #[test] - /// Checks the encoding working-set threshold, the base64 reply cap, and the retained-plus-encoding peak budget. - fn encoding_working_set_is_bounded_for_admitted_captures() { - assert!(encoding_working_bytes([8191, 2048], false).expect("checked") <= SCREENSHOT_MAX_ENCODING_BYTES); - assert!(encoding_working_bytes([8192, 8192], false).expect("checked") > SCREENSHOT_MAX_ENCODING_BYTES); - assert!(encoding_working_bytes([4000, 3000], true).expect("checked") <= SCREENSHOT_MAX_ENCODING_BYTES); - assert!(encoding_working_bytes([5000, 4000], true).expect("checked") > SCREENSHOT_MAX_ENCODING_BYTES); - // The base64 reply itself is capped too, before the string exists. - let png = png_worst_bytes(5000 * 4000 * 4, 4000).expect("checked"); - assert!(base64_len(png).expect("checked") > SCREENSHOT_MAX_BASE64_BYTES); - // Retained requests plus one encoding working set are held to a hard peak. - assert!(check_peak_budget(0, [64, 48], false).is_ok()); - assert!(check_peak_budget(SCREENSHOT_MAX_AGGREGATE_BYTES, [9000, 4096], false).is_err()); - } - - #[test] - /// An oversized base64 capture is refused with nothing reserved, while the same capture as a path is admitted. - fn base64_capture_over_the_encoding_budget_is_rejected_before_admission() { - let ctx = sized_context(egui::vec2(5000.0, 4000.0)); - let mut app = test_app(); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - let reply = rx.try_recv().expect("an oversized base64 capture is refused at admission"); - assert_eq!(reply["ok"], false, "{reply}"); - assert!( - reply["error"].as_str().is_some_and(|e| e.contains("encoding") || e.contains("base64")), - "the error names the encoding budget: {reply}" - ); - assert!(app.shots.requests.is_empty(), "a refused request reserves nothing"); - - // The same capture as a file is admitted within the per-image and encoding budgets. - let path = temp_png("encoding-path"); - let (tx2, rx2) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx2); - assert!(rx2.try_recv().is_err(), "the path form is admitted"); - assert_eq!(app.shots.requests.len(), 1); - app.shots.cancel_all(); - } - - #[test] - /// Oversized text, repeated maximum texts, keys and moves are refused without changing the queue, and the byte/event limits are both enforced. - fn synthetic_text_key_and_move_producers_honour_the_queue_budget() { - let ctx = egui::Context::default(); - let mut app = test_app(); - // One oversized text event is refused before it is queued. - let huge = "x".repeat(MAX_SYNTHETIC_TEXT_BYTES + 1); - let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.text", &json!({"text": huge})) else { panic!("ui.text returns immediately") }; - assert_eq!(reply["ok"], false, "{reply}"); - assert!(app.synthetic.is_empty()); - - // The byte budget stops repeated maximum-size texts; the queue is left unchanged. - let max = "x".repeat(MAX_SYNTHETIC_TEXT_BYTES); - let mut refused_at = None; - for i in 0..64 { - let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.text", &json!({"text": max.clone()})) else { - panic!("ui.text returns immediately") - }; - if reply["ok"] == false { - refused_at = Some((i, app.synthetic.len())); - break; - } - } - let (accepted, queued) = refused_at.expect("the synthetic byte budget is enforced"); - assert!(accepted > 0 && accepted < 64, "{accepted} texts fit"); - let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.text", &json!({"text": max})) else { panic!("ui.text returns immediately") }; - assert_eq!(reply["ok"], false); - assert_eq!(app.synthetic.len(), queued, "a refused text leaves the queue alone"); - - // Event count: keys and moves use the same overall budget as pointer gestures. - app.synthetic.clear(); - let pos = egui::pos2(1.0, 1.0); - app.synthetic.push_back(vec![egui::Event::PointerMoved(pos); MAX_QUEUED_SYNTHETIC_EVENTS - 1]); - let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.key", &json!({"key":"enter"})) else { panic!("ui.key returns immediately") }; - assert_eq!(reply["ok"], false, "two key events do not fit in the last slot: {reply}"); - assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), MAX_QUEUED_SYNTHETIC_EVENTS - 1); - - app.synthetic.clear(); - app.synthetic.push_back(vec![egui::Event::PointerMoved(pos); MAX_QUEUED_SYNTHETIC_EVENTS]); - let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.move", &json!({"x":2.0,"y":2.0})) else { panic!("ui.move returns immediately") }; - assert_eq!(reply["ok"], false, "{reply}"); - assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), MAX_QUEUED_SYNTHETIC_EVENTS); - } -} +mod tests; diff --git a/crates/ui-egui/src/control/screenshots.rs b/crates/ui-egui/src/control/screenshots.rs new file mode 100644 index 0000000000..5cf9e999b7 --- /dev/null +++ b/crates/ui-egui/src/control/screenshots.rs @@ -0,0 +1,741 @@ +use super::*; + +/// Most screenshots one client may have in flight or waiting for its reply at once. +pub const SCREENSHOT_MAX_REQUESTS: usize = 4; + +/// Retained bytes for one screenshot image. The renderer's readback buffer is a separate +/// transient allocation (recorded in peak RSS), not part of this reservation. +pub const SCREENSHOT_MAX_IMAGE_BYTES: usize = 128 * 1024 * 1024; + +/// Aggregate bytes reserved by every outstanding screenshot request. +pub const SCREENSHOT_MAX_AGGREGATE_BYTES: usize = 256 * 1024 * 1024; + +/// Largest transient working set allowed while one capture is encoded. It covers the RGBA copy +/// made from the retained `ColorImage`, the PNG stream and (for base64 replies) the encoded text. +/// Admission checks this before a request is issued, so a large capture cannot make the process +/// peak several times above its retained-image budget. +pub const SCREENSHOT_MAX_ENCODING_BYTES: usize = 256 * 1024 * 1024; + +/// Largest base64 reply this client produces. The encoded length is checked before the string is +/// allocated; at most [`SCREENSHOT_MAX_REQUESTS`] such replies can therefore wait in the channel, +/// keeping queued base64 text bounded too. +pub const SCREENSHOT_MAX_BASE64_BYTES: usize = 64 * 1024 * 1024; + +/// Hard bound on retained request images plus one capture's encoding working set. Admission, +/// issuance and every retry reject a request that would push the process over this, so the +/// aggregate reservation and the transient encoder peak together stay bounded. +pub const SCREENSHOT_MAX_PEAK_BYTES: usize = 512 * 1024 * 1024; + +/// Capture retries allowed inside one request's deadline after a resize/DPI/zoom/surface change. +pub const SCREENSHOT_MAX_RETRIES: u32 = 2; + +/// Reported once when the active document changes before a queued capture is issued or completed. +pub const SCREENSHOT_DOCUMENT_CHANGED: &str = "screenshot failed: the document changed while the capture was queued"; + +/// Reported once when a capture request or completion is not for the active viewport. +pub const SCREENSHOT_VIEWPORT_CHANGED: &str = "screenshot failed: the capture belongs to another viewport"; + +/// Reserved while the viewport has no capturable size yet (minimized, or not laid out at all): +/// the largest single image the per-image limit allows. The real size replaces it at issuance. +pub const UNKNOWN_CAPTURE_BYTES: usize = SCREENSHOT_MAX_IMAGE_BYTES; + +/// The viewport geometry a capture is admitted, issued and completed against. +/// +/// `SheetApp` keeps a monotonic epoch (see [`crate::SheetApp::logic`]) that increments on any +/// change of these values, so a resize back to the original size is still a mismatch: an image +/// taken in another layout must never be combined with the current geometry. The viewport id, +/// minimized/occluded state and visibility are part of the identity too: a minimize/restore or +/// an occlusion transition at the same pixel size is a new surface generation, not a no-op. +/// +/// The identity deliberately keeps `zoom`, `visible` and `occluded` even though interface zoom +/// does not change the expected physical surface size. A capture is bound to one layout generation, +/// so a zoom or occlusion transition during it is retried within [`SCREENSHOT_MAX_RETRIES`] and +/// then reported as a bounded failure instead of risking pixels from another generation. The common +/// wheel-zoom path is frozen while a capture is in flight; a non-glide zoom or a compositor +/// occlusion flap can still exhaust the retries, which is accepted as a rare, explicit error. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CaptureGeometry { + /// The viewport this capture belongs to. + pub viewport_id: egui::ViewportId, + /// Logical size of the content that is captured, or `None` when the window currently has no + /// capturable surface (minimized, or not laid out yet). A hidden-but-occluded window still + /// reports a size. + pub viewport: Option, + pub pixels_per_point: f32, + pub zoom: f32, + /// Maximum physical width/height the surface/renderer can capture. + pub max_texture_side: usize, + pub minimized: Option, + pub occluded: Option, + pub visible: Option, +} + +impl CaptureGeometry { + /// The geometry described by a native frame's fresh [`egui::RawInput`]. + /// + /// `raw.screen_rect` is the authoritative content size when `ViewportInfo::inner_rect` is + /// absent (Wayland always omits it, and a minimized window can omit both). The fallback to the + /// last UI pass's rect is deliberately *not* used here: a logic-only tick after a resize or + /// minimize must record the new state (or the explicit unknown state), never the old layout. + pub fn from_raw_input(raw: &egui::RawInput, ctx: &egui::Context) -> Self { + let info = raw.viewports.get(&raw.viewport_id).cloned().unwrap_or_default(); + Self::build(raw.viewport_id, &info, raw.screen_rect, raw.max_texture_side, ctx.zoom_factor(), ctx.pixels_per_point()) + } + + /// Builds a `CaptureGeometry` from fresh viewport information. A minimized window has no size; `inner_rect` falls back to the raw screen rect; native scale is multiplied by interface zoom, and a zero surface limit means uncapped. + fn build( + viewport_id: egui::ViewportId, + info: &egui::ViewportInfo, + screen_rect: Option, + max_texture_side: Option, + zoom: f32, + fallback_pixels_per_point: f32, + ) -> Self { + let minimized = info.minimized; + let visible = info.visible(); + // A minimized window has no surface to capture even if the platform still reports the + // last inner rectangle. Both rectangles missing likewise means "unknown size", not a + // reason to re-use an earlier layout. + let viewport = if minimized == Some(true) { + None + } else { + info.inner_rect.map(|rect| rect.size()).filter(positive_size).or_else(|| screen_rect.map(|rect| rect.size()).filter(positive_size)) + }; + let pixels_per_point = + info.native_pixels_per_point.filter(|p| p.is_finite() && *p > 0.0).map_or(fallback_pixels_per_point, |native| native * zoom); + Self { + viewport_id, + viewport, + pixels_per_point, + zoom, + // Zero means "unreported" (a bare test context); `expected_size` treats it as no cap. + max_texture_side: max_texture_side.unwrap_or(0), + minimized, + occluded: info.occluded, + visible, + } + } + + /// The geometry of egui's current input. This is a fallback for callers that do not run + /// [`crate::SheetApp::raw_input_hook`] (tests and embedders); native eframe always records the + /// fresh [`Self::from_raw_input`] value first, so issuance and drain never fall back here. + pub fn current(ctx: &egui::Context) -> Self { + let (viewport_id, info, screen_rect, max_texture_side, fallback_ppp) = ctx.input(|i| { + let viewport_id = i.raw.viewport_id; + let info = i.raw.viewports.get(&viewport_id).cloned().unwrap_or_default(); + // `screen_rect` from the pass if one supplied it; otherwise egui's retained content + // rect (which is only fresh when this is called from a UI pass, never a logic tick). + let screen_rect = i.raw.screen_rect.or_else(|| Some(i.viewport_rect())); + (viewport_id, info, screen_rect, i.max_texture_side, i.pixels_per_point()) + }); + Self::build(viewport_id, &info, screen_rect, Some(max_texture_side), ctx.zoom_factor(), fallback_ppp) + } + + /// Physical pixel size the screenshot should have, rounded and capped by the surface limit. + /// `None` when the viewport has no positive, finite size (minimized or not laid out yet). + pub fn expected_size(&self) -> Option<[usize; 2]> { + let viewport = self.viewport?; + let ppp = self.pixels_per_point; + if !viewport.x.is_finite() || !viewport.y.is_finite() || viewport.x <= 0.0 || viewport.y <= 0.0 { + return None; + } + if !ppp.is_finite() || ppp <= 0.0 { + return None; + } + // An unreported surface limit (0) must not cap every capture to one pixel. + let max = if self.max_texture_side == 0 { usize::MAX } else { self.max_texture_side }; + let axis = |points: f32| -> Option { + let px = (f64::from(points) * f64::from(ppp)).round(); + if !px.is_finite() || px <= 0.0 || px > usize::MAX as f64 { + return None; + } + Some((px as usize).min(max)) + }; + Some([axis(viewport.x)?, axis(viewport.y)?]) + } + + /// Checked `width * height * 4` bytes for the expected image, capped by the surface limit. + pub fn expected_bytes(&self) -> Option { + let [w, h] = self.expected_size()?; + w.checked_mul(h)?.checked_mul(4) + } + + /// Checked bytes of the largest image [`validate_image`] accepts for this geometry: one + /// physical pixel of rounding per axis. Reservations and the slot's per-image bound both use + /// this, so the aggregate budget is strict for the bytes actually retained. + pub fn retained_bytes(&self) -> Option { + let [w, h] = self.expected_size()?; + let w = w.checked_add(1)?; + let h = h.checked_add(1)?; + w.checked_mul(h)?.checked_mul(4) + } +} + +/// True only when both width and height are finite and positive. +fn positive_size(size: &egui::Vec2) -> bool { + size.x.is_finite() && size.y.is_finite() && size.x > 0.0 && size.y > 0.0 +} + +/// Conservative worst-case size of the PNG stream for a `bytes`-sized RGBA image with `height` +/// rows. PNG adds a filter byte per row and zlib framing; uncompressed blocks add a few bytes per +/// 64 KiB. 1% + 64 KiB plus the row bytes is safely above the real encoder output. +pub(super) fn png_worst_bytes(bytes: usize, height: usize) -> Option { + bytes.checked_add(bytes / 100)?.checked_add(height.checked_mul(2)?)?.checked_add(64 * 1024) +} + +/// Base64 length (with padding) for `len` bytes. +pub(super) fn base64_len(len: usize) -> Option { + len.checked_add(2)?.checked_div(3)?.checked_mul(4) +} + +/// Worst transient bytes for encoding one expected capture: the retained `ColorImage` plus the +/// RGBA copy made from it, then (for base64) the PNG stream and the encoded text. `None` on +/// overflow. Admission rejects a request whose working set exceeds +/// [`SCREENSHOT_MAX_ENCODING_BYTES`] before any slot or command exists. +pub fn encoding_working_bytes(expected: [usize; 2], base64: bool) -> Option { + let [w, h] = expected; + let bytes = w.checked_mul(h)?.checked_mul(4)?; + let retained_and_copy = bytes.checked_mul(2)?; + if !base64 { + return Some(retained_and_copy); + } + let png = png_worst_bytes(bytes, h)?; + let encoded = base64_len(png)?; + Some(retained_and_copy.max(bytes.checked_add(png)?.checked_add(encoded)?)) +} + +/// Reject an expected capture whose encoding working set or base64 reply would exceed the +/// explicit encoding limits. Checked before admission and again immediately before issuance. +pub(super) fn check_encoding_budget(expected: [usize; 2], base64: bool) -> Result<(), String> { + let [w, h] = expected; + let Some(bytes) = w.checked_mul(h).and_then(|p| p.checked_mul(4)) else { + return Err("screenshot failed: the window size overflows the screenshot budget".to_string()); + }; + let Some(working) = encoding_working_bytes(expected, base64) else { + return Err("screenshot failed: the window size overflows the screenshot encoding budget".to_string()); + }; + if working > SCREENSHOT_MAX_ENCODING_BYTES { + return Err(format!( + "screenshot encoding budget exceeded: {working} bytes of transient working memory for one {w}×{h} capture, over the {SCREENSHOT_MAX_ENCODING_BYTES}-byte encoding limit" + )); + } + if base64 { + let Some(png) = png_worst_bytes(bytes, h) else { + return Err("screenshot failed: the window size overflows the screenshot encoding budget".to_string()); + }; + let Some(encoded) = base64_len(png) else { + return Err("screenshot failed: the window size overflows the screenshot encoding budget".to_string()); + }; + if encoded > SCREENSHOT_MAX_BASE64_BYTES { + return Err(format!( + "screenshot base64 budget exceeded: the reply for one {w}×{h} capture could be {encoded} bytes, over the {SCREENSHOT_MAX_BASE64_BYTES}-byte base64 limit; request a path instead" + )); + } + } + Ok(()) +} + +/// Reject a request whose reserved retained bytes plus its encoding working set exceed +/// [`SCREENSHOT_MAX_PEAK_BYTES`]. `reserved` already includes this request's own reservation. +pub(super) fn check_peak_budget(reserved: usize, expected: [usize; 2], base64: bool) -> Result<(), String> { + let Some(working) = encoding_working_bytes(expected, base64) else { + return Err("screenshot failed: the window size overflows the screenshot encoding budget".to_string()); + }; + let peak = reserved.saturating_add(working); + if peak > SCREENSHOT_MAX_PEAK_BYTES { + return Err(format!( + "screenshot peak-memory budget exceeded: {reserved} bytes retained plus {working} bytes encoding for one capture is over the {SCREENSHOT_MAX_PEAK_BYTES}-byte peak limit" + )); + } + Ok(()) +} + +/// One admitted `ui.screenshot` request. The slot owns the image and its viewport waker; the +/// callback holds only a weak reference to it and never writes a file or sends a reply. +pub(super) struct Shot { + pub(super) token: u64, + pub(super) path: Option, + pub(super) reply: Sender, + /// Monotonic deadline for the whole request, including retries. + pub(super) deadline: f64, + /// Settle time before the first issuance (the UI must be still). + pub(super) ready_at: f64, + /// Real (non-discarded) UI passes counted towards settling. + pub(super) frames: u32, + /// `cumulative_pass_nr` of the last pass counted in `frames`, so repeated `ui` calls in one + /// pass count once and a settled frame is never counted twice. + pub(super) settle_pass: Option, + pub(super) issued: bool, + pub(super) retries: u32, + /// Geometry at admission, updated immediately before issuance. + pub(super) geometry: CaptureGeometry, + /// The viewport this request was admitted for. A callback or slot from another viewport must + /// never fill this request. + pub(super) viewport_id: egui::ViewportId, + /// The active document (`DocState::uid`) at admission. A queued capture is never rebound to a + /// document that became active later. + pub(super) document: Option, + /// Monotonic geometry epoch at issuance; any later change rejects the image. + pub(super) epoch: u64, + /// Physical size expected at issuance; `None` while the window has no capturable size. + pub(super) expected: Option<[usize; 2]>, + /// Bytes reserved from the aggregate budget until the request completes or is dropped. This + /// is the largest image the request may retain (one pixel of rounding per axis included) and + /// is also the slot's per-image bound. + pub(super) bytes: usize, + /// Created at issuance (and anew for every retry), never at admission: the bound must match + /// the geometry and reservation the command is issued against. + pub(super) slot: Option, +} + +#[derive(Default)] +pub struct Shots { + pub(super) token: u64, + pub(super) requests: Vec, +} + +impl Shots { + /// A screenshot was asked for and hasn't been delivered yet: animations hold still, so the + /// picture shows the settled state. + pub(crate) fn in_flight(&self) -> bool { + !self.requests.is_empty() + } + + /// Whether a hidden window still needs `RequestPaintWhileHidden` to reach issuance. Only a + /// request that is admitted or settling with a capturable size needs a first `ui` pass; once + /// `issue_screenshots` has sent the `Screenshot` command, that command's own `wants_paint` + /// paints the hidden window. A minimized request has no capturable size, so requesting hidden + /// full frames for its whole wait cannot make progress (the restore pass issues it). + pub(crate) fn needs_hidden_paint(&self) -> bool { + self.requests.iter().any(|r| !r.issued && r.expected.is_some()) + } + + /// Bytes currently reserved by admitted requests. + pub(super) fn reserved(&self) -> usize { + self.requests.iter().fold(0usize, |sum, r| sum.saturating_add(r.bytes)) + } + + /// Drop every outstanding request without a reply (app shutdown): callbacks become inert. + pub fn cancel_all(&mut self) { + for request in self.requests.drain(..) { + if let Some(slot) = request.slot { + slot.cancel(); + } + } + } +} + +/// Sends one screenshot rejection carrying the request token. +pub(super) fn reject_shot(reply: Sender, token: u64, message: impl std::fmt::Display) { + let _ = reply.send(json!({"ok": false, "error": message.to_string(), "request": token})); +} + +/// The geometry to use for admission, issuance and drain. +/// +/// Native eframe calls [`crate::SheetApp::raw_input_hook`] before every frame (including a +/// logic-only tick), so the recorded value carries fresh `RawInput` geometry even when egui's +/// last UI pass is stale. Tests and embedders that never call the hook fall back to egui's current +/// input; that fallback is only used when no raw frame was seen. +pub(crate) fn current_capture_geometry(app: &SheetApp, ctx: &egui::Context) -> CaptureGeometry { + if app.capture_geometry_from_raw { app.capture_geometry.unwrap_or_else(|| CaptureGeometry::current(ctx)) } else { CaptureGeometry::current(ctx) } +} + +/// Record `geometry` as the layout captures are bound to and bump the epoch when it differs. +/// +/// Issuance and drain use this instead of writing the fields separately: during a hidden +/// logic-only tick the viewport info can be newer than the last UI pass, and the epoch must +/// change together with the geometry or a valid image would be retried against its own epoch. +pub(crate) fn note_capture_geometry(app: &mut SheetApp, geometry: CaptureGeometry) -> u64 { + if app.capture_geometry != Some(geometry) { + app.capture_geometry = Some(geometry); + app.capture_epoch = app.capture_epoch.wrapping_add(1); + } + app.capture_epoch +} + +/// The bytes to reserve for one capture of `expected` against `geometry`, after checking the +/// applicable limits. Issuance and retry both use this (admission keeps its own inline checks) so a +/// retry cannot drift from the limits the first issuance applied. `expected` is `None` when the +/// viewport has no capturable size yet: that reserves [`UNKNOWN_CAPTURE_BYTES`] and still checks +/// the aggregate limit, while the per-image, encoding and peak checks only apply once a size is +/// known. +fn capture_bytes(app: &SheetApp, i: usize, geometry: CaptureGeometry, expected: Option<[usize; 2]>, base64: bool) -> Result { + let bytes = match expected { + Some(expected) => { + let bytes = geometry.retained_bytes().ok_or_else(|| "screenshot failed: the window size overflows the screenshot budget".to_string())?; + if bytes > SCREENSHOT_MAX_IMAGE_BYTES { + return Err(format!( + "screenshot budget exceeded: {bytes} bytes for one {}×{} image, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit", + expected[0], expected[1] + )); + } + check_encoding_budget(expected, base64)?; + bytes + } + None => UNKNOWN_CAPTURE_BYTES, + }; + let others = app.shots.requests.iter().enumerate().filter(|(j, _)| *j != i).fold(0usize, |sum, (_, r)| sum.saturating_add(r.bytes)); + if others.saturating_add(bytes) > SCREENSHOT_MAX_AGGREGATE_BYTES { + return Err(format!( + "screenshot budget exceeded: {bytes} bytes for this image would exceed the {SCREENSHOT_MAX_AGGREGATE_BYTES}-byte aggregate limit" + )); + } + if let Some(expected) = expected { + check_peak_budget(others.saturating_add(bytes), expected, base64)?; + } + Ok(bytes) +} + +/// Issued from `ui`: counts one real (non-discarded) settling pass per request, revalidates the +/// geometry and reserves the exact retained bytes, then sends one +/// `ViewportCommand::Screenshot(callback)` per request. +/// +/// The slot is created here, not at admission. Its per-image bound is the same reservation the +/// aggregate budget holds, so an image that would exceed the budget cannot be retained; a retry +/// after a resize creates a fresh slot for the new geometry. +pub fn issue_screenshots(app: &mut SheetApp, ctx: &egui::Context) { + let now = crate::monotonic_ms(); + let pass = ctx.cumulative_pass_nr(); + // A pass whose output is already going to be discarded is not a settling frame and its + // layout is not the one to capture. GridCraft requests no discard after this call. + let discard = ctx.will_discard(); + let active_document = app.session.active().map(|d| d.uid); + let current_viewport = ctx.viewport_id(); + let len = app.shots.requests.len(); + let mut failures: Vec<(usize, String)> = Vec::new(); + for i in 0..len { + let (issued, ready_at, settle_pass) = { + let r = &app.shots.requests[i]; + (r.issued, r.ready_at, r.settle_pass) + }; + if issued || discard || settle_pass == Some(pass) { + continue; + } + // One count per egui pass, however many times this `ui` runs in it. + let frames = { + let r = &mut app.shots.requests[i]; + r.settle_pass = Some(pass); + r.frames = r.frames.saturating_add(1); + r.frames + }; + if now < ready_at || frames < 3 { + continue; + } + // A queued capture belongs to the document and viewport it was admitted for. A switch + // before issuance is reported once and never rebound to the new document. + let (viewport_id, document, base64) = { + let r = &app.shots.requests[i]; + (r.viewport_id, r.document, r.path.is_none()) + }; + if viewport_id != current_viewport { + failures.push((i, SCREENSHOT_VIEWPORT_CHANGED.into())); + continue; + } + if document != active_document { + failures.push((i, SCREENSHOT_DOCUMENT_CHANGED.into())); + continue; + } + let current = current_capture_geometry(app, ctx); + let Some(expected) = current.expected_size() else { + // Still minimized or not laid out: keep the request queued and try again on a later + // real pass; `drain_screenshots` reports the size-specific timeout if none arrives. + app.shots.requests[i].expected = None; + continue; + }; + let bytes = match capture_bytes(app, i, current, Some(expected), base64) { + Ok(bytes) => bytes, + Err(message) => { + failures.push((i, message)); + continue; + } + }; + // Update the geometry and its epoch together, so the drain accepts exactly the layout the + // capture is issued against even when a hidden logic tick already saw the new viewport. + let epoch = note_capture_geometry(app, current); + let r = &mut app.shots.requests[i]; + r.geometry = current; + r.expected = Some(expected); + r.bytes = bytes; + r.epoch = epoch; + if let Some(old) = r.slot.take() { + old.cancel(); + } + let (slot, callback) = CaptureSlot::new(ctx, viewport_id, bytes); + r.slot = Some(slot); + ctx.send_viewport_cmd_to(viewport_id, egui::ViewportCommand::Screenshot(callback)); + r.issued = true; + } + for (i, message) in failures.into_iter().rev() { + reject_shot_at(app, i, message); + } + if app.shots.in_flight() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } +} + +/// Checks that the image has the pixel count its dimensions imply, is within one pixel of the expected size on each axis, and keeps the expected aspect ratio within that rounding. +pub(super) fn validate_image(image: &egui::ColorImage, expected: [usize; 2]) -> Result<(), String> { + let [w, h] = image.size; + let [ew, eh] = expected; + let pixels = w.checked_mul(h).ok_or_else(|| "screenshot dimensions overflow".to_string())?; + if image.pixels.len() != pixels { + return Err(format!("screenshot has {} pixels for its {w}×{h} dimensions", image.pixels.len())); + } + // Allow one physical pixel of rounding per axis, but not independent distortion: an image + // that is one pixel wider and one pixel shorter has a different aspect and cannot be the + // recorded viewport. Rejecting it forces the retry/timeout path instead of saving skewed pixels. + if w.abs_diff(ew) > 1 || h.abs_diff(eh) > 1 { + return Err(format!("screenshot is {w}×{h} pixels, expected {ew}×{eh}: the window changed size while it was captured")); + } + // Keep the returned scale's aspect within the rounding error of the recorded logical viewport. + let cross = w.checked_mul(eh).and_then(|a| h.checked_mul(ew).map(|b| a.abs_diff(b))); + let Some(cross) = cross else { + return Err("screenshot dimensions overflow the aspect check".to_string()); + }; + if cross > ew.max(eh) { + return Err(format!("screenshot is {w}×{h} pixels, expected {ew}×{eh}: the aspect ratio changed while it was captured")); + } + Ok(()) +} + +/// What [`drain_screenshots`] must do with one request after inspecting it. +enum DrainAction { + /// Still waiting; leave the request at the same index. + Keep, + /// Answer the request with this reply and remove it. + Remove(Json), + /// Re-arm the request for a fresh capture within its retry budget. + Restart, +} + +/// Called from `logic`: takes each completed slot once, validates identity, size, geometry/epoch +/// and **deadline** and only then encodes, writes and replies outside the slot. A stale, oversized +/// or late image is dropped without writing a file. +pub fn drain_screenshots(app: &mut SheetApp, ctx: &egui::Context) { + let now = crate::monotonic_ms(); + let current_geometry = current_capture_geometry(app, ctx); + let epoch = note_capture_geometry(app, current_geometry); + let active_document = app.session.active().map(|d| d.uid); + let current_viewport = ctx.viewport_id(); + let mut i = 0; + while i < app.shots.requests.len() { + match drain_action(app, i, now, current_geometry, epoch, active_document, current_viewport) { + DrainAction::Keep => i += 1, + DrainAction::Remove(reply) => finish_shot(app, i, reply), + DrainAction::Restart => match retry_capture(app, i, current_geometry, epoch, now) { + Ok(()) => i += 1, + Err(message) => reject_shot_at(app, i, message), + }, + } + } + if app.shots.in_flight() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } +} + +/// Validates one request and decides whether it stays queued, is answered or is re-armed. +fn drain_action( + app: &mut SheetApp, + i: usize, + now: f64, + current_geometry: CaptureGeometry, + epoch: u64, + active_document: Option, + current_viewport: egui::ViewportId, +) -> DrainAction { + let r = &mut app.shots.requests[i]; + // A document or viewport change is a definitive mismatch: cancel once, never retry and + // never write the pixels against the new identity. + if r.viewport_id != current_viewport { + cancel_shot(r); + return DrainAction::Remove(json!({"ok": false, "error": SCREENSHOT_VIEWPORT_CHANGED})); + } + if r.document != active_document { + cancel_shot(r); + return DrainAction::Remove(json!({"ok": false, "error": SCREENSHOT_DOCUMENT_CHANGED})); + } + if !r.issued { + return drain_unissued(r, now); + } + drain_completion(r, now, current_geometry, epoch) +} + +/// A request that never issued a capture: keep waiting, or report the timeout that fits whether +/// the window ever had a capturable size. +fn drain_unissued(r: &mut Shot, now: f64) -> DrainAction { + if now < r.deadline { + return DrainAction::Keep; + } + cancel_shot(r); + let message = if r.expected.is_none() { + "screenshot timeout: the window never reported a capturable size (it may be minimized or not laid out)" + } else { + "no frame was presented before the screenshot timeout" + }; + DrainAction::Remove(timeout_reply(message, r.token)) +} + +/// A request that issued a capture: inspect its slot once, then apply the deadline, validation +/// and geometry checks in the same order as issuance. +fn drain_completion(r: &mut Shot, now: f64, current_geometry: CaptureGeometry, epoch: u64) -> DrainAction { + match r.slot.as_ref().and_then(|slot| slot.take()) { + // A completion that arrives past the deadline is late even if the pixels are valid: the + // client was promised one answer within the deadline, so nothing is written for it. + Some(result) if now >= r.deadline => { + drop(result); + DrainAction::Remove(timeout_reply("screenshot timeout: the image completed after its deadline; no file was written", r.token)) + } + Some(Ok(image)) => drain_image(r, image, now, current_geometry, epoch), + // The slot refused the image before `validate_image` saw it: an image larger than the + // reservation is as much evidence of a resize as a smaller one. Retry within the same + // bounded budget instead of failing only when the window grew. A non-resize fault still + // exhausts the retries and reports the slot's error, so this stays fail-closed. + Some(Err(_)) if retry_allowed(r, now) => DrainAction::Restart, + Some(Err(e)) => DrainAction::Remove(json!({"ok": false, "error": e.to_string()})), + None if now >= r.deadline => { + cancel_shot(r); + DrainAction::Remove(timeout_reply("no frame was presented before the screenshot timeout", r.token)) + } + None => DrainAction::Keep, + } +} + +/// Validates a completed image against the size recorded at issuance and the current +/// geometry/epoch; only a valid image is saved, and only a retry that is still inside the +/// request's deadline and retry budget gets a fresh attempt. +fn drain_image(r: &mut Shot, image: std::sync::Arc, now: f64, current_geometry: CaptureGeometry, epoch: u64) -> DrainAction { + let validated = match r.expected { + Some(expected) => validate_image(&image, expected), + None => Err("screenshot has no expected size".to_string()), + }; + match validated { + Err(_) if retry_allowed(r, now) => { + drop(image); + DrainAction::Restart + } + Err(message) => DrainAction::Remove(json!({"ok": false, "error": message})), + Ok(()) if epoch != r.epoch || current_geometry != r.geometry => { + if !retry_allowed(r, now) { + return DrainAction::Remove(json!({ + "ok": false, + "error": "screenshot geometry changed while it was captured; no image was written" + })); + } + drop(image); + DrainAction::Restart + } + Ok(()) => DrainAction::Remove(save_image(image, r.path.as_deref())), + } +} + +/// Whether this request may take another capture: both limits are fixed for the whole request. +fn retry_allowed(r: &Shot, now: f64) -> bool { + r.retries < SCREENSHOT_MAX_RETRIES && now < r.deadline +} + +/// Takes and cancels the request's capture slot, if it has one. +fn cancel_shot(r: &mut Shot) { + if let Some(slot) = r.slot.take() { + slot.cancel(); + } +} + +/// Remove request `i`, cancel its slot and answer it once with a token-carrying reply. +fn finish_shot(app: &mut SheetApp, i: usize, reply: Json) { + let request = app.shots.requests.remove(i); + if let Some(slot) = request.slot { + slot.cancel(); + } + let _ = request.reply.send(with_token(reply, request.token)); +} + +/// Remove request `i`, cancel its slot and reject it with `message`. +fn reject_shot_at(app: &mut SheetApp, i: usize, message: impl std::fmt::Display) { + let request = app.shots.requests.remove(i); + if let Some(slot) = request.slot { + slot.cancel(); + } + reject_shot(request.reply, request.token, message); +} + +/// Re-arm one request for a fresh issuance against `geometry`: a new reservation (the maximum +/// retained image, or [`UNKNOWN_CAPTURE_BYTES`] while there is no size), a new epoch and no slot. +/// A fresh slot and callback are created by [`issue_screenshots`]; the old image was dropped by +/// the caller. +fn retry_capture(app: &mut SheetApp, i: usize, geometry: CaptureGeometry, epoch: u64, now: f64) -> Result<(), String> { + let expected = geometry.expected_size(); + let base64 = app.shots.requests.get(i).is_some_and(|r| r.path.is_none()); + let bytes = capture_bytes(app, i, geometry, expected, base64)?; + let r = &mut app.shots.requests[i]; + r.retries = r.retries.saturating_add(1); + r.issued = false; + r.frames = 3; + r.settle_pass = None; + r.ready_at = now; + r.expected = expected; + r.bytes = bytes; + r.geometry = geometry; + r.epoch = epoch; + if let Some(slot) = r.slot.take() { + slot.cancel(); + } + Ok(()) +} + +/// Encodes a screenshot as PNG to `path`, or returns it as base64. +/// +/// The retained `ColorImage` is released before encoding, and a path capture streams the PNG +/// straight to the file. A base64 reply is length-checked against +/// [`SCREENSHOT_MAX_BASE64_BYTES`] *before* the string is allocated, so one admitted capture cannot +/// retain an unbounded reply alongside its image. +pub fn save_image(image: std::sync::Arc, path: Option<&str>) -> Json { + use image::ImageEncoder as _; + let [w, h] = image.size; + let Some(capacity) = w.checked_mul(h).and_then(|p| p.checked_mul(4)) else { return json!({"ok": false, "error": "bad image"}) }; + if image.pixels.len().checked_mul(4) != Some(capacity) { + return json!({"ok": false, "error": "bad image"}); + } + let (Ok(width), Ok(height)) = (u32::try_from(w), u32::try_from(h)) else { return json!({"ok": false, "error": "bad image"}) }; + // One RGBA copy is unavoidable with the `image` encoder API. Drop the retained image before + // encoding so the peak is the copy plus encoder buffers, not both images plus the PNG stream. + let mut rgba = Vec::with_capacity(capacity); + for p in &image.pixels { + rgba.extend_from_slice(&p.to_array()); + } + drop(image); + match path { + Some(p) => match std::fs::File::create(p) { + Ok(file) => { + let encoder = image::codecs::png::PngEncoder::new(file); + match encoder.write_image(&rgba, width, height, image::ExtendedColorType::Rgba8) { + Ok(()) => json!({"ok": true, "result": {"path": p, "width": w, "height": h}}), + Err(e) => { + // Do not leave a truncated PNG behind. + let _ = std::fs::remove_file(p); + json!({"ok": false, "error": e.to_string()}) + } + } + } + Err(e) => json!({"ok": false, "error": e.to_string()}), + }, + None => { + let mut png = Vec::new(); + let encoder = image::codecs::png::PngEncoder::new(&mut png); + if let Err(e) = encoder.write_image(&rgba, width, height, image::ExtendedColorType::Rgba8) { + return json!({"ok": false, "error": e.to_string()}); + } + let Some(encoded_len) = base64_len(png.len()) else { + return json!({"ok": false, "error": "screenshot base64 length overflow"}); + }; + if encoded_len > SCREENSHOT_MAX_BASE64_BYTES { + return json!({ + "ok": false, + "error": format!( + "screenshot base64 budget exceeded: the {w}×{h} reply would be {encoded_len} bytes, over the {SCREENSHOT_MAX_BASE64_BYTES}-byte base64 limit; request a path instead" + ) + }); + } + json!({"ok": true, "result": {"base64": gridcraft_engine::io::base64_encode(&png), "width": w, "height": h}}) + } + } +} diff --git a/crates/ui-egui/src/control/synthetic.rs b/crates/ui-egui/src/control/synthetic.rs new file mode 100644 index 0000000000..a169cba649 --- /dev/null +++ b/crates/ui-egui/src/control/synthetic.rs @@ -0,0 +1,145 @@ +use super::*; + +pub(super) fn key_from(name: &str) -> Option { + egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() { + "enter" | "return" => Some(egui::Key::Enter), + "esc" | "escape" => Some(egui::Key::Escape), + "delete" | "del" => Some(egui::Key::Delete), + "backspace" => Some(egui::Key::Backspace), + "left" => Some(egui::Key::ArrowLeft), + "right" => Some(egui::Key::ArrowRight), + "up" => Some(egui::Key::ArrowUp), + "down" => Some(egui::Key::ArrowDown), + "space" => Some(egui::Key::Space), + "tab" => Some(egui::Key::Tab), + "pageup" => Some(egui::Key::PageUp), + "pagedown" => Some(egui::Key::PageDown), + "home" => Some(egui::Key::Home), + "end" => Some(egui::Key::End), + "f2" => Some(egui::Key::F2), + "f4" => Some(egui::Key::F4), + "f9" => Some(egui::Key::F9), + _ => None, + }) +} + +pub(super) fn modifiers(p: &Json) -> egui::Modifiers { + let b = |n: &str| p.get(n).and_then(Json::as_bool).unwrap_or(false); + egui::Modifiers { + alt: b("alt"), + ctrl: b("ctrl"), + shift: b("shift"), + mac_cmd: b("cmd") && cfg!(target_os = "macos"), + command: b("cmd") || (b("ctrl") && !cfg!(target_os = "macos")), + } +} + +pub(super) fn cell_center(app: &SheetApp, c: CellRef) -> Option { + let d = app.session.active()?; + let sh = d.wb.active()?; + let rect = app.grid.rect?; + let geo = crate::grid::Geo::new(sh, rect, app.view().scroll); + Some(geo.screen_cell_rect(sh, c).center()) +} + +/// Most synthetic events (pointer, key, text and move alike) a control client may keep queued at +/// once. The queue offers one batch per UI pass, and a hidden logic-only frame keeps the same +/// batch unapplied, so every producer must honour this budget. +pub const MAX_QUEUED_SYNTHETIC_EVENTS: usize = 10_000; + +/// Most bytes of synthetic input queued at once: the `Event` storage plus every text payload. +pub const MAX_QUEUED_SYNTHETIC_BYTES: usize = 8 * 1024 * 1024; + +/// Largest single `ui.text` string accepted (well below the control line limit; one paste-sized +/// string is plenty for a spreadsheet cell and keeps a burst from exhausting memory). +pub const MAX_SYNTHETIC_TEXT_BYTES: usize = 1024 * 1024; + +/// Bytes charged to one synthetic event: `size_of::()` plus the payload of a `Text` event. +fn event_bytes(event: &egui::Event) -> usize { + std::mem::size_of::() + + match event { + egui::Event::Text(text) => text.len(), + _ => 0, + } +} + +/// Saturating totals of queued synthetic events and their charged bytes across every pending batch. +fn synthetic_usage(app: &SheetApp) -> (usize, usize) { + app.synthetic + .iter() + .flat_map(|batch| batch.iter()) + .fold((0usize, 0usize), |(events, bytes), event| (events.saturating_add(1), bytes.saturating_add(event_bytes(event)))) +} + +/// Refuses a request that would push the queued synthetic input past either limit; the queue is +/// left unchanged. +fn synthetic_budget(app: &SheetApp, extra_events: usize, extra_bytes: usize) -> Result<(), String> { + let (events, bytes) = synthetic_usage(app); + if events.saturating_add(extra_events) > MAX_QUEUED_SYNTHETIC_EVENTS { + return Err(format!("too many synthetic input events (limit {MAX_QUEUED_SYNTHETIC_EVENTS} queued at once)")); + } + if bytes.saturating_add(extra_bytes) > MAX_QUEUED_SYNTHETIC_BYTES { + return Err(format!("synthetic input exceeds the {MAX_QUEUED_SYNTHETIC_BYTES}-byte queue limit")); + } + Ok(()) +} + +/// Checks the synthetic-event budget, then queues one pointer move followed by `count` press/release pairs (`count` is at least one). +pub(super) fn push_click(app: &mut SheetApp, pos: egui::Pos2, button: egui::PointerButton, count: u32, m: egui::Modifiers) -> Result<(), String> { + let count = count.max(1) as usize; + let events = count.saturating_mul(2).saturating_add(1); + synthetic_budget(app, events, events.saturating_mul(std::mem::size_of::()))?; + app.synthetic.push_back(vec![egui::Event::PointerMoved(pos)]); + let mut batch = Vec::with_capacity(count * 2); + for _ in 0..count { + batch.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: m }); + batch.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m }); + } + app.synthetic.push_back(batch); + Ok(()) +} + +/// Checks the synthetic-event budget, then queues a move, a press, `steps` interpolated moves and a release. +pub(super) fn push_drag(app: &mut SheetApp, a: egui::Pos2, b: egui::Pos2, steps: usize, m: egui::Modifiers) -> Result<(), String> { + let steps = steps.max(1); + let events = steps.saturating_add(3); + synthetic_budget(app, events, events.saturating_mul(std::mem::size_of::()))?; + app.synthetic.push_back(vec![egui::Event::PointerMoved(a)]); + app.synthetic.push_back(vec![egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m }]); + for i in 1..=steps { + let t = i as f32 / steps as f32; + app.synthetic.push_back(vec![egui::Event::PointerMoved(a + (b - a) * t)]); + } + app.synthetic.push_back(vec![egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }]); + Ok(()) +} + +/// Checks the synthetic-event budget and queues one key press/release pair. +pub(super) fn push_key(app: &mut SheetApp, key: egui::Key, m: egui::Modifiers) -> Result<(), String> { + let events = 2; + synthetic_budget(app, events, events * std::mem::size_of::())?; + app.synthetic.push_back(vec![ + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m }, + egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: m }, + ]); + Ok(()) +} + +/// Rejects text longer than `MAX_SYNTHETIC_TEXT_BYTES`, then queues one `Text` event under the shared queue budget. +pub(super) fn push_text(app: &mut SheetApp, text: String) -> Result<(), String> { + if text.len() > MAX_SYNTHETIC_TEXT_BYTES { + return Err(format!("text input is {} bytes, over the {MAX_SYNTHETIC_TEXT_BYTES}-byte limit for one event", text.len())); + } + let event = egui::Event::Text(text); + synthetic_budget(app, 1, event_bytes(&event))?; + app.synthetic.push_back(vec![event]); + Ok(()) +} + +/// Queues one pointer-move event under the shared synthetic queue budget. +pub(super) fn push_move(app: &mut SheetApp, pos: egui::Pos2) -> Result<(), String> { + let event = egui::Event::PointerMoved(pos); + synthetic_budget(app, 1, event_bytes(&event))?; + app.synthetic.push_back(vec![event]); + Ok(()) +} diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs new file mode 100644 index 0000000000..a680279e5e --- /dev/null +++ b/crates/ui-egui/src/control/tests.rs @@ -0,0 +1,979 @@ +use super::*; + +fn sample() -> Vec { + vec![ + json!({"id": "home.borders", "label": "Borders", "menu": ["Home", "Font"]}), + json!({"id": "home.bold", "label": "Bold", "menu": ["Home", "Font"]}), + json!({"id": "ui.ribbonTab", "label": "Ribbon Tab", "enabled": true, "ui": true}), + ] +} + +fn ids(v: &[Json]) -> Vec<&str> { + v.iter().filter_map(|c| c.get("id").and_then(Json::as_str)).collect() +} + +#[test] +fn search_filters_case_insensitively() { + assert_eq!(ids(&filter_commands(sample(), Some("border"))), ["home.borders"]); + assert_eq!(filter_commands(sample(), Some("BORDER")), filter_commands(sample(), Some("border"))); + assert_eq!(ids(&filter_commands(sample(), Some("ribbon"))), ["ui.ribbonTab"]); + assert_eq!(ids(&filter_commands(sample(), Some("font"))), ["home.borders", "home.bold"]); + assert!(filter_commands(sample(), Some("catalog_no_match_7391")).is_empty()); + assert_eq!(filter_commands(sample(), None).len(), 3); +} + +fn test_app() -> SheetApp { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + SheetApp::new(session, crate::Services::default()) +} + +#[test] +/// Checks that oversized click/drag counts are refused without changing already queued batches, that queued input counts toward the budget, and that normal gestures still queue fully. +fn oversized_pointer_requests_are_refused_and_leave_the_queue_alone() { + let mut app = test_app(); + let p = egui::pos2(20.0, 20.0); + let m = egui::Modifiers::NONE; + let btn = egui::PointerButton::Primary; + assert!(push_click(&mut app, p, btn, 2, m).is_ok()); + assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), 5); + assert!(push_click(&mut app, p, btn, u32::MAX, m).is_err()); + assert!(push_drag(&mut app, p, p, usize::MAX, m).is_err()); + assert!(push_drag(&mut app, p, p, MAX_QUEUED_SYNTHETIC_EVENTS, m).is_err()); + assert_eq!(app.synthetic.len(), 2); + assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), 5); + // Already queued input counts toward the budget. + assert!(push_click(&mut app, p, btn, (MAX_QUEUED_SYNTHETIC_EVENTS / 2) as u32, m).is_err()); + assert_eq!(app.synthetic.len(), 2); + // Normal requests still queue in full. + assert!(push_drag(&mut app, p, p + egui::vec2(40.0, 40.0), 3, m).is_ok()); + assert_eq!(app.synthetic.len(), 8); +} + +#[test] +fn print_command_discovery_documents_settings_and_submission() { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, crate::Services::default()); + let Outcome::Done(reply) = handle(&mut app, &egui::Context::default(), "engine.commands", &json!({"search":"print"})) else { + panic!("command discovery must return immediately"); + }; + assert_eq!(reply["ok"], true); + let commands = reply["result"].as_array().unwrap(); + for (id, expected_docs) in [ + ("ui.printSettings", &["copies", "duplex", "refresh", "discovering", "status", "detail"][..]), + ("ui.printSubmit", &["file.print", "pagination", "pending", "without opening a dialog"][..]), + ] { + let command = commands.iter().find(|command| command["id"] == id).unwrap(); + assert_eq!(command["ui"], true); + let docs = command["params"].as_str().unwrap(); + for expected in expected_docs { + assert!(docs.contains(expected), "{id}: missing {expected}"); + } + } + assert!(!app.printing.busy(), "discovery must not submit a job"); + assert!(app.backstage.is_none(), "discovery must not open settings"); +} + +#[test] +fn language_command_docs_list_every_language() { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, crate::Services::default()); + let Outcome::Done(reply) = handle(&mut app, &egui::Context::default(), "engine.commands", &json!({"search":"language"})) else { + panic!("command discovery must return immediately"); + }; + let commands = reply["result"].as_array().unwrap(); + let docs = commands.iter().find(|c| c["id"] == "app.language.set").unwrap()["params"].as_str().unwrap(); + for l in crate::i18n::Language::ALL { + assert!(docs.contains(&format!("\"{}\"", l.code())), "app.language.set docs miss {:?}: {docs}", l.code()); + } +} +/// One headless UI pass. A test has no renderer to apply the font-atlas delta, so dropping +/// it is intentional (0.37 panics when a `TexturesDelta` is dropped unapplied). +fn ui_pass(ctx: &egui::Context, raw: egui::RawInput, f: impl FnMut(&mut egui::Ui)) -> egui::FullOutput { + let mut out = ctx.run_ui(raw, f); + out.textures_delta.clear(); + out +} + +/// Returns an egui context initialized by one blank pass at `size` with an 8192-pixel texture limit. +fn sized_context(size: egui::Vec2) -> egui::Context { + let ctx = egui::Context::default(); + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), max_texture_side: Some(8192), ..Default::default() }; + let _ = ui_pass(&ctx, raw, |_| {}); + ctx +} + +/// Returns blank raw input with a screen rect at `size` and an 8192-pixel texture limit. +fn raw_of(size: egui::Vec2) -> egui::RawInput { + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), max_texture_side: Some(8192), ..Default::default() } +} + +/// Extracts cloned `ViewportCommand::Screenshot` callbacks from a `FullOutput` for the context's viewport. +fn screenshot_callbacks(out: &egui::FullOutput, ctx: &egui::Context) -> Vec { + out.viewport_output + .get(&ctx.viewport_id()) + .map(|output| { + output + .commands + .iter() + .filter_map(|command| match command { + egui::ViewportCommand::Screenshot(callback) => Some(callback.clone()), + _ => None, + }) + .collect::>() + }) + .unwrap_or_default() +} + +/// Run three settled passes with `raw` and return the callbacks issued in them. +fn issue_with(ctx: &egui::Context, app: &mut SheetApp, raw: egui::RawInput) -> Vec { + let mut callbacks = Vec::new(); + for _ in 0..3 { + let out = ui_pass(ctx, raw.clone(), |ui| issue_screenshots(app, ui.ctx())); + callbacks.extend(screenshot_callbacks(&out, ctx)); + } + callbacks +} + +/// Returns a per-process temporary PNG path after removing any stale file at that path. +fn temp_png(name: &str) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!("gridcraft-shot-{name}-{}.png", std::process::id())); + let _ = std::fs::remove_file(&path); + path +} + +#[test] +/// Checks rounded physical pixels, expected/retained byte counts, the texture-axis cap, the no-size case and overflow refusal. +fn capture_geometry_reports_checked_capped_pixels() { + let geometry = CaptureGeometry { + viewport_id: egui::ViewportId::ROOT, + viewport: Some(egui::vec2(800.0, 600.0)), + pixels_per_point: 2.0, + zoom: 1.0, + max_texture_side: 4096, + minimized: None, + occluded: None, + visible: None, + }; + assert_eq!(geometry.expected_size(), Some([1600, 1200])); + assert_eq!(geometry.expected_bytes(), Some(1600 * 1200 * 4)); + assert_eq!(geometry.retained_bytes(), Some(1601 * 1201 * 4), "one pixel of rounding per axis is reserved too"); + let capped = CaptureGeometry { max_texture_side: 1024, ..geometry }; + assert_eq!(capped.expected_size(), Some([1024, 1024]), "the surface limit caps both axes"); + let empty = CaptureGeometry { viewport: Some(egui::Vec2::ZERO), ..geometry }; + assert_eq!(empty.expected_size(), None, "a minimized window has no capturable size"); + assert_eq!(empty.retained_bytes(), None); + let huge = CaptureGeometry { viewport: Some(egui::vec2(f32::MAX, f32::MAX)), ..geometry }; + assert_eq!(huge.expected_size(), None, "overflow is refused, not truncated"); +} + +#[test] +/// Checks that one request beyond `SCREENSHOT_MAX_REQUESTS` is rejected with its token while earlier requests stay queued and do not reply early. +fn screenshot_admission_stops_on_the_count_budget_without_evicting() { + let ctx = sized_context(egui::vec2(800.0, 600.0)); + let mut app = test_app(); + let mut replies = Vec::new(); + for _ in 0..SCREENSHOT_MAX_REQUESTS { + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + replies.push(rx); + } + assert_eq!(app.shots.requests.len(), SCREENSHOT_MAX_REQUESTS); + assert!(replies.iter_mut().all(|r| r.try_recv().is_err()), "admitted requests do not reply early"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + let reply = rx.try_recv().expect("the over-count request is rejected"); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("limit 4")), "{reply}"); + assert!(reply["request"].as_u64().is_some(), "a rejection carries its request token: {reply}"); + assert_eq!(app.shots.requests.len(), SCREENSHOT_MAX_REQUESTS, "an earlier request is never evicted"); +} + +#[test] +/// Three roughly 100 MB path captures hit the aggregate byte limit; the third is rejected and only two requests remain. +fn screenshot_admission_stops_on_aggregate_bytes_before_the_count() { + let ctx = sized_context(egui::vec2(5000.0, 5000.0)); + let mut app = test_app(); + let path = temp_png("aggregate").to_string_lossy().into_owned(); + let mut rejected = None; + for _ in 0..3 { + let (tx, rx) = std::sync::mpsc::channel(); + // A path capture (not base64) isolates the retained-image aggregate budget. + admit(&mut app, &ctx, Some(path.clone()), tx); + if let Ok(reply) = rx.try_recv() { + rejected = Some(reply); + } + } + let reply = rejected.expect("three 100 MB captures exceed the 256 MiB aggregate budget"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("aggregate limit")), "{reply}"); + assert_eq!(app.shots.requests.len(), 2); +} + +#[test] +/// An unknown-size request reserves the placeholder, issues nothing while settling, then replies once on timeout and releases the reservation. +fn requests_without_a_size_stay_queued_and_time_out() { + let ctx = sized_context(egui::Vec2::ZERO); + let mut app = test_app(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + assert!(rx.try_recv().is_err(), "an unknown size is queued, not refused with an overflow error"); + assert_eq!(app.shots.requests[0].bytes, UNKNOWN_CAPTURE_BYTES, "the largest possible image is reserved meanwhile"); + // Settling passes without a size neither issue nor fail the request. + let callbacks = issue_with(&ctx, &mut app, raw_of(egui::Vec2::ZERO)); + assert!(callbacks.is_empty()); + assert!(!app.shots.requests[0].issued); + assert!(rx.try_recv().is_err()); + app.shots.requests[0].deadline = 0.0; + let _ = ui_pass(&ctx, raw_of(egui::Vec2::ZERO), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the size-less request times out once"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("capturable size")), "{reply}"); + assert_eq!(app.shots.reserved(), 0, "the placeholder reservation is released"); +} + +/// Counts `RequestPaintWhileHidden` commands in a logic output. +fn hidden_paints(out: &egui::LogicOutput) -> usize { + out.viewport_commands.values().flatten().filter(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden)).count() +} + +#[test] +/// A queued capture requests a hidden paint; once issued, the screenshot command supplies the paint itself. +fn a_queued_capture_requests_hidden_paint_until_it_is_issued() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let (tx, _rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + let queued = ctx.run_logic(&raw_of(egui::vec2(64.0, 48.0)), |ctx| app.logic(ctx)); + assert_eq!(hidden_paints(&queued), 1, "a queued capture needs a real `ui` pass"); + + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + let issued = ctx.run_logic(&raw_of(egui::vec2(64.0, 48.0)), |ctx| app.logic(ctx)); + assert_eq!(hidden_paints(&issued), 0, "the issued Screenshot command paints the hidden window by itself"); +} + +#[test] +/// A minimized request stays queued without requesting hidden paints, because it cannot issue until restored. +fn a_minimized_capture_does_not_force_hidden_frames_while_it_waits() { + let ctx = egui::Context::default(); + let mut app = test_app(); + let mut raw = raw_of(egui::vec2(800.0, 600.0)); + if let Some(v) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { + v.inner_rect = None; + v.minimized = Some(true); + } + app.raw_input_hook(&ctx, &mut raw); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + assert!(rx.try_recv().is_err()); + let waited = ctx.run_logic(&raw, |ctx| app.logic(ctx)); + assert_eq!(hidden_paints(&waited), 0, "a minimized request cannot issue until it is restored"); + app.shots.cancel_all(); +} + +#[test] +/// The real app harness issues exactly one callback, replies once with the token and dimensions, and releases the reservation. +fn one_admitted_capture_issues_one_callback_and_replies_once() { + use egui_kittest::kittest::Queryable as _; + + let mut app = test_app(); + app.use_prepared_fonts(); + let mut h = egui_kittest::Harness::builder() + .with_size(egui::vec2(900.0, 700.0)) + .with_context_setup(|ctx| { + ctx.set_fonts(crate::theme::pinned_font_definitions()); + crate::theme::apply(ctx, false); + }) + // This test completes the callback itself. Keep the real app/AccessKit tree, but + // do not let kittest's automatic screenshot handling initialize a GPU renderer. + .renderer(egui_kittest::LazyRenderer::Uninitialized { textures_delta: Default::default(), builder: None }) + .build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + assert!(h.query_by_label("Name Box").is_some(), "the capture fixture draws the real app, not an empty tree"); + h.check_accessibility(); + + let ctx = h.ctx.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(h.state_mut(), &ctx, None, tx); + let token = h.state().shots.requests[0].token; + h.state_mut().shots.requests[0].ready_at = 0.0; + let mut callbacks = Vec::new(); + for _ in 0..3 { + h.step(); + callbacks.extend(screenshot_callbacks(h.output(), &ctx)); + } + assert_eq!(callbacks.len(), 1, "the settled request is issued once"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([900, 700], egui::Color32::RED))); + h.step(); // the real logic drains the callback and the UI repaints + h.check_accessibility(); + assert!(h.query_by_label("Name Box").is_some(), "the app tree survives callback drain and repaint"); + let reply = rx.try_recv().expect("the drain replies once"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(reply["result"]["width"], 900); + assert_eq!(reply["result"]["height"], 700); + assert_eq!(reply["request"].as_u64(), Some(token), "the reply names its request"); + assert!(rx.try_recv().is_err(), "a completed slot is taken exactly once"); + assert_eq!(h.state().shots.reserved(), 0, "the reservation is released after the reply"); +} + +#[test] +/// Completing callbacks in reverse order replies on each matching token/path and writes each request's own pixels. +fn out_of_order_completions_reply_on_their_own_request() { + let ctx = sized_context(egui::vec2(8.0, 8.0)); + let mut app = test_app(); + let p1 = temp_png("order-1"); + let p2 = temp_png("order-2"); + let (tx1, rx1) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(p1.to_string_lossy().into_owned()), tx1); + let token1 = app.shots.requests[0].token; + let (tx2, rx2) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(p2.to_string_lossy().into_owned()), tx2); + let token2 = app.shots.requests[1].token; + for request in &mut app.shots.requests { + request.ready_at = 0.0; + } + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 2, "one callback per admitted request"); + // Complete the second request first: slots, not callback order, carry the result. + callbacks[1].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([8, 8], egui::Color32::BLUE))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let second = rx2.try_recv().expect("the second request gets its own reply"); + assert_eq!(second["ok"], true, "{second}"); + assert_eq!(second["request"].as_u64(), Some(token2)); + assert_eq!(second["result"]["path"].as_str(), Some(p2.to_string_lossy().as_ref())); + assert!(!p1.exists(), "the unfinished request has not written anything"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([8, 8], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let first = rx1.try_recv().expect("the first request gets its own reply"); + assert_eq!(first["request"].as_u64(), Some(token1)); + assert_eq!(first["result"]["path"].as_str(), Some(p1.to_string_lossy().as_ref())); + assert_ne!(std::fs::read(&p1).unwrap(), std::fs::read(&p2).unwrap(), "each reply carries its own pixels"); + let _ = std::fs::remove_file(&p1); + let _ = std::fs::remove_file(&p2); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// An expired request replies once with a timeout, writes no file and releases its reservation. +fn a_timed_out_capture_replies_once_and_never_writes() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let path = temp_png("timeout"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + app.shots.requests[0].deadline = 0.0; + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the timeout replies"); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("timeout")), "{reply}"); + assert!(!path.exists(), "a timed-out capture never writes a file"); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + assert!(rx.try_recv().is_err(), "a timeout is reported once"); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// A valid image that completes after its deadline gets one timeout reply and is not written. +fn a_capture_completed_after_its_deadline_writes_nothing() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let path = temp_png("late"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + let token = app.shots.requests[0].token; + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + // The deadline passes while the callback is outstanding; the image arrives afterwards. + app.shots.requests[0].deadline = 0.0; + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the late completion gets one timeout reply"); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("timeout")), "{reply}"); + assert_eq!(reply["request"].as_u64(), Some(token)); + assert!(!path.exists(), "a capture that completed after its deadline never writes a file"); + assert!(rx.try_recv().is_err(), "the late image is reported once"); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// Cancelling a request does not reply; a later callback for it is ignored and writes nothing. +fn a_callback_after_the_request_is_removed_is_inert() { + let ctx = sized_context(egui::vec2(32.0, 24.0)); + let mut app = test_app(); + let path = temp_png("removed"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + app.shots.cancel_all(); + assert!(rx.try_recv().is_err(), "cancelling a request does not reply"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + assert!(rx.try_recv().is_err(), "a callback for a removed request is inert"); + assert!(!path.exists()); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// The first issuance adopts a new geometry, updates the epoch and reservation, spends no retry, and the capture then succeeds. +fn a_resize_between_admission_and_issuance_does_not_cost_a_retry() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + app.shots.requests[0].ready_at = 0.0; + let admitted_epoch = app.shots.requests[0].epoch; + let resized = raw_of(egui::vec2(32.0, 24.0)); + let callbacks = issue_with(&ctx, &mut app, resized.clone()); + assert_eq!(callbacks.len(), 1); + let request = &app.shots.requests[0]; + assert_eq!(request.retries, 0, "the first issuance adopts the current geometry instead of spending a retry"); + assert_eq!(request.geometry.viewport, Some(egui::vec2(32.0, 24.0))); + assert_eq!(request.expected, Some([32, 24])); + assert_eq!(request.bytes, 33 * 25 * 4); + assert!(request.epoch > admitted_epoch, "issuance records the new epoch together with the geometry"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::RED))); + let _ = ui_pass(&ctx, resized, |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the once-issued capture replies"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// The reservation is the largest accepted rounded image (11×9×4 bytes for a 10×8 viewport), and that image is accepted. +fn the_retained_reservation_is_the_slot_bound() { + let ctx = sized_context(egui::vec2(10.0, 8.0)); + let mut app = test_app(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + // validate_image accepts one pixel of rounding per axis: an 11x9 image is the largest. + assert_eq!(app.shots.requests[0].bytes, 11 * 9 * 4); + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([11, 9], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the rounded image is accepted"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(reply["result"]["width"], 11); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// Oversized images retry within the bounded budget, never write, then report the per-image limit, while the valid peer is written. +fn an_image_beyond_the_reservation_is_released_without_writing() { + let ctx = sized_context(egui::vec2(10.0, 8.0)); + let mut app = test_app(); + let p1 = temp_png("over-1"); + let p2 = temp_png("over-2"); + let (tx1, rx1) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(p1.to_string_lossy().into_owned()), tx1); + let (tx2, rx2) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(p2.to_string_lossy().into_owned()), tx2); + for request in &mut app.shots.requests { + request.ready_at = 0.0; + } + assert_eq!(app.shots.reserved(), 2 * 11 * 9 * 4); + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 2); + // The slot refuses an image over the reservation instead of retaining bytes beyond it. + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([12, 9], egui::Color32::RED))); + // The other request completes with valid pixels. + callbacks[1].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([10, 8], egui::Color32::BLUE))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + // A larger image can be a genuine resize, so it receives the same bounded retries as + // a smaller one. Each oversized callback is released and never written while retrying. + for attempt in 1..=SCREENSHOT_MAX_RETRIES { + assert_eq!(app.shots.requests[0].retries, attempt); + assert!(rx1.try_recv().is_err(), "retry {attempt} has not replied early"); + assert!(!p1.exists(), "oversized pixels are never written on retry {attempt}"); + let callbacks = issue_with(&ctx, &mut app, raw_of(egui::vec2(10.0, 8.0))); + assert_eq!(callbacks.len(), 1, "the other valid request is already complete"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([12, 9], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + } + let refused = rx1.try_recv().expect("the oversized image is reported after bounded retries"); + assert_eq!(refused["ok"], false, "{refused}"); + assert!(refused["error"].as_str().is_some_and(|e| e.contains("per-image limit")), "{refused}"); + assert!(!p1.exists()); + let accepted = rx2.try_recv().expect("the valid image is written"); + assert_eq!(accepted["ok"], true, "{accepted}"); + let _ = std::fs::remove_file(&p2); + assert_eq!(app.shots.reserved(), 0, "both reservations are released"); +} + +#[test] +/// Two `ui` calls in one pass count as one settling frame, and a pass that requests a discard is not counted; only its rerun counts. +fn settling_counts_one_real_pass_and_skips_a_discarded_one() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let (tx, _rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + // Two `ui` calls in one pass are one settling frame. + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| { + issue_screenshots(&mut app, ui.ctx()); + issue_screenshots(&mut app, ui.ctx()); + }); + assert_eq!(app.shots.requests[0].frames, 1); + // A pass that already asked to be discarded does not count, even though `run_ui` repeats it. + let mut asked = false; + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| { + if !asked { + asked = true; + ui.ctx().request_discard("settling test"); + } + issue_screenshots(&mut app, ui.ctx()); + }); + assert_eq!(app.shots.requests[0].frames, 2, "only the rerun (real) pass counted"); +} + +#[test] +/// Issuance survives a discard requested after it, the image is written once, and a duplicate callback completion is inert. +fn a_discard_requested_after_issuance_still_issues_and_writes_once() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let path = temp_png("discard-after-issue"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + app.shots.requests[0].ready_at = 0.0; + let mut asked = false; + let mut callbacks = Vec::new(); + for _ in 0..3 { + let out = ui_pass(&ctx, egui::RawInput::default(), |ui| { + issue_screenshots(&mut app, ui.ctx()); + if !asked && app.shots.requests.first().is_some_and(|r| r.issued) { + asked = true; + ui.ctx().request_discard("discard after screenshot issuance"); + } + }); + callbacks.extend(screenshot_callbacks(&out, &ctx)); + } + assert!(asked, "the test requested a discard after issuance"); + assert_eq!(callbacks.len(), 1, "issuance happens exactly once across the discard repeat"); + let image = std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED)); + callbacks[0].clone().complete(image.clone()); + callbacks[0].clone().complete(image); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the discarded pass still delivers one reply"); + assert_eq!(reply["ok"], true, "{reply}"); + assert!(path.exists(), "the image is written exactly once"); + assert!(rx.try_recv().is_err(), "a second completion of the same callback is inert"); + let _ = std::fs::remove_file(&path); +} + +#[test] +/// A grow-resize spends one retry, reissues at the new size and then accepts the new image. +fn an_image_larger_than_the_reservation_retries_like_a_smaller_one() { + let ctx = sized_context(egui::vec2(10.0, 8.0)); + let mut app = test_app(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + + // The surface grew by two pixels while the image was outstanding. The slot's reservation + // refuses the larger image before `validate_image`; drain must retry at the new geometry + // instead of failing because the resize went in this direction. + let mut grown = raw_of(egui::vec2(20.0, 16.0)); + app.raw_input_hook(&ctx, &mut grown); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([12, 9], egui::Color32::RED))); + let _ = ctx.run_logic(&grown, |ctx| app.logic(ctx)); + let request = &app.shots.requests[0]; + assert_eq!(request.retries, 1, "the slot's per-image refusal spends one retry, not a failure"); + assert!(!request.issued); + assert_eq!(request.geometry.viewport, Some(egui::vec2(20.0, 16.0))); + assert!(rx.try_recv().is_err(), "a retried capture does not reply yet"); + + let callbacks = issue_with(&ctx, &mut app, raw_of(egui::vec2(20.0, 16.0))); + assert_eq!(callbacks.len(), 1); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([20, 16], egui::Color32::BLUE))); + let _ = ui_pass(&ctx, raw_of(egui::vec2(20.0, 16.0)), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the retried capture replies"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// Minimize retries reserve unknown-size bytes; only two fit the aggregate budget and stay queued (the rest are reported), and the survivors write after restore. +fn a_minimize_retry_without_a_size_still_checks_the_aggregate_budget() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let mut paths = Vec::new(); + let mut receivers = Vec::new(); + for n in 0..SCREENSHOT_MAX_REQUESTS { + let path = temp_png(&format!("minimize-retry-{n}")); + let (tx, rx) = std::sync::mpsc::channel(); + // A path capture isolates the retained-image aggregate budget. + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + paths.push(path); + receivers.push(rx); + } + for request in &mut app.shots.requests { + request.ready_at = 0.0; + } + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), SCREENSHOT_MAX_REQUESTS, "four path captures fit every budget"); + assert_eq!(app.shots.reserved(), SCREENSHOT_MAX_REQUESTS * 65 * 49 * 4); + + // The window is minimized with every capture outstanding. The drain retries each one + // against the new surface generation, which has no capturable size, so every retry + // reserves UNKNOWN_CAPTURE_BYTES. Only two unknown-size reservations fit the aggregate + // limit; the rest must be reported instead of inflating `reserved()` over the budget. + let mut minimized = raw_of(egui::vec2(64.0, 48.0)); + if let Some(viewport) = minimized.viewports.get_mut(&egui::ViewportId::ROOT) { + viewport.inner_rect = None; + viewport.minimized = Some(true); + } + app.raw_input_hook(&ctx, &mut minimized); + let image = std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED)); + for callback in &callbacks { + callback.clone().complete(image.clone()); + } + let _ = ctx.run_logic(&minimized, |ctx| app.logic(ctx)); + + assert!( + app.shots.reserved() <= SCREENSHOT_MAX_AGGREGATE_BYTES, + "size-less retries reserved {} bytes, over the {SCREENSHOT_MAX_AGGREGATE_BYTES}-byte aggregate limit", + app.shots.reserved() + ); + let rejected: Vec = receivers.iter_mut().filter_map(|rx| rx.try_recv().ok()).collect(); + assert_eq!(rejected.len(), 2, "two unknown-size retries no longer fit the aggregate budget"); + assert!(rejected.iter().all(|reply| reply["error"].as_str().is_some_and(|e| e.contains("aggregate limit"))), "{rejected:?}"); + assert_eq!(app.shots.requests.len(), 2, "the two fitting retries stay queued"); + assert!(app.shots.requests.iter().all(|r| !r.issued && r.expected.is_none() && r.bytes == UNKNOWN_CAPTURE_BYTES)); + assert!(receivers.iter_mut().all(|rx| rx.try_recv().is_err()), "the surviving retries wait for the restore"); + + // Restoring re-issues only the surviving requests at the real size; both fit because the + // over-reserved retries were rejected rather than kept. + let mut restored = raw_of(egui::vec2(64.0, 48.0)); + if let Some(viewport) = restored.viewports.get_mut(&egui::ViewportId::ROOT) { + viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(64.0, 48.0))); + viewport.minimized = Some(false); + viewport.occluded = Some(false); + } + app.raw_input_hook(&ctx, &mut restored); + let callbacks = issue_with(&ctx, &mut app, restored.clone()); + assert_eq!(callbacks.len(), 2, "the surviving requests issue on restore"); + for callback in &callbacks { + callback.clone().complete(image.clone()); + } + let _ = ctx.run_logic(&restored, |ctx| app.logic(ctx)); + + let mut delivered = 0; + for rx in &mut receivers { + if let Ok(reply) = rx.try_recv() { + assert_eq!(reply["ok"], true, "{reply}"); + delivered += 1; + } + } + assert_eq!(delivered, 2, "the two surviving captures reply once each"); + let written = paths.iter().filter(|path| path.exists()).count(); + assert_eq!(written, 2, "only the surviving captures write their image"); + for path in &paths { + let _ = std::fs::remove_file(path); + } + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// Fresh hidden viewport geometry invalidates the old image, spends one retry, and the reissued image succeeds. +fn a_hidden_resize_rejects_an_image_issued_before_it_and_reissues() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED))); + + // A hidden tick carries the resize in `RawInput::viewports`; the last UI pass still says + // 64x48, so only the fresh viewport info lets the drain see the change. + let hidden = || { + let mut raw = raw_of(egui::vec2(64.0, 48.0)); + if let Some(viewport) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { + viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(32.0, 400.0))); + viewport.native_pixels_per_point = Some(1.0); + } + raw + }; + let _ = ctx.run_logic(&hidden(), |ctx| app.logic(ctx)); + let request = &app.shots.requests[0]; + assert!(!request.issued, "the image captured before the hidden resize is not accepted"); + assert_eq!(request.retries, 1); + assert_eq!(request.geometry.viewport, Some(egui::vec2(32.0, 400.0))); + assert!(rx.try_recv().is_err(), "a retried capture does not reply yet"); + + // The next real pass issues against the new size and its image is accepted. + let resized = raw_of(egui::vec2(32.0, 24.0)); + let callbacks = issue_with(&ctx, &mut app, resized.clone()); + assert_eq!(callbacks.len(), 1, "the retry is issued once against the current geometry"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::BLUE))); + let _ = ui_pass(&ctx, resized, |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the retried capture replies"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// Draining four completed captures replies to all of them and permits a new admission. +fn a_drained_capture_frees_its_slot_for_the_next_admission() { + let ctx = sized_context(egui::vec2(8.0, 8.0)); + let mut app = test_app(); + let mut receivers = Vec::new(); + for _ in 0..SCREENSHOT_MAX_REQUESTS { + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + receivers.push(rx); + } + for request in &mut app.shots.requests { + request.ready_at = 0.0; + } + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + for callback in &callbacks { + callback.clone().complete(std::sync::Arc::new(egui::ColorImage::filled([8, 8], egui::Color32::GREEN))); + } + assert_eq!(app.shots.requests.len(), SCREENSHOT_MAX_REQUESTS); + // Draining before admitting is what `logic` does: completed requests release first. + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + assert_eq!(app.shots.requests.len(), 0); + assert!(receivers.iter_mut().all(|rx| rx.try_recv().is_ok())); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + assert!(rx.try_recv().is_err(), "a new request is admitted once the drained slots are free"); + assert_eq!(app.shots.requests.len(), 1); +} + +#[test] +/// Uses `screen_rect` when `inner_rect` is absent, applies native scale times zoom, and advances the epoch on a hidden resize. +fn raw_input_geometry_uses_screen_rect_when_inner_rect_is_absent() { + // Wayland always omits `inner_rect`; the fresh `RawInput::screen_rect` is the only size. + let ctx = sized_context(egui::vec2(800.0, 600.0)); + let mut app = test_app(); + let mut raw = raw_of(egui::vec2(1280.0, 720.0)); + if let Some(v) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { + v.inner_rect = None; + v.native_pixels_per_point = Some(2.0); + v.minimized = Some(false); + v.occluded = Some(false); + } + app.raw_input_hook(&ctx, &mut raw); + let geo = app.capture_geometry.expect("the raw frame records its geometry"); + assert_eq!(geo.viewport, Some(egui::vec2(1280.0, 720.0))); + assert_eq!(geo.pixels_per_point, 2.0, "native scale times zoom"); + assert_eq!(geo.expected_size(), Some([2560, 1440])); + + let epoch = app.capture_epoch; + // A hidden resize lands on a logic-only tick: no UI pass ran, but the raw frame is fresh. + let mut hidden = raw_of(egui::vec2(640.0, 480.0)); + if let Some(v) = hidden.viewports.get_mut(&egui::ViewportId::ROOT) { + v.inner_rect = None; + v.native_pixels_per_point = Some(2.0); + v.minimized = Some(false); + v.occluded = Some(true); + } + app.raw_input_hook(&ctx, &mut hidden); + let _ = ctx.run_logic(&hidden, |ctx| app.logic(ctx)); + assert!(app.capture_epoch > epoch, "a hidden resize is a new epoch"); + assert_eq!(app.capture_geometry.expect("geometry").viewport, Some(egui::vec2(640.0, 480.0))); +} + +#[test] +/// Minimized geometry has no size and reserves unknown bytes; restoring at the same size still changes the epoch. +fn minimized_geometry_has_no_size_and_restore_bumps_the_epoch() { + let ctx = egui::Context::default(); + let mut app = test_app(); + let mut minimized = raw_of(egui::vec2(800.0, 600.0)); + if let Some(v) = minimized.viewports.get_mut(&egui::ViewportId::ROOT) { + v.inner_rect = None; + v.minimized = Some(true); + } + app.raw_input_hook(&ctx, &mut minimized); + let geo = app.capture_geometry.expect("recorded"); + assert_eq!(geo.viewport, None, "a minimized window has no capturable size"); + assert_eq!(geo.visible, Some(false)); + + // Admission while minimized reserves the unknown-size maximum and waits. + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + assert_eq!(app.shots.requests[0].bytes, UNKNOWN_CAPTURE_BYTES); + assert!(rx.try_recv().is_err()); + let epoch = app.capture_epoch; + + // Restoring at the same logical size is still a new surface generation. + let mut restored = raw_of(egui::vec2(800.0, 600.0)); + if let Some(v) = restored.viewports.get_mut(&egui::ViewportId::ROOT) { + v.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + v.minimized = Some(false); + v.occluded = Some(false); + } + app.raw_input_hook(&ctx, &mut restored); + assert!(app.capture_epoch > epoch, "minimize/restore at the same size changes the epoch"); + assert_eq!(app.capture_geometry.expect("geometry").viewport, Some(egui::vec2(800.0, 600.0))); + app.shots.cancel_all(); +} + +#[test] +/// Switching documents before issuance cancels the request once with a document-changed error and writes nothing. +fn a_document_switch_before_issuance_cancels_without_writing() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let path = temp_png("doc-queued"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + app.shots.requests[0].ready_at = 0.0; + let admitted = app.shots.requests[0].document; + assert!(app.run("file.new", json!({})).is_ok()); + assert_ne!(app.session.active().map(|d| d.uid), admitted, "the test switched documents"); + + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert!(callbacks.is_empty(), "a capture for another document is never issued"); + let reply = rx.try_recv().expect("the queued request is cancelled once"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("document changed")), "{reply}"); + assert!(!path.exists(), "no pixels for the old document are written to its path"); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// Switching documents after issuance rejects the completed image once and writes nothing. +fn a_document_switch_after_issuance_rejects_the_completed_image() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let path = temp_png("doc-issued"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + assert!(app.run("file.new", json!({})).is_ok()); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the switched document is reported once"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("document changed")), "{reply}"); + assert!(!path.exists(), "a completion after a document switch never writes"); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// A request bound to another viewport is rejected once with a viewport error and writes nothing. +fn a_completion_for_another_viewport_is_rejected() { + let ctx = sized_context(egui::vec2(32.0, 24.0)); + let mut app = test_app(); + let path = temp_png("viewport"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + // A slot/request bound to a different viewport must not fill this one. + app.shots.requests[0].viewport_id = egui::ViewportId::from_hash_of("other-window"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the viewport mismatch is reported once"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("another viewport")), "{reply}"); + assert!(!path.exists()); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// Accepts one-pixel rounding on a single axis, but rejects opposite-axis deviation, aspect distortion and more than one pixel of error. +fn validate_image_rejects_distortion_but_allows_one_pixel_rounding() { + let image = |w: usize, h: usize| egui::ColorImage::filled([w, h], egui::Color32::RED); + assert!(validate_image(&image(100, 100), [100, 100]).is_ok()); + assert!(validate_image(&image(101, 100), [100, 100]).is_ok(), "one pixel of rounding on one axis is legitimate"); + assert!(validate_image(&image(100, 101), [100, 100]).is_ok()); + assert!(validate_image(&image(101, 99), [100, 100]).is_err(), "independent per-axis tolerance must not accept a distorted image"); + assert!(validate_image(&image(99, 101), [100, 100]).is_err()); + assert!(validate_image(&image(102, 100), [100, 100]).is_err(), "more than one pixel per axis is a different surface"); +} + +#[test] +/// Checks the encoding working-set threshold, the base64 reply cap, and the retained-plus-encoding peak budget. +fn encoding_working_set_is_bounded_for_admitted_captures() { + assert!(encoding_working_bytes([8191, 2048], false).expect("checked") <= SCREENSHOT_MAX_ENCODING_BYTES); + assert!(encoding_working_bytes([8192, 8192], false).expect("checked") > SCREENSHOT_MAX_ENCODING_BYTES); + assert!(encoding_working_bytes([4000, 3000], true).expect("checked") <= SCREENSHOT_MAX_ENCODING_BYTES); + assert!(encoding_working_bytes([5000, 4000], true).expect("checked") > SCREENSHOT_MAX_ENCODING_BYTES); + // The base64 reply itself is capped too, before the string exists. + let png = png_worst_bytes(5000 * 4000 * 4, 4000).expect("checked"); + assert!(base64_len(png).expect("checked") > SCREENSHOT_MAX_BASE64_BYTES); + // Retained requests plus one encoding working set are held to a hard peak. + assert!(check_peak_budget(0, [64, 48], false).is_ok()); + assert!(check_peak_budget(SCREENSHOT_MAX_AGGREGATE_BYTES, [9000, 4096], false).is_err()); +} + +#[test] +/// An oversized base64 capture is refused with nothing reserved, while the same capture as a path is admitted. +fn base64_capture_over_the_encoding_budget_is_rejected_before_admission() { + let ctx = sized_context(egui::vec2(5000.0, 4000.0)); + let mut app = test_app(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + let reply = rx.try_recv().expect("an oversized base64 capture is refused at admission"); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("encoding") || e.contains("base64")), "the error names the encoding budget: {reply}"); + assert!(app.shots.requests.is_empty(), "a refused request reserves nothing"); + + // The same capture as a file is admitted within the per-image and encoding budgets. + let path = temp_png("encoding-path"); + let (tx2, rx2) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx2); + assert!(rx2.try_recv().is_err(), "the path form is admitted"); + assert_eq!(app.shots.requests.len(), 1); + app.shots.cancel_all(); +} + +#[test] +/// Oversized text, repeated maximum texts, keys and moves are refused without changing the queue, and the byte/event limits are both enforced. +fn synthetic_text_key_and_move_producers_honour_the_queue_budget() { + let ctx = egui::Context::default(); + let mut app = test_app(); + // One oversized text event is refused before it is queued. + let huge = "x".repeat(MAX_SYNTHETIC_TEXT_BYTES + 1); + let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.text", &json!({"text": huge})) else { panic!("ui.text returns immediately") }; + assert_eq!(reply["ok"], false, "{reply}"); + assert!(app.synthetic.is_empty()); + + // The byte budget stops repeated maximum-size texts; the queue is left unchanged. + let max = "x".repeat(MAX_SYNTHETIC_TEXT_BYTES); + let mut refused_at = None; + for i in 0..64 { + let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.text", &json!({"text": max.clone()})) else { panic!("ui.text returns immediately") }; + if reply["ok"] == false { + refused_at = Some((i, app.synthetic.len())); + break; + } + } + let (accepted, queued) = refused_at.expect("the synthetic byte budget is enforced"); + assert!(accepted > 0 && accepted < 64, "{accepted} texts fit"); + let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.text", &json!({"text": max})) else { panic!("ui.text returns immediately") }; + assert_eq!(reply["ok"], false); + assert_eq!(app.synthetic.len(), queued, "a refused text leaves the queue alone"); + + // Event count: keys and moves use the same overall budget as pointer gestures. + app.synthetic.clear(); + let pos = egui::pos2(1.0, 1.0); + app.synthetic.push_back(vec![egui::Event::PointerMoved(pos); MAX_QUEUED_SYNTHETIC_EVENTS - 1]); + let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.key", &json!({"key":"enter"})) else { panic!("ui.key returns immediately") }; + assert_eq!(reply["ok"], false, "two key events do not fit in the last slot: {reply}"); + assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), MAX_QUEUED_SYNTHETIC_EVENTS - 1); + + app.synthetic.clear(); + app.synthetic.push_back(vec![egui::Event::PointerMoved(pos); MAX_QUEUED_SYNTHETIC_EVENTS]); + let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.move", &json!({"x":2.0,"y":2.0})) else { panic!("ui.move returns immediately") }; + assert_eq!(reply["ok"], false, "{reply}"); + assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), MAX_QUEUED_SYNTHETIC_EVENTS); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d8362da28a..b57230f652 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -13,6 +13,33 @@ use serde_json::{Map, Value as Json, json}; use crate::SheetApp; use crate::theme; +mod about; +mod analysis; +mod charts; +mod command_search; +mod cond_format_rules; +mod data; +mod find; +mod format_cells; +mod insert_function; +mod macros; +mod names; +mod paste; +mod show; +mod spelling; +mod web; +mod zoom; + +use analysis::toolpak_defaults; +use charts::select_chart; +use data::{abs, data_validation_fields, remove_duplicates_range, validation_defaults}; +use find::go_to_entries; +pub(crate) use names::insert_name; +use names::pasteable_names; +pub(crate) use show::build_params; +pub use show::show; +use zoom::zoom_preset; + #[derive(Clone, Debug, PartialEq)] pub enum Field { Text { key: &'static str, label: &'static str }, @@ -182,8 +209,6 @@ impl Dialog { } } - /// Build the named dialog around the current selection: its fields are prefilled from - /// `params`, falling back to what the selection makes sensible. pub fn open(app: &mut SheetApp, name: &str, params: Json) -> Option { let sel = app.session.active().map(|d| d.selection.current().a1()).unwrap_or_default(); let active = app.session.active().map(|d| d.selection.active.a1()).unwrap_or_default(); @@ -553,8 +578,6 @@ impl Dialog { ], json!({"range": remove_duplicates_range(app, &sel), "header": true}), ), - "fromWeb" => Dialog::custom("fromWeb", "From Web", json!({"url": "", "pick": 0})), - "queriesConnections" => Dialog::custom("queriesConnections", "Queries & Connections", json!({})), "advancedFilter" => { let list = app .session @@ -1003,2582 +1026,6 @@ impl Dialog { } } -/// Initial values of the Data Validation form: the rule stored on the active cell, so OK without -/// edits keeps it; a plain list rule when the cell has none. -fn validation_defaults(app: &SheetApp) -> Json { - let mut v = json!({"type": "list", "operator": "between", "ignoreBlank": true, "dropdown": true, "errorStyle": "stop"}); - let Some(doc) = app.session.active() else { return v }; - let Some(dv) = doc.wb.active().and_then(|s| s.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(doc.selection.active)))) else { - return v; - }; - // The enums serialize as their variant names; the command takes them in camelCase. - let camel = |x: Json| { - let name = x.as_str().unwrap_or_default(); - let mut chars = name.chars(); - chars.next().map(|c| c.to_lowercase().chain(chars).collect::()).unwrap_or_default() - }; - v["type"] = json!(camel(json!(dv.kind))); - v["operator"] = json!(camel(json!(dv.op))); - v["errorStyle"] = json!(camel(json!(dv.error_style))); - v["formula1"] = json!(dv.f1); - if let Some(f2) = &dv.f2 { - v["formula2"] = json!(f2); - } - v["ignoreBlank"] = json!(dv.allow_blank); - v["dropdown"] = json!(dv.in_cell_dropdown); - v["inputTitle"] = json!(dv.input_title); - v["inputMessage"] = json!(dv.input_message); - v["errorTitle"] = json!(dv.error_title); - v["errorMessage"] = json!(dv.error_message); - v -} - -/// The range Remove Duplicates starts with. A block selection is used as given; with only the -/// cursor placed, the table's data rows or the current region, as the command picks when it gets -/// no range (a single-cell range would remove nothing). -fn remove_duplicates_range(app: &SheetApp, sel: &str) -> String { - let Some(d) = app.session.active() else { return sel.to_string() }; - let Some(sh) = d.wb.active() else { return sel.to_string() }; - if !d.selection.current().is_single() { - return sel.to_string(); - } - let range = match sh.table_at(d.selection.active) { - Some(t) => t.data_range().map(|dr| gridcraft_engine::core::RangeRef::new(t.range.start, dr.end)).unwrap_or(t.range), - None => gridcraft_engine::selection::current_region(sh, d.selection.active), - }; - range.a1() -} - -fn abs(r: &gridcraft_engine::core::RangeRef) -> String { - let a = |c: gridcraft_engine::core::CellRef| format!("${}${}", gridcraft_engine::core::col_to_letters(c.col), c.row + 1); - if r.is_single() { a(r.start) } else { format!("{}:{}", a(r.start), a(r.end)) } -} - -/// The criterion controls in Excel's Settings tab depend on both Allow and Data. -/// `formula1` is the sole source for a list or custom rule; `formula2` is a -/// second bound only for Between and Not between. -fn data_validation_fields(kind: &str, operator: &str) -> Vec { - use Field::*; - let mut fields = vec![Choice { - key: "type", - label: "Allow", - options: vec![ - ("any", "Any value"), - ("whole", "Whole number"), - ("decimal", "Decimal"), - ("list", "List"), - ("date", "Date"), - ("time", "Time"), - ("textLength", "Text length"), - ("custom", "Custom"), - ], - }]; - let two_bounds = matches!(operator, "between" | "notBetween"); - match kind { - "list" => { - fields.push(Text { key: "formula1", label: "Source:" }); - fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); - fields.push(Check { key: "dropdown", label: "In-cell dropdown" }); - } - "custom" => { - fields.push(Text { key: "formula1", label: "Formula:" }); - fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); - } - "whole" | "decimal" | "date" | "time" | "textLength" => { - fields.push(Choice { - key: "operator", - label: "Data", - options: vec![ - ("between", "between"), - ("notBetween", "not between"), - ("equal", "equal to"), - ("notEqual", "not equal to"), - ("greater", "greater than"), - ("less", "less than"), - ("greaterOrEqual", "greater than or equal to"), - ("lessOrEqual", "less than or equal to"), - ], - }); - let lower = matches!(operator, "greater" | "greaterOrEqual"); - let (first, second) = match kind { - "date" => ( - if two_bounds || lower { - "Start date:" - } else if matches!(operator, "less" | "lessOrEqual") { - "End date:" - } else { - "Date:" - }, - "End date:", - ), - "time" => ( - if two_bounds || lower { - "Start time:" - } else if matches!(operator, "less" | "lessOrEqual") { - "End time:" - } else { - "Time:" - }, - "End time:", - ), - _ => ( - if two_bounds || lower { - "Minimum:" - } else if matches!(operator, "less" | "lessOrEqual") { - "Maximum:" - } else { - "Value:" - }, - "Maximum:", - ), - }; - fields.push(Text { key: "formula1", label: first }); - if two_bounds { - fields.push(Text { key: "formula2", label: second }); - } - fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); - } - _ => {} - } - fields.push(Text { key: "inputMessage", label: "Input message:" }); - fields.push(Choice { - key: "errorStyle", - label: "Error style", - options: vec![("stop", "Stop"), ("warning", "Warning"), ("information", "Information")], - }); - fields.push(Text { key: "errorMessage", label: "Error message:" }); - fields -} - -fn data_validation_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any"); - let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between"); - d.fields = data_validation_fields(kind, operator); - form_fields(ui, d); - ok_cancel(ui, confirm, open); -} - -fn advanced_filter_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - use Field::*; - let copy = d.values.get("action").and_then(Json::as_str) == Some("copy"); - d.fields = vec![ - Choice { - key: "action", - label: "Action", - options: vec![("inPlace", "Show matching rows in place"), ("copy", "Copy matching rows elsewhere")], - }, - Text { key: "range", label: "List range:" }, - Text { key: "criteria", label: "Criteria range (optional):" }, - ]; - if copy { - d.fields.push(Text { key: "copyTo", label: "Copy to:" }); - } - d.fields.push(Check { key: "unique", label: "Unique rows only" }); - form_fields(ui, d); - // Constrain the button row: the shared right-to-left layout otherwise - // consumes the window's remaining height instead of fitting the buttons. - ui.allocate_ui(vec2(ui.available_width(), ui.spacing().interact_size.y + 8.0), |ui| ok_cancel(ui, confirm, open)); -} - -/// Builds the command params from form values for dialogs that need translation. -/// The parameters OK sends to the dialog's command: its field values, reshaped per dialog. -pub(crate) fn build_params(d: &Dialog) -> Json { - let mut v = Json::Object(d.values.clone()); - match d.name.as_str() { - "advancedFilter" => { - if let Some(m) = v.as_object_mut() { - m.remove("action"); - if d.values.get("action").and_then(Json::as_str) != Some("copy") { - m.remove("copyTo"); - } - if m.get("criteria").and_then(Json::as_str).is_some_and(|s| s.trim().is_empty()) { - m.remove("criteria"); - } - } - } - "dataValidation" => { - let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any"); - let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between"); - if let Some(m) = v.as_object_mut() { - if !matches!(operator, "between" | "notBetween") || matches!(kind, "any" | "list" | "custom") { - m.remove("formula2"); - } - if matches!(kind, "any" | "list" | "custom") { - m.remove("operator"); - } - if kind == "any" { - m.remove("formula1"); - } - if kind != "list" { - m.remove("dropdown"); - } - } - } - "textToColumns" => { - let mut delims = Vec::new(); - for (k, c) in [("tab", "\t"), ("semicolon", ";"), ("comma", ","), ("space", " ")] { - if d.values.get(k).and_then(Json::as_bool) == Some(true) { - delims.push(json!(c)); - } - } - v["delimiters"] = Json::Array(delims); - } - "removeDuplicates" | "subtotal" => { - if let Some(t) = d.values.get("columnsText").and_then(Json::as_str).filter(|t| !t.trim().is_empty()) { - v["columns"] = Json::Array(t.split(',').map(|c| json!(c.trim().to_ascii_uppercase())).collect()); - } - } - "allowEditRanges" => { - if let Some(m) = v.as_object_mut() { - // Blank fields mean "unchanged"; the check box clears a password. - m.retain(|_, x| x.as_str().is_none_or(|s| !s.trim().is_empty())); - if m.remove("removePassword").and_then(|x| x.as_bool()) == Some(true) { - m.insert("password".into(), json!("")); - } - } - } - "cfQuick" => { - let mut rule = Map::new(); - for (k, val) in &d.values { - rule.insert(k.clone(), val.clone()); - } - v = json!({"rule": Json::Object(rule)}); - if let Some(pick) = d.pick.as_ref().filter(|pick| !pick.applies_to.is_empty() && !pick.sheet.is_empty()) { - v["sheet"] = json!(pick.sheet.as_str()); - v["range"] = json!(pick.applies_to); - } - } - "series" => { - if let Some(s) = d.values.get("stop").and_then(Json::as_str).and_then(|s| s.parse::().ok()) { - v["stop"] = json!(s); - } else if let Some(m) = v.as_object_mut() { - m.remove("stop"); - } - } - "defineName" => { - if let Some(m) = v.as_object_mut() { - for key in ["scope", "oldScope"] { - if m.get(key).and_then(Json::as_str) == Some("Workbook") { - m.remove(key); - } - } - } - } - _ => {} - } - v -} - -/// Takes and draws the active dialog, dispatches its body by name, consumes picture-inbox results, runs OK/confirm commands, handles Esc/close and restores the back-dialog stack. -pub fn show(app: &mut SheetApp, ctx: &egui::Context) { - let Some(mut d) = app.dialog.take() else { return }; - let home = app.ui_window(); - if d.name == "pasteSpecial" { - paste_special_shortcuts(&mut d, ctx); - } - let mut open = true; - let mut win_open = true; - let mut confirm = false; - let width = match d.name.as_str() { - "formatCells" => 560.0, - "about" => 660.0, - "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "macros" => 520.0, - "recommendedCharts" => 600.0, - "analysisToolPak" => 420.0, - "solver" => 460.0, - "zoom" => 220.0, - "pasteSpecial" => 480.0, - _ => 380.0, - }; - let window = egui::Window::new(app.ui.language.tr(&d.title)) - .id(egui::Id::unique("dialog").with(&d.name)) - .collapsible(false) - .resizable(false) - .default_width(width) - // A default position, not an anchor: egui anchors can't be dragged, and dialogs move by their title bar. - .pivot(egui::Align2::CENTER_CENTER) - .default_pos(ctx.content_rect().center() + vec2(0.0, -40.0)) - .open(&mut win_open); - let window = match d.name.as_str() { - "insertPicture" => window.default_height(240.0), - "advancedFilter" => window.default_height(0.0), - _ => window, - }; - window.show(ctx, |ui| { - ui.set_min_width(width - 20.0); - match d.name.as_str() { - "formatCells" => format_cells(app, ui, &mut d, &mut confirm), - "dataValidation" => data_validation_form(ui, &mut d, &mut confirm, &mut open), - "advancedFilter" => advanced_filter_form(ui, &mut d, &mut confirm, &mut open), - "insertFunction" => insert_function(app, ui, &mut d, &mut confirm), - "find" => find(app, ui, &mut d), - "goTo" => go_to(app, ui, &mut d, &mut confirm), - "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), - "commandSearch" => command_search(app, ui, &mut d, &mut confirm), - "fromWeb" => from_web(app, ui, &mut d, &mut confirm, &mut open), - "queriesConnections" => queries_connections(app, ui, &mut d, &mut confirm, &mut open), - "goalSeek" => goal_seek(app, ui, &mut d, &mut confirm), - "pasteSpecial" => paste_special(app, ui, &mut d, &mut confirm, &mut open), - "analysisToolPak" => analysis_toolpak(app, ui, &mut d, &mut confirm, &mut open), - "solver" => solver(ui, &mut d, &mut confirm, &mut open), - "solverResults" => solver_results(app, ui, &mut d, &mut open), - "spelling" => spelling(app, ui, &mut d, &mut open), - "agents" => { - ui.label("GridCraft is fully drivable by agents. Every menu item, button and gesture is a command:"); - ui.add_space(4.0); - ui.monospace("gridcraft --control 7979 # JSON-lines control channel\ngridcraft-cli mcp --connect 7979 # MCP bridged to this window\ngridcraft-cli mcp # headless MCP server"); - ui.add_space(4.0); - ui.label(format!("{} engine commands are available. See docs/mcp.md and docs/control-protocol.md.", gridcraft_engine::command_specs().len())); - } - "about" => about(ui, &mut d), - "journal" => { - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - for (id, p) in app.session.journal.iter().rev().take(500) { - ui.monospace(format!("{id} {p}")); - } - }); - } - "macros" => macros(app, ui, &mut d), - "statistics" | "accessibility" | "errorChecking" | "evaluateFormula" | "comments" => { - let r = if d.name == "comments" { - app.session.active().and_then(|doc| doc.wb.active().map(|s| json!(s.comments.iter().map(|(c, m)| json!({"cell": c.a1(), "author": m.author, "text": m.text})).collect::>()))) - } else { - d.result.clone() - }; - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - ui.monospace(serde_json::to_string_pretty(&r.unwrap_or(Json::Null)).unwrap_or_default()); - }); - } - "insertPicture" => { - // A private inbox dies with this dialog; late browser results - // cannot affect a later picture choice or another workbook. - let arrived = d.picture_inbox.as_ref().and_then(|inbox| { - inbox.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pop() - }); - if let Some((name, bytes)) = arrived { - d.picture_inbox = None; - if bytes.len() > 16 * 1024 * 1024 { - app.message = Some((d.title.clone(), "Choose a PNG or JPEG no larger than 16 MB.".into())); - } else { - d.values.remove("path"); - d.values.insert("alt".into(), json!(name)); - d.values.insert("base64".into(), json!(gridcraft_engine::io::base64_encode(&bytes))); - } - } - if app.services.pick_picture_async.is_some() { - let name = d.values.get("alt").and_then(Json::as_str).unwrap_or("No picture selected"); - ui.label(name); - } else { - ui.label("Picture file path (PNG or JPEG):"); - let mut path = d.values.get("path").and_then(Json::as_str).unwrap_or("").to_string(); - if ui.text_edit_singleline(&mut path).changed() { - d.values.remove("base64"); - d.values.insert("path".into(), json!(path)); - } - } - if (app.services.pick_picture.is_some() || app.services.pick_picture_async.is_some()) && ui.button("Browse…").clicked() { - if let Some(start) = &app.services.pick_picture_async { - let inbox = crate::Inbox::default(); - start(inbox.clone()); - d.picture_inbox = Some(inbox); - } else if let Some(pick) = &app.services.pick_picture - && let Some(path) = pick() - { - d.values.remove("base64"); - let alt = std::path::Path::new(&path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(""); - d.values.insert("alt".into(), json!(alt)); - d.values.insert("path".into(), json!(path)); - } - } - ui.label("Description (optional):"); - let mut alt = d.values.get("alt").and_then(Json::as_str).unwrap_or("").to_string(); - if ui.text_edit_singleline(&mut alt).changed() { - d.values.insert("alt".into(), json!(alt)); - } - if d.values.get("placement").and_then(Json::as_str) == Some("cell") { - ui.label(egui::RichText::new("Fits inside the selected cell and moves with its content.").small()); - } - ok_cancel(ui, &mut confirm, &mut open); - } - "pickList" => { - let items: Vec = app - .session - .active() - .and_then(|doc| { - let sh = doc.wb.active()?; - let a = doc.selection.active; - let mut seen = std::collections::BTreeSet::new(); - let mut r = a.row; - while r > 0 && !sh.value(gridcraft_engine::core::CellRef::new(r - 1, a.col)).is_empty() { - r -= 1; - seen.insert(sh.value(gridcraft_engine::core::CellRef::new(r, a.col)).display()); - } - Some(seen.into_iter().collect()) - }) - .unwrap_or_default(); - for it in items { - if ui.button(&it).clicked() { - app.run_or_alert("cell.set", json!({"input": it})); - open = false; - } - } - } - "saveCopy" => { - let fmt = d.values.get("format").and_then(Json::as_str).unwrap_or("xlsx").to_string(); - ui.label(format!("Save a copy as .{fmt}")); - let mut path = d.values.get("path").and_then(Json::as_str).unwrap_or("").to_string(); - ui.text_edit_singleline(&mut path); - d.values.insert("path".into(), json!(path.clone())); - ok_cancel(ui, &mut confirm, &mut open); - if confirm { - // A bare name has no folder: written as typed it would land in the - // process's working directory (`/` from the Finder, the install folder on - // Windows), never where the user looks. The picker chooses the folder. - let pick = app.services.pick_save.as_ref(); - let target = if path.is_empty() { - pick.and_then(|f| f(&format!("Copy.{fmt}"))) - } else if let Some(f) = pick.filter(|_| std::path::Path::new(&path).is_relative()) { - f(&if path.ends_with(&format!(".{fmt}")) { path } else { format!("{path}.{fmt}") }) - } else { - Some(path) - }; - if let Some(t) = target { - let id = match fmt.as_str() { - "csv" => "file.exportCsv", - "html" => "file.exportHtml", - _ => "file.saveAs", - }; - app.run_or_alert(id, json!({"path": t})); - } - } - } - "saveChanges" => { - ui.label(format!("Do you want to save the changes you made to {}?", d.values.get("title").and_then(Json::as_str).unwrap_or("this workbook"))); - let quitting = d.values.get("quit").and_then(Json::as_bool) == Some(true); - ui.horizontal(|ui| { - for (label, answer) in - [("Don't Save", crate::SaveAnswer::DontSave), ("Cancel", crate::SaveAnswer::Cancel), ("Save", crate::SaveAnswer::Save)] - { - if ui.button(label).clicked() && app.answer_save_changes(answer, quitting, ui.ctx()) { - open = false; - } - } - }); - } - "start" => { - ui.heading("Start a new workbook"); - ui.horizontal_wrapped(|ui| { - if ui.button("Blank workbook").clicked() { - app.run_or_alert("file.new", json!({})); - open = false; - } - for (n, t) in gridcraft_engine::sample::SAMPLES { - if ui.button(*t).clicked() { - app.run_or_alert("file.new", json!({"sample": n})); - open = false; - } - } - }); - ui.separator(); - ui.label(egui::RichText::new("Recent").strong()); - for path in app.ui.recent.clone() { - if ui.link(&path).clicked() { - app.open_path(&path); - open = false; - } - } - if ui.button("Open…").clicked() { - app.open_dialog("open", json!({})); - open = false; - } - } - _ => form(ui, &mut d, &mut confirm, &mut open), - } - }); - // Hyperlinks inside dialogs (About ▸ Contributors) open through the host's `open_url`, since - // the native shell has no browser integration of its own. - if let Some(open_url) = &app.services.open_url { - let urls: Vec = ctx.output_mut(|o| { - let mut urls = vec![]; - o.commands.retain(|c| match c { - egui::OutputCommand::OpenUrl(u) => { - urls.push(u.url.clone()); - false - } - _ => true, - }); - urls - }); - for u in urls { - open_url(&u); - } - } - if ctx.input(|i| i.key_pressed(Key::Escape)) || !win_open { - open = false; - } - if confirm && d.name == "insertPicture" && d.picture_target != app.view_key() { - app.message = Some((d.title.clone(), "Return to the original worksheet to insert this picture, or cancel and choose another cell.".into())); - confirm = false; - } - if confirm && let Some(cmd) = d.command { - let params = build_params(&d); - match app.run(cmd, params) { - Ok(r) => { - open = false; - if d.name == "solver" { - app.open_dialog("solverResults", r); - } else if d.name == "openPassword" { - let path = d.values.get("path").and_then(Json::as_str).map(str::to_string); - app.after_open(path.as_deref(), &r); - } - } - // Data Analysis asks before replacing data in its output range. - Err(e) if d.name == "analysisToolPak" && e.contains(gridcraft_engine::cmd::analysis::OVERWRITE_ERROR) => { - d.values.insert("askOverwrite".into(), json!(true)); - } - // A wrong password: say so, clear the field and stay open for another try. - Err(e) if d.name == "openPassword" => { - d.values.insert("password".into(), json!("")); - app.message = Some((d.title.clone(), crate::clean_error(&e))); - } - Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), - } - } else if confirm { - open = false; - } - if app.ui_window() != home { - // A command run from the dialog switched windows: whatever it opened shows in the new - // window, and this dialog (or the one it returns to) stays in its own. - let keep = if open { Some(d) } else { d.back.take().map(|b| *b) }; - if let Some(keep) = keep { - app.return_dialog(home, keep); - } - return; - } - if let Some(child) = app.dialog.as_mut() { - // The body opened another dialog (Name Manager › New…). It takes over; this one waits - // behind it and comes back when it closes, as Excel returns to the Name Manager. Go To - // hands over to Go To Special for good, as in Excel. - if open && d.name != "goTo" { - child.back = Some(Box::new(d)); - } else if child.back.is_none() { - child.back = d.back.take(); - } - return; - } - if open { - app.dialog = Some(d); - } else if let Some(back) = d.back.take() { - app.dialog = Some(*back); - } -} - -/// Paste Special as Excel lays it out: Paste and Operation each in two columns of options, Skip -/// blanks and Transpose side by side, and Paste Link as a button that pastes at once. The options -/// come from the dialog's `fields`, so the letter shortcuts and the control channel see the same. -fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - let lang = app.ui.language; - let t = theme::Tokens::get(ui.ctx()); - let choices: Vec<(&'static str, &'static str, Vec<(&'static str, &'static str)>)> = d - .fields - .iter() - .filter_map(|f| match f { - Field::Choice { key, label, options } => Some((*key, *label, options.clone())), - _ => None, - }) - .collect(); - for (key, label, options) in choices { - ui.add_space(4.0); - ui.label(egui::RichText::new(lang.tr(label)).strong()); - egui::Frame::new().stroke(Stroke::new(1.0, t.separator)).corner_radius(4.0).inner_margin(egui::Margin::symmetric(10, 6)).show(ui, |ui| { - ui.set_min_width(ui.available_width()); - let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); - // Column by column, as Excel fills them. - let rows = options.len().div_ceil(2); - egui::Grid::new(("paste_special", key)).num_columns(2).min_col_width(200.0).spacing(vec2(16.0, 4.0)).show(ui, |ui| { - for r in 0..rows { - for c in 0..2 { - if let Some((value, text)) = options.get(c * rows + r) { - if ui.radio(cur == *value, lang.tr(text)).clicked() { - d.set_value(key, json!(value)); - } - } else { - ui.label(""); - } - } - ui.end_row(); - } - }); - }); - } - ui.add_space(8.0); - // In the same columns as the options above. - egui::Frame::new().inner_margin(egui::Margin::symmetric(11, 0)).show(ui, |ui| { - egui::Grid::new("paste_special_checks").num_columns(2).min_col_width(200.0).spacing(vec2(16.0, 4.0)).show(ui, |ui| { - for (key, label) in [("skipBlanks", "Skip blanks"), ("transpose", "Transpose (E)")] { - let mut b = d.values.get(key).and_then(Json::as_bool).unwrap_or(false); - if ui.checkbox(&mut b, lang.tr(label)).changed() { - d.values.insert(key.into(), json!(b)); - } - } - ui.end_row(); - }); - }); - ui.add_space(8.0); - ui.separator(); - ui.horizontal(|ui| { - // Excel's Paste Link pastes references to the copied cells straight away. - if ui.button(lang.tr("Paste Link")).on_hover_text(lang.tr("Paste formulas that refer to the copied cells")).clicked() { - d.values.insert("what".into(), json!("all")); - d.values.insert("operation".into(), json!("none")); - d.values.insert("link".into(), json!(true)); - *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(t.on_accent)).fill(t.accent)); - if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { - *confirm = true; - } - if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() { - *open = false; - } - }); - }); -} - -/// Paste Special's legacy mnemonics select options; Enter still performs the paste. -fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) { - if ctx.text_edit_focused() { - return; - } - let right_alt = ctx.input(|i| { - i.key_down(Key::AltRight) - || i.events.iter().any(|event| { - matches!(event, egui::Event::Key { key: Key::AltRight, .. }) - || matches!(event, egui::Event::Key { physical_key: Some(Key::AltRight), .. }) - }) - }); - let mac = ctx.os() == egui::os::OperatingSystem::Mac; - let allowed = |m: egui::Modifiers| !m.ctrl && !m.command && !m.mac_cmd && !(m.alt && (mac || right_alt)); - let mnemonic = |key| match key { - Key::T => Some('t'), - Key::V => Some('v'), - Key::F => Some('f'), - Key::C => Some('c'), - Key::E => Some('e'), - _ => None, - }; - let mut text_to_consume = Vec::new(); - for event in ctx.input(|i| i.events.clone()) { - let egui::Event::Key { key, pressed: true, repeat, modifiers, .. } = event else { continue }; - if !allowed(modifiers) { - continue; - } - let Some(letter) = mnemonic(key) else { continue }; - text_to_consume.push(letter); - if repeat { - continue; - } - if key == Key::E { - let transpose = d.values.get("transpose").and_then(Json::as_bool).unwrap_or(false); - d.values.insert("transpose".into(), json!(!transpose)); - } else { - let what = match key { - Key::T => "formats", - Key::V => "values", - Key::F => "formulas", - _ => "comments", - }; - d.values.insert("what".into(), json!(what)); - } - } - // Browsers can deliver Text before or after Key. Act only on Key, then remove - // its matching text so one physical press cannot toggle Transpose twice. - ctx.input_mut(|i| { - i.events.retain(|event| match event { - egui::Event::Key { key, modifiers, .. } => !(allowed(*modifiers) && mnemonic(*key).is_some()), - egui::Event::Text(text) if text.len() == 1 => { - let letter = text.chars().next().unwrap_or_default().to_ascii_lowercase(); - if let Some(index) = text_to_consume.iter().position(|&c| c == letter) { - text_to_consume.remove(index); - false - } else { - true - } - } - _ => true, - }); - }); -} - -fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) { - ui.add_space(8.0); - // A row as tall as the buttons: a right-to-left layout straight in the window takes all the - // height it is offered, which stretched every form dialog to the screen's height. - ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let lang = crate::i18n::current(ui.ctx()); - 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; - } - if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() { - *open = false; - } - }) - }); -} - -fn form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - form_fields(ui, d); - ok_cancel(ui, confirm, open); -} - -/// Gives keyboard focus to the first text or number field the first time a form is drawn. A -/// focused number field is in typing mode with its value selected, so Alt+H, O, W then 12 and -/// Enter sets the width, as in Excel. -/// Keeps asking until the field really has focus: a new window's first frame is an invisible -/// sizing pass where a focus request is ignored. -fn take_first_focus(d: &mut Dialog, r: &egui::Response) { - if !d.focus_first_field { - return; - } - if !r.has_focus() { - r.request_focus(); - return; - } - // Focus has landed, and the field is now a text box: select its whole value so typing - // replaces it (the field's own select-on-focus fired before it was a text box). - let mut state = egui::text_edit::TextEditState::load(&r.ctx, r.id).unwrap_or_default(); - let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(usize::MAX / 2)); - state.cursor.set_char_range(Some(all)); - state.store(&r.ctx, r.id); - d.focus_first_field = false; -} - -/// Keeps a picking formula box's caret, and gives it the keyboard back after a grid click put a -/// reference in it (the click took the focus), its caret after the reference. -fn track_pick(pick: &mut RefPick, r: &egui::Response, typed: bool) { - if typed { - pick.point = None; - } - if pick.refocus { - let mut state = egui::text_edit::TextEditState::load(&r.ctx, r.id).unwrap_or_default(); - state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(pick.caret)))); - state.store(&r.ctx, r.id); - r.request_focus(); - pick.refocus = false; - } else if r.has_focus() - && let Some(range) = egui::text_edit::TextEditState::load(&r.ctx, r.id).and_then(|state| state.cursor.char_range()) - { - pick.caret = range.primary.index.into(); - } -} - -/// Draws a dialog's `fields`, editing its values. -fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { - // Field labels, choices and notes are English source strings, translated as they are drawn. - let lang = crate::i18n::current(ui.ctx()); - egui::Grid::new("form").num_columns(1).spacing(vec2(8.0, 6.0)).show(ui, |ui| { - for f in d.fields.clone() { - match f { - Field::Text { key, label } => { - ui.vertical(|ui| { - let label = ui.label(lang.tr(label)); - let mut s = d - .values - .get(key) - .map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| if v.is_null() { String::new() } else { v.to_string() })) - .unwrap_or_default(); - let multiline = key == "text"; - let r = if multiline { - ui.add(egui::TextEdit::multiline(&mut s).desired_rows(4).desired_width(320.0)).labelled_by(label.id) - } else { - ui.add(egui::TextEdit::singleline(&mut s).desired_width(320.0)).labelled_by(label.id) - }; - take_first_focus(d, &r); - if r.changed() { - d.values.insert(key.into(), json!(s)); - } - if let Some(pick) = d.pick.as_mut().filter(|pick| pick.key == key) { - track_pick(pick, &r, r.changed()); - } - }); - } - Field::Password { key, label } => { - // Masked as typed (Excel shows dots); "Show password" reveals it on purpose. - ui.vertical(|ui| { - let label = ui.label(lang.tr(label)); - let mut s = d.values.get(key).and_then(Json::as_str).unwrap_or_default().to_string(); - if ui.add(egui::TextEdit::singleline(&mut s).password(!d.show_password).desired_width(320.0)).labelled_by(label.id).changed() - { - d.values.insert(key.into(), json!(s)); - } - ui.checkbox(&mut d.show_password, lang.tr("Show password")); - }); - } - Field::Number { key, label } => { - ui.horizontal(|ui| { - let label = ui.label(lang.tr(label)); - let mut n = d.values.get(key).and_then(Json::as_f64).unwrap_or(0.0); - // Typed values count straight away: otherwise Enter or OK in the same frame - // confirms the old value, because a number field only commits when it loses focus. - let r = ui.add(egui::DragValue::new(&mut n).speed(0.5).update_while_editing(true)).labelled_by(label.id); - take_first_focus(d, &r); - if r.changed() { - d.values.insert(key.into(), json!(n)); - } - }); - } - Field::Choice { key, label, options } => { - ui.vertical(|ui| { - let field_label = ui.label(egui::RichText::new(lang.tr(label)).strong()); - let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); - if d.name == "dataValidation" { - let selected = options.iter().find(|(val, _)| *val == cur).map(|(_, text)| lang.tr(text)).unwrap_or(""); - egui::ComboBox::from_id_salt(("dataValidation", key)) - .selected_text(selected) - .width(300.0) - .show_ui(ui, |ui| { - for (val, text) in options { - if ui.selectable_label(cur == val, lang.tr(text)).clicked() { - d.set_value(key, json!(val)); - } - } - }) - .response - .labelled_by(field_label.id); - } else { - for (val, text) in options { - if ui.radio(cur == val, lang.tr(text)).clicked() { - d.set_value(key, json!(val)); - } - } - } - }); - } - Field::Check { key, label } => { - let mut b = d.values.get(key).and_then(Json::as_bool).unwrap_or(false); - if ui.checkbox(&mut b, lang.tr(label)).changed() { - d.values.insert(key.into(), json!(b)); - } - } - Field::Note(t) => { - // A note can carry user data (a URL, a file or sheet name); `tr` reads a `|` as a - // context separator, so such a note is shown as it is. - let text = if t.contains('|') { t.as_str() } else { lang.tr(&t) }; - ui.label(egui::RichText::new(text).small()); - } - } - ui.end_row(); - } - }); -} - -/// Help ▸ About GridCraft: the app, its contributors and the AI models that helped. The credits -/// are compiled in (`crate::credits`, docs/contributors.md). -fn about(ui: &mut egui::Ui, d: &mut Dialog) { - ui.horizontal(|ui| { - for tab in ["About", "Contributors", "Models"] { - if ui.selectable_label(d.tab == tab, tab).clicked() { - d.tab = tab.to_string(); - } - } - }); - ui.separator(); - match d.tab.as_str() { - "Contributors" => crate::credits::contributors_ui(ui), - "Models" => crate::credits::models_ui(ui), - _ => { - ui.heading("GridCraft"); - ui.label(format!("Version {}", env!("CARGO_PKG_VERSION"))); - ui.add_space(4.0); - ui.label("A clean-room, open-source, Rust-native spreadsheet. Part of ArtCraft."); - ui.hyperlink_to("getartcraft.com/apps/gridcraft", "https://getartcraft.com/apps/gridcraft"); - ui.hyperlink_to("Join the ArtCraft Discord", "https://discord.gg/artcraft"); - } - } -} - -/// The Macros dialog: lists GridCraft's own recorded scripts and (if the file carries one) the -/// preserved VBA project. Mirrors Excel's Developer ▸ Macros list, but is honest that VBA is -/// preserved and not executed. -fn macros(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { - ui.label("Automation in this workbook. GridCraft runs its own scripts; a file's VBA project is preserved but not executed."); - ui.add_space(6.0); - let items: Vec = d.result.as_ref().and_then(Json::as_array).cloned().unwrap_or_default(); - if items.is_empty() { - ui.weak("This workbook has no scripts or macros."); - return; - } - // Scripts that came with a file stay disabled until the user trusts the workbook (#2178). - if items.iter().any(|it| it.get("trusted").and_then(Json::as_bool) == Some(false)) { - ui.horizontal(|ui| { - ui.weak("Scripts in this workbook came from a file and are disabled."); - if ui.button("Trust Scripts").on_hover_text("Let this workbook's scripts run for the rest of this session").clicked() { - let _ = app.run("automate.trustScripts", json!({"trusted": true})); - d.result = app.session.run("view.macros", json!({})).ok(); - } - }); - ui.add_space(6.0); - } - egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { - for it in &items { - let name = it.get("name").and_then(Json::as_str).unwrap_or("(unnamed)"); - let is_vba = it.get("kind").and_then(Json::as_str) == Some("vba"); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(name).strong()); - if is_vba { - ui.weak("— VBA project (preserved, not run)"); - } else { - let steps = it.get("steps").and_then(Json::as_u64).unwrap_or(0); - ui.weak(format!("— {steps} step{}", if steps == 1 { "" } else { "s" })); - } - }); - } - }); -} - -/// Draws the six-tab Format Cells editor (number, alignment, font, border, fill, protection); the Number tab shows a sample preview, and the style draft updates. -fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { - // `d.tab` stays the English name (it is what `open_dialog` and tests pass); only the drawn - // labels are translated. The Fill tab is keyed by context: the Alignment tab's "Fill" is a - // different word in some languages. - let lang = app.ui.language; - ui.horizontal(|ui| { - for (tab, key) in [ - ("Number", "Number"), - ("Alignment", "Alignment"), - ("Font", "Font"), - ("Border", "Border"), - ("Fill", "Format Cells|Fill"), - ("Protection", "Protection"), - ] { - if ui.selectable_label(d.tab == tab, lang.tr(key)).clicked() { - d.tab = tab.to_string(); - } - } - }); - ui.separator(); - let mut st: gridcraft_engine::model::Style = d.values.get("style").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); - let sample = app.session.active().and_then(|doc| doc.wb.active().map(|s| s.value(doc.selection.active))).unwrap_or_default(); - match d.tab.as_str() { - "Number" => { - let mut code = st.num_fmt.as_str().to_string(); - ui.columns(2, |cols| { - cols[0].label(egui::RichText::new(lang.tr("Category:")).strong()); - for name in - ["General", "Number", "Currency", "Accounting", "Short Date", "Long Date", "Time", "Percentage", "Fraction", "Scientific", "Text"] - { - let c = gridcraft_engine::cmd::format::format_code_for(name).to_string(); - if cols[0].selectable_label(code == c, lang.tr(name)).clicked() { - code = c; - } - } - cols[1].label(egui::RichText::new(lang.tr("Sample")).strong()); - let preview = app.session.active().map(|doc| gridcraft_engine::display::format(&sample, &code, &doc.wb).text).unwrap_or_default(); - cols[1].label(egui::RichText::new(preview).font(theme::ui_font(15.0))); - cols[1].add_space(8.0); - let label = cols[1].label(lang.tr("Type (custom format code):")); - cols[1].text_edit_singleline(&mut code).labelled_by(label.id); - for c in [ - "0", - "0.00", - "#,##0", - "#,##0.00", - "#,##0;[Red]-#,##0", - "0%", - "0.00%", - "0.00E+00", - "# ?/?", - "m/d/yyyy", - "d-mmm-yy", - "mmmm d, yyyy", - "h:mm AM/PM", - "h:mm:ss", - "[h]:mm:ss", - "@", - ] { - if cols[1].small_button(c).clicked() { - code = c.to_string(); - } - } - }); - st.num_fmt = gridcraft_engine::model::NumFmt::new(&code); - } - "Alignment" => { - use gridcraft_engine::model::{HAlign, VAlign}; - ui.label(egui::RichText::new(lang.tr("Horizontal")).strong()); - ui.horizontal_wrapped(|ui| { - for (h, n) in [ - (HAlign::General, "General"), - (HAlign::Left, "Left"), - (HAlign::Center, "Center"), - (HAlign::Right, "Right"), - (HAlign::Fill, "Fill"), - (HAlign::Justify, "Justify"), - (HAlign::CenterAcross, "Center Across Selection"), - (HAlign::Distributed, "Distributed"), - ] { - ui.radio_value(&mut st.align.h, h, lang.tr(n)); - } - }); - ui.label(egui::RichText::new(lang.tr("Vertical")).strong()); - ui.horizontal_wrapped(|ui| { - for (v, n) in [ - (VAlign::Top, "Top"), - (VAlign::Center, "Center"), - (VAlign::Bottom, "Bottom"), - (VAlign::Justify, "Justify"), - (VAlign::Distributed, "Distributed"), - ] { - ui.radio_value(&mut st.align.v, v, lang.tr(n)); - } - }); - let mut indent = st.align.indent as f32; - ui.add(egui::Slider::new(&mut indent, 0.0..=15.0).text(lang.tr("Indent"))); - st.align.indent = indent as u8; - let mut rot = if st.align.rotation == 255 { 0.0 } else { st.align.rotation as f32 }; - ui.add(egui::Slider::new(&mut rot, -90.0..=90.0).text(lang.tr("Orientation (degrees)"))); - st.align.rotation = rot as i16; - ui.checkbox(&mut st.align.wrap, lang.tr("Wrap text")); - ui.checkbox(&mut st.align.shrink, lang.tr("Shrink to fit")); - } - "Font" => { - egui::ComboBox::from_label(lang.tr("Font")).selected_text(st.font.name.clone()).show_ui(ui, |ui| { - for f in crate::fonts::families() { - ui.selectable_value(&mut st.font.name, f.clone(), f.clone()); - } - }); - ui.add(egui::Slider::new(&mut st.font.size, 6.0..=72.0).text(lang.tr("Size"))); - ui.horizontal(|ui| { - ui.checkbox(&mut st.font.bold, lang.tr("Bold")); - ui.checkbox(&mut st.font.italic, lang.tr("Italic")); - ui.checkbox(&mut st.font.strike, lang.tr("Strikethrough")); - }); - use gridcraft_engine::model::{Underline, VertAlign}; - ui.horizontal(|ui| { - for (u, n) in [ - (Underline::None, "None"), - (Underline::Single, "Single"), - (Underline::Double, "Double"), - (Underline::SingleAccounting, "Single Accounting"), - (Underline::DoubleAccounting, "Double Accounting"), - ] { - ui.radio_value(&mut st.font.underline, u, lang.tr(n)); - } - }); - ui.horizontal(|ui| { - ui.radio_value(&mut st.font.vert, VertAlign::Baseline, lang.tr("Normal")); - ui.radio_value(&mut st.font.vert, VertAlign::Superscript, lang.tr("Superscript")); - ui.radio_value(&mut st.font.vert, VertAlign::Subscript, lang.tr("Subscript")); - }); - ui.label(lang.tr("Color:")); - let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default(); - if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("Automatic")) { - st.font.color = - if c == "none" { gridcraft_engine::model::Color::Auto } else { gridcraft_engine::model::Color::from_hex(&c).unwrap_or_default() }; - } - } - "Border" => { - use gridcraft_engine::model::{BorderLine, BorderStyle}; - let line = BorderLine { style: BorderStyle::Thin, color: gridcraft_engine::model::Color::Auto }; - ui.horizontal(|ui| { - if ui.button(lang.tr("None")).clicked() { - st.borders = Default::default(); - } - if ui.button(lang.tr("Outline")).clicked() { - st.borders.top = line; - st.borders.bottom = line; - st.borders.left = line; - st.borders.right = line; - } - }); - for (n, slot) in [ - ("Top", &mut st.borders.top), - ("Bottom", &mut st.borders.bottom), - ("Left", &mut st.borders.left), - ("Right", &mut st.borders.right), - ("Diagonal ↘", &mut st.borders.diag_down), - ("Diagonal ↗", &mut st.borders.diag_up), - ] { - let n = lang.tr(n); - ui.horizontal(|ui| { - ui.label(format!("{n}:")); - for (s, label) in [ - (BorderStyle::None, "none"), - (BorderStyle::Thin, "thin"), - (BorderStyle::Medium, "medium"), - (BorderStyle::Thick, "thick"), - (BorderStyle::Dashed, "dashed"), - (BorderStyle::Dotted, "dotted"), - (BorderStyle::Double, "double"), - ] { - let label = lang.tr(label); - let response = ui.selectable_label(slot.style == s, label); - let selected = slot.style == s; - response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected, format!("{n} {label}"))); - if response.clicked() { - slot.style = s; - } - } - }); - } - let theme = app.session.active().map(|doc| &doc.wb.theme).cloned().unwrap_or_default(); - crate::border_preview::sample(ui, &st.borders, &theme); - } - "Fill" => { - let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default(); - ui.label(lang.tr("Background Color:")); - if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("No Color")) { - st.fill = if c == "none" { - Default::default() - } else { - gridcraft_engine::model::Fill::solid(gridcraft_engine::model::Color::from_hex(&c).unwrap_or_default()) - }; - } - } - "Protection" => { - ui.checkbox(&mut st.protection.locked, lang.tr("Locked")); - ui.checkbox(&mut st.protection.hidden, lang.tr("Hidden")); - ui.label( - egui::RichText::new( - lang.tr("Locking cells or hiding formulas has no effect until you protect the worksheet (Review › Protect Sheet)."), - ) - .small(), - ); - } - _ => {} - } - if let Ok(v) = serde_json::to_value(&st) { - d.values.insert("style".into(), v); - } - let mut open = true; - ok_cancel(ui, confirm, &mut open); - if *confirm && let Some(style) = d.values.get("style").cloned() { - app.run_or_alert("home.formatCells", json!({"style": style})); - } - if !open { - app.dialog = None; - *confirm = true; // closes - } -} - -/// Insert Function: searches names (as the formula language spells them) and descriptions, shows -/// each hit's category and description, and starts editing `=NAME(` (or appends to the formula). -fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { - let language = app.ui.language; - ui.horizontal(|ui| { - ui.label(language.tr("Search:")); - let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(300.0).hint_text(language.tr("e.g. lookup, average, date"))); - r.request_focus(); - }); - let locale = language.formula_locale(); - let list = gridcraft_engine::cmd::formulas::function_list(); - // The names and descriptions searched are the ones shown, so in the interface language. - let q = d.search.to_lowercase(); - let hits: Vec<&Json> = list - .iter() - .filter(|f| { - q.is_empty() - || f["name"].as_str().is_some_and(|n| locale.function_name(n).to_lowercase().contains(&q)) - || f["description"].as_str().is_some_and(|n| language.function_description(n).unwrap_or(n).to_lowercase().contains(&q)) - }) - .take(300) - .collect(); - egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { - for (i, f) in hits.iter().enumerate() { - let name = locale.function_name(f["name"].as_str().unwrap_or("")); - let category = language.tr(function_category_label(f["category"].as_str().unwrap_or(""))); - let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{name:<18} {category}")).min_size(vec2(480.0, 20.0))); - if r.clicked() { - d.list_index = i; - } - if r.double_clicked() { - d.list_index = i; - *confirm = true; - } - } - }); - if let Some(f) = hits.get(d.list_index) { - let name = f["name"].as_str().unwrap_or(""); - ui.separator(); - ui.label(egui::RichText::new(crate::formula_locale::signature(name, locale, language).unwrap_or_default()).strong()); - ui.label(crate::formula_locale::description(name, locale, language).unwrap_or_default()); - } - let mut open = true; - ok_cancel(ui, confirm, &mut open); - if *confirm && let Some(n) = hits.get(d.list_index).and_then(|f| f["name"].as_str()) { - let n = app.session.active().map(|doc| crate::formula_locale::completion_name(n, locale, &doc.wb, doc.wb.active_sheet())).unwrap_or(n); - let cur = app.editor.as_ref().map(|e| e.text.clone()); - let text = match cur { - Some(t) if t.starts_with('=') => format!("{t}{n}("), - _ => format!("={n}("), - }; - app.begin_edit(Some(text), false); - } - if !open { - *confirm = true; - } -} - -/// Excel's name for a function category, given the catalog's id (`MathTrig`). -fn function_category_label(id: &str) -> &str { - match id { - "DateTime" => "Date & Time", - "Lookup" => "Lookup & Reference", - "MathTrig" => "Math & Trig", - "AddIn" => "Add-in and Automation", - // Financial, Logical, Text, Statistical, Engineering, Database, Information, … - other => other, - } -} - -/// Draws the Find/Replace form with case, whole-cell and workbook options, requests field focus once, and runs find/replace with the current parameters. -fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { - ui.horizontal(|ui| { - for tab in ["Find", "Replace"] { - if ui.selectable_label(d.tab == tab, tab).clicked() { - d.tab = tab.into(); - } - } - }); - let mut what = d.values.get("what").and_then(Json::as_str).unwrap_or("").to_string(); - let mut with = d.values.get("with").and_then(Json::as_str).unwrap_or("").to_string(); - ui.horizontal(|ui| { - let label = ui.label("Find what:"); - let r = ui.add(egui::TextEdit::singleline(&mut what).desired_width(260.0)).labelled_by(label.id); - // Like Excel, the cursor starts in "Find what" - asked for once, so clicking another field still works. - if !d.values.contains_key("focused") { - r.request_focus(); - d.values.insert("focused".into(), json!(true)); - } - }); - if d.tab == "Replace" { - ui.horizontal(|ui| { - let label = ui.label("Replace with:"); - ui.add(egui::TextEdit::singleline(&mut with).desired_width(244.0)).labelled_by(label.id); - }); - } - let mut case = d.values.get("matchCase").and_then(Json::as_bool).unwrap_or(false); - let mut whole = d.values.get("wholeCell").and_then(Json::as_bool).unwrap_or(false); - let mut wb = d.values.get("within").and_then(Json::as_str) == Some("workbook"); - ui.horizontal(|ui| { - ui.checkbox(&mut case, "Match case"); - ui.checkbox(&mut whole, "Match entire cell contents"); - ui.checkbox(&mut wb, "Within workbook"); - }); - d.values.insert("what".into(), json!(what)); - d.values.insert("with".into(), json!(with)); - d.values.insert("matchCase".into(), json!(case)); - d.values.insert("wholeCell".into(), json!(whole)); - d.values.insert("within".into(), json!(if wb { "workbook" } else { "sheet" })); - let base = json!({"what": what, "matchCase": case, "wholeCell": whole, "within": if wb { "workbook" } else { "sheet" }}); - ui.horizontal(|ui| { - if d.tab == "Replace" { - if ui.button("Replace All").clicked() { - let mut p = base.clone(); - p["with"] = json!(with); - p["all"] = json!(true); - match app.run("edit.replace", p) { - Ok(r) => app.toast = Some((format!("All done. We made {} replacements.", r["replaced"]), crate::now_ms())), - Err(e) => app.message = Some(("Find and Replace".into(), crate::clean_error(&e))), - } - } - if ui.button("Replace").clicked() { - let mut p = base.clone(); - p["with"] = json!(with); - p["all"] = json!(false); - let _ = app.run("edit.replace", p); - let _ = app.run("edit.find", base.clone()); - } - } - if ui.button("Find All").clicked() { - let mut p = base.clone(); - p["all"] = json!(true); - d.result = app.run("edit.find", p).ok(); - } - if ui.button("Find Next").clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { - if let Err(e) = app.run("edit.find", base.clone()) { - app.message = Some(("Find and Replace".into(), crate::clean_error(&e))); - } - app.grid.ensure_visible = true; - } - }); - if let Some(r) = &d.result - && let Some(list) = r["results"].as_array() - { - ui.separator(); - ui.label(format!("{} cell(s) found", list.len())); - egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { - for item in list { - let label = format!( - "{} {} {}", - item["sheet"].as_str().unwrap_or(""), - item["cell"].as_str().unwrap_or(""), - item["value"].as_str().unwrap_or("") - ); - if ui.selectable_label(false, label).clicked() { - if let Some(s) = item["sheet"].as_str() { - let _ = app.run("sheet.activate", json!({"sheet": s})); - } - let _ = app.run("selection.set", json!({"cell": item["cell"]})); - app.grid.ensure_visible = true; - } - } - }); - } -} - -/// Zoom's Magnification presets, as `preset` values and labels, in Excel's order. -const ZOOM_PRESETS: [(&str, &str); 5] = [("200", "200%"), ("100", "100%"), ("75", "75%"), ("50", "50%"), ("25", "25%")]; - -/// The `preset` that shows a zoom percentage: its Magnification entry, or Custom. -fn zoom_preset(percent: f64) -> &'static str { - ZOOM_PRESETS.iter().map(|(val, _)| *val).find(|val| val.parse::().ok() == Some(percent)).unwrap_or("custom") -} - -/// View › Zoom, laid out as Excel's: one Magnification column of 200% to 25%, Fit selection and -/// Custom with its percentage box. The current zoom is pre-selected; typing a percentage -/// selects Custom; Fit selection zooms so the selected cells fill the grid. -fn zoom(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - let lang = crate::i18n::current(ui.ctx()); - let cur = d.values.get("preset").and_then(Json::as_str).unwrap_or("custom").to_string(); - ui.label(egui::RichText::new(lang.tr("Magnification")).strong()); - for (val, text) in ZOOM_PRESETS { - if ui.radio(cur == val, text).clicked() { - d.set_value("preset", json!(val)); - } - } - if ui.radio(cur == "fit", lang.tr("Fit selection")).clicked() { - d.set_value("preset", json!("fit")); - } - ui.horizontal(|ui| { - if ui.radio(cur == "custom", format!("{}:", lang.tr("Custom"))).clicked() { - d.set_value("preset", json!("custom")); - } - // Whole percentages, as Excel shows them; the engine clamps to 10..400 as well. - let mut percent = d.values.get("percent").and_then(Json::as_f64).unwrap_or(100.0).round() as i64; - if ui.add(egui::DragValue::new(&mut percent).range(10..=400).speed(1.0)).changed() { - d.set_value("percent", json!(percent)); - } - ui.label("%"); - }); - 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 { - 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() -} - -/// Draws a virtualized name/reference list and editable reference field, links to Go To Special, and confirms with `edit.goTo`. -fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { - let lang = app.ui.language; - let entries: Vec = - 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| { - let label = ui.label(lang.tr("Reference:")); - ui.text_edit_singleline(&mut r).labelled_by(label.id).request_focus(); - }); - d.values.insert("reference".into(), json!(r.clone())); - if ui.button("Special…").clicked() { - app.open_dialog("goToSpecial", json!({})); - return; - } - let mut open = true; - ok_cancel(ui, confirm, &mut open); - if *confirm { - app.run_or_alert("edit.goTo", json!({"reference": r, "record": true})); - app.grid.ensure_visible = true; - } - if !open { - *confirm = true; - } -} - -/// Draws the sort-level editor: header toggle, column/by/order controls, add/remove level, and `data.sort` on confirmation. -fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { - let lang = app.ui.language; - let mut header = d.values.get("header").and_then(Json::as_bool).unwrap_or(true); - ui.checkbox(&mut header, "My list has headers"); - d.values.insert("header".into(), json!(header)); - let mut levels: Vec = d.values.get("levels").and_then(Json::as_array).cloned().unwrap_or_default(); - // Columns of the region around the active cell, labelled with their header (or first value). - let columns: Vec<(String, String)> = app - .session - .active() - .and_then(|d| { - let sh = d.wb.active()?; - let region = gridcraft_engine::selection::current_region(sh, d.selection.active); - Some( - (region.start.col..=region.end.col) - .map(|c| { - let letter = gridcraft_engine::core::col_to_letters(c); - // The region starts at the header row when one is present, so the - // label always comes from start.row: header text with headers on, - // first data value with them off. - let label_row = region.start.row; - let text = gridcraft_engine::display::cell_text(&d.wb, sh, gridcraft_engine::core::CellRef::new(label_row, c)); - let text: String = text.chars().take(24).collect(); - (letter.clone(), if text.is_empty() { letter } else { format!("{letter}: {text}") }) - }) - .collect(), - ) - }) - .unwrap_or_default(); - let mut remove = None; - for (i, lv) in levels.iter_mut().enumerate() { - ui.horizontal(|ui| { - let label = ui.label(lang.tr(if i == 0 { "Sort by" } else { "Then by" })); - let mut col = lv["column"].as_str().unwrap_or("A").to_string(); - if columns.is_empty() { - ui.add(egui::TextEdit::singleline(&mut col).desired_width(50.0)).labelled_by(label.id); - } else { - egui::ComboBox::from_id_salt(("sort_col", i)) - .selected_text(col.clone()) - .show_ui(ui, |ui| { - for (letter, label) in &columns { - ui.selectable_value(&mut col, letter.clone(), label.as_str()); - } - }) - .response - .labelled_by(label.id); - } - lv["column"] = json!(col.to_ascii_uppercase()); - let mut by = lv["by"].as_str().unwrap_or("values").to_string(); - egui::ComboBox::from_id_salt(("by", i)) - .selected_text(match by.as_str() { - "cellColor" => "Cell Color", - "fontColor" => "Font Color", - _ => "Cell Values", - }) - .show_ui(ui, |ui| { - ui.selectable_value(&mut by, "values".into(), "Cell Values"); - ui.selectable_value(&mut by, "cellColor".into(), "Cell Color"); - ui.selectable_value(&mut by, "fontColor".into(), "Font Color"); - }) - .response - .accessible_name(lang.tr("Sort On")); - lv["by"] = json!(by); - let mut desc = lv["order"].as_str() == Some("desc"); - ui.radio_value(&mut desc, false, "A to Z"); - ui.radio_value(&mut desc, true, "Z to A"); - lv["order"] = json!(if desc { "desc" } else { "asc" }); - if crate::widgets::icon_button(ui, Icon::Close, crate::theme::Tokens::get(ui.ctx()).text_dim, "Remove level", vec2(18.0, 18.0)).clicked() - { - remove = Some(i); - } - }); - } - if let Some(i) = remove - && levels.len() > 1 - { - levels.remove(i); - } - if ui.button("+ Add Level").clicked() { - let used: Vec = levels.iter().filter_map(|l| l.get("column").and_then(Json::as_str).map(str::to_string)).collect(); - let next = columns - .iter() - .map(|(l, _)| l) - .find(|l| !used.contains(l)) - .cloned() - .or_else(|| columns.last().map(|(l, _)| l.clone())) - .unwrap_or_else(|| "B".into()); - levels.push(json!({"column": next, "order": "asc"})); - } - d.values.insert("levels".into(), Json::Array(levels.clone())); - let mut open = true; - ok_cancel(ui, confirm, &mut open); - if *confirm { - app.run_or_alert("data.sort", json!({"header": header, "keys": levels})); - } - if !open { - *confirm = true; - } -} - -fn name_manager(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) { - let list = app.session.run("formulas.nameManager", json!({})).unwrap_or(Json::Null); - let names = list.as_array().cloned().unwrap_or_default(); - let selected = names.get(d.list_index).cloned(); - let field = |n: &Json, k: &str| n.get(k).and_then(Json::as_str).unwrap_or("").to_string(); - ui.horizontal(|ui| { - if ui.button("New…").clicked() { - app.open_dialog("defineName", json!({})); - } - if ui.add_enabled(selected.is_some(), egui::Button::new("Edit…")).clicked() - && let Some(n) = &selected - { - app.open_dialog( - "defineName", - json!({ - "name": field(n, "name"), "scope": field(n, "scope"), "comment": field(n, "comment"), - "refersTo": field(n, "refersTo"), "oldName": field(n, "name"), "oldScope": field(n, "scope"), - }), - ); - } - if ui.add_enabled(selected.is_some(), egui::Button::new("Delete")).clicked() - && let Some(n) = &selected - { - app.run_or_alert("formulas.deleteName", json!({"name": field(n, "name"), "scope": field(n, "scope")})); - } - }); - ui.add_space(4.0); - egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { - egui::Grid::new("names").striped(true).num_columns(4).show(ui, |ui| { - ui.strong("Name"); - ui.strong("Value"); - ui.strong("Refers To"); - ui.strong("Scope"); - ui.end_row(); - if names.is_empty() { - ui.label(egui::RichText::new("No names defined").italics()); - ui.end_row(); - } - for (i, n) in names.iter().enumerate() { - if ui.selectable_label(i == d.list_index, field(n, "name")).clicked() { - d.list_index = i; - } - ui.label(n.get("value").map(|v| v.to_string()).unwrap_or_default().chars().take(30).collect::()); - ui.label(field(n, "refersTo")); - ui.label(field(n, "scope")); - ui.end_row(); - } - }); - }); - ui.add_space(8.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button(" Close ").clicked() { - *open = false; - } - }); -} - -/// The names Paste Names offers in the active workbook. -fn pasteable_names(app: &SheetApp) -> Vec { - 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 = - 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); 21] = [ - ("column", "", "Clustered Column"), - ("column", "stacked", "Stacked Column"), - ("column", "stacked100", "100% Stacked Column"), - ("bar", "", "Clustered Bar"), - ("bar", "stacked", "Stacked Bar"), - ("bar", "stacked100", "100% Stacked Bar"), - ("line", "", "Line"), - ("line", "markers", "Line with Markers"), - ("line", "stacked", "Stacked Line"), - ("pie", "", "Pie"), - ("doughnut", "", "Doughnut"), - ("area", "", "Area"), - ("area", "stacked", "Stacked Area"), - ("area", "stacked100", "100% Stacked Area"), - ("scatter", "", "Scatter"), - ("scatter", "lines", "Scatter with Lines"), - ("radar", "", "Radar"), - ("histogram", "", "Histogram"), - ("waterfall", "", "Waterfall"), - ("funnel", "", "Funnel"), - ("combo", "", "Combo"), -]; - -/// The chart types listed on the dialog's current tab. -fn chart_choices(d: &Dialog) -> Vec<(String, String, String)> { - if d.tab == "All Charts" { - return ALL_CHARTS.iter().map(|(t, s, l)| (t.to_string(), s.to_string(), l.to_string())).collect(); - } - let field = |x: &Json, k: &str| x.get(k).and_then(Json::as_str).unwrap_or("").to_string(); - d.result - .as_ref() - .and_then(Json::as_array) - .map(|a| a.iter().map(|x| (field(x, "type"), field(x, "subtype"), field(x, "label"))).collect()) - .unwrap_or_default() -} - -/// Selects entry `i` of the current tab: OK inserts it. -fn select_chart(d: &mut Dialog, i: usize) { - let Some((t, sub, _)) = chart_choices(d).into_iter().nth(i) else { return }; - d.list_index = i; - d.values.insert("type".into(), json!(t)); - d.values.insert("subtype".into(), json!(sub)); -} - -fn chart_icon(t: &str) -> Icon { - match t { - "column" | "bar" | "histogram" | "waterfall" | "funnel" | "combo" => Icon::ChartBar, - "line" | "area" | "radar" => Icon::ChartLine, - "pie" | "doughnut" => Icon::ChartPie, - "scatter" | "bubble" => Icon::ChartScatter, - _ => Icon::Chart, - } -} - -fn recommended_charts(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - ui.horizontal(|ui| { - for tab in ["Recommended Charts", "All Charts"] { - if ui.selectable_label(d.tab == tab, tab).clicked() && d.tab != tab { - d.tab = tab.to_string(); - select_chart(d, 0); - } - } - }); - ui.separator(); - let choices = chart_choices(d); - let t = crate::theme::Tokens::get(ui.ctx()); - ui.horizontal_top(|ui| { - ui.vertical(|ui| { - ui.set_width(190.0); - egui::ScrollArea::vertical().id_salt("chart_choices").max_height(300.0).show(ui, |ui| { - for (i, (kind, _, label)) in choices.iter().enumerate() { - let resp = ui - .horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); - icons::paint(ui.painter(), r.shrink(2.0), chart_icon(kind), t.text); - ui.selectable_label(i == d.list_index, label.as_str()) - }) - .inner; - if resp.clicked() { - select_chart(d, i); - } - if resp.double_clicked() { - select_chart(d, i); - *confirm = true; - } - } - }); - }); - ui.add_space(12.0); - ui.vertical(|ui| { - let label = choices.get(d.list_index).map(|c| c.2.clone()).unwrap_or_default(); - ui.strong(label); - let (rect, _) = ui.allocate_exact_size(vec2(360.0, 216.0), Sense::hover()); - ui.painter().rect_filled(rect, 2.0, Color32::WHITE); - ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); - let params = json!({"type": d.values.get("type").cloned().unwrap_or(Json::Null), "subtype": d.values.get("subtype").cloned().unwrap_or(Json::Null)}); - if let Some(doc) = app.session.active() - && let Ok((sheet, chart)) = gridcraft_engine::cmd::insert::build_chart(&app.session, ¶ms) - { - crate::chartview::paint_chart(ui.painter(), rect, &doc.wb, sheet, &chart); - } - }); - }); - ok_cancel(ui, confirm, open); - if *confirm { - *confirm = false; - let params = - json!({"type": d.values.get("type").cloned().unwrap_or(Json::Null), "subtype": d.values.get("subtype").cloned().unwrap_or(Json::Null)}); - match app.run("insert.chart", params) { - Ok(r) => { - // The new chart is selected, as after inserting any object. - app.selected_chart = r.get("chart").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()); - *open = false; - } - Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), - } - } -} - -/// Draws chart range, series keep/remove and category-range controls and runs `chart.selectData` when something changed. -fn select_data(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - let text = |d: &Dialog, k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); - let edit = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str| { - let label = ui.label(label); - let mut v = text(d, key); - if ui.add(egui::TextEdit::singleline(&mut v).desired_width(420.0)).labelled_by(label.id).changed() { - d.values.insert(key.into(), json!(v)); - } - }; - edit(ui, d, "range", "Chart data range:"); - ui.add_space(6.0); - ui.strong("Legend Entries (Series)"); - let names: Vec = - d.values.get("names").and_then(Json::as_array).map(|a| a.iter().map(|n| n.as_str().unwrap_or("").to_string()).collect()).unwrap_or_default(); - let mut remove: Vec = - d.values.get("remove").and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_u64).collect()).unwrap_or_default(); - let kept = names.len().saturating_sub(remove.len()); - egui::ScrollArea::vertical().id_salt("series").max_height(160.0).show(ui, |ui| { - for (i, name) in names.iter().enumerate() { - let i = i as u64; - let removed = remove.contains(&i); - ui.horizontal(|ui| { - let label = if name.is_empty() { format!("Series{}", i + 1) } else { name.clone() }; - ui.label(if removed { egui::RichText::new(label).strikethrough().weak() } else { egui::RichText::new(label) }); - if removed { - if ui.small_button("Keep").clicked() { - remove.retain(|x| *x != i); - } - } else if ui.add_enabled(kept > 1, egui::Button::new("Remove").small()).clicked() { - remove.push(i); - } - }); - } - }); - d.values.insert("remove".into(), json!(remove)); - ui.add_space(6.0); - edit(ui, d, "categories", "Horizontal (Category) Axis Labels:"); - ok_cancel(ui, confirm, open); - if *confirm { - *confirm = false; - let mut params = serde_json::Map::new(); - params.insert("chart".into(), d.values.get("chart").cloned().unwrap_or(Json::Null)); - let range_changed = text(d, "range") != text(d, "range0"); - if range_changed { - params.insert("range".into(), json!(text(d, "range"))); - } else if !remove.is_empty() { - // A new range rebuilds the series; removals only apply to the current ones. - params.insert("removeSeries".into(), json!(remove)); - } - if text(d, "categories") != text(d, "categories0") || (range_changed && !text(d, "categories").is_empty()) { - params.insert("categories".into(), json!(text(d, "categories"))); - } - if params.len() == 1 { - *open = false; - return; - } - match app.run("chart.selectData", Json::Object(params)) { - Ok(_) => *open = false, - Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), - } - } -} - -fn manage_rules(app: &mut SheetApp, ui: &mut egui::Ui) { - let list = app.session.run("home.manageRules", json!({})).unwrap_or(Json::Null); - let rules = list["rules"].as_array().cloned().unwrap_or_default(); - if rules.is_empty() { - ui.label("There are no conditional formatting rules on this sheet."); - } - for (i, r) in rules.iter().enumerate() { - ui.horizontal(|ui| { - let kind = r["rule"].as_object().and_then(|o| o.keys().next().cloned()).unwrap_or_default(); - ui.label(format!( - "{}. {kind} applies to {}", - i + 1, - r["ranges"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::>().join(",")).unwrap_or_default() - )); - if ui.small_button("▲").clicked() { - app.run_or_alert("home.manageRules", json!({"moveUp": i})); - } - if ui.small_button("▼").clicked() { - app.run_or_alert("home.manageRules", json!({"moveDown": i})); - } - if ui.small_button("Delete").clicked() { - app.run_or_alert("home.manageRules", json!({"delete": i})); - } - }); - } -} - -/// The command search box: commands whose label or id matches what was -/// typed, keyboard-navigable, Enter runs the highlighted one. -fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { - let (down, up, home, end, pgup, pgdn) = ui.input_mut(|i| { - ( - i.consume_key(egui::Modifiers::NONE, Key::ArrowDown), - i.consume_key(egui::Modifiers::NONE, Key::ArrowUp), - i.consume_key(egui::Modifiers::CTRL, Key::Home), - i.consume_key(egui::Modifiers::CTRL, Key::End), - i.consume_key(egui::Modifiers::NONE, Key::PageUp), - i.consume_key(egui::Modifiers::NONE, Key::PageDown), - ) - }); - let width = ui.available_width(); - let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(width).hint_text("Type a command, e.g. freeze, chart, bold…")); - - if !d.focus_initialized { - r.request_focus(); - d.focus_initialized = true; - } - - let q = d.search.to_ascii_lowercase(); - let cmds: Vec<(String, String, String)> = app - .session - .commands() - .into_iter() - .filter(|c| c.enabled) - .filter(|c| q.is_empty() || c.label.to_ascii_lowercase().contains(&q) || c.id.to_ascii_lowercase().contains(&q)) - .map(|c| (c.id.to_string(), c.label.to_string(), c.menu.join(" › "))) - .take(40) - .collect(); - - let last = cmds.len().saturating_sub(1); - - let row_h = ui.spacing().interact_size.y.max(20.0); - let row_step = row_h + ui.spacing().item_spacing.y; - - const LIST_HEIGHT: f32 = 320.0; - - let page_size = ((LIST_HEIGHT / row_step).floor() as usize).saturating_sub(1).max(1); - - if down { - d.list_index = (d.list_index + 1).min(last); - } - if up { - d.list_index = d.list_index.saturating_sub(1); - } - if pgdn { - d.list_index = (d.list_index + page_size).min(last); - } - if pgup { - d.list_index = d.list_index.saturating_sub(page_size); - } - if home { - d.list_index = 0; - } - if end { - d.list_index = last; - } - - d.list_index = d.list_index.min(last); - - egui::ScrollArea::vertical().max_height(LIST_HEIGHT).auto_shrink([true, false]).show(ui, |ui| { - let scroll_area_width = ui.available_width(); - for (i, (id, label, menu)) in cmds.iter().enumerate() { - let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{label} {menu}")).min_size(vec2(scroll_area_width, 20.0))); - - if (down || up || home || end || pgup || pgdn) && i == d.list_index && !ui.clip_rect().contains_rect(r.rect) { - r.scroll_to_me(Some(if down || end || pgdn { egui::Align::Max } else { egui::Align::Min })); - } - - if r.clicked() { - d.list_index = i; - *confirm = true; - } - - r.on_hover_text(id); - } - }); - if ui.input(|i| i.key_pressed(Key::Enter)) { - *confirm = true; - } - if *confirm && let Some((id, ..)) = cmds.get(d.list_index) { - let id = id.clone(); - app.dialog = None; - app.run_or_alert(&id, json!({})); - } -} - -/// Data ▸ Queries & Connections: the workbook's saved From Web connections, each with a -/// one-click Sync, its last-synced time, and the refresh errors and unavailable-source -/// messages in the same window (Excel's Queries & Connections pane). -fn queries_connections(app: &mut SheetApp, ui: &mut egui::Ui, _d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - let queries = app.run("data.queriesConnections", json!({})).ok().and_then(|r| r.as_array().cloned()).unwrap_or_default(); - if queries.is_empty() { - ui.label("No saved connections. Import a table with Data ▸ From Web to create one."); - } else { - egui::ScrollArea::vertical().max_height(280.0).id_salt("qc_list").show(ui, |ui| { - for q in &queries { - let name = q["name"].as_str().unwrap_or_default(); - let sheet = q["sheet"].as_str().unwrap_or_default(); - egui::CollapsingHeader::new(name).default_open(true).show(ui, |ui| { - ui.label(format!("Sheet: {sheet}")); - if let Some(url) = q.get("url").and_then(Json::as_str) { - ui.label(format!("Source: {}", crate::clean_url_for_display(url))); - } - let synced = q.get("last_synced").and_then(Json::as_u64).filter(|t| *t > 0); - match synced { - Some(ms) => ui.weak(format!("Last synced: {}", crate::format_sync_time(ms))), - None => ui.weak("Last synced: never"), - }; - ui.horizontal(|ui| { - if ui.button("Sync").clicked() { - match app.run("data.webRefresh", json!({"name": name})) { - Ok(r) => { - let refreshed = r["refreshed"].as_u64().unwrap_or(0); - let errors = r["errors"].as_array().map(Vec::len).unwrap_or(0); - if refreshed > 0 && errors == 0 { - app.toast = Some((format!("Synced {name}"), crate::now_ms())); - } else if let Some(e) = r["errors"].as_array().and_then(|a| a.first()) { - let msg = e["error"].as_str().unwrap_or_default(); - app.message = Some(("Queries & Connections".into(), format!("{name}: {msg}"))); - } - } - Err(e) => app.message = Some(("Queries & Connections".into(), crate::clean_error(&e))), - } - } - if ui.button("Go to table").clicked() { - let _ = app.run("sheet.activate", json!({"sheet": sheet})); - *open = false; - } - }); - }); - ui.separator(); - } - }); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("Refresh All").clicked() { - match app.run("data.webRefresh", json!({})) { - Ok(r) => { - let refreshed = r["refreshed"].as_u64().unwrap_or(0); - let errors = r["errors"].as_array().cloned().unwrap_or_default(); - if errors.is_empty() { - app.toast = Some((format!("Synced {refreshed} connection(s)"), crate::now_ms())); - } else { - let msg = errors.iter().filter_map(|e| e["error"].as_str()).collect::>().join("; "); - app.message = Some(("Queries & Connections".into(), msg)); - } - } - Err(e) => app.message = Some(("Queries & Connections".into(), crate::clean_error(&e))), - } - } - }); - } - let mut load = true; - ok_cancel(ui, confirm, &mut load); - if *confirm || !load { - *open = false; - } -} - -/// Data ▸ From Web: Excel's Navigator. Enter a URL, the page's detected tables are previewed -/// on the right, and Load imports the selected one into a new sheet. -fn from_web(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - let get = |k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); - let mut url = get("url"); - egui::Grid::new("fw").show(ui, |ui| { - ui.label("URL:"); - ui.add(egui::TextEdit::singleline(&mut url).desired_width(360.0).hint_text("https://example.com/table.html")); - // An edited address invalidates the preview: don't let Load pair the old page's - // table names with the new URL. - if d.values.get("preview_url").and_then(Json::as_str).is_some_and(|u| u != url.trim()) { - d.values.remove("preview_url"); - d.values.remove("tables"); - d.values.remove("pick"); - } - if ui.button("Go").clicked() && !url.trim().is_empty() { - d.values.insert("url".into(), json!(url.trim())); - d.values.insert("preview_url".into(), json!(url.trim())); - d.values.insert("tables".into(), Json::Null); - d.values.insert("pick".into(), json!(0)); - let r = app.run("data.webPreview", json!({"url": url.trim()})); - match r { - Ok(res) => d.values.insert("tables".into(), res["tables"].clone()), - Err(e) => { - app.message = Some(("From Web".into(), crate::clean_error(&e))); - d.values.insert("tables".into(), Json::Null) - } - }; - } - ui.end_row(); - }); - let tables: Vec = d.values.get("tables").and_then(Json::as_array).cloned().unwrap_or_default(); - if !tables.is_empty() { - ui.separator(); - ui.horizontal(|ui| { - ui.vertical(|ui| { - ui.strong("Suggested Tables"); - let mut pick = d.values.get("pick").and_then(Json::as_u64).unwrap_or(0) as usize; - for (i, t) in tables.iter().enumerate() { - let name = t["name"].as_str().unwrap_or("Table"); - let rows = t["rows"].as_u64().unwrap_or(0); - if ui.selectable_label(i == pick, format!("{name} ({rows} rows)")).clicked() { - pick = i; - d.values.insert("pick".into(), json!(i)); - } - } - }); - ui.separator(); - let pick = d.values.get("pick").and_then(Json::as_u64).unwrap_or(0) as usize; - if let Some(t) = tables.get(pick) { - ui.vertical(|ui| { - ui.strong("Preview"); - egui::ScrollArea::vertical().max_height(200.0).id_salt("fw_preview").show(ui, |ui| { - egui::Grid::new("fw_preview_grid").striped(true).show(ui, |ui| { - if let Some(rows) = t["preview"].as_array() { - for row in rows { - for cell in row.as_array().into_iter().flatten() { - ui.label(cell.as_str().unwrap_or_default()); - } - ui.end_row(); - } - } - }); - }); - }); - } - }); - } else if d.values.get("tables").is_some_and(|t| t.is_null()) { - ui.label("No tables were found on this page."); - } else { - ui.label("Enter an address and choose Go to detect the page's tables."); - } - d.values.insert("url".into(), json!(url.clone())); - let mut load = true; - ok_cancel(ui, confirm, &mut load); - if *confirm && url.trim().is_empty() { - // OK with no address: stay open and say so, instead of closing with nothing. - app.message = Some(("From Web".into(), "Enter an https:// address first.".into())); - *confirm = false; - } - if *confirm && !url.trim().is_empty() { - let pick = d.values.get("pick").and_then(Json::as_u64).unwrap_or(0) as usize; - let table = tables.get(pick).and_then(|t| t["name"].as_str()).map(String::from); - let params = match table { - Some(t) => { - // A page can hold duplicate table names: which one was picked is which one to - // import, and the connection saves it so refreshes keep importing the same. - let occurrence = tables.iter().take(pick).filter(|o| o["name"].as_str().is_some_and(|n| n.eq_ignore_ascii_case(&t))).count(); - json!({"url": url.trim(), "table": t, "occurrence": occurrence}) - } - None => json!({"url": url.trim()}), - }; - match app.run("data.fromWeb", params) { - Ok(r) => app.toast = Some((format!("Imported {} rows into {}", r["rows"], r["sheet"]), crate::now_ms())), - Err(e) => { - app.message = Some(("From Web".into(), crate::clean_error(&e))); - *confirm = false; - } - } - } - if !load { - *confirm = true; - *open = false; - } -} - -fn goal_seek(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { - let get = |k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); - let (mut set, mut to, mut changing) = (get("set"), get("to"), get("changing")); - egui::Grid::new("gs").show(ui, |ui| { - ui.label("Set cell:"); - ui.text_edit_singleline(&mut set); - ui.end_row(); - ui.label("To value:"); - ui.text_edit_singleline(&mut to); - ui.end_row(); - ui.label("By changing cell:"); - ui.text_edit_singleline(&mut changing); - ui.end_row(); - }); - d.values.insert("set".into(), json!(set.clone())); - d.values.insert("to".into(), json!(to.clone())); - d.values.insert("changing".into(), json!(changing.clone())); - let mut open = true; - ok_cancel(ui, confirm, &mut open); - if *confirm { - match app.run("data.goalSeek", json!({"set": set, "to": to.parse::().unwrap_or(0.0), "changing": changing})) { - Ok(r) => app.toast = Some((format!("Goal Seeking found a solution: {}", r["value"]), crate::now_ms())), - Err(e) => app.message = Some(("Goal Seek".into(), crate::clean_error(&e))), - } - } - if !open { - *confirm = true; - } -} - -/// The fields of one Analysis ToolPak tool's form (input and options; output is drawn apart). -fn toolpak_fields(tool: &str) -> Vec { - use Field::*; - let grouped = || Choice { key: "groupedBy", label: "Grouped By:", options: vec![("columns", "Columns"), ("rows", "Rows")] }; - let labels = || Check { key: "labels", label: "Labels in first row" }; - let range = || Text { key: "range", label: "Input Range:" }; - let two = || vec![Text { key: "range1", label: "Variable 1 Range:" }, Text { key: "range2", label: "Variable 2 Range:" }]; - match tool { - "descriptive" => vec![ - range(), - grouped(), - labels(), - Check { key: "summary", label: "Summary statistics" }, - Text { key: "confidence", label: "Confidence Level for Mean (%), blank for none:" }, - Text { key: "kthLargest", label: "Kth Largest (blank for none):" }, - Text { key: "kthSmallest", label: "Kth Smallest (blank for none):" }, - ], - "histogram" => vec![ - range(), - Text { key: "bins", label: "Bin Range (blank for automatic bins):" }, - labels(), - Check { key: "pareto", label: "Pareto (sorted histogram)" }, - Check { key: "cumulative", label: "Cumulative Percentage" }, - ], - "correlation" | "covariance" | "rankPercentile" => vec![range(), grouped(), labels()], - "anovaSingle" => vec![range(), grouped(), labels(), Text { key: "alpha", label: "Alpha:" }], - "regression" => vec![ - Text { key: "inputY", label: "Input Y Range:" }, - Text { key: "inputX", label: "Input X Range:" }, - labels(), - Check { key: "constantZero", label: "Constant is Zero" }, - Text { key: "confidence", label: "Confidence Level (%):" }, - Check { key: "residuals", label: "Residuals" }, - Check { key: "standardizedResiduals", label: "Standardized Residuals" }, - Check { key: "normalProbability", label: "Normal Probability" }, - ], - "movingAverage" => { - vec![range(), labels(), Text { key: "interval", label: "Interval:" }, Check { key: "standardErrors", label: "Standard Errors" }] - } - "exponentialSmoothing" => { - vec![range(), labels(), Text { key: "damping", label: "Damping factor:" }, Check { key: "standardErrors", label: "Standard Errors" }] - } - "fTest" => { - let mut v = two(); - v.extend([labels(), Text { key: "alpha", label: "Alpha:" }]); - v - } - _ => { - let mut v = two(); - v.extend([ - Text { key: "hypothesizedDifference", label: "Hypothesized Mean Difference:" }, - labels(), - Text { key: "alpha", label: "Alpha:" }, - ]); - v - } - } -} - -/// The starting values of a tool's form: the selection as its input, a new sheet as output. -fn toolpak_defaults(sel: &str, tool: &str) -> Map { - let mut v = json!({ - "tool": tool, "range": sel, "inputY": "", "inputX": "", "range1": sel, "range2": "", "bins": "", - "groupedBy": "columns", "labels": false, "summary": true, "confidence": "", "kthLargest": "", "kthSmallest": "", - "interval": "3", "damping": "0.3", "alpha": "0.05", "hypothesizedDifference": "0", - "outputTo": "newSheet", "output": "", "newSheet": "", - }); - if tool == "regression" { - v["confidence"] = json!("95"); - v["inputY"] = json!(sel); - } - v.as_object().cloned().unwrap_or_default() -} - -/// Data › Data Analysis: pick a tool, then fill in its form. OK runs `data.analysisToolPak` -/// (through the dialog's command); a report that would replace data asks first. -fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - use gridcraft_engine::cmd::analysis::TOOLS; - if d.values.get("askOverwrite").and_then(Json::as_bool) == Some(true) { - ui.label("The output range already holds data. Replace it with the report?"); - ui.horizontal(|ui| { - if ui.button("Replace").clicked() { - d.values.remove("askOverwrite"); - d.values.insert("overwrite".into(), json!(true)); - *confirm = true; - } - if ui.button("Cancel").clicked() { - d.values.remove("askOverwrite"); - } - }); - return; - } - let tool = d.values.get("tool").and_then(Json::as_str).unwrap_or("").to_string(); - let Some((_, label)) = TOOLS.iter().find(|(id, _)| *id == tool) else { - ui.label(egui::RichText::new("Analysis Tools").strong()); - let pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string(); - let mut chosen = false; - egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { - for (id, label) in TOOLS { - let r = ui.selectable_label(pick == *id, *label); - if r.clicked() { - d.values.insert("pick".into(), json!(id)); - } - if r.double_clicked() { - chosen = true; - } - } - }); - let mut ok = false; - ok_cancel(ui, &mut ok, open); - let pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string(); - if (ok || chosen) && TOOLS.iter().any(|(id, _)| *id == pick) { - let sel = app.session.active().map(|doc| doc.selection.current().a1()).unwrap_or_default(); - d.values = toolpak_defaults(&sel, &pick); - } - return; - }; - d.title = label.to_string(); - d.fields = toolpak_fields(&tool); - form_fields(ui, d); - ui.separator(); - ui.label(egui::RichText::new("Output options").strong()); - let to = d.values.get("outputTo").and_then(Json::as_str).unwrap_or("newSheet").to_string(); - for (key, choice, text_label) in [("output", "range", "Output Range:"), ("newSheet", "newSheet", "New Worksheet Ply:")] { - ui.horizontal(|ui| { - if ui.radio(to == choice, text_label).clicked() { - d.values.insert("outputTo".into(), json!(choice)); - } - let mut t = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); - if ui.add(egui::TextEdit::singleline(&mut t).desired_width(160.0)).changed() { - d.values.insert(key.into(), json!(t)); - d.values.insert("outputTo".into(), json!(choice)); - } - }); - } - ok_cancel(ui, confirm, open); -} - -/// Data › Solver: the Solver Parameters form. Solve runs `data.solver` through the dialog's -/// command; the result opens Solver Results. -fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - let lang = crate::i18n::current(ui.ctx()); - let text = |d: &Dialog, k: &str| d.values.get(k).map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())).unwrap_or_default(); - let edit = |ui: &mut egui::Ui, d: &mut Dialog, k: &str, width: f32, label: &str| { - let mut t = text(d, k); - if ui.add(egui::TextEdit::singleline(&mut t).desired_width(width)).accessible_name(lang.tr(label)).changed() { - d.values.insert(k.into(), json!(t)); - } - }; - ui.horizontal(|ui| { - ui.label("Set Objective:"); - edit(ui, d, "objective", 200.0, "Set Objective:"); - }); - let goal = text(d, "goal"); - ui.horizontal(|ui| { - ui.label("To:"); - for (g, label) in [("max", "Max"), ("min", "Min"), ("value", "Value Of:")] { - if ui.radio(goal == g, label).clicked() { - d.values.insert("goal".into(), json!(g)); - } - } - edit(ui, d, "value", 80.0, "Value Of:"); - }); - ui.label("By Changing Variable Cells:"); - edit(ui, d, "variables", 420.0, "By Changing Variable Cells:"); - ui.label("Subject to the Constraints:"); - let list: Vec = d - .values - .get("constraints") - .and_then(Json::as_array) - .map(|a| a.iter().map(|c| c.as_str().map(str::to_string).unwrap_or_else(|| c.to_string())).collect()) - .unwrap_or_default(); - egui::Frame::group(ui.style()).show(ui, |ui| { - ui.set_min_size(vec2(420.0, 90.0)); - egui::ScrollArea::vertical().max_height(110.0).show(ui, |ui| { - for (i, c) in list.iter().enumerate() { - if ui.selectable_label(d.list_index == i, c).clicked() { - d.list_index = i; - } - } - }); - }); - ui.horizontal(|ui| { - edit(ui, d, "newLhs", 110.0, "Constraint Cell Reference"); - let op = text(d, "newOp"); - egui::ComboBox::from_id_salt("solverOp") - .selected_text(op.clone()) - .width(60.0) - .show_ui(ui, |ui| { - for o in ["<=", "=", ">=", "int", "bin"] { - if ui.selectable_label(op == o, o).clicked() { - d.values.insert("newOp".into(), json!(o)); - } - } - }) - .response - .accessible_name(lang.tr("Constraint Operator")); - if !matches!(op.as_str(), "int" | "bin") { - edit(ui, d, "newRhs", 110.0, "Constraint Value"); - } - if ui.button("Add").clicked() { - let lhs = text(d, "newLhs"); - if !lhs.trim().is_empty() { - let rhs = if matches!(op.as_str(), "int" | "bin") { String::new() } else { format!(" {}", text(d, "newRhs").trim()) }; - let mut list = list.clone(); - list.push(format!("{} {op}{rhs}", lhs.trim())); - d.values.insert("constraints".into(), json!(list)); - d.values.insert("newLhs".into(), json!("")); - d.values.insert("newRhs".into(), json!("")); - } - } - if ui.add_enabled(d.list_index < list.len(), egui::Button::new("Delete")).clicked() { - let mut list = list.clone(); - if d.list_index < list.len() { - list.remove(d.list_index); - } - d.values.insert("constraints".into(), json!(list)); - } - }); - let mut nonneg = d.values.get("nonNegative").and_then(Json::as_bool).unwrap_or(true); - if ui.checkbox(&mut nonneg, "Make Unconstrained Variables Non-Negative").changed() { - d.values.insert("nonNegative".into(), json!(nonneg)); - } - let method = text(d, "method"); - ui.horizontal(|ui| { - ui.label("Select a Solving Method:"); - let label = |m: &str| match m { - "simplex" => "Simplex LP", - "evolutionary" => "Evolutionary", - _ => "GRG Nonlinear", - }; - egui::ComboBox::from_id_salt("solverMethod") - .selected_text(label(&method)) - .show_ui(ui, |ui| { - for m in ["grg", "simplex", "evolutionary"] { - if ui.selectable_label(method == m, label(m)).clicked() { - d.values.insert("method".into(), json!(m)); - } - } - }) - .response - .accessible_name(lang.tr("Select a Solving Method:")); - }); - ui.label( - egui::RichText::new(match method.as_str() { - "simplex" => "Simplex LP: for linear models; integer and binary constraints by branch and bound.", - "evolutionary" => "Evolutionary: a genetic search for models that aren't smooth; every variable cell needs bounds.", - _ => "GRG Nonlinear: a local optimum of a smooth model, from the current values.", - }) - .small(), - ); - ui.add_space(8.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - 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; - } - if ui.button(" Close ").clicked() { - *open = false; - } - }); -} - -/// Solver Results: the outcome, and keeping or restoring the variable cells. -fn solver_results(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) { - let message = d.values.get("message").and_then(Json::as_str).unwrap_or("").to_string(); - ui.label(message); - let changed = d.values.get("changed").and_then(Json::as_bool).unwrap_or(false); - let restore = d.values.get("restore").and_then(Json::as_bool).unwrap_or(false); - if changed { - ui.add_space(4.0); - if ui.radio(!restore, "Keep Solver Solution").clicked() { - d.values.insert("restore".into(), json!(false)); - } - if ui.radio(restore, "Restore Original Values").clicked() { - d.values.insert("restore".into(), json!(true)); - } - } - // Reports describe the solution whether it is kept or not. - let solved = d.values.get("values").and_then(Json::as_object).is_some_and(|v| !v.is_empty()); - if solved { - ui.add_space(4.0); - ui.label("Reports:"); - for (key, label) in [("answerReport", "Answer"), ("limitsReport", "Limits")] { - let mut on = d.values.get(key).and_then(Json::as_bool).unwrap_or(false); - if ui.checkbox(&mut on, label).changed() { - d.values.insert(key.into(), json!(on)); - } - } - } - let mut confirm = false; - ok_cancel(ui, &mut confirm, open); - if confirm { - // The solution (and the saved model) is one undo step. - if changed && restore { - app.run_or_alert("edit.undo", json!({})); - } - let reports: Vec<&str> = [("answerReport", "answer"), ("limitsReport", "limits")] - .into_iter() - .filter(|(key, _)| d.values.get(*key).and_then(Json::as_bool) == Some(true)) - .map(|(_, kind)| kind) - .collect(); - if solved && !reports.is_empty() { - let field = |k: &str| d.values.get(k).cloned().unwrap_or(Json::Null); - let params = json!({"reports": reports, "model": field("model"), "values": field("values"), "original": field("original"), "message": field("message")}); - app.run_or_alert("data.solverReport", params); - } - *open = false; - } -} - -fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) { - let r = d.result.clone().unwrap_or(Json::Null); - if let Some(e) = r.get("error").and_then(Json::as_str) { - ui.label(app.ui.language.tr_error(e)); - return; - } - let issues = r["issues"].as_array().cloned().unwrap_or_default(); - let Some(it) = issues.get(d.list_index) else { - ui.label("The spelling check is complete for the entire sheet."); - if ui.button("OK").clicked() { - *open = false; - } - return; - }; - 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(crate::theme::Tokens::get(ui.ctx()).danger)); - let sugg: Vec = - 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:"); - let mut pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string(); - for s in &sugg { - if ui.selectable_label(pick == *s, s).clicked() { - pick = s.clone(); - } - } - if sugg.is_empty() { - ui.label(egui::RichText::new("(No suggestions)").italics()); - } - d.values.insert("pick".into(), json!(pick.clone())); - let _ = app.session.run("selection.set", json!({"cell": cell})); - ui.horizontal(|ui| { - if ui.button("Ignore Once").clicked() { - d.list_index += 1; - } - if ui.button("Add to Dictionary").clicked() { - let _ = app.session.run("review.addToDictionary", json!({"word": word})); - d.list_index += 1; - } - if !pick.is_empty() { - if ui.button("Change").clicked() { - app.run_or_alert("review.changeSpelling", json!({"cell": cell, "word": word, "to": pick})); - d.list_index += 1; - } - if ui.button("Change All").clicked() { - app.run_or_alert("review.changeSpelling", json!({"word": word, "to": pick, "all": true})); - d.list_index += 1; - } - } - }); -} - -#[cfg(test)] -mod data_validation_tests { - use super::*; - - #[test] - fn list_uses_one_source_and_does_not_submit_a_second_list() { - let fields = data_validation_fields("list", "between"); - assert!(fields.iter().any(|f| matches!(f, Field::Text { key: "formula1", label: "Source:" }))); - assert!(!fields.iter().any(|f| matches!(f, Field::Text { key: "formula2", .. } | Field::Choice { key: "operator", .. }))); - - let dialog = Dialog::form( - "dataValidation", - "Data Validation", - "data.validation", - fields, - json!({"type": "list", "operator": "between", "formula1": "ABC,DEF", "formula2": "GHI,JKL", "dropdown": true}), - ); - let params = build_params(&dialog); - assert_eq!(params["formula1"], "ABC,DEF"); - assert!(params.get("formula2").is_none()); - assert!(params.get("operator").is_none()); - } - - #[test] - fn only_between_operators_show_and_submit_two_bounds() { - let two = data_validation_fields("whole", "between"); - assert!(two.iter().any(|f| matches!(f, Field::Text { key: "formula2", label: "Maximum:" }))); - let one = data_validation_fields("date", "lessOrEqual"); - assert!(one.iter().any(|f| matches!(f, Field::Text { key: "formula1", label: "End date:" }))); - assert!(!one.iter().any(|f| matches!(f, Field::Text { key: "formula2", .. }))); - - let dialog = Dialog::form( - "dataValidation", - "Data Validation", - "data.validation", - one, - json!({"type": "date", "operator": "lessOrEqual", "formula1": "2026-10-12", "formula2": "2030-01-01"}), - ); - let params = build_params(&dialog); - assert_eq!(params["operator"], "lessOrEqual"); - assert_eq!(params["formula1"], "2026-10-12"); - assert!(params.get("formula2").is_none()); - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/dialogs/about.rs b/crates/ui-egui/src/dialogs/about.rs new file mode 100644 index 0000000000..a337627d96 --- /dev/null +++ b/crates/ui-egui/src/dialogs/about.rs @@ -0,0 +1,26 @@ +use super::*; + +/// Help ▸ About GridCraft: the app, its contributors and the AI models that helped. The credits +/// are compiled in (`crate::credits`, docs/contributors.md). +pub(super) fn about(ui: &mut egui::Ui, d: &mut Dialog) { + ui.horizontal(|ui| { + for tab in ["About", "Contributors", "Models"] { + if ui.selectable_label(d.tab == tab, tab).clicked() { + d.tab = tab.to_string(); + } + } + }); + ui.separator(); + match d.tab.as_str() { + "Contributors" => crate::credits::contributors_ui(ui), + "Models" => crate::credits::models_ui(ui), + _ => { + ui.heading("GridCraft"); + ui.label(format!("Version {}", env!("CARGO_PKG_VERSION"))); + ui.add_space(4.0); + ui.label("A clean-room, open-source, Rust-native spreadsheet. Part of ArtCraft."); + ui.hyperlink_to("getartcraft.com/apps/gridcraft", "https://getartcraft.com/apps/gridcraft"); + ui.hyperlink_to("Join the ArtCraft Discord", "https://discord.gg/artcraft"); + } + } +} diff --git a/crates/ui-egui/src/dialogs/analysis.rs b/crates/ui-egui/src/dialogs/analysis.rs new file mode 100644 index 0000000000..595be229c6 --- /dev/null +++ b/crates/ui-egui/src/dialogs/analysis.rs @@ -0,0 +1,344 @@ +use super::show::{form_fields, ok_cancel}; +use super::*; + +pub(super) fn goal_seek(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + let get = |k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); + let (mut set, mut to, mut changing) = (get("set"), get("to"), get("changing")); + egui::Grid::new("gs").show(ui, |ui| { + ui.label("Set cell:"); + ui.text_edit_singleline(&mut set); + ui.end_row(); + ui.label("To value:"); + ui.text_edit_singleline(&mut to); + ui.end_row(); + ui.label("By changing cell:"); + ui.text_edit_singleline(&mut changing); + ui.end_row(); + }); + d.values.insert("set".into(), json!(set.clone())); + d.values.insert("to".into(), json!(to.clone())); + d.values.insert("changing".into(), json!(changing.clone())); + let mut open = true; + ok_cancel(ui, confirm, &mut open); + if *confirm { + match app.run("data.goalSeek", json!({"set": set, "to": to.parse::().unwrap_or(0.0), "changing": changing})) { + Ok(r) => app.toast = Some((format!("Goal Seeking found a solution: {}", r["value"]), crate::now_ms())), + Err(e) => app.message = Some(("Goal Seek".into(), crate::clean_error(&e))), + } + } + if !open { + *confirm = true; + } +} + +/// The fields of one Analysis ToolPak tool's form (input and options; output is drawn apart). +fn toolpak_fields(tool: &str) -> Vec { + use Field::*; + let grouped = || Choice { key: "groupedBy", label: "Grouped By:", options: vec![("columns", "Columns"), ("rows", "Rows")] }; + let labels = || Check { key: "labels", label: "Labels in first row" }; + let range = || Text { key: "range", label: "Input Range:" }; + let two = || vec![Text { key: "range1", label: "Variable 1 Range:" }, Text { key: "range2", label: "Variable 2 Range:" }]; + match tool { + "descriptive" => vec![ + range(), + grouped(), + labels(), + Check { key: "summary", label: "Summary statistics" }, + Text { key: "confidence", label: "Confidence Level for Mean (%), blank for none:" }, + Text { key: "kthLargest", label: "Kth Largest (blank for none):" }, + Text { key: "kthSmallest", label: "Kth Smallest (blank for none):" }, + ], + "histogram" => vec![ + range(), + Text { key: "bins", label: "Bin Range (blank for automatic bins):" }, + labels(), + Check { key: "pareto", label: "Pareto (sorted histogram)" }, + Check { key: "cumulative", label: "Cumulative Percentage" }, + ], + "correlation" | "covariance" | "rankPercentile" => vec![range(), grouped(), labels()], + "anovaSingle" => vec![range(), grouped(), labels(), Text { key: "alpha", label: "Alpha:" }], + "regression" => vec![ + Text { key: "inputY", label: "Input Y Range:" }, + Text { key: "inputX", label: "Input X Range:" }, + labels(), + Check { key: "constantZero", label: "Constant is Zero" }, + Text { key: "confidence", label: "Confidence Level (%):" }, + Check { key: "residuals", label: "Residuals" }, + Check { key: "standardizedResiduals", label: "Standardized Residuals" }, + Check { key: "normalProbability", label: "Normal Probability" }, + ], + "movingAverage" => { + vec![range(), labels(), Text { key: "interval", label: "Interval:" }, Check { key: "standardErrors", label: "Standard Errors" }] + } + "exponentialSmoothing" => { + vec![range(), labels(), Text { key: "damping", label: "Damping factor:" }, Check { key: "standardErrors", label: "Standard Errors" }] + } + "fTest" => { + let mut v = two(); + v.extend([labels(), Text { key: "alpha", label: "Alpha:" }]); + v + } + _ => { + let mut v = two(); + v.extend([ + Text { key: "hypothesizedDifference", label: "Hypothesized Mean Difference:" }, + labels(), + Text { key: "alpha", label: "Alpha:" }, + ]); + v + } + } +} + +/// The starting values of a tool's form: the selection as its input, a new sheet as output. +pub(super) fn toolpak_defaults(sel: &str, tool: &str) -> Map { + let mut v = json!({ + "tool": tool, "range": sel, "inputY": "", "inputX": "", "range1": sel, "range2": "", "bins": "", + "groupedBy": "columns", "labels": false, "summary": true, "confidence": "", "kthLargest": "", "kthSmallest": "", + "interval": "3", "damping": "0.3", "alpha": "0.05", "hypothesizedDifference": "0", + "outputTo": "newSheet", "output": "", "newSheet": "", + }); + if tool == "regression" { + v["confidence"] = json!("95"); + v["inputY"] = json!(sel); + } + v.as_object().cloned().unwrap_or_default() +} + +/// Data › Data Analysis: pick a tool, then fill in its form. OK runs `data.analysisToolPak` +/// (through the dialog's command); a report that would replace data asks first. +pub(super) fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + use gridcraft_engine::cmd::analysis::TOOLS; + if d.values.get("askOverwrite").and_then(Json::as_bool) == Some(true) { + ui.label("The output range already holds data. Replace it with the report?"); + ui.horizontal(|ui| { + if ui.button("Replace").clicked() { + d.values.remove("askOverwrite"); + d.values.insert("overwrite".into(), json!(true)); + *confirm = true; + } + if ui.button("Cancel").clicked() { + d.values.remove("askOverwrite"); + } + }); + return; + } + let tool = d.values.get("tool").and_then(Json::as_str).unwrap_or("").to_string(); + let Some((_, label)) = TOOLS.iter().find(|(id, _)| *id == tool) else { + ui.label(egui::RichText::new("Analysis Tools").strong()); + let pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string(); + let mut chosen = false; + egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { + for (id, label) in TOOLS { + let r = ui.selectable_label(pick == *id, *label); + if r.clicked() { + d.values.insert("pick".into(), json!(id)); + } + if r.double_clicked() { + chosen = true; + } + } + }); + let mut ok = false; + ok_cancel(ui, &mut ok, open); + let pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string(); + if (ok || chosen) && TOOLS.iter().any(|(id, _)| *id == pick) { + let sel = app.session.active().map(|doc| doc.selection.current().a1()).unwrap_or_default(); + d.values = toolpak_defaults(&sel, &pick); + } + return; + }; + d.title = label.to_string(); + d.fields = toolpak_fields(&tool); + form_fields(ui, d); + ui.separator(); + ui.label(egui::RichText::new("Output options").strong()); + let to = d.values.get("outputTo").and_then(Json::as_str).unwrap_or("newSheet").to_string(); + for (key, choice, text_label) in [("output", "range", "Output Range:"), ("newSheet", "newSheet", "New Worksheet Ply:")] { + ui.horizontal(|ui| { + if ui.radio(to == choice, text_label).clicked() { + d.values.insert("outputTo".into(), json!(choice)); + } + let mut t = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); + if ui.add(egui::TextEdit::singleline(&mut t).desired_width(160.0)).changed() { + d.values.insert(key.into(), json!(t)); + d.values.insert("outputTo".into(), json!(choice)); + } + }); + } + ok_cancel(ui, confirm, open); +} + +/// Data › Solver: the Solver Parameters form. Solve runs `data.solver` through the dialog's +/// command; the result opens Solver Results. +pub(super) fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let lang = crate::i18n::current(ui.ctx()); + let text = |d: &Dialog, k: &str| d.values.get(k).map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())).unwrap_or_default(); + let edit = |ui: &mut egui::Ui, d: &mut Dialog, k: &str, width: f32, label: &str| { + let mut t = text(d, k); + if ui.add(egui::TextEdit::singleline(&mut t).desired_width(width)).accessible_name(lang.tr(label)).changed() { + d.values.insert(k.into(), json!(t)); + } + }; + ui.horizontal(|ui| { + ui.label("Set Objective:"); + edit(ui, d, "objective", 200.0, "Set Objective:"); + }); + let goal = text(d, "goal"); + ui.horizontal(|ui| { + ui.label("To:"); + for (g, label) in [("max", "Max"), ("min", "Min"), ("value", "Value Of:")] { + if ui.radio(goal == g, label).clicked() { + d.values.insert("goal".into(), json!(g)); + } + } + edit(ui, d, "value", 80.0, "Value Of:"); + }); + ui.label("By Changing Variable Cells:"); + edit(ui, d, "variables", 420.0, "By Changing Variable Cells:"); + ui.label("Subject to the Constraints:"); + let list: Vec = d + .values + .get("constraints") + .and_then(Json::as_array) + .map(|a| a.iter().map(|c| c.as_str().map(str::to_string).unwrap_or_else(|| c.to_string())).collect()) + .unwrap_or_default(); + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.set_min_size(vec2(420.0, 90.0)); + egui::ScrollArea::vertical().max_height(110.0).show(ui, |ui| { + for (i, c) in list.iter().enumerate() { + if ui.selectable_label(d.list_index == i, c).clicked() { + d.list_index = i; + } + } + }); + }); + ui.horizontal(|ui| { + edit(ui, d, "newLhs", 110.0, "Constraint Cell Reference"); + let op = text(d, "newOp"); + egui::ComboBox::from_id_salt("solverOp") + .selected_text(op.clone()) + .width(60.0) + .show_ui(ui, |ui| { + for o in ["<=", "=", ">=", "int", "bin"] { + if ui.selectable_label(op == o, o).clicked() { + d.values.insert("newOp".into(), json!(o)); + } + } + }) + .response + .accessible_name(lang.tr("Constraint Operator")); + if !matches!(op.as_str(), "int" | "bin") { + edit(ui, d, "newRhs", 110.0, "Constraint Value"); + } + if ui.button("Add").clicked() { + let lhs = text(d, "newLhs"); + if !lhs.trim().is_empty() { + let rhs = if matches!(op.as_str(), "int" | "bin") { String::new() } else { format!(" {}", text(d, "newRhs").trim()) }; + let mut list = list.clone(); + list.push(format!("{} {op}{rhs}", lhs.trim())); + d.values.insert("constraints".into(), json!(list)); + d.values.insert("newLhs".into(), json!("")); + d.values.insert("newRhs".into(), json!("")); + } + } + if ui.add_enabled(d.list_index < list.len(), egui::Button::new("Delete")).clicked() { + let mut list = list.clone(); + if d.list_index < list.len() { + list.remove(d.list_index); + } + d.values.insert("constraints".into(), json!(list)); + } + }); + let mut nonneg = d.values.get("nonNegative").and_then(Json::as_bool).unwrap_or(true); + if ui.checkbox(&mut nonneg, "Make Unconstrained Variables Non-Negative").changed() { + d.values.insert("nonNegative".into(), json!(nonneg)); + } + let method = text(d, "method"); + ui.horizontal(|ui| { + ui.label("Select a Solving Method:"); + let label = |m: &str| match m { + "simplex" => "Simplex LP", + "evolutionary" => "Evolutionary", + _ => "GRG Nonlinear", + }; + egui::ComboBox::from_id_salt("solverMethod") + .selected_text(label(&method)) + .show_ui(ui, |ui| { + for m in ["grg", "simplex", "evolutionary"] { + if ui.selectable_label(method == m, label(m)).clicked() { + d.values.insert("method".into(), json!(m)); + } + } + }) + .response + .accessible_name(lang.tr("Select a Solving Method:")); + }); + ui.label( + egui::RichText::new(match method.as_str() { + "simplex" => "Simplex LP: for linear models; integer and binary constraints by branch and bound.", + "evolutionary" => "Evolutionary: a genetic search for models that aren't smooth; every variable cell needs bounds.", + _ => "GRG Nonlinear: a local optimum of a smooth model, from the current values.", + }) + .small(), + ); + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + 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; + } + if ui.button(" Close ").clicked() { + *open = false; + } + }); +} + +/// Solver Results: the outcome, and keeping or restoring the variable cells. +pub(super) fn solver_results(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) { + let message = d.values.get("message").and_then(Json::as_str).unwrap_or("").to_string(); + ui.label(message); + let changed = d.values.get("changed").and_then(Json::as_bool).unwrap_or(false); + let restore = d.values.get("restore").and_then(Json::as_bool).unwrap_or(false); + if changed { + ui.add_space(4.0); + if ui.radio(!restore, "Keep Solver Solution").clicked() { + d.values.insert("restore".into(), json!(false)); + } + if ui.radio(restore, "Restore Original Values").clicked() { + d.values.insert("restore".into(), json!(true)); + } + } + // Reports describe the solution whether it is kept or not. + let solved = d.values.get("values").and_then(Json::as_object).is_some_and(|v| !v.is_empty()); + if solved { + ui.add_space(4.0); + ui.label("Reports:"); + for (key, label) in [("answerReport", "Answer"), ("limitsReport", "Limits")] { + let mut on = d.values.get(key).and_then(Json::as_bool).unwrap_or(false); + if ui.checkbox(&mut on, label).changed() { + d.values.insert(key.into(), json!(on)); + } + } + } + let mut confirm = false; + ok_cancel(ui, &mut confirm, open); + if confirm { + // The solution (and the saved model) is one undo step. + if changed && restore { + app.run_or_alert("edit.undo", json!({})); + } + let reports: Vec<&str> = [("answerReport", "answer"), ("limitsReport", "limits")] + .into_iter() + .filter(|(key, _)| d.values.get(*key).and_then(Json::as_bool) == Some(true)) + .map(|(_, kind)| kind) + .collect(); + if solved && !reports.is_empty() { + let field = |k: &str| d.values.get(k).cloned().unwrap_or(Json::Null); + let params = json!({"reports": reports, "model": field("model"), "values": field("values"), "original": field("original"), "message": field("message")}); + app.run_or_alert("data.solverReport", params); + } + *open = false; + } +} diff --git a/crates/ui-egui/src/dialogs/charts.rs b/crates/ui-egui/src/dialogs/charts.rs new file mode 100644 index 0000000000..bd7da3d66c --- /dev/null +++ b/crates/ui-egui/src/dialogs/charts.rs @@ -0,0 +1,187 @@ +use super::show::ok_cancel; +use super::*; + +/// Every chart type the All Charts tab offers: `(type, subtype, label)` for `insert.chart`. +const ALL_CHARTS: [(&str, &str, &str); 21] = [ + ("column", "", "Clustered Column"), + ("column", "stacked", "Stacked Column"), + ("column", "stacked100", "100% Stacked Column"), + ("bar", "", "Clustered Bar"), + ("bar", "stacked", "Stacked Bar"), + ("bar", "stacked100", "100% Stacked Bar"), + ("line", "", "Line"), + ("line", "markers", "Line with Markers"), + ("line", "stacked", "Stacked Line"), + ("pie", "", "Pie"), + ("doughnut", "", "Doughnut"), + ("area", "", "Area"), + ("area", "stacked", "Stacked Area"), + ("area", "stacked100", "100% Stacked Area"), + ("scatter", "", "Scatter"), + ("scatter", "lines", "Scatter with Lines"), + ("radar", "", "Radar"), + ("histogram", "", "Histogram"), + ("waterfall", "", "Waterfall"), + ("funnel", "", "Funnel"), + ("combo", "", "Combo"), +]; + +/// The chart types listed on the dialog's current tab. +fn chart_choices(d: &Dialog) -> Vec<(String, String, String)> { + if d.tab == "All Charts" { + return ALL_CHARTS.iter().map(|(t, s, l)| (t.to_string(), s.to_string(), l.to_string())).collect(); + } + let field = |x: &Json, k: &str| x.get(k).and_then(Json::as_str).unwrap_or("").to_string(); + d.result + .as_ref() + .and_then(Json::as_array) + .map(|a| a.iter().map(|x| (field(x, "type"), field(x, "subtype"), field(x, "label"))).collect()) + .unwrap_or_default() +} + +/// Selects entry `i` of the current tab: OK inserts it. +pub(super) fn select_chart(d: &mut Dialog, i: usize) { + let Some((t, sub, _)) = chart_choices(d).into_iter().nth(i) else { return }; + d.list_index = i; + d.values.insert("type".into(), json!(t)); + d.values.insert("subtype".into(), json!(sub)); +} + +fn chart_icon(t: &str) -> Icon { + match t { + "column" | "bar" | "histogram" | "waterfall" | "funnel" | "combo" => Icon::ChartBar, + "line" | "area" | "radar" => Icon::ChartLine, + "pie" | "doughnut" => Icon::ChartPie, + "scatter" | "bubble" => Icon::ChartScatter, + _ => Icon::Chart, + } +} + +pub(super) fn recommended_charts(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + ui.horizontal(|ui| { + for tab in ["Recommended Charts", "All Charts"] { + if ui.selectable_label(d.tab == tab, tab).clicked() && d.tab != tab { + d.tab = tab.to_string(); + select_chart(d, 0); + } + } + }); + ui.separator(); + let choices = chart_choices(d); + let t = crate::theme::Tokens::get(ui.ctx()); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(190.0); + egui::ScrollArea::vertical().id_salt("chart_choices").max_height(300.0).show(ui, |ui| { + for (i, (kind, _, label)) in choices.iter().enumerate() { + let resp = ui + .horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); + icons::paint(ui.painter(), r.shrink(2.0), chart_icon(kind), t.text); + ui.selectable_label(i == d.list_index, label.as_str()) + }) + .inner; + if resp.clicked() { + select_chart(d, i); + } + if resp.double_clicked() { + select_chart(d, i); + *confirm = true; + } + } + }); + }); + ui.add_space(12.0); + ui.vertical(|ui| { + let label = choices.get(d.list_index).map(|c| c.2.clone()).unwrap_or_default(); + ui.strong(label); + let (rect, _) = ui.allocate_exact_size(vec2(360.0, 216.0), Sense::hover()); + ui.painter().rect_filled(rect, 2.0, Color32::WHITE); + ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); + let params = json!({"type": d.values.get("type").cloned().unwrap_or(Json::Null), "subtype": d.values.get("subtype").cloned().unwrap_or(Json::Null)}); + if let Some(doc) = app.session.active() + && let Ok((sheet, chart)) = gridcraft_engine::cmd::insert::build_chart(&app.session, ¶ms) + { + crate::chartview::paint_chart(ui.painter(), rect, &doc.wb, sheet, &chart); + } + }); + }); + ok_cancel(ui, confirm, open); + if *confirm { + *confirm = false; + let params = + json!({"type": d.values.get("type").cloned().unwrap_or(Json::Null), "subtype": d.values.get("subtype").cloned().unwrap_or(Json::Null)}); + match app.run("insert.chart", params) { + Ok(r) => { + // The new chart is selected, as after inserting any object. + app.selected_chart = r.get("chart").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()); + *open = false; + } + Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), + } + } +} + +/// Draws chart range, series keep/remove and category-range controls and runs `chart.selectData` when something changed. +pub(super) fn select_data(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let text = |d: &Dialog, k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); + let edit = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str| { + let label = ui.label(label); + let mut v = text(d, key); + if ui.add(egui::TextEdit::singleline(&mut v).desired_width(420.0)).labelled_by(label.id).changed() { + d.values.insert(key.into(), json!(v)); + } + }; + edit(ui, d, "range", "Chart data range:"); + ui.add_space(6.0); + ui.strong("Legend Entries (Series)"); + let names: Vec = + d.values.get("names").and_then(Json::as_array).map(|a| a.iter().map(|n| n.as_str().unwrap_or("").to_string()).collect()).unwrap_or_default(); + let mut remove: Vec = + d.values.get("remove").and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_u64).collect()).unwrap_or_default(); + let kept = names.len().saturating_sub(remove.len()); + egui::ScrollArea::vertical().id_salt("series").max_height(160.0).show(ui, |ui| { + for (i, name) in names.iter().enumerate() { + let i = i as u64; + let removed = remove.contains(&i); + ui.horizontal(|ui| { + let label = if name.is_empty() { format!("Series{}", i + 1) } else { name.clone() }; + ui.label(if removed { egui::RichText::new(label).strikethrough().weak() } else { egui::RichText::new(label) }); + if removed { + if ui.small_button("Keep").clicked() { + remove.retain(|x| *x != i); + } + } else if ui.add_enabled(kept > 1, egui::Button::new("Remove").small()).clicked() { + remove.push(i); + } + }); + } + }); + d.values.insert("remove".into(), json!(remove)); + ui.add_space(6.0); + edit(ui, d, "categories", "Horizontal (Category) Axis Labels:"); + ok_cancel(ui, confirm, open); + if *confirm { + *confirm = false; + let mut params = serde_json::Map::new(); + params.insert("chart".into(), d.values.get("chart").cloned().unwrap_or(Json::Null)); + let range_changed = text(d, "range") != text(d, "range0"); + if range_changed { + params.insert("range".into(), json!(text(d, "range"))); + } else if !remove.is_empty() { + // A new range rebuilds the series; removals only apply to the current ones. + params.insert("removeSeries".into(), json!(remove)); + } + if text(d, "categories") != text(d, "categories0") || (range_changed && !text(d, "categories").is_empty()) { + params.insert("categories".into(), json!(text(d, "categories"))); + } + if params.len() == 1 { + *open = false; + return; + } + match app.run("chart.selectData", Json::Object(params)) { + Ok(_) => *open = false, + Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), + } + } +} diff --git a/crates/ui-egui/src/dialogs/command_search.rs b/crates/ui-egui/src/dialogs/command_search.rs new file mode 100644 index 0000000000..509ca4700d --- /dev/null +++ b/crates/ui-egui/src/dialogs/command_search.rs @@ -0,0 +1,88 @@ +use super::*; + +pub(super) fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + let (down, up, home, end, pgup, pgdn) = ui.input_mut(|i| { + ( + i.consume_key(egui::Modifiers::NONE, Key::ArrowDown), + i.consume_key(egui::Modifiers::NONE, Key::ArrowUp), + i.consume_key(egui::Modifiers::CTRL, Key::Home), + i.consume_key(egui::Modifiers::CTRL, Key::End), + i.consume_key(egui::Modifiers::NONE, Key::PageUp), + i.consume_key(egui::Modifiers::NONE, Key::PageDown), + ) + }); + let width = ui.available_width(); + let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(width).hint_text("Type a command, e.g. freeze, chart, bold…")); + + if !d.focus_initialized { + r.request_focus(); + d.focus_initialized = true; + } + + let q = d.search.to_ascii_lowercase(); + let cmds: Vec<(String, String, String)> = app + .session + .commands() + .into_iter() + .filter(|c| c.enabled) + .filter(|c| q.is_empty() || c.label.to_ascii_lowercase().contains(&q) || c.id.to_ascii_lowercase().contains(&q)) + .map(|c| (c.id.to_string(), c.label.to_string(), c.menu.join(" › "))) + .take(40) + .collect(); + + let last = cmds.len().saturating_sub(1); + + let row_h = ui.spacing().interact_size.y.max(20.0); + let row_step = row_h + ui.spacing().item_spacing.y; + + const LIST_HEIGHT: f32 = 320.0; + + let page_size = ((LIST_HEIGHT / row_step).floor() as usize).saturating_sub(1).max(1); + + if down { + d.list_index = (d.list_index + 1).min(last); + } + if up { + d.list_index = d.list_index.saturating_sub(1); + } + if pgdn { + d.list_index = (d.list_index + page_size).min(last); + } + if pgup { + d.list_index = d.list_index.saturating_sub(page_size); + } + if home { + d.list_index = 0; + } + if end { + d.list_index = last; + } + + d.list_index = d.list_index.min(last); + + egui::ScrollArea::vertical().max_height(LIST_HEIGHT).auto_shrink([true, false]).show(ui, |ui| { + let scroll_area_width = ui.available_width(); + for (i, (id, label, menu)) in cmds.iter().enumerate() { + let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{label} {menu}")).min_size(vec2(scroll_area_width, 20.0))); + + if (down || up || home || end || pgup || pgdn) && i == d.list_index && !ui.clip_rect().contains_rect(r.rect) { + r.scroll_to_me(Some(if down || end || pgdn { egui::Align::Max } else { egui::Align::Min })); + } + + if r.clicked() { + d.list_index = i; + *confirm = true; + } + + r.on_hover_text(id); + } + }); + if ui.input(|i| i.key_pressed(Key::Enter)) { + *confirm = true; + } + if *confirm && let Some((id, ..)) = cmds.get(d.list_index) { + let id = id.clone(); + app.dialog = None; + app.run_or_alert(&id, json!({})); + } +} diff --git a/crates/ui-egui/src/dialogs/cond_format_rules.rs b/crates/ui-egui/src/dialogs/cond_format_rules.rs new file mode 100644 index 0000000000..216f79e31f --- /dev/null +++ b/crates/ui-egui/src/dialogs/cond_format_rules.rs @@ -0,0 +1,28 @@ +use super::*; + +pub(super) fn manage_rules(app: &mut SheetApp, ui: &mut egui::Ui) { + let list = app.session.run("home.manageRules", json!({})).unwrap_or(Json::Null); + let rules = list["rules"].as_array().cloned().unwrap_or_default(); + if rules.is_empty() { + ui.label("There are no conditional formatting rules on this sheet."); + } + for (i, r) in rules.iter().enumerate() { + ui.horizontal(|ui| { + let kind = r["rule"].as_object().and_then(|o| o.keys().next().cloned()).unwrap_or_default(); + ui.label(format!( + "{}. {kind} applies to {}", + i + 1, + r["ranges"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::>().join(",")).unwrap_or_default() + )); + if ui.small_button("▲").clicked() { + app.run_or_alert("home.manageRules", json!({"moveUp": i})); + } + if ui.small_button("▼").clicked() { + app.run_or_alert("home.manageRules", json!({"moveDown": i})); + } + if ui.small_button("Delete").clicked() { + app.run_or_alert("home.manageRules", json!({"delete": i})); + } + }); + } +} diff --git a/crates/ui-egui/src/dialogs/data.rs b/crates/ui-egui/src/dialogs/data.rs new file mode 100644 index 0000000000..615c64ab97 --- /dev/null +++ b/crates/ui-egui/src/dialogs/data.rs @@ -0,0 +1,327 @@ +#[cfg(test)] +use super::show::build_params; +use super::show::{form_fields, ok_cancel}; +use super::*; + +/// Initial values of the Data Validation form: the rule stored on the active cell, so OK without +/// edits keeps it; a plain list rule when the cell has none. +pub(super) fn validation_defaults(app: &SheetApp) -> Json { + let mut v = json!({"type": "list", "operator": "between", "ignoreBlank": true, "dropdown": true, "errorStyle": "stop"}); + let Some(doc) = app.session.active() else { return v }; + let Some(dv) = doc.wb.active().and_then(|s| s.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(doc.selection.active)))) else { + return v; + }; + // The enums serialize as their variant names; the command takes them in camelCase. + let camel = |x: Json| { + let name = x.as_str().unwrap_or_default(); + let mut chars = name.chars(); + chars.next().map(|c| c.to_lowercase().chain(chars).collect::()).unwrap_or_default() + }; + v["type"] = json!(camel(json!(dv.kind))); + v["operator"] = json!(camel(json!(dv.op))); + v["errorStyle"] = json!(camel(json!(dv.error_style))); + v["formula1"] = json!(dv.f1); + if let Some(f2) = &dv.f2 { + v["formula2"] = json!(f2); + } + v["ignoreBlank"] = json!(dv.allow_blank); + v["dropdown"] = json!(dv.in_cell_dropdown); + v["inputTitle"] = json!(dv.input_title); + v["inputMessage"] = json!(dv.input_message); + v["errorTitle"] = json!(dv.error_title); + v["errorMessage"] = json!(dv.error_message); + v +} + +/// The range Remove Duplicates starts with. A block selection is used as given; with only the +/// cursor placed, the table's data rows or the current region, as the command picks when it gets +/// no range (a single-cell range would remove nothing). +pub(super) fn remove_duplicates_range(app: &SheetApp, sel: &str) -> String { + let Some(d) = app.session.active() else { return sel.to_string() }; + let Some(sh) = d.wb.active() else { return sel.to_string() }; + if !d.selection.current().is_single() { + return sel.to_string(); + } + let range = match sh.table_at(d.selection.active) { + Some(t) => t.data_range().map(|dr| gridcraft_engine::core::RangeRef::new(t.range.start, dr.end)).unwrap_or(t.range), + None => gridcraft_engine::selection::current_region(sh, d.selection.active), + }; + range.a1() +} + +pub(super) fn abs(r: &gridcraft_engine::core::RangeRef) -> String { + let a = |c: gridcraft_engine::core::CellRef| format!("${}${}", gridcraft_engine::core::col_to_letters(c.col), c.row + 1); + if r.is_single() { a(r.start) } else { format!("{}:{}", a(r.start), a(r.end)) } +} + +/// The criterion controls in Excel's Settings tab depend on both Allow and Data. +/// `formula1` is the sole source for a list or custom rule; `formula2` is a +/// second bound only for Between and Not between. +pub(super) fn data_validation_fields(kind: &str, operator: &str) -> Vec { + use Field::*; + let mut fields = vec![Choice { + key: "type", + label: "Allow", + options: vec![ + ("any", "Any value"), + ("whole", "Whole number"), + ("decimal", "Decimal"), + ("list", "List"), + ("date", "Date"), + ("time", "Time"), + ("textLength", "Text length"), + ("custom", "Custom"), + ], + }]; + let two_bounds = matches!(operator, "between" | "notBetween"); + match kind { + "list" => { + fields.push(Text { key: "formula1", label: "Source:" }); + fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); + fields.push(Check { key: "dropdown", label: "In-cell dropdown" }); + } + "custom" => { + fields.push(Text { key: "formula1", label: "Formula:" }); + fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); + } + "whole" | "decimal" | "date" | "time" | "textLength" => { + fields.push(Choice { + key: "operator", + label: "Data", + options: vec![ + ("between", "between"), + ("notBetween", "not between"), + ("equal", "equal to"), + ("notEqual", "not equal to"), + ("greater", "greater than"), + ("less", "less than"), + ("greaterOrEqual", "greater than or equal to"), + ("lessOrEqual", "less than or equal to"), + ], + }); + let lower = matches!(operator, "greater" | "greaterOrEqual"); + let (first, second) = match kind { + "date" => ( + if two_bounds || lower { + "Start date:" + } else if matches!(operator, "less" | "lessOrEqual") { + "End date:" + } else { + "Date:" + }, + "End date:", + ), + "time" => ( + if two_bounds || lower { + "Start time:" + } else if matches!(operator, "less" | "lessOrEqual") { + "End time:" + } else { + "Time:" + }, + "End time:", + ), + _ => ( + if two_bounds || lower { + "Minimum:" + } else if matches!(operator, "less" | "lessOrEqual") { + "Maximum:" + } else { + "Value:" + }, + "Maximum:", + ), + }; + fields.push(Text { key: "formula1", label: first }); + if two_bounds { + fields.push(Text { key: "formula2", label: second }); + } + fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); + } + _ => {} + } + fields.push(Text { key: "inputMessage", label: "Input message:" }); + fields.push(Choice { + key: "errorStyle", + label: "Error style", + options: vec![("stop", "Stop"), ("warning", "Warning"), ("information", "Information")], + }); + fields.push(Text { key: "errorMessage", label: "Error message:" }); + fields +} + +pub(super) fn data_validation_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any"); + let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between"); + d.fields = data_validation_fields(kind, operator); + form_fields(ui, d); + ok_cancel(ui, confirm, open); +} + +pub(super) fn advanced_filter_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + use Field::*; + let copy = d.values.get("action").and_then(Json::as_str) == Some("copy"); + d.fields = vec![ + Choice { + key: "action", + label: "Action", + options: vec![("inPlace", "Show matching rows in place"), ("copy", "Copy matching rows elsewhere")], + }, + Text { key: "range", label: "List range:" }, + Text { key: "criteria", label: "Criteria range (optional):" }, + ]; + if copy { + d.fields.push(Text { key: "copyTo", label: "Copy to:" }); + } + d.fields.push(Check { key: "unique", label: "Unique rows only" }); + form_fields(ui, d); + // Constrain the button row: the shared right-to-left layout otherwise + // consumes the window's remaining height instead of fitting the buttons. + ui.allocate_ui(vec2(ui.available_width(), ui.spacing().interact_size.y + 8.0), |ui| ok_cancel(ui, confirm, open)); +} + +/// Draws the sort-level editor: header toggle, column/by/order controls, add/remove level, and `data.sort` on confirmation. +pub(super) fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + let lang = app.ui.language; + let mut header = d.values.get("header").and_then(Json::as_bool).unwrap_or(true); + ui.checkbox(&mut header, "My list has headers"); + d.values.insert("header".into(), json!(header)); + let mut levels: Vec = d.values.get("levels").and_then(Json::as_array).cloned().unwrap_or_default(); + // Columns of the region around the active cell, labelled with their header (or first value). + let columns: Vec<(String, String)> = app + .session + .active() + .and_then(|d| { + let sh = d.wb.active()?; + let region = gridcraft_engine::selection::current_region(sh, d.selection.active); + Some( + (region.start.col..=region.end.col) + .map(|c| { + let letter = gridcraft_engine::core::col_to_letters(c); + // The region starts at the header row when one is present, so the + // label always comes from start.row: header text with headers on, + // first data value with them off. + let label_row = region.start.row; + let text = gridcraft_engine::display::cell_text(&d.wb, sh, gridcraft_engine::core::CellRef::new(label_row, c)); + let text: String = text.chars().take(24).collect(); + (letter.clone(), if text.is_empty() { letter } else { format!("{letter}: {text}") }) + }) + .collect(), + ) + }) + .unwrap_or_default(); + let mut remove = None; + for (i, lv) in levels.iter_mut().enumerate() { + ui.horizontal(|ui| { + let label = ui.label(lang.tr(if i == 0 { "Sort by" } else { "Then by" })); + let mut col = lv["column"].as_str().unwrap_or("A").to_string(); + if columns.is_empty() { + ui.add(egui::TextEdit::singleline(&mut col).desired_width(50.0)).labelled_by(label.id); + } else { + egui::ComboBox::from_id_salt(("sort_col", i)) + .selected_text(col.clone()) + .show_ui(ui, |ui| { + for (letter, label) in &columns { + ui.selectable_value(&mut col, letter.clone(), label.as_str()); + } + }) + .response + .labelled_by(label.id); + } + lv["column"] = json!(col.to_ascii_uppercase()); + let mut by = lv["by"].as_str().unwrap_or("values").to_string(); + egui::ComboBox::from_id_salt(("by", i)) + .selected_text(match by.as_str() { + "cellColor" => "Cell Color", + "fontColor" => "Font Color", + _ => "Cell Values", + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut by, "values".into(), "Cell Values"); + ui.selectable_value(&mut by, "cellColor".into(), "Cell Color"); + ui.selectable_value(&mut by, "fontColor".into(), "Font Color"); + }) + .response + .accessible_name(lang.tr("Sort On")); + lv["by"] = json!(by); + let mut desc = lv["order"].as_str() == Some("desc"); + ui.radio_value(&mut desc, false, "A to Z"); + ui.radio_value(&mut desc, true, "Z to A"); + lv["order"] = json!(if desc { "desc" } else { "asc" }); + if crate::widgets::icon_button(ui, Icon::Close, crate::theme::Tokens::get(ui.ctx()).text_dim, "Remove level", vec2(18.0, 18.0)).clicked() + { + remove = Some(i); + } + }); + } + if let Some(i) = remove + && levels.len() > 1 + { + levels.remove(i); + } + if ui.button("+ Add Level").clicked() { + let used: Vec = levels.iter().filter_map(|l| l.get("column").and_then(Json::as_str).map(str::to_string)).collect(); + let next = columns + .iter() + .map(|(l, _)| l) + .find(|l| !used.contains(l)) + .cloned() + .or_else(|| columns.last().map(|(l, _)| l.clone())) + .unwrap_or_else(|| "B".into()); + levels.push(json!({"column": next, "order": "asc"})); + } + d.values.insert("levels".into(), Json::Array(levels.clone())); + let mut open = true; + ok_cancel(ui, confirm, &mut open); + if *confirm { + app.run_or_alert("data.sort", json!({"header": header, "keys": levels})); + } + if !open { + *confirm = true; + } +} + +#[cfg(test)] +mod data_validation_tests { + use super::*; + + #[test] + fn list_uses_one_source_and_does_not_submit_a_second_list() { + let fields = data_validation_fields("list", "between"); + assert!(fields.iter().any(|f| matches!(f, Field::Text { key: "formula1", label: "Source:" }))); + assert!(!fields.iter().any(|f| matches!(f, Field::Text { key: "formula2", .. } | Field::Choice { key: "operator", .. }))); + + let dialog = Dialog::form( + "dataValidation", + "Data Validation", + "data.validation", + fields, + json!({"type": "list", "operator": "between", "formula1": "ABC,DEF", "formula2": "GHI,JKL", "dropdown": true}), + ); + let params = build_params(&dialog); + assert_eq!(params["formula1"], "ABC,DEF"); + assert!(params.get("formula2").is_none()); + assert!(params.get("operator").is_none()); + } + + #[test] + fn only_between_operators_show_and_submit_two_bounds() { + let two = data_validation_fields("whole", "between"); + assert!(two.iter().any(|f| matches!(f, Field::Text { key: "formula2", label: "Maximum:" }))); + let one = data_validation_fields("date", "lessOrEqual"); + assert!(one.iter().any(|f| matches!(f, Field::Text { key: "formula1", label: "End date:" }))); + assert!(!one.iter().any(|f| matches!(f, Field::Text { key: "formula2", .. }))); + + let dialog = Dialog::form( + "dataValidation", + "Data Validation", + "data.validation", + one, + json!({"type": "date", "operator": "lessOrEqual", "formula1": "2026-10-12", "formula2": "2030-01-01"}), + ); + let params = build_params(&dialog); + assert_eq!(params["operator"], "lessOrEqual"); + assert_eq!(params["formula1"], "2026-10-12"); + assert!(params.get("formula2").is_none()); + } +} diff --git a/crates/ui-egui/src/dialogs/find.rs b/crates/ui-egui/src/dialogs/find.rs new file mode 100644 index 0000000000..27bd3687ed --- /dev/null +++ b/crates/ui-egui/src/dialogs/find.rs @@ -0,0 +1,151 @@ +use super::show::ok_cancel; +use super::*; + +/// Draws the Find/Replace form with case, whole-cell and workbook options, requests field focus once, and runs find/replace with the current parameters. +pub(super) fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { + ui.horizontal(|ui| { + for tab in ["Find", "Replace"] { + if ui.selectable_label(d.tab == tab, tab).clicked() { + d.tab = tab.into(); + } + } + }); + let mut what = d.values.get("what").and_then(Json::as_str).unwrap_or("").to_string(); + let mut with = d.values.get("with").and_then(Json::as_str).unwrap_or("").to_string(); + ui.horizontal(|ui| { + let label = ui.label("Find what:"); + let r = ui.add(egui::TextEdit::singleline(&mut what).desired_width(260.0)).labelled_by(label.id); + // Like Excel, the cursor starts in "Find what" - asked for once, so clicking another field still works. + if !d.values.contains_key("focused") { + r.request_focus(); + d.values.insert("focused".into(), json!(true)); + } + }); + if d.tab == "Replace" { + ui.horizontal(|ui| { + let label = ui.label("Replace with:"); + ui.add(egui::TextEdit::singleline(&mut with).desired_width(244.0)).labelled_by(label.id); + }); + } + let mut case = d.values.get("matchCase").and_then(Json::as_bool).unwrap_or(false); + let mut whole = d.values.get("wholeCell").and_then(Json::as_bool).unwrap_or(false); + let mut wb = d.values.get("within").and_then(Json::as_str) == Some("workbook"); + ui.horizontal(|ui| { + ui.checkbox(&mut case, "Match case"); + ui.checkbox(&mut whole, "Match entire cell contents"); + ui.checkbox(&mut wb, "Within workbook"); + }); + d.values.insert("what".into(), json!(what)); + d.values.insert("with".into(), json!(with)); + d.values.insert("matchCase".into(), json!(case)); + d.values.insert("wholeCell".into(), json!(whole)); + d.values.insert("within".into(), json!(if wb { "workbook" } else { "sheet" })); + let base = json!({"what": what, "matchCase": case, "wholeCell": whole, "within": if wb { "workbook" } else { "sheet" }}); + ui.horizontal(|ui| { + if d.tab == "Replace" { + if ui.button("Replace All").clicked() { + let mut p = base.clone(); + p["with"] = json!(with); + p["all"] = json!(true); + match app.run("edit.replace", p) { + Ok(r) => app.toast = Some((format!("All done. We made {} replacements.", r["replaced"]), crate::now_ms())), + Err(e) => app.message = Some(("Find and Replace".into(), crate::clean_error(&e))), + } + } + if ui.button("Replace").clicked() { + let mut p = base.clone(); + p["with"] = json!(with); + p["all"] = json!(false); + let _ = app.run("edit.replace", p); + let _ = app.run("edit.find", base.clone()); + } + } + if ui.button("Find All").clicked() { + let mut p = base.clone(); + p["all"] = json!(true); + d.result = app.run("edit.find", p).ok(); + } + if ui.button("Find Next").clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { + if let Err(e) = app.run("edit.find", base.clone()) { + app.message = Some(("Find and Replace".into(), crate::clean_error(&e))); + } + app.grid.ensure_visible = true; + } + }); + if let Some(r) = &d.result + && let Some(list) = r["results"].as_array() + { + ui.separator(); + ui.label(format!("{} cell(s) found", list.len())); + egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { + for item in list { + let label = format!( + "{} {} {}", + item["sheet"].as_str().unwrap_or(""), + item["cell"].as_str().unwrap_or(""), + item["value"].as_str().unwrap_or("") + ); + if ui.selectable_label(false, label).clicked() { + if let Some(s) = item["sheet"].as_str() { + let _ = app.run("sheet.activate", json!({"sheet": s})); + } + let _ = app.run("selection.set", json!({"cell": item["cell"]})); + app.grid.ensure_visible = true; + } + } + }); + } +} + +/// What the Go To dialog lists: the named ranges of every sheet, then the references gone to +/// before, newest first. +pub(super) fn go_to_entries(app: &SheetApp) -> Vec { + 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() +} + +/// Draws a virtualized name/reference list and editable reference field, links to Go To Special, and confirms with `edit.goTo`. +pub(super) fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + let lang = app.ui.language; + let entries: Vec = + 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| { + let label = ui.label(lang.tr("Reference:")); + ui.text_edit_singleline(&mut r).labelled_by(label.id).request_focus(); + }); + d.values.insert("reference".into(), json!(r.clone())); + if ui.button("Special…").clicked() { + app.open_dialog("goToSpecial", json!({})); + return; + } + let mut open = true; + ok_cancel(ui, confirm, &mut open); + if *confirm { + app.run_or_alert("edit.goTo", json!({"reference": r, "record": true})); + app.grid.ensure_visible = true; + } + if !open { + *confirm = true; + } +} diff --git a/crates/ui-egui/src/dialogs/format_cells.rs b/crates/ui-egui/src/dialogs/format_cells.rs new file mode 100644 index 0000000000..f28c1de1bf --- /dev/null +++ b/crates/ui-egui/src/dialogs/format_cells.rs @@ -0,0 +1,227 @@ +use super::show::ok_cancel; +use super::*; + +/// Draws the six-tab Format Cells editor (number, alignment, font, border, fill, protection); the Number tab shows a sample preview, and the style draft updates. +pub(super) fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + // `d.tab` stays the English name (it is what `open_dialog` and tests pass); only the drawn + // labels are translated. The Fill tab is keyed by context: the Alignment tab's "Fill" is a + // different word in some languages. + let lang = app.ui.language; + ui.horizontal(|ui| { + for (tab, key) in [ + ("Number", "Number"), + ("Alignment", "Alignment"), + ("Font", "Font"), + ("Border", "Border"), + ("Fill", "Format Cells|Fill"), + ("Protection", "Protection"), + ] { + if ui.selectable_label(d.tab == tab, lang.tr(key)).clicked() { + d.tab = tab.to_string(); + } + } + }); + ui.separator(); + let mut st: gridcraft_engine::model::Style = d.values.get("style").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + let sample = app.session.active().and_then(|doc| doc.wb.active().map(|s| s.value(doc.selection.active))).unwrap_or_default(); + match d.tab.as_str() { + "Number" => { + let mut code = st.num_fmt.as_str().to_string(); + ui.columns(2, |cols| { + cols[0].label(egui::RichText::new(lang.tr("Category:")).strong()); + for name in + ["General", "Number", "Currency", "Accounting", "Short Date", "Long Date", "Time", "Percentage", "Fraction", "Scientific", "Text"] + { + let c = gridcraft_engine::cmd::format::format_code_for(name).to_string(); + if cols[0].selectable_label(code == c, lang.tr(name)).clicked() { + code = c; + } + } + cols[1].label(egui::RichText::new(lang.tr("Sample")).strong()); + let preview = app.session.active().map(|doc| gridcraft_engine::display::format(&sample, &code, &doc.wb).text).unwrap_or_default(); + cols[1].label(egui::RichText::new(preview).font(theme::ui_font(15.0))); + cols[1].add_space(8.0); + let label = cols[1].label(lang.tr("Type (custom format code):")); + cols[1].text_edit_singleline(&mut code).labelled_by(label.id); + for c in [ + "0", + "0.00", + "#,##0", + "#,##0.00", + "#,##0;[Red]-#,##0", + "0%", + "0.00%", + "0.00E+00", + "# ?/?", + "m/d/yyyy", + "d-mmm-yy", + "mmmm d, yyyy", + "h:mm AM/PM", + "h:mm:ss", + "[h]:mm:ss", + "@", + ] { + if cols[1].small_button(c).clicked() { + code = c.to_string(); + } + } + }); + st.num_fmt = gridcraft_engine::model::NumFmt::new(&code); + } + "Alignment" => { + use gridcraft_engine::model::{HAlign, VAlign}; + ui.label(egui::RichText::new(lang.tr("Horizontal")).strong()); + ui.horizontal_wrapped(|ui| { + for (h, n) in [ + (HAlign::General, "General"), + (HAlign::Left, "Left"), + (HAlign::Center, "Center"), + (HAlign::Right, "Right"), + (HAlign::Fill, "Fill"), + (HAlign::Justify, "Justify"), + (HAlign::CenterAcross, "Center Across Selection"), + (HAlign::Distributed, "Distributed"), + ] { + ui.radio_value(&mut st.align.h, h, lang.tr(n)); + } + }); + ui.label(egui::RichText::new(lang.tr("Vertical")).strong()); + ui.horizontal_wrapped(|ui| { + for (v, n) in [ + (VAlign::Top, "Top"), + (VAlign::Center, "Center"), + (VAlign::Bottom, "Bottom"), + (VAlign::Justify, "Justify"), + (VAlign::Distributed, "Distributed"), + ] { + ui.radio_value(&mut st.align.v, v, lang.tr(n)); + } + }); + let mut indent = st.align.indent as f32; + ui.add(egui::Slider::new(&mut indent, 0.0..=15.0).text(lang.tr("Indent"))); + st.align.indent = indent as u8; + let mut rot = if st.align.rotation == 255 { 0.0 } else { st.align.rotation as f32 }; + ui.add(egui::Slider::new(&mut rot, -90.0..=90.0).text(lang.tr("Orientation (degrees)"))); + st.align.rotation = rot as i16; + ui.checkbox(&mut st.align.wrap, lang.tr("Wrap text")); + ui.checkbox(&mut st.align.shrink, lang.tr("Shrink to fit")); + } + "Font" => { + egui::ComboBox::from_label(lang.tr("Font")).selected_text(st.font.name.clone()).show_ui(ui, |ui| { + for f in crate::fonts::families() { + ui.selectable_value(&mut st.font.name, f.clone(), f.clone()); + } + }); + ui.add(egui::Slider::new(&mut st.font.size, 6.0..=72.0).text(lang.tr("Size"))); + ui.horizontal(|ui| { + ui.checkbox(&mut st.font.bold, lang.tr("Bold")); + ui.checkbox(&mut st.font.italic, lang.tr("Italic")); + ui.checkbox(&mut st.font.strike, lang.tr("Strikethrough")); + }); + use gridcraft_engine::model::{Underline, VertAlign}; + ui.horizontal(|ui| { + for (u, n) in [ + (Underline::None, "None"), + (Underline::Single, "Single"), + (Underline::Double, "Double"), + (Underline::SingleAccounting, "Single Accounting"), + (Underline::DoubleAccounting, "Double Accounting"), + ] { + ui.radio_value(&mut st.font.underline, u, lang.tr(n)); + } + }); + ui.horizontal(|ui| { + ui.radio_value(&mut st.font.vert, VertAlign::Baseline, lang.tr("Normal")); + ui.radio_value(&mut st.font.vert, VertAlign::Superscript, lang.tr("Superscript")); + ui.radio_value(&mut st.font.vert, VertAlign::Subscript, lang.tr("Subscript")); + }); + ui.label(lang.tr("Color:")); + let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default(); + if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("Automatic")) { + st.font.color = + if c == "none" { gridcraft_engine::model::Color::Auto } else { gridcraft_engine::model::Color::from_hex(&c).unwrap_or_default() }; + } + } + "Border" => { + use gridcraft_engine::model::{BorderLine, BorderStyle}; + let line = BorderLine { style: BorderStyle::Thin, color: gridcraft_engine::model::Color::Auto }; + ui.horizontal(|ui| { + if ui.button(lang.tr("None")).clicked() { + st.borders = Default::default(); + } + if ui.button(lang.tr("Outline")).clicked() { + st.borders.top = line; + st.borders.bottom = line; + st.borders.left = line; + st.borders.right = line; + } + }); + for (n, slot) in [ + ("Top", &mut st.borders.top), + ("Bottom", &mut st.borders.bottom), + ("Left", &mut st.borders.left), + ("Right", &mut st.borders.right), + ("Diagonal ↘", &mut st.borders.diag_down), + ("Diagonal ↗", &mut st.borders.diag_up), + ] { + let n = lang.tr(n); + ui.horizontal(|ui| { + ui.label(format!("{n}:")); + for (s, label) in [ + (BorderStyle::None, "none"), + (BorderStyle::Thin, "thin"), + (BorderStyle::Medium, "medium"), + (BorderStyle::Thick, "thick"), + (BorderStyle::Dashed, "dashed"), + (BorderStyle::Dotted, "dotted"), + (BorderStyle::Double, "double"), + ] { + let label = lang.tr(label); + let response = ui.selectable_label(slot.style == s, label); + let selected = slot.style == s; + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected, format!("{n} {label}"))); + if response.clicked() { + slot.style = s; + } + } + }); + } + let theme = app.session.active().map(|doc| &doc.wb.theme).cloned().unwrap_or_default(); + crate::border_preview::sample(ui, &st.borders, &theme); + } + "Fill" => { + let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default(); + ui.label(lang.tr("Background Color:")); + if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("No Color")) { + st.fill = if c == "none" { + Default::default() + } else { + gridcraft_engine::model::Fill::solid(gridcraft_engine::model::Color::from_hex(&c).unwrap_or_default()) + }; + } + } + "Protection" => { + ui.checkbox(&mut st.protection.locked, lang.tr("Locked")); + ui.checkbox(&mut st.protection.hidden, lang.tr("Hidden")); + ui.label( + egui::RichText::new( + lang.tr("Locking cells or hiding formulas has no effect until you protect the worksheet (Review › Protect Sheet)."), + ) + .small(), + ); + } + _ => {} + } + if let Ok(v) = serde_json::to_value(&st) { + d.values.insert("style".into(), v); + } + let mut open = true; + ok_cancel(ui, confirm, &mut open); + if *confirm && let Some(style) = d.values.get("style").cloned() { + app.run_or_alert("home.formatCells", json!({"style": style})); + } + if !open { + app.dialog = None; + *confirm = true; // closes + } +} diff --git a/crates/ui-egui/src/dialogs/insert_function.rs b/crates/ui-egui/src/dialogs/insert_function.rs new file mode 100644 index 0000000000..2a240b1afd --- /dev/null +++ b/crates/ui-egui/src/dialogs/insert_function.rs @@ -0,0 +1,72 @@ +use super::show::ok_cancel; +use super::*; + +/// Insert Function: searches names (as the formula language spells them) and descriptions, shows +/// each hit's category and description, and starts editing `=NAME(` (or appends to the formula). +pub(super) fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + let language = app.ui.language; + ui.horizontal(|ui| { + ui.label(language.tr("Search:")); + let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(300.0).hint_text(language.tr("e.g. lookup, average, date"))); + r.request_focus(); + }); + let locale = language.formula_locale(); + let list = gridcraft_engine::cmd::formulas::function_list(); + // The names and descriptions searched are the ones shown, so in the interface language. + let q = d.search.to_lowercase(); + let hits: Vec<&Json> = list + .iter() + .filter(|f| { + q.is_empty() + || f["name"].as_str().is_some_and(|n| locale.function_name(n).to_lowercase().contains(&q)) + || f["description"].as_str().is_some_and(|n| language.function_description(n).unwrap_or(n).to_lowercase().contains(&q)) + }) + .take(300) + .collect(); + egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { + for (i, f) in hits.iter().enumerate() { + let name = locale.function_name(f["name"].as_str().unwrap_or("")); + let category = language.tr(function_category_label(f["category"].as_str().unwrap_or(""))); + let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{name:<18} {category}")).min_size(vec2(480.0, 20.0))); + if r.clicked() { + d.list_index = i; + } + if r.double_clicked() { + d.list_index = i; + *confirm = true; + } + } + }); + if let Some(f) = hits.get(d.list_index) { + let name = f["name"].as_str().unwrap_or(""); + ui.separator(); + ui.label(egui::RichText::new(crate::formula_locale::signature(name, locale, language).unwrap_or_default()).strong()); + ui.label(crate::formula_locale::description(name, locale, language).unwrap_or_default()); + } + let mut open = true; + ok_cancel(ui, confirm, &mut open); + if *confirm && let Some(n) = hits.get(d.list_index).and_then(|f| f["name"].as_str()) { + let n = app.session.active().map(|doc| crate::formula_locale::completion_name(n, locale, &doc.wb, doc.wb.active_sheet())).unwrap_or(n); + let cur = app.editor.as_ref().map(|e| e.text.clone()); + let text = match cur { + Some(t) if t.starts_with('=') => format!("{t}{n}("), + _ => format!("={n}("), + }; + app.begin_edit(Some(text), false); + } + if !open { + *confirm = true; + } +} + +/// Excel's name for a function category, given the catalog's id (`MathTrig`). +fn function_category_label(id: &str) -> &str { + match id { + "DateTime" => "Date & Time", + "Lookup" => "Lookup & Reference", + "MathTrig" => "Math & Trig", + "AddIn" => "Add-in and Automation", + // Financial, Logical, Text, Statistical, Engineering, Database, Information, … + other => other, + } +} diff --git a/crates/ui-egui/src/dialogs/macros.rs b/crates/ui-egui/src/dialogs/macros.rs new file mode 100644 index 0000000000..269643f8d0 --- /dev/null +++ b/crates/ui-egui/src/dialogs/macros.rs @@ -0,0 +1,40 @@ +use super::*; + +/// The Macros dialog: lists GridCraft's own recorded scripts and (if the file carries one) the +/// preserved VBA project. Mirrors Excel's Developer ▸ Macros list, but is honest that VBA is +/// preserved and not executed. +pub(super) fn macros(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { + ui.label("Automation in this workbook. GridCraft runs its own scripts; a file's VBA project is preserved but not executed."); + ui.add_space(6.0); + let items: Vec = d.result.as_ref().and_then(Json::as_array).cloned().unwrap_or_default(); + if items.is_empty() { + ui.weak("This workbook has no scripts or macros."); + return; + } + // Scripts that came with a file stay disabled until the user trusts the workbook (#2178). + if items.iter().any(|it| it.get("trusted").and_then(Json::as_bool) == Some(false)) { + ui.horizontal(|ui| { + ui.weak("Scripts in this workbook came from a file and are disabled."); + if ui.button("Trust Scripts").on_hover_text("Let this workbook's scripts run for the rest of this session").clicked() { + let _ = app.run("automate.trustScripts", json!({"trusted": true})); + d.result = app.session.run("view.macros", json!({})).ok(); + } + }); + ui.add_space(6.0); + } + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + for it in &items { + let name = it.get("name").and_then(Json::as_str).unwrap_or("(unnamed)"); + let is_vba = it.get("kind").and_then(Json::as_str) == Some("vba"); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(name).strong()); + if is_vba { + ui.weak("— VBA project (preserved, not run)"); + } else { + let steps = it.get("steps").and_then(Json::as_u64).unwrap_or(0); + ui.weak(format!("— {steps} step{}", if steps == 1 { "" } else { "s" })); + } + }); + } + }); +} diff --git a/crates/ui-egui/src/dialogs/names.rs b/crates/ui-egui/src/dialogs/names.rs new file mode 100644 index 0000000000..f12374daee --- /dev/null +++ b/crates/ui-egui/src/dialogs/names.rs @@ -0,0 +1,134 @@ +use super::show::ok_cancel; +use super::*; + +pub(super) fn name_manager(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) { + let list = app.session.run("formulas.nameManager", json!({})).unwrap_or(Json::Null); + let names = list.as_array().cloned().unwrap_or_default(); + let selected = names.get(d.list_index).cloned(); + let field = |n: &Json, k: &str| n.get(k).and_then(Json::as_str).unwrap_or("").to_string(); + ui.horizontal(|ui| { + if ui.button("New…").clicked() { + app.open_dialog("defineName", json!({})); + } + if ui.add_enabled(selected.is_some(), egui::Button::new("Edit…")).clicked() + && let Some(n) = &selected + { + app.open_dialog( + "defineName", + json!({ + "name": field(n, "name"), "scope": field(n, "scope"), "comment": field(n, "comment"), + "refersTo": field(n, "refersTo"), "oldName": field(n, "name"), "oldScope": field(n, "scope"), + }), + ); + } + if ui.add_enabled(selected.is_some(), egui::Button::new("Delete")).clicked() + && let Some(n) = &selected + { + app.run_or_alert("formulas.deleteName", json!({"name": field(n, "name"), "scope": field(n, "scope")})); + } + }); + ui.add_space(4.0); + egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { + egui::Grid::new("names").striped(true).num_columns(4).show(ui, |ui| { + ui.strong("Name"); + ui.strong("Value"); + ui.strong("Refers To"); + ui.strong("Scope"); + ui.end_row(); + if names.is_empty() { + ui.label(egui::RichText::new("No names defined").italics()); + ui.end_row(); + } + for (i, n) in names.iter().enumerate() { + if ui.selectable_label(i == d.list_index, field(n, "name")).clicked() { + d.list_index = i; + } + ui.label(n.get("value").map(|v| v.to_string()).unwrap_or_default().chars().take(30).collect::()); + ui.label(field(n, "refersTo")); + ui.label(field(n, "scope")); + ui.end_row(); + } + }); + }); + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(" Close ").clicked() { + *open = false; + } + }); +} + +/// The names Paste Names offers in the active workbook. +pub(super) fn pasteable_names(app: &SheetApp) -> Vec { + 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. +pub(super) fn paste_name(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let names: Vec = + 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); +} diff --git a/crates/ui-egui/src/dialogs/paste.rs b/crates/ui-egui/src/dialogs/paste.rs new file mode 100644 index 0000000000..bc01a7da21 --- /dev/null +++ b/crates/ui-egui/src/dialogs/paste.rs @@ -0,0 +1,139 @@ +use super::*; + +/// Paste Special as Excel lays it out: Paste and Operation each in two columns of options, Skip +/// blanks and Transpose side by side, and Paste Link as a button that pastes at once. The options +/// come from the dialog's `fields`, so the letter shortcuts and the control channel see the same. +pub(super) fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let lang = app.ui.language; + let t = theme::Tokens::get(ui.ctx()); + let choices: Vec<(&'static str, &'static str, Vec<(&'static str, &'static str)>)> = d + .fields + .iter() + .filter_map(|f| match f { + Field::Choice { key, label, options } => Some((*key, *label, options.clone())), + _ => None, + }) + .collect(); + for (key, label, options) in choices { + ui.add_space(4.0); + ui.label(egui::RichText::new(lang.tr(label)).strong()); + egui::Frame::new().stroke(Stroke::new(1.0, t.separator)).corner_radius(4.0).inner_margin(egui::Margin::symmetric(10, 6)).show(ui, |ui| { + ui.set_min_width(ui.available_width()); + let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); + // Column by column, as Excel fills them. + let rows = options.len().div_ceil(2); + egui::Grid::new(("paste_special", key)).num_columns(2).min_col_width(200.0).spacing(vec2(16.0, 4.0)).show(ui, |ui| { + for r in 0..rows { + for c in 0..2 { + if let Some((value, text)) = options.get(c * rows + r) { + if ui.radio(cur == *value, lang.tr(text)).clicked() { + d.set_value(key, json!(value)); + } + } else { + ui.label(""); + } + } + ui.end_row(); + } + }); + }); + } + ui.add_space(8.0); + // In the same columns as the options above. + egui::Frame::new().inner_margin(egui::Margin::symmetric(11, 0)).show(ui, |ui| { + egui::Grid::new("paste_special_checks").num_columns(2).min_col_width(200.0).spacing(vec2(16.0, 4.0)).show(ui, |ui| { + for (key, label) in [("skipBlanks", "Skip blanks"), ("transpose", "Transpose (E)")] { + let mut b = d.values.get(key).and_then(Json::as_bool).unwrap_or(false); + if ui.checkbox(&mut b, lang.tr(label)).changed() { + d.values.insert(key.into(), json!(b)); + } + } + ui.end_row(); + }); + }); + ui.add_space(8.0); + ui.separator(); + ui.horizontal(|ui| { + // Excel's Paste Link pastes references to the copied cells straight away. + if ui.button(lang.tr("Paste Link")).on_hover_text(lang.tr("Paste formulas that refer to the copied cells")).clicked() { + d.values.insert("what".into(), json!("all")); + d.values.insert("operation".into(), json!("none")); + d.values.insert("link".into(), json!(true)); + *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(t.on_accent)).fill(t.accent)); + if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { + *confirm = true; + } + if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() { + *open = false; + } + }); + }); +} + +/// Paste Special's legacy mnemonics select options; Enter still performs the paste. +pub(super) fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) { + if ctx.text_edit_focused() { + return; + } + let right_alt = ctx.input(|i| { + i.key_down(Key::AltRight) + || i.events.iter().any(|event| { + matches!(event, egui::Event::Key { key: Key::AltRight, .. }) + || matches!(event, egui::Event::Key { physical_key: Some(Key::AltRight), .. }) + }) + }); + let mac = ctx.os() == egui::os::OperatingSystem::Mac; + let allowed = |m: egui::Modifiers| !m.ctrl && !m.command && !m.mac_cmd && !(m.alt && (mac || right_alt)); + let mnemonic = |key| match key { + Key::T => Some('t'), + Key::V => Some('v'), + Key::F => Some('f'), + Key::C => Some('c'), + Key::E => Some('e'), + _ => None, + }; + let mut text_to_consume = Vec::new(); + for event in ctx.input(|i| i.events.clone()) { + let egui::Event::Key { key, pressed: true, repeat, modifiers, .. } = event else { continue }; + if !allowed(modifiers) { + continue; + } + let Some(letter) = mnemonic(key) else { continue }; + text_to_consume.push(letter); + if repeat { + continue; + } + if key == Key::E { + let transpose = d.values.get("transpose").and_then(Json::as_bool).unwrap_or(false); + d.values.insert("transpose".into(), json!(!transpose)); + } else { + let what = match key { + Key::T => "formats", + Key::V => "values", + Key::F => "formulas", + _ => "comments", + }; + d.values.insert("what".into(), json!(what)); + } + } + // Browsers can deliver Text before or after Key. Act only on Key, then remove + // its matching text so one physical press cannot toggle Transpose twice. + ctx.input_mut(|i| { + i.events.retain(|event| match event { + egui::Event::Key { key, modifiers, .. } => !(allowed(*modifiers) && mnemonic(*key).is_some()), + egui::Event::Text(text) if text.len() == 1 => { + let letter = text.chars().next().unwrap_or_default().to_ascii_lowercase(); + if let Some(index) = text_to_consume.iter().position(|&c| c == letter) { + text_to_consume.remove(index); + false + } else { + true + } + } + _ => true, + }); + }); +} diff --git a/crates/ui-egui/src/dialogs/show.rs b/crates/ui-egui/src/dialogs/show.rs new file mode 100644 index 0000000000..c0e445bec4 --- /dev/null +++ b/crates/ui-egui/src/dialogs/show.rs @@ -0,0 +1,580 @@ +use super::about::about; +use super::analysis::{analysis_toolpak, goal_seek, solver, solver_results}; +use super::charts::{recommended_charts, select_data}; +use super::command_search::command_search; +use super::cond_format_rules::manage_rules; +use super::data::{advanced_filter_form, data_validation_form, sort}; +use super::find::{find, go_to}; +use super::format_cells::format_cells; +use super::insert_function::insert_function; +use super::macros::macros; +use super::names::{name_manager, paste_name}; +use super::paste::{paste_special, paste_special_shortcuts}; +use super::spelling::spelling; +use super::web::{from_web, queries_connections}; +use super::zoom::zoom; +use super::*; + +/// Builds the command params from form values for dialogs that need translation. +/// The parameters OK sends to the dialog's command: its field values, reshaped per dialog. +pub(crate) fn build_params(d: &Dialog) -> Json { + let mut v = Json::Object(d.values.clone()); + match d.name.as_str() { + "advancedFilter" => { + if let Some(m) = v.as_object_mut() { + m.remove("action"); + if d.values.get("action").and_then(Json::as_str) != Some("copy") { + m.remove("copyTo"); + } + if m.get("criteria").and_then(Json::as_str).is_some_and(|s| s.trim().is_empty()) { + m.remove("criteria"); + } + } + } + "dataValidation" => { + let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any"); + let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between"); + if let Some(m) = v.as_object_mut() { + if !matches!(operator, "between" | "notBetween") || matches!(kind, "any" | "list" | "custom") { + m.remove("formula2"); + } + if matches!(kind, "any" | "list" | "custom") { + m.remove("operator"); + } + if kind == "any" { + m.remove("formula1"); + } + if kind != "list" { + m.remove("dropdown"); + } + } + } + "textToColumns" => { + let mut delims = Vec::new(); + for (k, c) in [("tab", "\t"), ("semicolon", ";"), ("comma", ","), ("space", " ")] { + if d.values.get(k).and_then(Json::as_bool) == Some(true) { + delims.push(json!(c)); + } + } + v["delimiters"] = Json::Array(delims); + } + "removeDuplicates" | "subtotal" => { + if let Some(t) = d.values.get("columnsText").and_then(Json::as_str).filter(|t| !t.trim().is_empty()) { + v["columns"] = Json::Array(t.split(',').map(|c| json!(c.trim().to_ascii_uppercase())).collect()); + } + } + "allowEditRanges" => { + if let Some(m) = v.as_object_mut() { + // Blank fields mean "unchanged"; the check box clears a password. + m.retain(|_, x| x.as_str().is_none_or(|s| !s.trim().is_empty())); + if m.remove("removePassword").and_then(|x| x.as_bool()) == Some(true) { + m.insert("password".into(), json!("")); + } + } + } + "cfQuick" => { + let mut rule = Map::new(); + for (k, val) in &d.values { + rule.insert(k.clone(), val.clone()); + } + v = json!({"rule": Json::Object(rule)}); + if let Some(pick) = d.pick.as_ref().filter(|pick| !pick.applies_to.is_empty() && !pick.sheet.is_empty()) { + v["sheet"] = json!(pick.sheet.as_str()); + v["range"] = json!(pick.applies_to); + } + } + "series" => { + if let Some(s) = d.values.get("stop").and_then(Json::as_str).and_then(|s| s.parse::().ok()) { + v["stop"] = json!(s); + } else if let Some(m) = v.as_object_mut() { + m.remove("stop"); + } + } + "defineName" => { + if let Some(m) = v.as_object_mut() { + for key in ["scope", "oldScope"] { + if m.get(key).and_then(Json::as_str) == Some("Workbook") { + m.remove(key); + } + } + } + } + _ => {} + } + v +} + +/// Takes and draws the active dialog, dispatches its body by name, consumes picture-inbox results, runs OK/confirm commands, handles Esc/close and restores the back-dialog stack. +pub fn show(app: &mut SheetApp, ctx: &egui::Context) { + let Some(mut d) = app.dialog.take() else { return }; + let home = app.ui_window(); + if d.name == "pasteSpecial" { + paste_special_shortcuts(&mut d, ctx); + } + let mut open = true; + let mut win_open = true; + let mut confirm = false; + let width = match d.name.as_str() { + "formatCells" => 560.0, + "about" => 660.0, + "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "macros" => 520.0, + "recommendedCharts" => 600.0, + "analysisToolPak" => 420.0, + "solver" => 460.0, + "zoom" => 220.0, + "pasteSpecial" => 480.0, + _ => 380.0, + }; + let window = egui::Window::new(app.ui.language.tr(&d.title)) + .id(egui::Id::unique("dialog").with(&d.name)) + .collapsible(false) + .resizable(false) + .default_width(width) + // A default position, not an anchor: egui anchors can't be dragged, and dialogs move by their title bar. + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center() + vec2(0.0, -40.0)) + .open(&mut win_open); + let window = match d.name.as_str() { + "insertPicture" => window.default_height(240.0), + "advancedFilter" => window.default_height(0.0), + _ => window, + }; + window.show(ctx, |ui| { + ui.set_min_width(width - 20.0); + match d.name.as_str() { + "formatCells" => format_cells(app, ui, &mut d, &mut confirm), + "dataValidation" => data_validation_form(ui, &mut d, &mut confirm, &mut open), + "advancedFilter" => advanced_filter_form(ui, &mut d, &mut confirm, &mut open), + "insertFunction" => insert_function(app, ui, &mut d, &mut confirm), + "find" => find(app, ui, &mut d), + "goTo" => go_to(app, ui, &mut d, &mut confirm), + "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), + "commandSearch" => command_search(app, ui, &mut d, &mut confirm), + "fromWeb" => from_web(app, ui, &mut d, &mut confirm, &mut open), + "queriesConnections" => queries_connections(app, ui, &mut d, &mut confirm, &mut open), + "goalSeek" => goal_seek(app, ui, &mut d, &mut confirm), + "pasteSpecial" => paste_special(app, ui, &mut d, &mut confirm, &mut open), + "analysisToolPak" => analysis_toolpak(app, ui, &mut d, &mut confirm, &mut open), + "solver" => solver(ui, &mut d, &mut confirm, &mut open), + "solverResults" => solver_results(app, ui, &mut d, &mut open), + "spelling" => spelling(app, ui, &mut d, &mut open), + "agents" => { + ui.label("GridCraft is fully drivable by agents. Every menu item, button and gesture is a command:"); + ui.add_space(4.0); + ui.monospace("gridcraft --control 7979 # JSON-lines control channel\ngridcraft-cli mcp --connect 7979 # MCP bridged to this window\ngridcraft-cli mcp # headless MCP server"); + ui.add_space(4.0); + ui.label(format!("{} engine commands are available. See docs/mcp.md and docs/control-protocol.md.", gridcraft_engine::command_specs().len())); + } + "about" => about(ui, &mut d), + "journal" => { + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for (id, p) in app.session.journal.iter().rev().take(500) { + ui.monospace(format!("{id} {p}")); + } + }); + } + "macros" => macros(app, ui, &mut d), + "statistics" | "accessibility" | "errorChecking" | "evaluateFormula" | "comments" => { + let r = if d.name == "comments" { + app.session.active().and_then(|doc| doc.wb.active().map(|s| json!(s.comments.iter().map(|(c, m)| json!({"cell": c.a1(), "author": m.author, "text": m.text})).collect::>()))) + } else { + d.result.clone() + }; + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + ui.monospace(serde_json::to_string_pretty(&r.unwrap_or(Json::Null)).unwrap_or_default()); + }); + } + "insertPicture" => { + // A private inbox dies with this dialog; late browser results + // cannot affect a later picture choice or another workbook. + let arrived = d.picture_inbox.as_ref().and_then(|inbox| { + inbox.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pop() + }); + if let Some((name, bytes)) = arrived { + d.picture_inbox = None; + if bytes.len() > 16 * 1024 * 1024 { + app.message = Some((d.title.clone(), "Choose a PNG or JPEG no larger than 16 MB.".into())); + } else { + d.values.remove("path"); + d.values.insert("alt".into(), json!(name)); + d.values.insert("base64".into(), json!(gridcraft_engine::io::base64_encode(&bytes))); + } + } + if app.services.pick_picture_async.is_some() { + let name = d.values.get("alt").and_then(Json::as_str).unwrap_or("No picture selected"); + ui.label(name); + } else { + ui.label("Picture file path (PNG or JPEG):"); + let mut path = d.values.get("path").and_then(Json::as_str).unwrap_or("").to_string(); + if ui.text_edit_singleline(&mut path).changed() { + d.values.remove("base64"); + d.values.insert("path".into(), json!(path)); + } + } + if (app.services.pick_picture.is_some() || app.services.pick_picture_async.is_some()) && ui.button("Browse…").clicked() { + if let Some(start) = &app.services.pick_picture_async { + let inbox = crate::Inbox::default(); + start(inbox.clone()); + d.picture_inbox = Some(inbox); + } else if let Some(pick) = &app.services.pick_picture + && let Some(path) = pick() + { + d.values.remove("base64"); + let alt = std::path::Path::new(&path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(""); + d.values.insert("alt".into(), json!(alt)); + d.values.insert("path".into(), json!(path)); + } + } + ui.label("Description (optional):"); + let mut alt = d.values.get("alt").and_then(Json::as_str).unwrap_or("").to_string(); + if ui.text_edit_singleline(&mut alt).changed() { + d.values.insert("alt".into(), json!(alt)); + } + if d.values.get("placement").and_then(Json::as_str) == Some("cell") { + ui.label(egui::RichText::new("Fits inside the selected cell and moves with its content.").small()); + } + ok_cancel(ui, &mut confirm, &mut open); + } + "pickList" => { + let items: Vec = app + .session + .active() + .and_then(|doc| { + let sh = doc.wb.active()?; + let a = doc.selection.active; + let mut seen = std::collections::BTreeSet::new(); + let mut r = a.row; + while r > 0 && !sh.value(gridcraft_engine::core::CellRef::new(r - 1, a.col)).is_empty() { + r -= 1; + seen.insert(sh.value(gridcraft_engine::core::CellRef::new(r, a.col)).display()); + } + Some(seen.into_iter().collect()) + }) + .unwrap_or_default(); + for it in items { + if ui.button(&it).clicked() { + app.run_or_alert("cell.set", json!({"input": it})); + open = false; + } + } + } + "saveCopy" => { + let fmt = d.values.get("format").and_then(Json::as_str).unwrap_or("xlsx").to_string(); + ui.label(format!("Save a copy as .{fmt}")); + let mut path = d.values.get("path").and_then(Json::as_str).unwrap_or("").to_string(); + ui.text_edit_singleline(&mut path); + d.values.insert("path".into(), json!(path.clone())); + ok_cancel(ui, &mut confirm, &mut open); + if confirm { + // A bare name has no folder: written as typed it would land in the + // process's working directory (`/` from the Finder, the install folder on + // Windows), never where the user looks. The picker chooses the folder. + let pick = app.services.pick_save.as_ref(); + let target = if path.is_empty() { + pick.and_then(|f| f(&format!("Copy.{fmt}"))) + } else if let Some(f) = pick.filter(|_| std::path::Path::new(&path).is_relative()) { + f(&if path.ends_with(&format!(".{fmt}")) { path } else { format!("{path}.{fmt}") }) + } else { + Some(path) + }; + if let Some(t) = target { + let id = match fmt.as_str() { + "csv" => "file.exportCsv", + "html" => "file.exportHtml", + _ => "file.saveAs", + }; + app.run_or_alert(id, json!({"path": t})); + } + } + } + "saveChanges" => { + ui.label(format!("Do you want to save the changes you made to {}?", d.values.get("title").and_then(Json::as_str).unwrap_or("this workbook"))); + let quitting = d.values.get("quit").and_then(Json::as_bool) == Some(true); + ui.horizontal(|ui| { + for (label, answer) in + [("Don't Save", crate::SaveAnswer::DontSave), ("Cancel", crate::SaveAnswer::Cancel), ("Save", crate::SaveAnswer::Save)] + { + if ui.button(label).clicked() && app.answer_save_changes(answer, quitting, ui.ctx()) { + open = false; + } + } + }); + } + "start" => { + ui.heading("Start a new workbook"); + ui.horizontal_wrapped(|ui| { + if ui.button("Blank workbook").clicked() { + app.run_or_alert("file.new", json!({})); + open = false; + } + for (n, t) in gridcraft_engine::sample::SAMPLES { + if ui.button(*t).clicked() { + app.run_or_alert("file.new", json!({"sample": n})); + open = false; + } + } + }); + ui.separator(); + ui.label(egui::RichText::new("Recent").strong()); + for path in app.ui.recent.clone() { + if ui.link(&path).clicked() { + app.open_path(&path); + open = false; + } + } + if ui.button("Open…").clicked() { + app.open_dialog("open", json!({})); + open = false; + } + } + _ => form(ui, &mut d, &mut confirm, &mut open), + } + }); + // Hyperlinks inside dialogs (About ▸ Contributors) open through the host's `open_url`, since + // the native shell has no browser integration of its own. + if let Some(open_url) = &app.services.open_url { + let urls: Vec = ctx.output_mut(|o| { + let mut urls = vec![]; + o.commands.retain(|c| match c { + egui::OutputCommand::OpenUrl(u) => { + urls.push(u.url.clone()); + false + } + _ => true, + }); + urls + }); + for u in urls { + open_url(&u); + } + } + if ctx.input(|i| i.key_pressed(Key::Escape)) || !win_open { + open = false; + } + if confirm && d.name == "insertPicture" && d.picture_target != app.view_key() { + app.message = Some((d.title.clone(), "Return to the original worksheet to insert this picture, or cancel and choose another cell.".into())); + confirm = false; + } + if confirm && let Some(cmd) = d.command { + let params = build_params(&d); + match app.run(cmd, params) { + Ok(r) => { + open = false; + if d.name == "solver" { + app.open_dialog("solverResults", r); + } else if d.name == "openPassword" { + let path = d.values.get("path").and_then(Json::as_str).map(str::to_string); + app.after_open(path.as_deref(), &r); + } + } + // Data Analysis asks before replacing data in its output range. + Err(e) if d.name == "analysisToolPak" && e.contains(gridcraft_engine::cmd::analysis::OVERWRITE_ERROR) => { + d.values.insert("askOverwrite".into(), json!(true)); + } + // A wrong password: say so, clear the field and stay open for another try. + Err(e) if d.name == "openPassword" => { + d.values.insert("password".into(), json!("")); + app.message = Some((d.title.clone(), crate::clean_error(&e))); + } + Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), + } + } else if confirm { + open = false; + } + if app.ui_window() != home { + // A command run from the dialog switched windows: whatever it opened shows in the new + // window, and this dialog (or the one it returns to) stays in its own. + let keep = if open { Some(d) } else { d.back.take().map(|b| *b) }; + if let Some(keep) = keep { + app.return_dialog(home, keep); + } + return; + } + if let Some(child) = app.dialog.as_mut() { + // The body opened another dialog (Name Manager › New…). It takes over; this one waits + // behind it and comes back when it closes, as Excel returns to the Name Manager. Go To + // hands over to Go To Special for good, as in Excel. + if open && d.name != "goTo" { + child.back = Some(Box::new(d)); + } else if child.back.is_none() { + child.back = d.back.take(); + } + return; + } + if open { + app.dialog = Some(d); + } else if let Some(back) = d.back.take() { + app.dialog = Some(*back); + } +} + +pub(super) fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) { + ui.add_space(8.0); + // A row as tall as the buttons: a right-to-left layout straight in the window takes all the + // height it is offered, which stretched every form dialog to the screen's height. + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let lang = crate::i18n::current(ui.ctx()); + 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; + } + if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() { + *open = false; + } + }) + }); +} + +fn form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + form_fields(ui, d); + ok_cancel(ui, confirm, open); +} + +/// Gives keyboard focus to the first text or number field the first time a form is drawn. A +/// focused number field is in typing mode with its value selected, so Alt+H, O, W then 12 and +/// Enter sets the width, as in Excel. +/// Keeps asking until the field really has focus: a new window's first frame is an invisible +/// sizing pass where a focus request is ignored. +fn take_first_focus(d: &mut Dialog, r: &egui::Response) { + if !d.focus_first_field { + return; + } + if !r.has_focus() { + r.request_focus(); + return; + } + // Focus has landed, and the field is now a text box: select its whole value so typing + // replaces it (the field's own select-on-focus fired before it was a text box). + let mut state = egui::text_edit::TextEditState::load(&r.ctx, r.id).unwrap_or_default(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(usize::MAX / 2)); + state.cursor.set_char_range(Some(all)); + state.store(&r.ctx, r.id); + d.focus_first_field = false; +} + +/// Keeps a picking formula box's caret, and gives it the keyboard back after a grid click put a +/// reference in it (the click took the focus), its caret after the reference. +fn track_pick(pick: &mut RefPick, r: &egui::Response, typed: bool) { + if typed { + pick.point = None; + } + if pick.refocus { + let mut state = egui::text_edit::TextEditState::load(&r.ctx, r.id).unwrap_or_default(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(pick.caret)))); + state.store(&r.ctx, r.id); + r.request_focus(); + pick.refocus = false; + } else if r.has_focus() + && let Some(range) = egui::text_edit::TextEditState::load(&r.ctx, r.id).and_then(|state| state.cursor.char_range()) + { + pick.caret = range.primary.index.into(); + } +} + +/// Draws a dialog's `fields`, editing its values. +pub(super) fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { + // Field labels, choices and notes are English source strings, translated as they are drawn. + let lang = crate::i18n::current(ui.ctx()); + egui::Grid::new("form").num_columns(1).spacing(vec2(8.0, 6.0)).show(ui, |ui| { + for f in d.fields.clone() { + match f { + Field::Text { key, label } => { + ui.vertical(|ui| { + let label = ui.label(lang.tr(label)); + let mut s = d + .values + .get(key) + .map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| if v.is_null() { String::new() } else { v.to_string() })) + .unwrap_or_default(); + let multiline = key == "text"; + let r = if multiline { + ui.add(egui::TextEdit::multiline(&mut s).desired_rows(4).desired_width(320.0)).labelled_by(label.id) + } else { + ui.add(egui::TextEdit::singleline(&mut s).desired_width(320.0)).labelled_by(label.id) + }; + take_first_focus(d, &r); + if r.changed() { + d.values.insert(key.into(), json!(s)); + } + if let Some(pick) = d.pick.as_mut().filter(|pick| pick.key == key) { + track_pick(pick, &r, r.changed()); + } + }); + } + Field::Password { key, label } => { + // Masked as typed (Excel shows dots); "Show password" reveals it on purpose. + ui.vertical(|ui| { + let label = ui.label(lang.tr(label)); + let mut s = d.values.get(key).and_then(Json::as_str).unwrap_or_default().to_string(); + if ui.add(egui::TextEdit::singleline(&mut s).password(!d.show_password).desired_width(320.0)).labelled_by(label.id).changed() + { + d.values.insert(key.into(), json!(s)); + } + ui.checkbox(&mut d.show_password, lang.tr("Show password")); + }); + } + Field::Number { key, label } => { + ui.horizontal(|ui| { + let label = ui.label(lang.tr(label)); + let mut n = d.values.get(key).and_then(Json::as_f64).unwrap_or(0.0); + // Typed values count straight away: otherwise Enter or OK in the same frame + // confirms the old value, because a number field only commits when it loses focus. + let r = ui.add(egui::DragValue::new(&mut n).speed(0.5).update_while_editing(true)).labelled_by(label.id); + take_first_focus(d, &r); + if r.changed() { + d.values.insert(key.into(), json!(n)); + } + }); + } + Field::Choice { key, label, options } => { + ui.vertical(|ui| { + let field_label = ui.label(egui::RichText::new(lang.tr(label)).strong()); + let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); + if d.name == "dataValidation" { + let selected = options.iter().find(|(val, _)| *val == cur).map(|(_, text)| lang.tr(text)).unwrap_or(""); + egui::ComboBox::from_id_salt(("dataValidation", key)) + .selected_text(selected) + .width(300.0) + .show_ui(ui, |ui| { + for (val, text) in options { + if ui.selectable_label(cur == val, lang.tr(text)).clicked() { + d.set_value(key, json!(val)); + } + } + }) + .response + .labelled_by(field_label.id); + } else { + for (val, text) in options { + if ui.radio(cur == val, lang.tr(text)).clicked() { + d.set_value(key, json!(val)); + } + } + } + }); + } + Field::Check { key, label } => { + let mut b = d.values.get(key).and_then(Json::as_bool).unwrap_or(false); + if ui.checkbox(&mut b, lang.tr(label)).changed() { + d.values.insert(key.into(), json!(b)); + } + } + Field::Note(t) => { + // A note can carry user data (a URL, a file or sheet name); `tr` reads a `|` as a + // context separator, so such a note is shown as it is. + let text = if t.contains('|') { t.as_str() } else { lang.tr(&t) }; + ui.label(egui::RichText::new(text).small()); + } + } + ui.end_row(); + } + }); +} diff --git a/crates/ui-egui/src/dialogs/spelling.rs b/crates/ui-egui/src/dialogs/spelling.rs new file mode 100644 index 0000000000..2a53b6d8a2 --- /dev/null +++ b/crates/ui-egui/src/dialogs/spelling.rs @@ -0,0 +1,54 @@ +use super::*; + +pub(super) fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) { + let r = d.result.clone().unwrap_or(Json::Null); + if let Some(e) = r.get("error").and_then(Json::as_str) { + ui.label(app.ui.language.tr_error(e)); + return; + } + let issues = r["issues"].as_array().cloned().unwrap_or_default(); + let Some(it) = issues.get(d.list_index) else { + ui.label("The spelling check is complete for the entire sheet."); + if ui.button("OK").clicked() { + *open = false; + } + return; + }; + 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(crate::theme::Tokens::get(ui.ctx()).danger)); + let sugg: Vec = + 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:"); + let mut pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string(); + for s in &sugg { + if ui.selectable_label(pick == *s, s).clicked() { + pick = s.clone(); + } + } + if sugg.is_empty() { + ui.label(egui::RichText::new("(No suggestions)").italics()); + } + d.values.insert("pick".into(), json!(pick.clone())); + let _ = app.session.run("selection.set", json!({"cell": cell})); + ui.horizontal(|ui| { + if ui.button("Ignore Once").clicked() { + d.list_index += 1; + } + if ui.button("Add to Dictionary").clicked() { + let _ = app.session.run("review.addToDictionary", json!({"word": word})); + d.list_index += 1; + } + if !pick.is_empty() { + if ui.button("Change").clicked() { + app.run_or_alert("review.changeSpelling", json!({"cell": cell, "word": word, "to": pick})); + d.list_index += 1; + } + if ui.button("Change All").clicked() { + app.run_or_alert("review.changeSpelling", json!({"word": word, "to": pick, "all": true})); + d.list_index += 1; + } + } + }); +} diff --git a/crates/ui-egui/src/dialogs/web.rs b/crates/ui-egui/src/dialogs/web.rs new file mode 100644 index 0000000000..a74dcc1012 --- /dev/null +++ b/crates/ui-egui/src/dialogs/web.rs @@ -0,0 +1,180 @@ +use super::show::ok_cancel; +use super::*; + +/// Data ▸ Queries & Connections: the workbook's saved From Web connections, each with a +/// one-click Sync, its last-synced time, and the refresh errors and unavailable-source +/// messages in the same window (Excel's Queries & Connections pane). +pub(super) fn queries_connections(app: &mut SheetApp, ui: &mut egui::Ui, _d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let queries = app.run("data.queriesConnections", json!({})).ok().and_then(|r| r.as_array().cloned()).unwrap_or_default(); + if queries.is_empty() { + ui.label("No saved connections. Import a table with Data ▸ From Web to create one."); + } else { + egui::ScrollArea::vertical().max_height(280.0).id_salt("qc_list").show(ui, |ui| { + for q in &queries { + let name = q["name"].as_str().unwrap_or_default(); + let sheet = q["sheet"].as_str().unwrap_or_default(); + egui::CollapsingHeader::new(name).default_open(true).show(ui, |ui| { + ui.label(format!("Sheet: {sheet}")); + if let Some(url) = q.get("url").and_then(Json::as_str) { + ui.label(format!("Source: {}", crate::clean_url_for_display(url))); + } + let synced = q.get("last_synced").and_then(Json::as_u64).filter(|t| *t > 0); + match synced { + Some(ms) => ui.weak(format!("Last synced: {}", crate::format_sync_time(ms))), + None => ui.weak("Last synced: never"), + }; + ui.horizontal(|ui| { + if ui.button("Sync").clicked() { + match app.run("data.webRefresh", json!({"name": name})) { + Ok(r) => { + let refreshed = r["refreshed"].as_u64().unwrap_or(0); + let errors = r["errors"].as_array().map(Vec::len).unwrap_or(0); + if refreshed > 0 && errors == 0 { + app.toast = Some((format!("Synced {name}"), crate::now_ms())); + } else if let Some(e) = r["errors"].as_array().and_then(|a| a.first()) { + let msg = e["error"].as_str().unwrap_or_default(); + app.message = Some(("Queries & Connections".into(), format!("{name}: {msg}"))); + } + } + Err(e) => app.message = Some(("Queries & Connections".into(), crate::clean_error(&e))), + } + } + if ui.button("Go to table").clicked() { + let _ = app.run("sheet.activate", json!({"sheet": sheet})); + *open = false; + } + }); + }); + ui.separator(); + } + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Refresh All").clicked() { + match app.run("data.webRefresh", json!({})) { + Ok(r) => { + let refreshed = r["refreshed"].as_u64().unwrap_or(0); + let errors = r["errors"].as_array().cloned().unwrap_or_default(); + if errors.is_empty() { + app.toast = Some((format!("Synced {refreshed} connection(s)"), crate::now_ms())); + } else { + let msg = errors.iter().filter_map(|e| e["error"].as_str()).collect::>().join("; "); + app.message = Some(("Queries & Connections".into(), msg)); + } + } + Err(e) => app.message = Some(("Queries & Connections".into(), crate::clean_error(&e))), + } + } + }); + } + let mut load = true; + ok_cancel(ui, confirm, &mut load); + if *confirm || !load { + *open = false; + } +} + +/// Data ▸ From Web: Excel's Navigator. Enter a URL, the page's detected tables are previewed +/// on the right, and Load imports the selected one into a new sheet. +pub(super) fn from_web(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let get = |k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); + let mut url = get("url"); + egui::Grid::new("fw").show(ui, |ui| { + ui.label("URL:"); + ui.add(egui::TextEdit::singleline(&mut url).desired_width(360.0).hint_text("https://example.com/table.html")); + // An edited address invalidates the preview: don't let Load pair the old page's + // table names with the new URL. + if d.values.get("preview_url").and_then(Json::as_str).is_some_and(|u| u != url.trim()) { + d.values.remove("preview_url"); + d.values.remove("tables"); + d.values.remove("pick"); + } + if ui.button("Go").clicked() && !url.trim().is_empty() { + d.values.insert("url".into(), json!(url.trim())); + d.values.insert("preview_url".into(), json!(url.trim())); + d.values.insert("tables".into(), Json::Null); + d.values.insert("pick".into(), json!(0)); + let r = app.run("data.webPreview", json!({"url": url.trim()})); + match r { + Ok(res) => d.values.insert("tables".into(), res["tables"].clone()), + Err(e) => { + app.message = Some(("From Web".into(), crate::clean_error(&e))); + d.values.insert("tables".into(), Json::Null) + } + }; + } + ui.end_row(); + }); + let tables: Vec = d.values.get("tables").and_then(Json::as_array).cloned().unwrap_or_default(); + if !tables.is_empty() { + ui.separator(); + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.strong("Suggested Tables"); + let mut pick = d.values.get("pick").and_then(Json::as_u64).unwrap_or(0) as usize; + for (i, t) in tables.iter().enumerate() { + let name = t["name"].as_str().unwrap_or("Table"); + let rows = t["rows"].as_u64().unwrap_or(0); + if ui.selectable_label(i == pick, format!("{name} ({rows} rows)")).clicked() { + pick = i; + d.values.insert("pick".into(), json!(i)); + } + } + }); + ui.separator(); + let pick = d.values.get("pick").and_then(Json::as_u64).unwrap_or(0) as usize; + if let Some(t) = tables.get(pick) { + ui.vertical(|ui| { + ui.strong("Preview"); + egui::ScrollArea::vertical().max_height(200.0).id_salt("fw_preview").show(ui, |ui| { + egui::Grid::new("fw_preview_grid").striped(true).show(ui, |ui| { + if let Some(rows) = t["preview"].as_array() { + for row in rows { + for cell in row.as_array().into_iter().flatten() { + ui.label(cell.as_str().unwrap_or_default()); + } + ui.end_row(); + } + } + }); + }); + }); + } + }); + } else if d.values.get("tables").is_some_and(|t| t.is_null()) { + ui.label("No tables were found on this page."); + } else { + ui.label("Enter an address and choose Go to detect the page's tables."); + } + d.values.insert("url".into(), json!(url.clone())); + let mut load = true; + ok_cancel(ui, confirm, &mut load); + if *confirm && url.trim().is_empty() { + // OK with no address: stay open and say so, instead of closing with nothing. + app.message = Some(("From Web".into(), "Enter an https:// address first.".into())); + *confirm = false; + } + if *confirm && !url.trim().is_empty() { + let pick = d.values.get("pick").and_then(Json::as_u64).unwrap_or(0) as usize; + let table = tables.get(pick).and_then(|t| t["name"].as_str()).map(String::from); + let params = match table { + Some(t) => { + // A page can hold duplicate table names: which one was picked is which one to + // import, and the connection saves it so refreshes keep importing the same. + let occurrence = tables.iter().take(pick).filter(|o| o["name"].as_str().is_some_and(|n| n.eq_ignore_ascii_case(&t))).count(); + json!({"url": url.trim(), "table": t, "occurrence": occurrence}) + } + None => json!({"url": url.trim()}), + }; + match app.run("data.fromWeb", params) { + Ok(r) => app.toast = Some((format!("Imported {} rows into {}", r["rows"], r["sheet"]), crate::now_ms())), + Err(e) => { + app.message = Some(("From Web".into(), crate::clean_error(&e))); + *confirm = false; + } + } + } + if !load { + *confirm = true; + *open = false; + } +} diff --git a/crates/ui-egui/src/dialogs/zoom.rs b/crates/ui-egui/src/dialogs/zoom.rs new file mode 100644 index 0000000000..a81b03b359 --- /dev/null +++ b/crates/ui-egui/src/dialogs/zoom.rs @@ -0,0 +1,39 @@ +use super::show::ok_cancel; +use super::*; + +/// Zoom's Magnification presets, as `preset` values and labels, in Excel's order. +const ZOOM_PRESETS: [(&str, &str); 5] = [("200", "200%"), ("100", "100%"), ("75", "75%"), ("50", "50%"), ("25", "25%")]; + +/// The `preset` that shows a zoom percentage: its Magnification entry, or Custom. +pub(super) fn zoom_preset(percent: f64) -> &'static str { + ZOOM_PRESETS.iter().map(|(val, _)| *val).find(|val| val.parse::().ok() == Some(percent)).unwrap_or("custom") +} + +/// View › Zoom, laid out as Excel's: one Magnification column of 200% to 25%, Fit selection and +/// Custom with its percentage box. The current zoom is pre-selected; typing a percentage +/// selects Custom; Fit selection zooms so the selected cells fill the grid. +pub(super) fn zoom(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let lang = crate::i18n::current(ui.ctx()); + let cur = d.values.get("preset").and_then(Json::as_str).unwrap_or("custom").to_string(); + ui.label(egui::RichText::new(lang.tr("Magnification")).strong()); + for (val, text) in ZOOM_PRESETS { + if ui.radio(cur == val, text).clicked() { + d.set_value("preset", json!(val)); + } + } + if ui.radio(cur == "fit", lang.tr("Fit selection")).clicked() { + d.set_value("preset", json!("fit")); + } + ui.horizontal(|ui| { + if ui.radio(cur == "custom", format!("{}:", lang.tr("Custom"))).clicked() { + d.set_value("preset", json!("custom")); + } + // Whole percentages, as Excel shows them; the engine clamps to 10..400 as well. + let mut percent = d.values.get("percent").and_then(Json::as_f64).unwrap_or(100.0).round() as i64; + if ui.add(egui::DragValue::new(&mut percent).range(10..=400).speed(1.0)).changed() { + d.set_value("percent", json!(percent)); + } + ui.label("%"); + }); + ok_cancel(ui, confirm, open); +} diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 2f9a1ba6eb..e3715a6dec 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -15,6 +15,37 @@ use crate::SheetApp; use crate::editor::EditState; use crate::theme::{self, Tokens}; +mod cell_paint; +mod cursor; +mod editor; +mod geo; +mod interact; +mod keyboard; +mod menus; +mod quadrant; +mod selection; +mod show; +mod tables; + +pub(crate) use cell_paint::paint_border; +#[cfg(test)] +pub(crate) use cell_paint::{cell_text_y, rotated_text_pos, wrapped_cell_text_y}; +#[cfg(test)] +use cell_paint::{center_across_end, display_text, look, numfmt_color, painted_halign}; +pub use editor::validation_arrow; +#[cfg(test)] +use editor::{editor_slack, editor_width, list_picker_height}; +pub use geo::Geo; +pub use interact::{follow_link, freeze_handles, point_move}; +pub(crate) use quadrant::paint_quadrant; +pub(crate) use selection::paint_selection; +pub use selection::selection_fill; +pub(crate) use show::horizontal_scrollbar; +pub use show::show; +#[cfg(test)] +use tables::filter_table_range; +pub use tables::{TableHeading, filter_button_rect, heading_table, table_headings}; + pub const COL_HEADER_H: f32 = 21.0; /// Sheet units are 96-dpi pixels (as in XLSX); on screen one unit is drawn at this many points, /// which matches desktop spreadsheet density (a default 20 px row is 16 pt tall). @@ -238,4560 +269,11 @@ impl WheelZoom { } } -/// Geometry of the grid for one frame (handles zoom and frozen panes). -#[derive(Clone)] -pub struct Geo { - pub rect: Rect, - pub cells: Rect, - pub z: f32, - pub header_w: f32, - pub header_h: f32, - /// The outline bands (grouped rows / columns): left of the row headers and above the column - /// headers; 0 without an outline (see `outline`). - pub band_w: f32, - pub band_h: f32, - pub row_outline: Arc, - pub col_outline: Arc, - pub fr: u32, - pub fc: u32, - pub frozen_w: f32, - pub frozen_h: f32, - pub scroll: egui::Vec2, - /// Sheet Right-to-Left: everything is laid out left-to-right as usual (these "logical" - /// coordinates are what `x`, `cell_rect`, `col_at`… return) and mirrored across `rect`'s - /// vertical centre line on screen: painted shapes by `mirror_painted`, widgets by - /// `mirror_rect`/`mirror_pos`, and pointer input back by `mirror_pos` (an involution). - pub rtl: bool, - /// Prefix sums over custom row heights / column widths, built once per frame. - ri: std::sync::Arc, - ci: std::sync::Arc, - /// Page Layout view: cells sit on pages (`scroll` is then in page-layout units). - pub paper: Option>, -} - -impl Geo { - pub fn new(sh: &Sheet, rect: Rect, scroll: egui::Vec2) -> Geo { - let zoom = (sh.zoom as f32 / 100.0).clamp(0.1, 4.0); - let z = zoom * DISPLAY_SCALE; - let header_h = if sh.show_headings { (COL_HEADER_H * zoom.max(0.75)).round() } else { 0.0 }; - let ri = std::sync::Arc::new(LineIndex::new(&sh.rows, sh.default_row_height, MAX_ROWS)); - let ci = std::sync::Arc::new(LineIndex::new(&sh.cols, sh.default_col_width, MAX_COLS)); - let last_row = ri.at((scroll.y + rect.height() / z) as f64 + 200.0) + 1; - let digits = (last_row as f32).log10().floor() as i32 + 1; - let header_w = if sh.show_headings { ((digits.max(2) as f32 * 7.0 + 14.0) * zoom.max(0.75)).round() } else { 0.0 }; - 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::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 }; - let mut band_h = if symbols { crate::outline::band(col_outline.levels, zoom) } else { 0.0 }; - // Without headings the level buttons still need a corner to sit in. - if symbols && row_outline.levels > 0 && header_h == 0.0 { - band_h = band_h.max(crate::outline::lane(zoom) + 3.0); - } - if symbols && col_outline.levels > 0 && header_w == 0.0 { - band_w = band_w.max(crate::outline::lane(zoom) + 3.0); - } - let cells = Rect::from_min_max(pos2(rect.left() + band_w + header_w, rect.top() + band_h + header_h), rect.max); - Geo { - rect, - cells, - z, - header_w, - header_h, - band_w, - band_h, - row_outline, - col_outline, - fr, - fc, - frozen_w, - frozen_h, - scroll, - rtl: sh.right_to_left, - ri, - ci, - paper: None, - } - } - /// Left edge of the row headers (right of the outline band). - pub fn head_left(&self) -> f32 { - self.cells.left() - self.header_w - } - /// Top edge of the column headers (below the outline band). - pub fn head_top(&self) -> f32 { - self.cells.top() - self.header_h - } - /// Page Layout view geometry: `rect` excludes the rulers; cells are drawn at the print scale - /// on pages laid out by `grid`; frozen panes don't apply (as in Excel). - pub fn paged(sh: &Sheet, rect: Rect, scroll: egui::Vec2, grid: Arc) -> Geo { - let mut g = Geo::new(sh, rect, scroll); - let zoom = (sh.zoom as f32 / 100.0).clamp(0.1, 4.0); - g.z = zoom * DISPLAY_SCALE * grid.scale.clamp(0.1, 4.0); - (g.fr, g.fc, g.frozen_w, g.frozen_h) = (0, 0, 0.0, 0.0); - g.paper = Some(grid); - g - } - /// The point is over cells (in Page Layout view: on a page, inside its margins). - pub fn in_cells(&self, p: Pos2) -> bool { - if !self.cells.contains(p) { - return false; - } - match &self.paper { - Some(pg) => pg.on_cells( - self.scroll.x as f64 + ((p.x - self.cells.left()) / self.z) as f64, - self.scroll.y as f64 + ((p.y - self.cells.top()) / self.z) as f64, - ), - None => true, - } - } - /// Scroll offset (in `scroll` units) at which `row` reaches the top of the scrolled area. - pub fn scroll_top_of(&self, sh: &Sheet, row: u32) -> f32 { - match &self.paper { - Some(pg) => pg.row_y(row) as f32, - None => (sh.row_top(row) - sh.row_top(self.fr)) as f32, - } - } - /// Scroll offset (in `scroll` units) at which `col` reaches the left of the scrolled area. - pub fn scroll_left_of(&self, sh: &Sheet, col: u32) -> f32 { - match &self.paper { - Some(pg) => pg.col_x(col) as f32, - None => (sh.col_left(col) - sh.col_left(self.fc)) as f32, - } - } - /// Screen x for a logical x (and back): identity unless the sheet is right-to-left. - pub fn mirror_x(&self, x: f32) -> f32 { - if self.rtl { self.rect.left() + self.rect.right() - x } else { x } - } - pub fn mirror_pos(&self, p: Pos2) -> Pos2 { - pos2(self.mirror_x(p.x), p.y) - } - pub fn mirror_rect(&self, r: Rect) -> Rect { - if self.rtl { Rect::from_min_max(pos2(self.mirror_x(r.right()), r.top()), pos2(self.mirror_x(r.left()), r.bottom())) } else { r } - } - /// On-screen rectangle of a cell (`cell_rect` mirrored for right-to-left sheets). - pub fn screen_cell_rect(&self, sh: &Sheet, c: CellRef) -> Rect { - self.mirror_rect(self.cell_rect(sh, c)) - } - pub fn x(&self, _sh: &Sheet, col: u32) -> f32 { - if let Some(pg) = &self.paper { - return self.cells.left() + (pg.col_x(col) - self.scroll.x as f64) as f32 * self.z; - } - if col < self.fc { - self.cells.left() + self.ci.start(col) as f32 * self.z - } else { - self.cells.left() + self.frozen_w + (self.ci.start(col) as f32 - self.ci.start(self.fc) as f32 - self.scroll.x) * self.z - } - } - pub fn y(&self, _sh: &Sheet, row: u32) -> f32 { - if let Some(pg) = &self.paper { - return self.cells.top() + (pg.row_y(row) - self.scroll.y as f64) as f32 * self.z; - } - if row < self.fr { - self.cells.top() + self.ri.start(row) as f32 * self.z - } else { - self.cells.top() + self.frozen_h + (self.ri.start(row) as f32 - self.ri.start(self.fr) as f32 - self.scroll.y) * self.z - } - } - pub fn cell_rect(&self, sh: &Sheet, c: CellRef) -> Rect { - Rect::from_min_max( - pos2(self.x(sh, c.col), self.y(sh, c.row)), - pos2(self.x(sh, c.col) + sh.col_width(c.col) * self.z, self.y(sh, c.row) + sh.row_height(c.row) * self.z), - ) - } - pub fn range_rect(&self, sh: &Sheet, r: RangeRef) -> Rect { - let a = self.cell_rect(sh, r.start); - let b = self.cell_rect(sh, r.end); - Rect::from_min_max(a.min, b.max) - } - pub fn col_at(&self, _sh: &Sheet, x: f32) -> u32 { - if let Some(pg) = &self.paper { - return pg.col_at(self.scroll.x as f64 + ((x - self.cells.left()) / self.z) as f64); - } - let dx = (x - self.cells.left()) / self.z; - if dx < self.frozen_w / self.z { - self.ci.at(dx.max(0.0) as f64) - } else { - self.ci.at(self.ci.start(self.fc) + self.scroll.x as f64 + (dx - self.frozen_w / self.z) as f64) - } - } - pub fn row_at(&self, _sh: &Sheet, y: f32) -> u32 { - if let Some(pg) = &self.paper { - return pg.row_at(self.scroll.y as f64 + ((y - self.cells.top()) / self.z) as f64); - } - let dy = (y - self.cells.top()) / self.z; - if dy < self.frozen_h / self.z { - self.ri.at(dy.max(0.0) as f64) - } else { - self.ri.at(self.ri.start(self.fr) + self.scroll.y as f64 + (dy - self.frozen_h / self.z) as f64) - } - } - pub fn cell_at(&self, sh: &Sheet, p: Pos2) -> CellRef { - CellRef::new(self.row_at(sh, p.y), self.col_at(sh, p.x)) - } - /// Visible scrolled rows (from the first visible row after the frozen ones). - fn scroll_rows(&self, _sh: &Sheet) -> (u32, u32) { - let top = self.ri.start(self.fr) + self.scroll.y as f64; - let r0 = self.ri.at(top).max(self.fr); - let r1 = self.ri.at(top + ((self.cells.height() - self.frozen_h) / self.z) as f64).min(MAX_ROWS - 1); - (r0, r1) - } - fn scroll_cols(&self, _sh: &Sheet) -> (u32, u32) { - let left = self.ci.start(self.fc) + self.scroll.x as f64; - let c0 = self.ci.at(left).max(self.fc); - let c1 = self.ci.at(left + ((self.cells.width() - self.frozen_w) / self.z) as f64).min(MAX_COLS - 1); - (c0, c1) - } - /// Screen areas of the panes (frozen rows/columns or the scrolling part) that hold part of `r`. - /// The panes share one coordinate system, so a range that is not clipped to these is also drawn - /// over the frozen rows/columns when it is scrolled under them. - pub fn range_panes(&self, r: RangeRef) -> Vec { - let split = pos2(self.cells.left() + self.frozen_w, self.cells.top() + self.frozen_h); - let mut xs = Vec::new(); - if r.start.col < self.fc { - xs.push((self.cells.left(), split.x)); - } - if r.end.col >= self.fc { - xs.push((split.x, self.cells.right())); - } - let mut ys = Vec::new(); - if r.start.row < self.fr { - ys.push((self.cells.top(), split.y)); - } - if r.end.row >= self.fr { - ys.push((split.y, self.cells.bottom())); - } - ys.iter().flat_map(|&(y0, y1)| xs.iter().map(move |&(x0, x1)| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)))).collect() - } - /// True when cell `c` is in the scrolling pane but scrolled entirely under the frozen rows or columns. - pub fn hidden_under_frozen(&self, sh: &Sheet, c: CellRef) -> bool { - let r = self.cell_rect(sh, c); - (self.fc > 0 && c.col >= self.fc && r.right() <= self.cells.left() + self.frozen_w) - || (self.fr > 0 && c.row >= self.fr && r.bottom() <= self.cells.top() + self.frozen_h) - } -} - -/// Resolved look of a cell for painting. -struct Look { - style: Style, - fill: Option, - text_color: Color32, -} - -fn col32(c: Color, wb: &Workbook, default: Color32) -> Color32 { - c.resolve(&wb.theme).map(theme::color32).unwrap_or(default) -} - -fn numfmt_color(c: gridcraft_engine::numfmt::FormatColor) -> Color32 { - c.rgb().map(|[r, g, b]| Color32::from_rgb(r, g, b)).unwrap_or(Color32::BLACK) -} - -/// Last column of a Center Across Selection run starting at `start`: it extends over following columns (up to `limit`) while `continues` holds. -fn center_across_end(start: u32, limit: u32, continues: impl Fn(u32) -> bool) -> u32 { - let mut end = start; - while end < limit && end + 1 < MAX_COLS && continues(end + 1) { - end += 1; - } - end -} - -fn look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut CfCache) -> (Look, Option) { - let mut style = wb.styles.get(sh.style_id(c)).clone(); - if let Some(t) = sh.table_at(c) { - style = gridcraft_engine::tables::table_cell_style(wb, si, t, c); - } - let cfl = cf.look(wb, si, c); - if let Some(l) = &cfl - && let Some(st) = &l.style - { - if st.fill.pattern != PatternType::None { - style.fill = st.fill; - } - if st.font.color != Color::Auto { - style.font.color = st.font.color; - } - if st.font.bold { - style.font.bold = true; - } - if st.font.italic { - style.font.italic = true; - } - if st.font.underline != Underline::None { - style.font.underline = st.font.underline; - } - if st.font.strike { - style.font.strike = true; - } - // In a differential style General means "not set" (#1163). - if st.num_fmt.as_str() != "General" { - style.num_fmt = st.num_fmt.clone(); - } - for (dst, src) in [ - (&mut style.borders.left, st.borders.left), - (&mut style.borders.right, st.borders.right), - (&mut style.borders.top, st.borders.top), - (&mut style.borders.bottom, st.borders.bottom), - ] { - if !src.is_none() { - *dst = src; - } - } - } - let mut fill = match style.fill.pattern { - PatternType::None => None, - PatternType::Solid => style.fill.fg.resolve(&wb.theme).map(theme::color32), - _ => { - // Patterns: approximate with a blend of foreground and background. - let fg = style.fill.fg.resolve(&wb.theme).unwrap_or([0, 0, 0]); - let bg = style.fill.bg.resolve(&wb.theme).unwrap_or([255, 255, 255]); - Some(Color32::from_rgb( - ((fg[0] as u16 + bg[0] as u16 * 3) / 4) as u8, - ((fg[1] as u16 + bg[1] as u16 * 3) / 4) as u8, - ((fg[2] as u16 + bg[2] as u16 * 3) / 4) as u8, - )) - } - }; - if let Some(l) = &cfl - && let Some(rgb) = l.scale_fill - { - fill = Some(theme::color32(rgb)); - } - let text_color = col32(style.font.color, wb, Color32::BLACK); - (Look { style, fill, text_color }, cfl) -} - -/// Text for a value with General narrowing to the available width. -fn display_text( - wb: &Workbook, - sh: &Sheet, - c: CellRef, - v: &Value, - st: &Style, - width_px: f32, - char_w: f32, -) -> (String, Option, bool, Option<(char, usize)>) { - if sh.show_formulas - && let Some(f) = sh.cell(c).and_then(|x| x.formula.as_ref()) - { - return (format!("={}", f.text), None, false, None); - } - if !sh.show_zeros && v.as_f64() == Some(0.0) { - return (String::new(), None, false, None); - } - let code = st.num_fmt.as_str(); - // A checkbox cell draws TRUE/FALSE (and empty) as a box; other values show as General. - if code == "checkbox" && matches!(v, Value::Bool(_) | Value::Empty) { - return (String::new(), None, false, None); - } - if code == "General" - && let Value::Number(n) = v - { - let max_chars = ((width_px - 4.0) / char_w).floor().max(1.0) as usize; - return match gridcraft_engine::numfmt::format_general_fit(*n, max_chars.min(11)) { - Some(t) => (t, None, true, None), - None => ("#".repeat(max_chars.max(1)), None, true, None), - }; - } - let f = gridcraft_engine::display::format(v, code, wb); - (f.text, f.color.map(numfmt_color), f.numeric, f.fill) -} - -pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); - let available = ui.available_rect_before_wrap(); - // A right-to-left sheet has its vertical scrollbar on the left. - let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left); - let bar = 14.0_f32.min(available.width()); - let rect = if rtl { - Rect::from_min_max(pos2(available.left() + bar, available.top()), available.max) - } else { - Rect::from_min_max(available.min, pos2(available.right() - bar, available.bottom())) - }; - let resp = ui.allocate_rect(rect, Sense::click_and_drag()); - app.grid.rect = Some(rect); - let Some(d) = app.session.active() else { return }; - let wb: Arc = d.wb.clone(); - let si = wb.active_sheet(); - let Some(sh) = wb.sheet(si) else { return }; - let sel = d.selection.clone(); - let place = (d.uid, si); - let mut view = app.view(); - // Page Layout view: pages on a desk, rulers above and left of the headings. - let paper = sh.view == gridcraft_engine::model::SheetViewMode::PageLayout; - let ruler = if paper && !app.session.hide_ruler { crate::paper_view::RULER } else { 0.0 }; - // Right-to-left sheets carry the vertical ruler on the right, beside the row headings. - let grid_rect = if rtl { - Rect::from_min_max(pos2(rect.left(), (rect.top() + ruler).min(rect.bottom())), pos2((rect.right() - ruler).max(rect.left()), rect.bottom())) - } else { - Rect::from_min_max(pos2((rect.left() + ruler).min(rect.right()), (rect.top() + ruler).min(rect.bottom())), rect.max) - }; - let make_geo = |app: &mut SheetApp, scroll: egui::Vec2| -> Geo { - if paper { - let probe = Geo::new(sh, grid_rect, scroll); - if let Some(g) = crate::paper_view::grid_for(app, &wb, si, sh, probe.cells.size(), scroll) { - let g = crate::paper_view::with_dragged_margin(app, &wb, si, g); - return Geo::paged(sh, grid_rect, scroll, g); - } - } - Geo::new(sh, rect, scroll) - }; - // Scrolling. - let (scroll_delta, zoom_delta, modifiers, now) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers, i.time)); - if (resp.hovered() || resp.contains_pointer()) && !crate::text_box::contains_pointer(app, ui.ctx()) { - if zoom_delta != 1.0 { - // egui's zoom factor is e^(speed × points); one notch is `line_scroll_speed` points. - let notch = ui.ctx().options(|o| o.input_options.scroll_zoom_speed * o.input_options.line_scroll_speed); - let turned = zoom_delta.ln() / notch; - let mut zooming = WheelZoom::at(app, place, sh.zoom, now); - if now - zooming.time > WHEEL_ZOOM_IDLE { - zooming.notches = 0.0; - } - if turned.is_finite() { - zooming.notches = (zooming.notches + turned).clamp(-32.0, 32.0); - } - zooming.time = now; - // A notch steps once 60% of it has turned, and the rest of it brings `notches` back to - // about 0. (Past half, so what's left after a step can't step back.) Steps count from - // where a glide is going, not from where it has got to. - let from = zooming.glide.map_or(sh.zoom, |(to, _)| to); - let mut z = from; - while zooming.notches >= 0.6 { - z = wheel_zoom_step(z, true); - zooming.notches -= 1.0; - } - while zooming.notches <= -0.6 { - z = wheel_zoom_step(z, false); - zooming.notches += 1.0; - } - if z != from { - zooming.glide = Some((z, sh.zoom)); - } - app.grid.wheel_zoom = Some(zooming); - } else if scroll_delta != egui::Vec2::ZERO { - let z = if paper { make_geo(app, view.scroll).z } else { sh.zoom as f32 / 100.0 * DISPLAY_SCALE }; - let mut d = scroll_delta; - // A horizontal swipe moves the content the way the fingers go, which is towards - // higher columns on a mirrored sheet. - if sh.right_to_left { - d.x = -d.x; - } - if modifiers.shift && d.x == 0.0 { - d = vec2(d.y, 0.0); - } - view.scroll -= d / z; - } - } - // A one-finger touch drag pans the sheet, the content following the finger; it doesn't select. - // (Two fingers pinch-zoom through `zoom_delta` above.) - if resp.drag_started() && ui.input(|i| i.any_touches()) { - app.grid.touch_pan = true; - } - if app.grid.touch_pan && resp.dragged() && ui.input(|i| i.multi_touch().is_none()) { - let z = if paper { make_geo(app, view.scroll).z } else { sh.zoom as f32 / 100.0 * DISPLAY_SCALE }; - let mut d = resp.drag_delta(); - if sh.right_to_left { - d.x = -d.x; - } - view.scroll -= d / z; - } - // The zoom glides to its step like egui eases a wheel notch (90% of the way in 0.1 s), or goes - // there at once without animations (as for the selection outline below: View › Animations - // off, egui's off in the headless harness, or a screenshot on its way). - let mut zooming = WheelZoom::at(app, place, sh.zoom, now); - if let Some((to, shown)) = zooming.glide { - let glides = app.ui.animations && ui.ctx().global_style().animation_time > 0.0 && !app.shots.in_flight(); - let share = egui::emath::exponential_smooth_factor(0.9, 0.1, ui.input(|i| i.stable_dt).min(0.1)); - let z = if glides { zoom_glide_step(shown, to, share) } else { to }; - let _ = app.session.run("view.zoom", json!({"percent": z})); - zooming.glide = (z != to).then_some((to, z)); - ui.ctx().request_repaint(); - } - // Store a glide's progress, or that it stopped. - if app.grid.wheel_zoom.is_some_and(|w| w.glide.is_some()) { - app.grid.wheel_zoom = Some(zooming); - } - view.scroll = view.scroll.max(egui::Vec2::ZERO); - let (mut max_x, mut max_y) = (sh.col_left(MAX_COLS - 20) as f32, sh.row_top(MAX_ROWS - 40) as f32); - if paper { - // Page layout units run past sheet units (margins and gaps); the end is known once the - // pages reach the last row / column. - let g = make_geo(app, view.scroll); - if let Some(pg) = &g.paper { - let (ex, ey) = pg.extent(); - max_x = if pg.cols_complete { (ex as f32 - g.cells.width() / g.z).max(0.0) } else { 1.0e9 }; - max_y = if pg.rows_complete { (ey as f32 - g.cells.height() / g.z).max(0.0) } else { 1.0e9 }; - } - } - view.scroll.x = view.scroll.x.min(max_x); - view.scroll.y = view.scroll.y.min(max_y); - let mut geo = make_geo(app, view.scroll); - // Keep the active cell (or the moving end of an extension) visible after keyboard moves. - if app.grid.ensure_visible { - app.grid.ensure_visible = false; - let moving = std::mem::take(&mut app.grid.reveal_moving_end); - let cell = if moving { sel.moving_end() } else { sel.active }; - let from = app.grid.reveal_from.take(); - let target = app.editor.as_ref().and_then(|e| e.point_cell).unwrap_or(cell); - // A whole column's moving end sits on the sheet's last row (a whole row's on its last - // column): when an extension moves it only across, the view keeps its place the other way. - // An extension that reaches the last row itself (Ctrl+Shift+Down past the data) still - // follows it. - let extended = (moving && target == cell).then(|| sel.current()); - let before = view.scroll; - if paper { - crate::paper_view::ensure_covers(app, &wb, si, target); - geo = make_geo(app, view.scroll); - } - let cr = geo.cell_rect(sh, target); - let area = Rect::from_min_max(pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h), geo.cells.max); - if target.row >= geo.fr { - if cr.top() < area.top() { - view.scroll.y -= (area.top() - cr.top()) / geo.z; - } else if cr.bottom() > area.bottom() - 2.0 { - view.scroll.y += (cr.bottom() - area.bottom() + 2.0) / geo.z; - } - } else if from.is_some_and(|f| f.row >= geo.fr) { - // A move from the scrolled rows into the frozen ones (Page Up or Ctrl+Up onto a frozen - // row 1) brings the scrolled rows back to their top; the rows it passed over would - // otherwise stay out of sight. - view.scroll.y = 0.0; - } - if target.col >= geo.fc { - if cr.left() < area.left() { - view.scroll.x -= (area.left() - cr.left()) / geo.z; - } else if cr.right() > area.right() - 2.0 { - view.scroll.x += ((cr.right() - area.right() + 2.0) / geo.z).min(cr.left() - area.left()); - } - } else if from.is_some_and(|f| f.col >= geo.fc) { - view.scroll.x = 0.0; - } - if extended.is_some_and(|r| r.is_full_cols()) && from.is_some_and(|f| f.row == cell.row) { - view.scroll.y = before.y; - } - if extended.is_some_and(|r| r.is_full_rows()) && from.is_some_and(|f| f.col == cell.col) { - view.scroll.x = before.x; - } - view.scroll = view.scroll.max(egui::Vec2::ZERO); - geo = make_geo(app, view.scroll); - } - // Snap scrolling to whole rows/columns at rest like Excel? Keep smooth (better on trackpads). - if let Some(v) = app.view_mut() { - *v = view; - } - app.grid.cells_rect = Some(geo.mirror_rect(geo.cells)); - // The selection outline glides to a new selection. It stays on its cells when Animations is - // off, when egui's animations are (the headless harness), and while a screenshot is on its way. - // A mouse button held down (a click or a drag selecting cells) moves it at once, as in Excel: - // a gliding outline trails the pointer and reads as lag. - let pointer_selecting = ui.input(|i| i.pointer.any_down()); - let outline = app.grid.glide.frame(crate::selection_glide::Frame { - place, - range: sel.current(), - target: geo.range_rect(sh, sel.current()), - view: geo.cells, - now, - frame_nr: ui.ctx().cumulative_frame_nr(), - animate: app.ui.animations && ui.ctx().global_style().animation_time > 0.0 && !app.shots.in_flight() && !pointer_selecting, - }); - if app.grid.glide.is_gliding() { - ui.ctx().request_repaint(); - } - app.grid.geo = Some(geo.clone()); - let painter = ui.painter_at(rect); - painter.rect_filled(rect, 0.0, t.grid_bg); - // Everything up to the headers is painted in logical (left-to-right) coordinates. - let mirror_from = painter.add(egui::Shape::Noop); - let mut cf = CfCache::new(); - let quads = if geo.paper.is_some() { - crate::paper_view::paint_pages(app, &painter, &geo, &wb, si, sh, &sel, outline, &mut cf, &t) - } else { - paint_panes(app, &painter, &geo, &wb, si, sh, &sel, outline, &mut cf, &t) - }; - // Objects above cells. A hidden object can't stay selected: Delete or the Format pane would act - // on something the user can't see. - if crate::chartview::objects_hidden(&wb) { - app.selected_chart = None; - } - let objects = crate::chartview::paint_objects(app, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh); - paint_overlays(app, ui, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh, &sel); - if sh.view == gridcraft_engine::model::SheetViewMode::PageBreakPreview { - paint_pages(app, &painter.with_clip_rect(geo.cells), &geo, sh); - } - if geo.paper.is_some() { - crate::paper_view::paint_headers_footers(app, ui, &painter, &geo, &wb, sh); - } - // Headers. - if sh.show_headings { - let headings = table_headings(&geo, sh, sel.active); - paint_headers(&painter, &geo, sh, &sel, &t, &quads, &headings); - paint_freeze_handles(&painter, &geo, sh, &app.grid.drag, &t); - } - crate::outline::paint(&painter, &geo, sh, &t); - if geo.rtl { - let mirror_to = painter.add(egui::Shape::Noop); - mirror_painted(ui.ctx(), painter.layer_id(), mirror_from.0..mirror_to.0, &geo, &objects); - } - if geo.paper.is_some() { - crate::paper_view::paint_rulers(app, &painter, rect, &geo, &t); - } - // Interaction. - let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu, app.grid.fill_menu); - // A pan the grid never saw end (it was hidden mid-drag) must not swallow the next press. - if app.grid.touch_pan && !resp.dragged() && !resp.drag_stopped() { - app.grid.touch_pan = false; - } - if app.grid.touch_pan { - // Skip the frames of a touch pan, its release included. - if !resp.dragged() { - app.grid.touch_pan = false; - } - } else { - crate::paper_view::interact(app, ui, &resp, &geo, sh, &wb); - interact(app, ui, &resp, &geo, sh, &wb); - } - update_cursor(app, ui, &resp, &geo, sh); - crate::text_box::show(app, ui, &geo, sh); - if app.editor.as_ref().is_some_and(|e| !e.from_formula_bar) { - in_cell_editor(app, ui, &geo, sh, &wb); - } - filter_menu(app, ui, &geo, previous_popups.0 == app.grid.filter_menu); - list_picker(app, ui, &geo, previous_popups.1 == app.grid.list_picker); - header_menu(app, ui, previous_popups.2 == app.grid.header_menu); - context_menu(app, ui); - fill_menu(app, ui, previous_popups.3 == app.grid.fill_menu); - fill_options_tag(app, ui, &geo, sh); - object_menu(app, ui); - error_check_tag(app, ui, &geo, sh); - let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) }; - // The track starts below the column headings and frozen rows; the bar's column beside them - // takes its colour too instead of showing a light square in dark mode. - ui.painter().rect_filled(Rect::from_min_max(pos2(track_x.0, rect.top()), pos2(track_x.1, rect.bottom())), 0.0, t.tab_bar); - vertical_scrollbar(app, ui, sh, &geo, track_x, max_y, &t); - if app.session.clipboard.is_some() { - // By the clock, not per frame: a gliding outline repaints more often than the ants step. - app.grid.marching_phase = crate::selection_glide::ants_phase(now); - ui.ctx().request_repaint_after(std::time::Duration::from_millis(60)); - } -} - -/// Normal view: up to four panes (frozen rows/columns), each painted with its own clip. -#[allow(clippy::too_many_arguments)] -fn paint_panes( - app: &SheetApp, - painter: &Painter, - geo: &Geo, - wb: &Workbook, - si: usize, - sh: &Sheet, - sel: &gridcraft_engine::Selection, - outline: Rect, - cf: &mut CfCache, - t: &Tokens, -) -> Vec<((u32, u32), (u32, u32), Rect)> { - // Quadrants: (rows, cols, clip). - let (sr0, sr1) = geo.scroll_rows(sh); - let (sc0, sc1) = geo.scroll_cols(sh); - let fr_rows = (0, geo.fr.saturating_sub(1)); - let fr_cols = (0, geo.fc.saturating_sub(1)); - let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h); - let mut quads = vec![((sr0, sr1), (sc0, sc1), Rect::from_min_max(split, geo.cells.max))]; - if geo.fr > 0 { - quads.push((fr_rows, (sc0, sc1), Rect::from_min_max(pos2(split.x, geo.cells.top()), pos2(geo.cells.right(), split.y)))); - } - if geo.fc > 0 { - quads.push(((sr0, sr1), fr_cols, Rect::from_min_max(pos2(geo.cells.left(), split.y), pos2(split.x, geo.cells.bottom())))); - } - if geo.fr > 0 && geo.fc > 0 { - quads.push((fr_rows, fr_cols, Rect::from_min_max(geo.cells.min, split))); - } - for (rows, cols, clip) in &quads { - if rows.0 > rows.1 || cols.0 > cols.1 { - continue; - } - let p = painter.with_clip_rect(*clip); - paint_quadrant(&p, geo, wb, si, sh, *rows, *cols, cf, t); - paint_selection(&p, geo, sh, sel, outline, t, app, *rows, *cols); - } - // Freeze lines. - if geo.fr > 0 { - painter.line_segment([pos2(geo.cells.left(), split.y), pos2(geo.cells.right(), split.y)], Stroke::new(1.0, Color32::from_gray(150))); - } - if geo.fc > 0 { - painter.line_segment([pos2(split.x, geo.cells.top()), pos2(split.x, geo.cells.bottom())], Stroke::new(1.0, Color32::from_gray(150))); - } - quads -} - -/// The worksheet is painted virtually, so it needs its own scrollbar rather than a -/// ScrollArea containing all million rows. Keep the scroll extent stable during a drag. -fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &Geo, track_x: (f32, f32), limit: f32, t: &Tokens) { - let track = Rect::from_min_max(pos2(track_x.0, geo.cells.top() + geo.frozen_h), pos2(track_x.1, geo.cells.bottom())); - if track.height() <= 0.0 || track.width() <= 0.0 { - return; - } - let visible = track.height() / geo.z; - let scroll = app.view().scroll.y; - let last = sh.used_range().map(|r| r.end.row.saturating_add(10)).unwrap_or(100).clamp(100, MAX_ROWS - 1); - let content = geo.scroll_top_of(sh, last); - let extent = (content - visible).max(scroll).max(0.0).min(limit); - let id = ui.scope_id().with("vscroll"); - let resp = ui.interact(track, id, Sense::click_and_drag()); - let drag_id = id.with("extent"); - if resp.drag_started() { - ui.ctx().data_mut(|d| d.insert_temp(drag_id, extent)); - } - let extent = if resp.dragged() { ui.ctx().data(|d| d.get_temp::(drag_id)).unwrap_or(extent) } else { extent }; - let thumb_h = (track.height() * visible / (extent + visible)).clamp(24.0_f32.min(track.height()), track.height()); - let travel = track.height() - thumb_h; - let thumb_top = track.top() + if extent > 0.0 { travel * (scroll / extent).clamp(0.0, 1.0) } else { 0.0 }; - let thumb = Rect::from_min_size(pos2(track.left() + 3.0, thumb_top), vec2((track.width() - 6.0).max(0.0), thumb_h)); - ui.painter().rect_filled(track, 0.0, t.tab_bar); - ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled }); - let mut next = scroll; - if resp.dragged() && travel > 0.0 { - next += resp.drag_delta().y / travel * extent; - } else if resp.clicked() - && let Some(p) = resp.interact_pointer_pos() - { - if p.y < thumb.top() { - next -= visible; - } else if p.y > thumb.bottom() { - next += visible; - } - } - if resp.hovered() { - next -= ui.input(|i| i.smooth_scroll_delta.y) / geo.z; - } - next = next.clamp(0.0, limit); - if next != scroll { - if let Some(v) = app.view_mut() { - v.scroll.y = next; - } - ui.ctx().request_repaint(); - } -} - -/// The horizontal counterpart of `vertical_scrollbar`, drawn in the sheet tab bar as in Excel. The tab -/// bar is laid out before the grid, so this reads the last frame's geometry and leaves clamping the -/// far end to the grid, which runs later in the same frame. -pub(crate) fn horizontal_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, track: Rect, t: &Tokens) { - app.grid.hscroll = Some(track); - if track.height() <= 0.0 || track.width() <= 0.0 { - return; - } - let Some(geo) = app.grid.geo.clone() else { return }; - let Some(wb) = app.session.active().map(|d| d.wb.clone()) else { return }; - let Some(sh) = wb.active() else { return }; - let visible = ((geo.cells.width() - geo.frozen_w) / geo.z).max(0.0); - let scroll = app.view().scroll.x; - let last = sh.used_range().map(|r| r.end.col.saturating_add(10)).unwrap_or(26).clamp(26, MAX_COLS - 1); - let content = geo.scroll_left_of(sh, last); - let extent = (content - visible).max(scroll).max(0.0); - let id = ui.scope_id().with("hscroll"); - let resp = ui.interact(track, id, Sense::click_and_drag()); - let drag_id = id.with("extent"); - if resp.drag_started() { - ui.ctx().data_mut(|d| d.insert_temp(drag_id, extent)); - } - let extent = if resp.dragged() { ui.ctx().data(|d| d.get_temp::(drag_id)).unwrap_or(extent) } else { extent }; - let thumb_w = (track.width() * visible / (extent + visible).max(1.0)).clamp(24.0_f32.min(track.width()), track.width()); - let travel = track.width() - thumb_w; - let offset = if extent > 0.0 { travel * (scroll / extent).clamp(0.0, 1.0) } else { 0.0 }; - // A right-to-left sheet scrolls towards the left, so its thumb starts at the right end. - let dir = if geo.rtl { -1.0 } else { 1.0 }; - let thumb_left = if geo.rtl { track.right() - thumb_w - offset } else { track.left() + offset }; - let thumb = Rect::from_min_size(pos2(thumb_left, track.top() + 3.0), vec2(thumb_w, (track.height() - 6.0).max(0.0))); - ui.painter().rect_filled(track, 0.0, t.tab_bar); - ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled }); - let mut next = scroll; - if resp.dragged() && travel > 0.0 { - next += dir * resp.drag_delta().x / travel * extent; - } else if resp.clicked() - && let Some(p) = resp.interact_pointer_pos() - { - if p.x < thumb.left() { - next -= dir * visible; - } else if p.x > thumb.right() { - next += dir * visible; - } - } - if resp.hovered() { - let d = ui.input(|i| i.smooth_scroll_delta); - next -= (if d.x != 0.0 { dir * d.x } else { d.y }) / geo.z; - } - next = next.max(0.0); - if next != scroll { - if let Some(v) = app.view_mut() { - v.scroll.x = next; - } - ui.ctx().request_repaint(); - } -} - -pub(crate) fn wrapped_cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 { - let pad_v = 1.5 * z; - let y_top = rect.top() + pad_v; - // Not clamped to `y_top`: text taller than the row overflows upward (Bottom) or both ways - // (Center), and the cell's clip rect trims it, so Middle and Bottom stay distinct from Top. - let y_bottom = rect.bottom() - gh - pad_v; - match valign { - VAlign::Top => y_top, - VAlign::Center | VAlign::Justify | VAlign::Distributed => (y_top + y_bottom) / 2.0, - VAlign::Bottom => y_bottom, - } -} - -pub(crate) fn cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 { - let top_inset = 1.0 * z; - let y_top = rect.top() - top_inset; - let y_bottom = (rect.bottom() - gh - 0.5 * z).max(y_top); - match valign { - VAlign::Top => y_top, - VAlign::Center | VAlign::Justify | VAlign::Distributed => (y_top + y_bottom) / 2.0, - VAlign::Bottom => y_bottom, - } -} - -/// Where to put a `TextShape` holding a galley of `size` turned by `angle` (egui radians, so -/// negative for upward text) for it to sit in `rect` per the cell's alignment. egui turns the -/// galley about `pos`, its top-left corner, so the turned box is placed first and `pos` follows. -/// General keeps the text at the left. Bottom (the default) puts upward text at the bottom, but -/// downward text at the top, where it starts reading. -pub(crate) fn rotated_text_pos(rect: Rect, size: egui::Vec2, angle: f32, h: HAlign, v: VAlign, pad: f32, indent: f32) -> Pos2 { - let rot = egui::emath::Rot2::from_angle(angle); - let turned = Rect::from_points(&[vec2(0.0, 0.0), vec2(size.x, 0.0), vec2(0.0, size.y), size].map(|c| (rot * c).to_pos2())); - let x = match h { - HAlign::Center | HAlign::CenterAcross | HAlign::Distributed => rect.center().x - turned.width() / 2.0, - HAlign::Right => rect.right() - pad - indent - turned.width(), - _ => rect.left() + pad + indent, - }; - let y = match v { - VAlign::Center | VAlign::Justify | VAlign::Distributed => rect.center().y - turned.height() / 2.0, - VAlign::Bottom if angle < 0.0 => rect.bottom() - pad - turned.height(), - _ => rect.top() + pad, - }; - pos2(x, y) - turned.min.to_vec2() -} - -/// Mirror what the grid painted into `layer` (the shape indices in `range`) for a right-to-left -/// sheet. Geometry is reflected; text and meshes (glyphs, pictures) keep their reading direction -/// and only move so that their box lands mirrored. Floating objects (`objects`: shape index -/// ranges with their logical rect) move as a whole, so a chart keeps its axes and a picture -/// isn't flipped. -fn mirror_painted(ctx: &egui::Context, layer: egui::LayerId, range: std::ops::Range, geo: &Geo, objects: &[crate::chartview::PaintedObject]) { - let axis = geo.rect.left() + geo.rect.right(); - ctx.graphics_mut(|g| { - let list = g.entry(layer); - for i in range { - let object = objects.iter().find(|o| o.shapes.contains(&i)); - list.mutate_shape(egui::layers::ShapeIdx(i), |cs| { - cs.clip_rect = geo.mirror_rect(cs.clip_rect); - match object { - Some(o) => cs.shape.translate(vec2(axis - o.rect.right() - o.rect.left(), 0.0)), - None => mirror_shape(&mut cs.shape, axis), - } - }); - } - }); -} - -/// Reflect one shape across the vertical line `x = axis / 2`. -fn mirror_shape(shape: &mut egui::Shape, axis: f32) { - use egui::Shape; - let flip = |p: &mut Pos2| p.x = axis - p.x; - match shape { - Shape::Noop | Shape::Callback(_) => {} - Shape::Vec(v) => v.iter_mut().for_each(|s| mirror_shape(s, axis)), - Shape::Circle(c) => flip(&mut c.center), - Shape::Ellipse(e) => flip(&mut e.center), - Shape::LineSegment { points, .. } => points.iter_mut().for_each(flip), - Shape::Path(p) => { - p.points.iter_mut().for_each(flip); - // Keep the preferred (clockwise) winding for filled polygons. - p.points.reverse(); - } - Shape::Rect(r) => { - r.rect = Rect::from_min_max(pos2(axis - r.rect.right(), r.rect.top()), pos2(axis - r.rect.left(), r.rect.bottom())); - let c = r.corner_radius; - r.corner_radius = egui::CornerRadius { nw: c.ne, ne: c.nw, sw: c.se, se: c.sw }; - } - Shape::QuadraticBezier(b) => b.points.iter_mut().for_each(flip), - Shape::CubicBezier(b) => b.points.iter_mut().for_each(flip), - _ => { - let b = shape.visual_bounding_rect(); - if b.is_finite() { - shape.translate(vec2(axis - b.right() - b.left(), 0.0)); - } - } - } -} - -#[allow(clippy::too_many_arguments)] -pub(crate) fn paint_quadrant( - p: &Painter, - geo: &Geo, - wb: &Workbook, - si: usize, - sh: &Sheet, - rows: (u32, u32), - cols: (u32, u32), - cf: &mut CfCache, - t: &Tokens, -) { - let z = geo.z; - let (r0, r1) = rows; - let (c0, c1) = cols; - let range = RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)); - // Merges are looked up 20 columns beyond the quadrant too: text overflowing in from off-screen cells stops at them. - let near = RangeRef::new(CellRef::new(r0, c0.saturating_sub(20)), CellRef::new(r1, c1.saturating_add(20).min(MAX_COLS - 1))); - let merges: Vec = sh.merges.iter().filter(|m| m.intersects(&near)).copied().collect(); - let in_merge = |c: CellRef| merges.iter().find(|m| m.contains(c)).copied(); - // Gridlines. - if sh.show_gridlines { - let stroke = Stroke::new(1.0, t.gridline); - let top = geo.y(sh, r0); - let bottom = geo.y(sh, r1 + 1); - let left = geo.x(sh, c0); - let right = geo.x(sh, c1 + 1); - for c in c0..=c1 + 1 { - if c <= c1 && sh.col_width(c) == 0.0 { - continue; - } - let x = geo.x(sh, c).round() - 0.5; - p.line_segment([pos2(x, top), pos2(x, bottom)], stroke); - } - for r in r0..=r1 + 1 { - if r <= r1 && sh.row_height(r) == 0.0 { - continue; - } - let y = geo.y(sh, r).round() - 0.5; - p.line_segment([pos2(left, y), pos2(right, y)], stroke); - } - } - // Merged areas: blank them (no inner gridlines) and paint as one cell. - let mut cells: Vec = Vec::new(); - for r in r0..=r1 { - if sh.row_height(r) == 0.0 { - continue; - } - for c in c0..=c1 { - if sh.col_width(c) == 0.0 { - continue; - } - cells.push(CellRef::new(r, c)); - } - } - let empty_ok = |c: CellRef| -> bool { sh.value_ref(c).is_none_or(Value::is_empty) && in_merge(c).is_none() }; - // Backgrounds. - let mut looks: Vec<(CellRef, Rect, Look, Option)> = Vec::with_capacity(cells.len().min(20_000)); - for &c in &cells { - let (rect, anchor) = match in_merge(c) { - Some(m) if m.start != c && !(c.row == r0.max(m.start.row) && c.col == c0.max(m.start.col)) => continue, - Some(m) => (geo.range_rect(sh, m), m.start), - None => (geo.cell_rect(sh, c), c), - }; - let (lk, cfl) = look(wb, si, sh, anchor, cf); - if let Some(f) = lk.fill { - p.rect_filled(rect.expand(0.0), 0.0, f); - } else if in_merge(c).is_some() { - p.rect_filled(rect.shrink(0.5), 0.0, Color32::WHITE); - } - looks.push((anchor, rect, lk, cfl)); - } - // Text cells just outside the quadrant whose text may overflow into it: the nearest non-empty - // cell left of `c0` and right of `c1` in each row (within the same frozen / scrolled pane). - let mut outside: Vec<(CellRef, Rect, Look, Option)> = Vec::new(); - let same_pane = |col: u32| (col < geo.fc) == (c0 < geo.fc); - for r in r0..=r1 { - if sh.row_height(r) == 0.0 { - continue; - } - let left = (c0.saturating_sub(20)..c0).rev().map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n)); - let right = (c1.saturating_add(1)..=c1.saturating_add(20).min(MAX_COLS - 1)).map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n)); - for n in [left, right].into_iter().flatten() { - if same_pane(n.col) && sh.col_width(n.col) > 0.0 && in_merge(n).is_none() { - let (lk, cfl) = look(wb, si, sh, n, cf); - outside.push((n, geo.cell_rect(sh, n), lk, cfl)); - } - } - } - // Text and per-cell decorations. - for (c, rect, lk, cfl) in looks.iter().chain(&outside) { - let c = *c; - if let Some(picture) = sh.cell_pictures.get(&c) { - crate::cell_pictures::paint(p, *rect, picture); - continue; - } - let v = sh.value(c); - if let Some(l) = cfl - && let Some((frac, rgb, gradient)) = l.bar - { - let bar = Rect::from_min_size(pos2(rect.left() + 2.0, rect.top() + 2.0), vec2((rect.width() - 4.0) * frac, rect.height() - 4.0)); - let col = theme::color32(rgb); - if gradient { - let mut mesh = egui::Mesh::default(); - let light = Color32::from_rgb( - ((rgb[0] as u16 + 255 * 3) / 4) as u8, - ((rgb[1] as u16 + 255 * 3) / 4) as u8, - ((rgb[2] as u16 + 255 * 3) / 4) as u8, - ); - let i = mesh.vertices.len() as u32; - for (pt, cc) in [(bar.left_top(), col), (bar.right_top(), light), (bar.right_bottom(), light), (bar.left_bottom(), col)] { - mesh.colored_vertex(pt, cc); - } - mesh.add_triangle(i, i + 1, i + 2); - mesh.add_triangle(i, i + 2, i + 3); - p.add(mesh); - p.rect_stroke(bar, 0.0, Stroke::new(1.0, col), StrokeKind::Inside); - } else { - p.rect_filled(bar, 0.0, col); - } - } - let mut text_left = rect.left(); - if let Some(l) = cfl - && let Some((set, idx)) = &l.icon - { - let r = Rect::from_min_size(pos2(rect.left() + 2.0, rect.center().y - 6.0 * z), vec2(12.0 * z, 12.0 * z)); - paint_cf_icon(p, r, set, *idx); - text_left += 15.0 * z; - } - if lk.style.num_fmt.as_str() == "checkbox" && matches!(v, Value::Bool(_) | Value::Empty) { - let s = (11.0 * z * 1.2).clamp(8.0, 24.0); - let b = Rect::from_center_size(rect.center(), vec2(s, s)); - let on = v == Value::Bool(true); - p.rect_filled(b, 2.0, if on { Color32::from_rgb(0x10, 0x7C, 0x41) } else { Color32::WHITE }); - p.rect_stroke( - b, - 2.0, - Stroke::new(1.0, if on { Color32::from_rgb(0x10, 0x7C, 0x41) } else { Color32::from_gray(110) }), - StrokeKind::Inside, - ); - if on { - crate::icons::paint_on_sheet(p, b.shrink(1.5), crate::icons::Icon::Check, Color32::WHITE); - } - continue; - } - if cfl.as_ref().is_some_and(|l| l.hide_value) || v.is_empty() { - continue; - } - // Align and clip header text inside the content area, away from the dropdown. - let filter_space = sh.filter_button_space(c) * z; - let text_rect = Rect::from_min_max(rect.min, pos2((rect.right() - filter_space).max(rect.left()), rect.bottom())); - let rect = &text_rect; - let st = &lk.style; - let size = st.font.size * 96.0 / 72.0 * z; - let fam = theme::cell_family(&st.font.name, st.font.bold, st.font.italic); - let font = FontId::new(size, fam); - // Digits fit by the cell's own font and size, not the default 11pt (9pt marks in narrow columns). - let char_w = p.layout_no_wrap("0".to_string(), font.clone(), Color32::PLACEHOLDER).size().x.max(1.0); - let avail_w = rect.right() - text_left; - let (mut text, ncolor, numeric, fill_char) = display_text(wb, sh, c, &v, st, avail_w / z.max(0.1) * z, char_w); - if sh.show_formulas && sh.cell(c).is_some_and(|cell| cell.formula.is_some()) { - text = crate::formula_locale::display(&text, crate::i18n::current(p.ctx()).formula_locale(), wb, si); - } - if text.is_empty() { - continue; - } - let color = ncolor.unwrap_or(lk.text_color); - let halign = match st.align.h { - HAlign::General => match v { - Value::Number(_) => HAlign::Right, - Value::Bool(_) | Value::Error(_) => HAlign::Center, - _ if numeric => HAlign::Right, - _ => HAlign::Left, - }, - h => painted_halign(h, geo.rtl), - }; - let indent = st.align.indent as f32 * 9.0 * z; - let pad = 2.5 * z; - let rotated = st.align.rotation != 0 && st.align.rotation != 255; - // Rotated text wraps along its own direction (below), not across the column width. - if !rotated && (st.align.wrap || st.align.h == HAlign::Justify) { - let wrap_w = (rect.width() - 2.0 * pad - indent).max(4.0); - let mut job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, wrap_w); - job.halign = match halign { - HAlign::Center | HAlign::CenterAcross => egui::Align::Center, - HAlign::Right => egui::Align::RIGHT, - _ => egui::Align::LEFT, - }; - let galley = p.layout_job(job); - let x = match halign { - HAlign::Center | HAlign::CenterAcross => rect.center().x, - HAlign::Right => rect.right() - pad, - _ => text_left + pad + indent, - }; - let y = wrapped_cell_text_y(st.align.v, *rect, galley.size().y, z); - p.with_clip_rect(rect.intersect(p.clip_rect())).galley(pos2(x, y), galley, color); - continue; - } - // Wrap Text off: Excel ignores the line breaks and draws the whole string on one line. - let first_line: String = text.chars().filter(|&ch| ch != '\n' && ch != '\r').collect(); - // The fill position indexes `text`; move it back by the breaks removed before it. - let fill_char = fill_char.map(|(ch, pos)| (ch, pos.saturating_sub(text.get(..pos).map_or(0, |s| s.matches(['\n', '\r']).count())))); - // Shrink to fit: too-wide content is drawn in a smaller font so it fits the cell (or the - // merged area): it neither spills, nor is clipped, nor turns into ####. - let small = (st.align.shrink && !rotated && st.align.h != HAlign::Fill && fill_char.is_none()) - .then(|| shrink_font(p, &first_line, &font, avail_w - 2.0 * pad - indent)) - .flatten(); - let shrunk = small.is_some(); - let font = small.unwrap_or(font); - let mut galley = p.layout_no_wrap(first_line.clone(), font.clone(), color); - let mut w = galley.size().x; - // Numbers and dates never overflow: they show #### when too narrow. - let hashed = !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill; - if hashed { - // Measured on `#` itself: it is wider than `0` in some fonts, and #### must fit its cell. - let hash_w = p.layout_no_wrap("#".to_string(), font.clone(), color).size().x.max(1.0); - let n = ((avail_w - 2.0 * pad) / hash_w).floor().max(1.0) as usize; - galley = p.layout_no_wrap("#".repeat(n), font.clone(), color); - w = galley.size().x; - } - // Repeat fill (`*` in number formats) / Fill alignment. A number already shown as #### keeps - // it: rebuilding it from the text would overflow and clip its leading digits. - if let Some((ch, pos)) = fill_char - && !hashed - && pos <= first_line.len() - { - let base_w = w; - let cw = p.layout_no_wrap(ch.to_string(), font.clone(), color).size().x.max(1.0); - let n = (((avail_w - 2.0 * pad) - base_w) / cw).floor().max(0.0) as usize; - let mut s = first_line.clone(); - if first_line.is_char_boundary(pos) { - s.insert_str(pos, &ch.to_string().repeat(n)); - } - galley = p.layout_no_wrap(s, font.clone(), color); - w = galley.size().x; - } - // Center Across Selection: the text is centred over this cell and the empty cells to its right that also use it. - let across_end = if halign == HAlign::CenterAcross && !rotated && in_merge(c).is_none() { - center_across_end(c.col, c1 + 20, |col| { - let n = CellRef::new(c.row, col); - empty_ok(n) && wb.styles.get(sh.style_id(n)).align.h == HAlign::CenterAcross - }) - } else { - c.col - }; - // Overflow into empty neighbours for text (not upright text, whose width is its line height). - let mut clip = *rect; - let overflows = !numeric && !matches!(v, Value::Number(_)) && st.align.rotation.abs() != 90 && w > rect.width() - 2.0 * pad; - if filter_space == 0.0 && (overflows || across_end > c.col) && in_merge(c).is_none() { - let mut right = rect.right(); - let mut left = rect.left(); - if across_end > c.col { - right = geo.x(sh, across_end + 1); - } - if matches!(halign, HAlign::Left | HAlign::Center | HAlign::Justify) { - let mut cc = c.col + 1; - while right - rect.left() < w + 2.0 * pad && cc < MAX_COLS && empty_ok(CellRef::new(c.row, cc)) && cc <= c1 + 20 { - right = geo.x(sh, cc + 1); - cc += 1; - } - } - if matches!(halign, HAlign::Right | HAlign::Center) { - let mut cc = c.col; - while rect.right() - left < w + 2.0 * pad && cc > 0 && empty_ok(CellRef::new(c.row, cc - 1)) && cc + 20 > c0 { - cc -= 1; - left = geo.x(sh, cc); - } - } - clip = Rect::from_min_max(pos2(left, rect.top()), pos2(right, rect.bottom())); - // Paint over the gridlines the text passes through. - if lk.fill.is_none() { - let inner = Rect::from_min_max(pos2(clip.left() + 1.0, clip.top() + 0.5), pos2(clip.right() - 1.0, clip.bottom() - 1.0)); - let extra_l = Rect::from_min_max(inner.min, pos2(rect.left() + 1.0, inner.bottom())); - let extra_r = Rect::from_min_max(pos2(rect.right() - 1.0, inner.top()), inner.max); - if extra_l.width() > 0.0 { - p.rect_filled(extra_l, 0.0, t.grid_bg); - } - if extra_r.width() > 0.0 { - p.rect_filled(extra_r, 0.0, t.grid_bg); - } - } - } - let x = match halign { - HAlign::CenterAcross => clip.center().x - w / 2.0, - HAlign::Center | HAlign::Distributed => rect.center().x - w / 2.0, - HAlign::Right => rect.right() - pad - w - indent, - _ => text_left + pad + indent, - }; - let gh = galley.size().y; - let y = cell_text_y(st.align.v, *rect, gh, z); - let cp = p.with_clip_rect(clip.intersect(p.clip_rect())); - if rotated { - let angle = -(st.align.rotation as f32).to_radians(); - // Upright (±90°) wrapped text wraps to the row height: lines then stack across the column. - let galley = if st.align.wrap && st.align.rotation.abs() == 90 { - let job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, (rect.height() - 2.0 * pad).max(4.0)); - p.layout_job(job) - } else { - galley.clone() - }; - let pos = rotated_text_pos(*rect, galley.size(), angle, painted_halign(st.align.h, geo.rtl), st.align.v, pad, indent); - let mut shape = egui::epaint::TextShape::new(pos, galley, color); - shape.angle = angle; - cp.add(shape); - } else { - cp.galley(pos2(x, y), galley.clone(), color); - } - let baseline = y + gh * 0.82; - match st.font.underline { - Underline::None => {} - Underline::Double | Underline::DoubleAccounting => { - cp.line_segment([pos2(x, baseline + 1.0), pos2(x + w, baseline + 1.0)], Stroke::new(1.0, color)); - cp.line_segment([pos2(x, baseline + 3.0), pos2(x + w, baseline + 3.0)], Stroke::new(1.0, color)); - } - _ => { - cp.line_segment([pos2(x, baseline + 1.0), pos2(x + w, baseline + 1.0)], Stroke::new(1.0, color)); - } - } - if st.font.strike { - cp.line_segment([pos2(x, y + gh * 0.55), pos2(x + w, y + gh * 0.55)], Stroke::new(1.0, color)); - } - } - // Borders (over fills and text). - for (c, rect, lk, _) in &looks { - let b = &lk.style.borders; - let merge = merges.iter().find(|m| m.start == *c); - let edges = match merge { - // A merged area's outline lives on the cells along each of its edges (Excel writes the - // right edge on the last column's cells and the bottom edge on the last row's), so each - // edge is drawn cell by cell from the cell it belongs to. - Some(m) => merged_edges(wb, si, sh, geo, *m, *rect, (r0, r1), (c0, c1), cf), - None => vec![ - (b.top, [rect.left_top(), rect.right_top()]), - (b.bottom, [rect.left_bottom(), rect.right_bottom()]), - (b.left, [rect.left_top(), rect.left_bottom()]), - (b.right, [rect.right_top(), rect.right_bottom()]), - ], - }; - for (line, pts) in edges { - if line.is_none() { - continue; - } - let col = col32(line.color, wb, Color32::BLACK); - paint_border(p, pts, line.style, col); - } - if !b.diag_down.is_none() { - paint_border(p, [rect.left_top(), rect.right_bottom()], b.diag_down.style, col32(b.diag_down.color, wb, Color32::BLACK)); - } - if !b.diag_up.is_none() { - paint_border(p, [rect.left_bottom(), rect.right_top()], b.diag_up.style, col32(b.diag_up.color, wb, Color32::BLACK)); - } - // Comment / note indicator. - if let Some(cm) = sh.comments.get(c) { - let tri_col = if cm.threaded { Color32::from_rgb(0x88, 0x55, 0xC8) } else { Color32::from_rgb(0xD0, 0x2E, 0x2E) }; - let s = 6.0 * z.max(0.7); - p.add(egui::Shape::convex_polygon( - vec![rect.right_top(), pos2(rect.right(), rect.top() + s), pos2(rect.right() - s, rect.top())], - tri_col, - Stroke::NONE, - )); - } - // Background error checking: a number stored as text gets Excel's green corner (#1994). - if !sh.show_formulas && sh.cell(*c).and_then(gridcraft_engine::cmd::formulas::number_stored_as_text).is_some() { - let s = 6.0 * z.max(0.7); - p.add(egui::Shape::convex_polygon( - vec![rect.left_top(), pos2(rect.left() + s, rect.top()), pos2(rect.left(), rect.top() + s)], - t.error_check_green, - Stroke::NONE, - )); - } - } - // Sparklines. - for sp in &sh.sparklines { - if !range.contains(sp.cell) { - continue; - } - let rect = geo.cell_rect(sh, sp.cell).shrink(3.0); - let vals: Vec> = match gridcraft_engine::calc::evaluate(wb, si, sp.cell, &sp.source) { - Value::Array(a) => a.data.iter().map(Value::as_f64).collect(), - v => vec![v.as_f64()], - }; - let col = sp.color.resolve(&wb.theme).unwrap_or([0x2B, 0x7C, 0xD3]); - let prims = gridcraft_chart::render_sparkline(sp.kind, &vals, [col[0], col[1], col[2], 255], sp.markers, rect.width(), rect.height()); - crate::chartview::paint_prims(p, rect.min, &prims, 1.0); - } - // Filter buttons. - let mut buttons: Vec<(CellRef, bool)> = Vec::new(); - if let Some(af) = &sh.autofilter { - for col in af.range.start.col..=af.range.end.col { - let active = af.criteria.iter().any(|(o, _)| af.range.start.col + o == col); - buttons.push((CellRef::new(af.range.start.row, col), active)); - } - } - for tb in &sh.tables { - if tb.filter_button && tb.header_row { - for col in tb.range.start.col..=tb.range.end.col { - buttons.push((CellRef::new(tb.range.start.row, col), false)); - } - } - } - for (c, active) in buttons { - if !range.contains(c) { - continue; - } - paint_filter_button(p, filter_button_rect(geo, sh, c), active); - } -} - -pub fn filter_button_rect(geo: &Geo, sh: &Sheet, c: CellRef) -> Rect { - let rect = geo.cell_rect(sh, c); - // Keep the button within the same header slot that AutoFit reserves. - let pad = 2.0 * geo.z; - let s = (rect.height() - 4.0).clamp(10.0, 18.0).min((sh.filter_button_space(c) * geo.z - pad).max(0.0)); - Rect::from_min_size(pos2(rect.right() - s - pad, rect.bottom() - s - pad), vec2(s, s)) -} - -/// A Table column heading that shows the column's header name in place of its letters. -#[derive(Clone, Debug)] -pub struct TableHeading { - pub col: u32, - /// The table's header row, which the filter menu is keyed by. - pub header_row: u32, - pub name: String, - /// The heading slot over the column (logical coordinates). - pub rect: Rect, - /// The filter button, when the table shows filter buttons. - pub button: Option, - /// The column is filtered (the button shows the funnel). - pub filtered: bool, -} - -/// The Table whose header names replace the column letters: the one holding the active cell, -/// with its header row scrolled out of view and some of its rows still in view (Excel, #1423). -pub fn heading_table<'a>(geo: &Geo, sh: &'a Sheet, active: CellRef) -> Option<&'a gridcraft_engine::model::Table> { - // Page Layout view keeps the letters. - if geo.paper.is_some() || geo.header_h <= 0.0 { - return None; - } - let tb = sh.tables.iter().find(|t| t.header_row && t.range.contains(active))?; - let header = tb.range.start.row; - // A header row in the frozen rows is always on screen. - if header < geo.fr { - return None; - } - let top = geo.cells.top() + geo.frozen_h; - let bottom = |r: u32| geo.y(sh, r) + sh.row_height(r) * geo.z; - (bottom(header) <= top + 0.5 && bottom(tb.range.end.row) > top + 0.5).then_some(tb) -} - -/// The headings of `heading_table`'s visible columns. -pub fn table_headings(geo: &Geo, sh: &Sheet, active: CellRef) -> Vec { - let Some(tb) = heading_table(geo, sh, active) else { return Vec::new() }; - let af = sh.autofilter.as_ref().filter(|af| af.range == tb.range); - let mut out = Vec::new(); - for col in tb.range.start.col..=tb.range.end.col { - let w = sh.col_width(col) * geo.z; - let off = col - tb.range.start.col; - let Some(column) = tb.columns.get(off as usize) else { continue }; - if w <= 0.0 { - continue; - } - let rect = Rect::from_min_size(pos2(geo.x(sh, col), geo.head_top()), vec2(w, geo.header_h)); - // Clear of the 4-point column resize edge, and never wider than the column. - let s = (rect.height() - 6.0).clamp(10.0, 16.0).min(w - 7.0); - let button = (tb.filter_button && s > 0.0).then(|| Rect::from_min_size(pos2(rect.right() - s - 5.0, rect.center().y - s / 2.0), vec2(s, s))); - let filtered = tb.filter.iter().any(|(o, _)| *o == off) || af.is_some_and(|af| af.criteria.iter().any(|(o, _)| *o == off)); - out.push(TableHeading { col, header_row: tb.range.start.row, name: column.name.clone(), rect, button, filtered }); - } - out -} - -fn paint_filter_button(p: &Painter, r: Rect, filtered: bool) { - 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 -/// first row's and first column's cells, the bottom and right edges from the last row's and last -/// column's cells, one segment per visible cell (limited to the visible `rows` × `cols`). -#[allow(clippy::too_many_arguments)] -fn merged_edges( - wb: &Workbook, - si: usize, - sh: &Sheet, - geo: &Geo, - m: RangeRef, - rect: Rect, - rows: (u32, u32), - cols: (u32, u32), - cf: &mut CfCache, -) -> Vec<(BorderLine, [Pos2; 2])> { - let mut out = Vec::new(); - let (cs, ce) = (m.start.col.max(cols.0), m.end.col.min(cols.1)); - for col in cs..=ce { - if sh.col_width(col) == 0.0 { - continue; - } - let (x0, x1) = (geo.x(sh, col), geo.x(sh, col.saturating_add(1))); - let top = look(wb, si, sh, CellRef::new(m.start.row, col), cf).0.style.borders.top; - let bottom = look(wb, si, sh, CellRef::new(m.end.row, col), cf).0.style.borders.bottom; - out.push((top, [pos2(x0, rect.top()), pos2(x1, rect.top())])); - out.push((bottom, [pos2(x0, rect.bottom()), pos2(x1, rect.bottom())])); - } - let (rs, re) = (m.start.row.max(rows.0), m.end.row.min(rows.1)); - for row in rs..=re { - if sh.row_height(row) == 0.0 { - continue; - } - let (y0, y1) = (geo.y(sh, row), geo.y(sh, row.saturating_add(1))); - let left = look(wb, si, sh, CellRef::new(row, m.start.col), cf).0.style.borders.left; - let right = look(wb, si, sh, CellRef::new(row, m.end.col), cf).0.style.borders.right; - out.push((left, [pos2(rect.left(), y0), pos2(rect.left(), y1)])); - out.push((right, [pos2(rect.right(), y0), pos2(rect.right(), y1)])); - } - out -} - -/// The smaller font that makes `text` fit in `room` points of width (Shrink to fit), or `None` -/// when it already fits at its own size. -fn shrink_font(p: &Painter, text: &str, font: &FontId, room: f32) -> Option { - let natural = p.layout_no_wrap(text.to_string(), font.clone(), Color32::PLACEHOLDER).size().x; - let room = room.max(1.0); - if natural.is_nan() || natural <= room { - return None; - } - // Glyph advances don't scale exactly linearly (rounding), so leave a little slack. - let size = (font.size * room / natural * 0.97).max(1.0); - Some(FontId::new(size, font.family.clone())) -} - -pub(crate) fn paint_border(p: &Painter, pts: [Pos2; 2], style: BorderStyle, col: Color32) { - let w = style.width(); - let (a, b) = (pts[0], pts[1]); - match style { - BorderStyle::Double => { - let horiz = (a.y - b.y).abs() < 0.5; - let off = if horiz { vec2(0.0, 1.5) } else { vec2(1.5, 0.0) }; - p.line_segment([a - off, b - off], Stroke::new(1.0, col)); - p.line_segment([a + off, b + off], Stroke::new(1.0, col)); - } - BorderStyle::Dashed - | BorderStyle::MediumDashed - | BorderStyle::DashDot - | BorderStyle::MediumDashDot - | BorderStyle::DashDotDot - | BorderStyle::MediumDashDotDot - | BorderStyle::SlantDashDot => { - p.extend(egui::Shape::dashed_line(&[a, b], Stroke::new(w, col), 4.0, 2.0)); - } - BorderStyle::Dotted | BorderStyle::Hair => { - p.extend(egui::Shape::dotted_line(&[a, b], col, 2.0, w * 0.5)); - } - _ => { - p.line_segment([a, b], Stroke::new(w, col)); - } - } -} - -fn paint_cf_icon(p: &Painter, r: Rect, set: &str, idx: usize) { - let n = gridcraft_engine::cf::icon_count(set); - let palette3 = [Color32::from_rgb(0xD1, 0x3B, 0x2F), Color32::from_rgb(0xF2, 0xB8, 0x2E), Color32::from_rgb(0x2E, 0x9E, 0x4F)]; - let col = if n <= 3 { - palette3.get(idx).copied().unwrap_or(Color32::GRAY) - } else { - let t = idx as f32 / (n - 1).max(1) as f32; - Color32::from_rgb( - (0xD1 as f32 * (1.0 - t) + 0x2E as f32 * t) as u8, - (0x3B as f32 * (1.0 - t) + 0x9E as f32 * t) as u8, - (0x2F as f32 * (1.0 - t) + 0x4F as f32 * t) as u8, - ) - }; - let lower = set.to_ascii_lowercase(); - if lower.contains("arrow") { - let c = r.center(); - let s = r.width() / 2.0; - let pts = if idx + 1 == n { - vec![pos2(c.x, c.y - s), pos2(c.x + s, c.y + 0.1 * s), pos2(c.x - s, c.y + 0.1 * s)] - } else if idx == 0 { - vec![pos2(c.x, c.y + s), pos2(c.x - s, c.y - 0.1 * s), pos2(c.x + s, c.y - 0.1 * s)] - } else { - vec![pos2(c.x + s, c.y), pos2(c.x - 0.1 * s, c.y - s), pos2(c.x - 0.1 * s, c.y + s)] - }; - p.add(egui::Shape::convex_polygon(pts, col, Stroke::NONE)); - } else if lower.contains("rating") || lower.contains("quarter") { - p.circle_stroke(r.center(), r.width() / 2.0 - 0.5, Stroke::new(1.0, Color32::GRAY)); - let frac = idx as f32 / (n - 1).max(1) as f32; - p.rect_filled( - Rect::from_min_size(pos2(r.left() + 2.0, r.bottom() - 2.0 - (r.height() - 4.0) * frac), vec2(r.width() - 4.0, (r.height() - 4.0) * frac)), - 1.0, - Color32::from_rgb(0x2B, 0x7C, 0xD3), - ); - } else { - p.circle_filled(r.center(), r.width() / 2.0 - 0.5, col); - } -} - -/// Where the active cell is on screen: the whole merged cell when it is part of one. -fn active_rect(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection) -> Rect { - match sh.merge_at(sel.active) { - Some(m) => geo.range_rect(sh, m), - None => geo.cell_rect(sh, sel.active), - } -} - -/// The rectangles shaded for a selection: every area except the active cell, which stays clear so -/// it is plain where typing goes. A single selected cell has no fill. `outline` is where the -/// current area is drawn this frame: it glides to a new selection, and its fill with it. -pub fn selection_fill(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, outline: Rect) -> Vec { - selection_fill_where(geo, sh, sel, outline, |_| true) -} - -/// `selection_fill` for the areas `keep` accepts (a frozen pane draws only the areas it shows). -fn selection_fill_where(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, outline: Rect, keep: impl Fn(&RangeRef) -> bool) -> Vec { - if sel.is_single_cell() { - return vec![]; - } - let active = active_rect(geo, sh, sel); - let area = |r: &RangeRef| if *r == sel.current() { outline } else { geo.range_rect(sh, *r) }; - sel.ranges.iter().filter(|r| keep(r)).flat_map(|r| rect_around(area(r), active)).collect() -} - -/// `area` without `hole`: the strips above and below the hole, then what is left beside it. The -/// selection fill is translucent, so the active cell can't be cleared by painting over it; the -/// pieces never overlap for the same reason. Pieces with no size, or whose numbers aren't finite, -/// are dropped. -fn rect_around(area: Rect, hole: Rect) -> impl Iterator { - // `intersect` takes the sane side of a NaN, which would make a broken hole cover the area. - let hole = if hole.is_finite() { area.intersect(hole) } else { Rect::NOTHING }; - let parts = if hole.is_positive() { - [ - Rect::from_min_max(area.min, pos2(area.max.x, hole.min.y)), - Rect::from_min_max(pos2(area.min.x, hole.max.y), area.max), - Rect::from_min_max(pos2(area.min.x, hole.min.y), pos2(hole.min.x, hole.max.y)), - Rect::from_min_max(pos2(hole.max.x, hole.min.y), pos2(area.max.x, hole.max.y)), - ] - } else { - [area, Rect::NOTHING, Rect::NOTHING, Rect::NOTHING] - }; - parts.into_iter().filter(|r| r.is_positive() && r.is_finite()) -} - -/// True when `r` covers at least one cell of the pane that shows `rows` x `cols` (inclusive). -/// The panes of a frozen sheet share one coordinate system, so a range that lives only in the -/// scrolling pane would otherwise be drawn over the frozen pane as well. -fn range_in_pane(r: RangeRef, rows: (u32, u32), cols: (u32, u32)) -> bool { - r.start.row <= rows.1 && r.end.row >= rows.0 && r.start.col <= cols.1 && r.end.col >= cols.0 -} - -/// The four sides of a `width` wide outline centred on `rect`, cut to `clip` (a little beyond it). -/// -/// A stroked rectangle that spans a whole column (20 million points tall) loses its precision in -/// the tessellator and its left and right sides vanish; sides filled from rectangles that stay -/// near the viewport do not. Sides wholly outside `clip` are left out. -pub(crate) fn outline_bands(rect: Rect, width: f32, clip: Rect) -> Vec { - if !rect.is_finite() || !clip.is_finite() { - return Vec::new(); - } - let h = width / 2.0; - let clip = clip.expand(width * 2.0); - let sides = [ - Rect::from_min_max(pos2(rect.left() - h, rect.top() - h), pos2(rect.left() + h, rect.bottom() + h)), - Rect::from_min_max(pos2(rect.right() - h, rect.top() - h), pos2(rect.right() + h, rect.bottom() + h)), - Rect::from_min_max(pos2(rect.left() - h, rect.top() - h), pos2(rect.right() + h, rect.top() + h)), - Rect::from_min_max(pos2(rect.left() - h, rect.bottom() - h), pos2(rect.right() + h, rect.bottom() + h)), - ]; - sides.into_iter().map(|s| s.intersect(clip)).filter(|s| s.is_finite() && s.width() > 0.0 && s.height() > 0.0).collect() -} - -/// `outline` is where the current area's border is drawn this frame (see `selection_glide`). -#[allow(clippy::too_many_arguments)] -pub(crate) fn paint_selection( - p: &Painter, - geo: &Geo, - sh: &Sheet, - sel: &gridcraft_engine::Selection, - outline: Rect, - t: &Tokens, - app: &SheetApp, - rows: (u32, u32), - cols: (u32, u32), -) { - // Fill all areas except the active cell. - for rect in selection_fill_where(geo, sh, sel, outline, |r| range_in_pane(*r, rows, cols)) { - p.rect_filled(rect, 0.0, t.sel_fill); - } - let active_range = sh.merge_at(sel.active).unwrap_or_else(|| RangeRef::cell(sel.active)); - let active = active_rect(geo, sh, sel); - let active_here = range_in_pane(active_range, rows, cols); - let cur_here = range_in_pane(sel.current(), rows, cols); - // Editing a formula: outline its references instead of the selection. - if let Some(ed) = &app.editor - && ed.is_formula() - && app.session.active().is_some_and(|d| d.uid == ed.doc) - { - let origin = app.session.active().and_then(|d| d.wb.sheet(ed.sheet)).map_or(sh.name.as_str(), |o| o.name.as_str()); - for (_, _, r, sheet, idx) in crate::editor::formula_refs(&ed.text) { - if !crate::editor::ref_is_on_sheet(sheet.as_deref(), origin, &sh.name) { - continue; - } - if !range_in_pane(r, rows, cols) { - continue; - } - let rr = geo.range_rect(sh, r); - 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()] { - p.rect_filled(Rect::from_center_size(corner, vec2(4.0, 4.0)), 0.0, col); - } - } - } - if cur_here { - for side in outline_bands(outline.expand(0.5), 2.0, p.clip_rect()) { - p.rect_filled(side, 0.0, t.sel_border); - } - } - if active_here && sel.ranges.len() > 1 { - p.rect_stroke(active, 0.0, Stroke::new(1.0, t.sel_border), StrokeKind::Inside); - } - // Fill handle (bottom-right of the current area). - if app.editor.is_none() && range_in_pane(RangeRef::cell(sel.current().end), rows, cols) { - let h = Rect::from_center_size(outline.right_bottom(), vec2(6.0, 6.0)); - p.rect_filled(h.expand(1.0), 0.0, Color32::WHITE); - p.rect_filled(h, 0.0, t.sel_border); - } - // Fill-handle drag preview. - if let crate::grid::Drag::Fill { target: Some(tr), .. } = &app.grid.drag - && range_in_pane(*tr, rows, cols) - { - let rr = geo.range_rect(sh, *tr); - p.extend(egui::Shape::dashed_line( - &[rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()], - Stroke::new(1.5, Color32::from_gray(90)), - 4.0, - 3.0, - )); - } - if let crate::grid::Drag::Move { to: Some(to), .. } = &app.grid.drag { - let r = sel.current(); - let moved = RangeRef::new(*to, to.offset_clamped(r.height() as i64 - 1, r.width() as i64 - 1)); - if range_in_pane(moved, rows, cols) { - let rr = geo.range_rect(sh, moved); - p.rect_stroke(rr, 0.0, Stroke::new(2.0, Color32::from_gray(110)), StrokeKind::Middle); - } - } - if let crate::grid::Drag::MoveCols { before: Some(before), .. } = &app.grid.drag { - let x = geo.x(sh, *before); - p.line_segment([pos2(x, geo.rect.top()), pos2(x, geo.rect.bottom())], Stroke::new(3.0, t.accent)); - } - // Marching ants around the copied range. - if let Some(clip) = &app.session.clipboard - && app.session.active().is_some_and(|d| d.uid == clip.doc_uid && d.wb.active_sheet() == clip.sheet) - && range_in_pane(clip.range, rows, cols) - { - // A multi-area copy marks each area as selected. - let areas = if clip.areas.is_empty() { std::slice::from_ref(&clip.range) } else { clip.areas.as_slice() }; - for area in areas { - let rr = geo.range_rect(sh, *area).expand(0.5); - let phase = app.grid.marching_phase; - let pts = [rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()]; - p.extend(egui::Shape::dashed_line_with_offset(&pts, Stroke::new(2.0, t.sel_border), &[4.0], &[4.0], phase)); - } - } -} - -pub(crate) fn paint_headers( - p: &Painter, - geo: &Geo, - sh: &Sheet, - sel: &gridcraft_engine::Selection, - t: &Tokens, - quads: &[((u32, u32), (u32, u32), Rect)], - headings: &[TableHeading], -) { - let font = theme::ui_font(heading_font_size(geo.z)); - // The headers' own area: inside the outline bands. - let r = Rect::from_min_max(pos2(geo.head_left(), geo.head_top()), geo.rect.max); - let top = Rect::from_min_max(pos2(geo.cells.left(), r.top()), pos2(r.right(), geo.cells.top())); - let left = Rect::from_min_max(pos2(r.left(), geo.cells.top()), pos2(geo.cells.left(), r.bottom())); - p.rect_filled(top, 0.0, t.header_bg); - p.rect_filled(left, 0.0, t.header_bg); - let bounds = sel.bounds(); - let col_sel = |c: u32| sel.ranges.iter().any(|x| c >= x.start.col && c <= x.end.col); - let row_sel = |rr: u32| sel.ranges.iter().any(|x| rr >= x.start.row && rr <= x.end.row); - let full_col = |c: u32| sel.ranges.iter().any(|x| x.is_full_cols() && c >= x.start.col && c <= x.end.col); - let full_row = |rr: u32| sel.ranges.iter().any(|x| x.is_full_rows() && rr >= x.start.row && rr <= x.end.row); - let _ = bounds; - // Hidden columns and rows leave a double divider where they sit, as in Excel. - let hidden_mark = Stroke::new(1.0, t.header_text.gamma_multiply(0.6)); - let mut done_cols = std::collections::HashSet::new(); - let mut done_rows = std::collections::HashSet::new(); - for (rows, cols, clip) in quads { - let pc = p.with_clip_rect(Rect::from_min_max(pos2(clip.left(), top.top()), pos2(clip.right(), top.bottom()))); - if cols.0 <= cols.1 { - for c in cols.0..=cols.1 { - if !done_cols.insert((c, clip.left() as i32)) { - continue; - } - let w = sh.col_width(c) * geo.z; - if w <= 0.0 { - continue; - } - let x = geo.x(sh, c); - let cr = Rect::from_min_size(pos2(x, top.top()), vec2(w, top.height())); - if col_sel(c) { - pc.rect_filled(cr, 0.0, if full_col(c) { t.header_all_bg } else { t.header_sel_bg }); - pc.line_segment([pos2(cr.left(), cr.bottom() - 1.0), pos2(cr.right(), cr.bottom() - 1.0)], Stroke::new(2.0, t.accent)); - } - pc.line_segment([pos2(cr.right() - 0.5, cr.top() + 3.0), pos2(cr.right() - 0.5, cr.bottom())], Stroke::new(1.0, t.header_line)); - let (before, after) = hidden_around(c, |i| sh.col_width(i)); - for x in [before.then_some(cr.left()), after.then_some(cr.right())].into_iter().flatten() { - for dx in [-1.5, 1.5] { - pc.line_segment([pos2(x + dx, cr.top() + 3.0), pos2(x + dx, cr.bottom())], hidden_mark); - } - } - let color = if col_sel(c) { t.header_sel_text } else { t.header_text }; - match headings.iter().find(|h| h.col == c) { - Some(h) => { - // The name is cut off at the button, as a cell's text is at its neighbour. - let end = h.button.map_or(cr.right(), |b| b.left()) - 2.0; - let room = Rect::from_min_max(cr.min, pos2(end.max(cr.left()), cr.bottom())).intersect(pc.clip_rect()); - let pad = 4.0 * (geo.z / DISPLAY_SCALE).max(0.75); - pc.with_clip_rect(room).text(pos2(cr.left() + pad, cr.center().y), Align2::LEFT_CENTER, &h.name, font.clone(), color); - if let Some(b) = h.button { - paint_filter_button(&pc, b, h.filtered); - } - } - None => { - pc.text(cr.center(), Align2::CENTER_CENTER, col_to_letters(c), font.clone(), color); - } - } - } - } - let pr = p.with_clip_rect(Rect::from_min_max(pos2(left.left(), clip.top()), pos2(left.right(), clip.bottom()))); - if rows.0 <= rows.1 { - for rr in rows.0..=rows.1 { - if !done_rows.insert((rr, clip.top() as i32)) { - continue; - } - let h = sh.row_height(rr) * geo.z; - if h <= 0.0 { - continue; - } - let y = geo.y(sh, rr); - let cr = Rect::from_min_size(pos2(left.left(), y), vec2(left.width(), h)); - if row_sel(rr) { - pr.rect_filled(cr, 0.0, if full_row(rr) { t.header_all_bg } else { t.header_sel_bg }); - pr.line_segment([pos2(cr.right() - 1.0, cr.top()), pos2(cr.right() - 1.0, cr.bottom())], Stroke::new(2.0, t.accent)); - } - pr.line_segment([pos2(cr.left() + 3.0, cr.bottom() - 0.5), pos2(cr.right(), cr.bottom() - 0.5)], Stroke::new(1.0, t.header_line)); - let (before, after) = hidden_around(rr, |i| sh.row_height(i)); - for y in [before.then_some(cr.top()), after.then_some(cr.bottom())].into_iter().flatten() { - for dy in [-1.5, 1.5] { - pr.line_segment([pos2(cr.left() + 3.0, y + dy), pos2(cr.right(), y + dy)], hidden_mark); - } - } - let color = if row_sel(rr) { t.header_sel_text } else { t.header_text }; - pr.text(cr.center(), Align2::CENTER_CENTER, (rr as u64 + 1).to_string(), font.clone(), color); - } - } - } - // Corner (select all) and edges. - let corner = Rect::from_min_max(r.min, geo.cells.min); - p.rect_filled(corner, 0.0, t.header_bg); - let tri = vec![ - pos2(corner.right() - 3.0, corner.top() + 5.0), - pos2(corner.right() - 3.0, corner.bottom() - 3.0), - pos2(corner.left() + 7.0, corner.bottom() - 3.0), - ]; - p.add(egui::Shape::convex_polygon(tri, Color32::from_gray(190), Stroke::NONE)); - p.line_segment([pos2(r.left(), geo.cells.top() - 0.5), pos2(r.right(), geo.cells.top() - 0.5)], Stroke::new(1.0, t.header_line)); - p.line_segment([pos2(geo.cells.left() - 0.5, r.top()), pos2(geo.cells.left() - 0.5, r.bottom())], Stroke::new(1.0, t.header_line)); -} - -/// Whether the line before / after visible line `i` is hidden (zero size), so its header edges -/// get the hidden marker. Line 0 has nothing before it. -fn hidden_around(i: u32, size: impl Fn(u32) -> f32) -> (bool, bool) { - (i > 0 && size(i - 1) <= 0.0, i < u32::MAX && size(i + 1) <= 0.0) -} - -// ------------------------------------------------------------------ interaction - -/// How close (in points) a press must be to an object's bottom-right corner to resize it instead of moving it. -const OBJECT_RESIZE_RADIUS: f32 = 10.0; - -/// Decide after interaction so drag start and release use the new state in the same frame. -fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet) { - if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() { - return; - } - let ctx = ui.ctx(); - let pointer = ctx.input(|i| i.pointer.clone()); - let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p)); - let in_cells = pos.is_some_and(|p| geo.in_cells(p)); - let (freeze_cols, freeze_rows) = freeze_handles(geo); - // Hover bookkeeping also serves cell notes, independently of who owns the cursor. - if resp.hovered() - && let Some(p) = pos - && !freeze_cols.contains(p) - && !freeze_rows.contains(p) - && !matches!(app.grid.drag, Drag::Freeze { .. }) - { - app.grid.hover_cell = in_cells.then(|| geo.cell_at(sh, p)); - } - // Draw tools own the cursor over cells, including while recording a stroke. - if !app.session.draw_tool.is_empty() && in_cells { - ctx.set_cursor_icon(CursorIcon::Crosshair); - return; - } - // A held drag owns the cursor even outside the grid; a press below egui's threshold still - // has Drag::None and uses hover. A click can briefly leave a drag state after release. - if resp.dragged() - && pointer.primary_down() - && let Some(cursor) = drag_cursor(&app.grid.drag) - { - ctx.set_cursor_icon(cursor); - return; - } - if !resp.hovered() { - return; - } - let Some(p) = pos else { return }; - hover_cursor(app, ctx, resp, &pointer, geo, sh, p); -} - -/// The cursor a held drag shows, or `None` when the drag leaves it to hover. -fn drag_cursor(drag: &Drag) -> Option { - match drag { - Drag::None => None, - Drag::Select => Some(CursorIcon::Cell), - Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default), - Drag::Fill { .. } => Some(CursorIcon::Crosshair), - Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { resize: false, .. } => Some(CursorIcon::Move), - Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn), - Drag::ResizeRow { .. } => Some(CursorIcon::ResizeRow), - Drag::Freeze { cols, .. } => Some(if *cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }), - Drag::Object { resize: true, .. } => Some(CursorIcon::ResizeNwSe), - } -} - -/// The cursor over the grid when no drag owns it: freeze bars, header edges, the fill handle, cells. -fn hover_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, pointer: &egui::PointerState, geo: &Geo, sh: &Sheet, p: Pos2) { - let (freeze_cols, freeze_rows) = freeze_handles(geo); - if freeze_cols.contains(p) || freeze_rows.contains(p) { - freeze_cursor(app, ctx, resp, pointer, freeze_cols.contains(p)); - return; - } - let in_cells = geo.in_cells(p); - let in_col_header = p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left(); - let in_row_header = p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top(); - let near_col_edge = in_col_header && col_edge(geo, sh, p.x).is_some(); - let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default(); - let cur_rect = geo.range_rect(sh, sel.current()); - let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0)); - let handle_panes = geo.range_panes(RangeRef::cell(sel.current().end)); - let border_panes = geo.range_panes(sel.current()); - let on_handle = handle.contains(p) && handle_panes.iter().any(|r| r.contains(p)); - let on_cur_border = on_border(cur_rect, p) && border_panes.iter().any(|r| r.contains(p)); - let selected_object = crate::chartview::hit(app, geo, sh, p).filter(|&(_, id, _)| app.selected_chart == Some(id)); - let near_corner = selected_object.is_some_and(|(_, _, rect)| (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS); - if app.grid.drag == Drag::None && near_corner { - ctx.set_cursor_icon(CursorIcon::ResizeNwSe); - } else if near_col_edge || (in_row_header && row_edge(geo, sh, p.y).is_some()) { - header_edge_cursor(app, ctx, pointer, geo, sh, p, near_col_edge); - } else if on_handle && app.editor.is_none() { - ctx.set_cursor_icon(CursorIcon::Crosshair); - if app.grid.drag == Drag::None && !pointer.any_down() { - resp.clone().on_hover_text_at_pointer("Drag to fill cells. Drag from inside a cell to select a range."); - } - } else if in_cells && app.editor.is_none() && on_cur_border { - ctx.set_cursor_icon(CursorIcon::Move); - } else if in_cells { - ctx.set_cursor_icon(CursorIcon::Cell); - } else if in_col_header { - let col = geo.col_at(sh, p.x); - let selected = sel.ranges.len() == 1 && sel.current().is_full_cols() && sel.current().contains(CellRef::new(0, col)); - ctx.set_cursor_icon(if selected { CursorIcon::Move } else { CursorIcon::Default }); - } -} - -/// The resize cursor for dragging a freeze bar. -fn freeze_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, pointer: &egui::PointerState, cols: bool) { - ctx.set_cursor_icon(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }); - if app.grid.drag == Drag::None && !pointer.any_down() { - let tip = if cols { "Drag to freeze columns" } else { "Drag to freeze rows" }; - resp.clone().on_hover_text_at_pointer(crate::i18n::current(ctx).tr(tip)); - } -} - -/// The resize cursor over a column or row header edge. Just past a hidden column or row, Excel -/// shows a split cursor: dragging or double-clicking there unhides it. -fn header_edge_cursor(app: &SheetApp, ctx: &egui::Context, pointer: &egui::PointerState, geo: &Geo, sh: &Sheet, p: Pos2, near_col_edge: bool) { - let hidden = - app.grid.drag == Drag::None && if near_col_edge { hidden_col_edge(geo, sh, p.x).is_some() } else { hidden_row_edge(geo, sh, p.y).is_some() }; - match pointer.hover_pos() { - Some(at) if hidden => paint_split_cursor(ctx, at, near_col_edge), - _ => ctx.set_cursor_icon(if near_col_edge { CursorIcon::ResizeColumn } else { CursorIcon::ResizeRow }), - } -} - -fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) { - // Text selection and editing must not also move the object or edit worksheet cells. - if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() { - return; - } - let ctx = ui.ctx().clone(); - // Enter/Space synthesize egui clicks; only real pointer clicks use sheet coordinates. - let primary_clicked = resp.clicked_by(egui::PointerButton::Primary); - let pointer = ctx.input(|i| i.pointer.clone()); - let mods = ctx.input(|i| i.modifiers); - // Hit-testing works in logical coordinates (see `Geo::rtl`). - let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p)); - let in_cells = pos.is_some_and(|p| geo.in_cells(p)); - let in_col_header = pos.is_some_and(|p| p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left()); - let in_row_header = pos.is_some_and(|p| p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top()); - let in_corner = pos.is_some_and(|p| p.x < geo.cells.left() && p.y < geo.cells.top() && p.x >= geo.head_left() && p.y >= geo.head_top()); - let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default(); - let cur_rect = geo.range_rect(sh, sel.current()); - let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0)); - // The fill handle and the move border only react in the panes where they are painted, not - // over the frozen rows/columns that a scrolled selection lies under. - let handle_panes = geo.range_panes(RangeRef::cell(sel.current().end)); - let border_panes = geo.range_panes(sel.current()); - let on_handle = |p: Pos2| handle.contains(p) && handle_panes.iter().any(|r| r.contains(p)); - let on_cur_border = |p: Pos2| on_border(cur_rect, p) && border_panes.iter().any(|r| r.contains(p)); - - let (freeze_cols, freeze_rows) = freeze_handles(geo); - // Draw tab tools: the pen records a stroke, the eraser deletes ink under the pointer. - if !app.session.draw_tool.is_empty() && in_cells { - ctx.set_cursor_icon(CursorIcon::Crosshair); - if app.session.draw_tool == "pen" { - if let Some(p) = pos - && resp.dragged() - { - // Through the cell under the pointer, so frozen panes and pages map right. - let c = geo.cell_at(sh, p); - let sx = sh.col_left(c.col) as f32 + (p.x - geo.x(sh, c.col)) / geo.z; - let sy = sh.row_top(c.row) as f32 + (p.y - geo.y(sh, c.row)) / geo.z; - app.grid.ink.push([sx, sy]); - } - if !app.grid.ink.is_empty() { - // Live preview. - let pts: Vec = app - .grid - .ink - .iter() - .map(|q| { - let (col, row) = (sh.col_at(q[0].max(0.0) as f64), sh.row_at(q[1].max(0.0) as f64)); - geo.mirror_pos(pos2( - geo.x(sh, col) + (q[0] - sh.col_left(col) as f32) * geo.z, - geo.y(sh, row) + (q[1] - sh.row_top(row) as f32) * geo.z, - )) - }) - .collect(); - ui.painter().add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(2.0 * geo.z, Color32::from_rgb(0x1F, 0x5F, 0xC9)))); - } - if resp.drag_stopped() && app.grid.ink.len() >= 2 { - let pts: Vec<[f32; 2]> = std::mem::take(&mut app.grid.ink); - let _ = app.run("draw.stroke", json!({"points": pts})); - } - return; - } - if app.session.draw_tool == "eraser" && (primary_clicked || resp.dragged()) { - if let Some(p) = pos - && let Some(("shape", id, _)) = crate::chartview::hit(app, geo, sh, p) - && sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::Ink) - { - let _ = app.run("object.delete", json!({"kind": "shape", "id": id})); - } - return; - } - } - // A plain click on a slicer value tile toggles it (Ctrl/Cmd for multi-select); the clear - // button resets it. Runs before the generic object select/drag so tiles stay clickable. - if primary_clicked - && !resp.drag_started() - && let Some(p) = pos - && let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet(), p) - { - app.selected_chart = Some(id); - match act { - crate::chartview::SlicerAction::Toggle(value) => { - let _ = app.run("slicer.toggle", json!({"slicer": id, "value": value, "multi": mods.command || mods.ctrl})); - } - crate::chartview::SlicerAction::Clear => { - let _ = app.run("slicer.clear", json!({"slicer": id})); - } - } - return; - } - // Objects (charts, pictures, shapes, slicers) take clicks first. Only the primary button moves - // them: a right-click that wobbles into a drag still opens the object's menu (#2067). - let primary_drag = resp.drag_started_by(egui::PointerButton::Primary); - if primary_drag || primary_clicked { - // A drag is recognised only after the pointer has moved, so grab the object where the press began. - let at = if primary_drag { pointer.press_origin().or(pos) } else { pos }; - if let Some(p) = at - && let Some((kind, id, rect)) = crate::chartview::hit(app, geo, sh, p) - { - app.selected_chart = Some(id); - if kind == "shape" - && resp.double_clicked() - && sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::TextBox) - { - app.begin_text_box_edit(id); - return; - } - if kind == "chart" && resp.double_clicked() { - app.grid.pane = Some("formatChart".into()); - } - if primary_drag { - let a = object_anchor(sh, kind, id); - let resize = (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS; - if let Some(a) = a { - app.grid.drag = Drag::Object { kind, id, start: p, orig: (a.cell, a.dx, a.dy, a.width, a.height), resize }; - } - } - return; - } - app.selected_chart = None; - } - // A right-click on a chart, picture, shape or slicer opens the object's menu, not the cell's - // under it; a right press that wobbles into a drag counts too (#2067). - let secondary_drag = resp.drag_started_by(egui::PointerButton::Secondary); - if (resp.secondary_clicked() || secondary_drag) - && let Some(p) = if secondary_drag { pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) } else { pos } - && let Some((kind, id, _)) = crate::chartview::hit(app, geo, sh, p) - { - app.selected_chart = Some(id); - app.grid.object_menu = Some((geo.mirror_pos(p), kind, id)); - return; - } - - // A double-click is also a click: handle header AutoFit before resize setup returns. - if resp.double_clicked() - && let Some(p) = pos - { - let unhide = if in_col_header { - hidden_col_edge(geo, sh, p.x).map(|col| ("home.unhideColumns", json!({"cols": RangeRef::cols(col, col).a1()}))) - } else if in_row_header { - hidden_row_edge(geo, sh, p.y).map(|row| ("home.unhideRows", json!({"rows": RangeRef::rows(row, row).a1()}))) - } else { - None - }; - let autofit = if unhide.is_some() { - unhide - } else if in_col_header { - col_edge(geo, sh, p.x).map(|(col, _)| { - let selected = sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col); - ("home.autofitColumnWidth", if selected { json!({}) } else { json!({"cols": RangeRef::cols(col, col).a1()}) }) - }) - } else if in_row_header { - row_edge(geo, sh, p.y).map(|(row, _)| { - let selected = sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row); - ("home.autofitRowHeight", if selected { json!({}) } else { json!({"rows": RangeRef::rows(row, row).a1()}) }) - }) - } else { - None - }; - if let Some((command, params)) = autofit { - app.grid.drag = Drag::None; - app.grid.drag_select = false; - app.run_or_alert(command, params); - resp.request_focus(); - return; - } - } - - // Double-click the fill handle: a double-click is also a click, so run it before the handle press returns. - if resp.double_clicked() - && let Some(p) = pos - && on_handle(p) - { - // Fill down as far as the neighbouring column goes. - let src = sel.current(); - let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 }; - let cap = src.end.row.saturating_add(1_000_000).min(MAX_ROWS - 1); - let last = if src.end.row < cap { sh.last_contiguous_nonempty(neighbour, src.end.row + 1, cap) } else { src.end.row }; - if last > src.end.row { - let target = RangeRef::new(src.start, CellRef::new(last, src.end.col)); - let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()})); - } - app.grid.drag = Drag::None; - app.grid.drag_select = false; - resp.request_focus(); - return; - } - - // Press: decide the drag mode. - if resp.drag_started() || (primary_clicked && app.grid.drag == Drag::None) { - // Where the press began (a drag is only recognised after the pointer has moved). - let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) else { return }; - // The outline band's − / + and level buttons act on release, like buttons. - if let Some(action) = crate::outline::hit(geo, sh, p) { - if primary_clicked { - let (command, params) = action.command(); - app.run_or_alert(command, params); - } - return; - } - // Freeze handles sit on the header edges (and the corner's edges when nothing is frozen). - if freeze_cols.contains(p) || freeze_rows.contains(p) { - app.grid.drag = Drag::Freeze { cols: freeze_cols.contains(p), to: None }; - return; - } - if in_corner { - let _ = app.session.run("selection.set", json!({"range": RangeRef::all().a1()})); - return; - } - if in_col_header { - if let Some((c, x)) = col_edge(geo, sh, p.x) { - app.grid.drag = Drag::ResizeCol { col: c, start: p.x, orig: sh.col_width(c).max(0.0) * 0.0 + (x - geo.x(sh, c)) / geo.z }; - return; - } - let c = geo.col_at(sh, p.x); - // A Table heading's filter button, while the header names stand in for the letters. - if let Some(h) = table_headings(geo, sh, sel.active).into_iter().find(|h| h.col == c && h.button.is_some_and(|b| b.contains(p))) { - app.grid.filter_menu = Some((c, h.header_row, geo.mirror_pos(p))); - return; - } - if point_header(app, wb, sh, RangeRef::cols(c, c), mods.shift, mods.command) { - return; - } - // An armed Format Painter paints the columns dragged over instead of moving them. - if resp.drag_started() - && !mods.any() - && app.session.format_painter.is_none() - && sel.ranges.len() == 1 - && sel.current().is_full_cols() - && sel.current().contains(CellRef::new(0, c)) - { - app.grid.drag = Drag::MoveCols { source: sel.current(), before: None }; - return; - } - let range = if mods.shift { RangeRef::cols(sel.anchor.col.min(c), sel.anchor.col.max(c)) } else { RangeRef::cols(c, c) }; - select_ranges(app, range, mods.command, CellRef::new(geo.row_at(sh, geo.cells.top() + 1.0), c)); - app.grid.drag = Drag::Cols(if mods.shift { sel.anchor.col } else { c }); - return; - } - if in_row_header { - if let Some((r, y)) = row_edge(geo, sh, p.y) { - app.grid.drag = Drag::ResizeRow { row: r, start: p.y, orig: (y - geo.y(sh, r)) / geo.z }; - return; - } - let r = geo.row_at(sh, p.y); - if point_header(app, wb, sh, RangeRef::rows(r, r), mods.shift, mods.command) { - return; - } - let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) }; - select_ranges(app, range, mods.command, CellRef::new(r, geo.col_at(sh, geo.cells.left() + 1.0))); - app.grid.drag = Drag::Rows(if mods.shift { sel.anchor.row } else { r }); - return; - } - if !in_cells { - return; - } - let c = geo.cell_at(sh, p); - // Data validation list arrow next to the active cell. - if let Some(r) = validation_arrow(sh, geo, sel.active) - && r.contains(p) - { - app.grid.list_picker = Some(sel.active); - return; - } - // Filter dropdown buttons. - if is_filter_button(sh, geo, p) { - app.grid.filter_menu = Some((c.col, c.row, geo.mirror_pos(p))); - return; - } - // A dialog's formula box in point mode (New Formatting Rule) takes the reference; the - // selection, which the dialog acts on, stays. - if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) { - d.pick_reference(c, &sh.name, false); - app.grid.drag = Drag::Select; - app.grid.drag_select = true; - if resp.drag_started() - && let Some(p) = pos - { - select_to_pointer(app, geo, sh, wb, p); - } - return; - } - // Point mode while editing a formula: clicking inserts a reference. - if let Some(ed) = app.editor.as_mut() - && ed.can_point() - { - let anchor = if mods.shift && ed.point_at_caret() { - ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet()).map(|(_, cell)| cell).unwrap_or(c) - } else { - c - }; - let range = RangeRef::new(anchor, c); - let text = if range.is_single() { c.a1() } else { range.a1() }; - let sheet_name = (ed.sheet != wb.active_sheet()).then(|| sh.name.clone()); - let r = match sheet_name { - Some(n) => format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&n)), - None => text, - }; - if mods.command && !mods.shift { - ed.append_ref(&r); - } else { - ed.insert_ref(&r); - } - ed.request_focus = true; - ed.point_cell = Some(c); - ed.point_anchor = Some((wb.active_sheet(), anchor)); - app.grid.drag = Drag::Select; - app.grid.drag_select = true; - if resp.drag_started() - && let Some(p) = pos - { - select_to_pointer(app, geo, sh, wb, p); - } - return; - } - if app.editor.is_some() && !app.commit_edit(0, 0, false, false) { - return; - } - if on_handle(p) { - app.grid.drag = Drag::Fill { target: None, menu: resp.drag_started_by(egui::PointerButton::Secondary) }; - return; - } - if on_cur_border(p) && resp.drag_started() { - app.grid.drag = Drag::Move { grab: c, to: None }; - return; - } - // Double-click edits. - let now = crate::now_ms(); - if let Some((last, at)) = app.grid.last_click - && last == c - && now - at < 450.0 - && !mods.shift - { - app.grid.last_click = None; - // A PivotTable's value cell shows its source records on a new sheet (Excel's Show - // Details); its labels and headers still edit. - if sh.pivots.iter().any(|pt| pt.last_range.is_some_and(|r| r.contains(c))) { - let not_a_value_cell = - gridcraft_engine::EngineError::Disabled("pivot.showDetails".into(), gridcraft_engine::pivot::NOT_A_VALUE_CELL.into()).to_string(); - match app.run("pivot.showDetails", json!({"cell": c.a1()})) { - Ok(_) => return, - Err(e) if e == not_a_value_cell => {} - Err(e) => { - app.message = Some(("GridCraft".into(), crate::clean_error(&e))); - return; - } - } - } - // Following a hyperlink requires a single click in Excel; double-click edits. - app.begin_edit(None, false); - return; - } - app.grid.last_click = Some((c, now)); - if mods.shift { - let _ = app.session.run("selection.set", json!({"range": RangeRef::new(sel.anchor, c).a1(), "active": sel.active.a1()})); - if let Some(d) = app.session.active_mut() { - d.selection.anchor = sel.anchor; - } - } else if mods.command { - // Ctrl+click (without Alt) on a hyperlink's text follows it instead of adding the cell to the selection. - if let Some(h) = sh.hyperlinks.get(&c).cloned() - && primary_clicked - && !mods.alt - && p.x < geo.cell_rect(sh, c).left() + geo.cell_rect(sh, c).width() * 0.8 - { - follow_link(app, &h.target); - } else { - select_ranges(app, RangeRef::cell(c), true, c); - } - } else { - let _ = app.session.run("selection.set", json!({"cell": c.a1()})); - if primary_clicked && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" { - let _ = app.run("cell.toggleCheckbox", json!({"cell": c.a1()})); - } - // A click on a hyperlink's text follows it (like Excel); elsewhere in the cell selects. - if let Some(h) = sh.hyperlinks.get(&c).cloned() - && primary_clicked - && !mods.any() - { - let r = geo.cell_rect(sh, c); - if p.x < r.left() + r.width() * 0.8 { - follow_link(app, &h.target); - } - } - } - app.grid.drag = Drag::Select; - app.grid.drag_select = true; - if resp.drag_started() - && let Some(p) = pos - { - // The first recognised drag already includes movement away from the press. - select_to_pointer(app, geo, sh, wb, p); - } - return; - } - - // Dragging. - if resp.dragged() - && let Some(p) = pos - { - // Auto-scroll near edges. - let edge = 24.0; - let mut auto = egui::Vec2::ZERO; - if p.y > geo.cells.bottom() - edge { - auto.y = 20.0; - } - if p.y < geo.cells.top() + edge * 0.5 && !matches!(app.grid.drag, Drag::ResizeRow { .. }) { - auto.y = -20.0; - } - if p.x > geo.cells.right() - edge { - auto.x = 30.0; - } - if p.x < geo.cells.left() + edge * 0.5 && !matches!(app.grid.drag, Drag::ResizeCol { .. }) { - auto.x = -30.0; - } - let pc = CellRef::new( - geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)), - geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)), - ); - match app.grid.drag.clone() { - Drag::Select => { - select_to_pointer(app, geo, sh, wb, p); - scroll_by(app, auto); - } - Drag::Rows(anchor) => { - let r = geo.row_at(sh, p.y); - let _ = - app.session.run("selection.set", json!({"range": RangeRef::rows(anchor.min(r), anchor.max(r)).a1(), "active": sel.active.a1()})); - scroll_by(app, vec2(0.0, auto.y)); - } - Drag::Cols(anchor) => { - let c = geo.col_at(sh, p.x); - let _ = - app.session.run("selection.set", json!({"range": RangeRef::cols(anchor.min(c), anchor.max(c)).a1(), "active": sel.active.a1()})); - scroll_by(app, vec2(auto.x, 0.0)); - } - Drag::MoveCols { source, .. } => { - let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)); - let midpoint = geo.x(sh, col) + sh.col_width(col) * geo.z * 0.5; - let before = (col + u32::from(p.x >= midpoint)).min(MAX_COLS - 1); - let before = (!(source.start.col..=source.end.col + 1).contains(&before)).then_some(before); - app.grid.drag = Drag::MoveCols { source, before }; - scroll_by(app, vec2(auto.x, 0.0)); - } - Drag::ResizeCol { col, start, orig } => { - let w = (orig + (p.x - start) / geo.z).max(0.0); - app.toast = - Some((format!("Width: {:.2} ({} pixels)", gridcraft_engine::cmd::format::points_to_chars(w as f64), w.round()), crate::now_ms())); - // Inside a selection of whole columns, every selected column takes the new width - // (Ctrl-selected areas included); otherwise only the dragged one. - let cols = if sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col) { - sel.ranges.iter().filter(|r| r.is_full_cols()).map(|r| r.a1()).collect::>().join(",") - } else { - RangeRef::cols(col, col).a1() - }; - let _ = app.session.run("home.columnWidth", json!({"cols": cols, "width": w.round()})); - coalesce_last_undo(app); - } - Drag::ResizeRow { row, start, orig } => { - let h = (orig + (p.y - start) / geo.z).max(0.0); - app.toast = Some((format!("Height: {:.2} ({} pixels)", h * 0.75, h.round()), crate::now_ms())); - let rows = if sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row) { - sel.ranges - .iter() - .filter(|r| r.is_full_rows()) - .map(|r| format!("{}:{}", r.start.row + 1, r.end.row + 1)) - .collect::>() - .join(",") - } else { - format!("{}:{}", row + 1, row + 1) - }; - let _ = app.session.run("home.rowHeight", json!({"rows": rows, "height": h.round()})); - coalesce_last_undo(app); - } - Drag::Freeze { cols, .. } => { - app.grid.drag = Drag::Freeze { cols, to: Some(freeze_boundary(geo, sh, p, cols)) }; - } - Drag::Fill { menu, .. } => { - let src = sel.current(); - let target = fill_target(src, pc); - app.grid.drag = Drag::Fill { target: Some(target), menu }; - scroll_by(app, auto); - } - Drag::Move { grab, .. } => { - let src = sel.current(); - let dr = pc.row as i64 - grab.row as i64; - let dc = pc.col as i64 - grab.col as i64; - app.grid.drag = Drag::Move { grab, to: Some(src.start.offset_clamped(dr, dc)) }; - scroll_by(app, auto); - } - Drag::Object { kind, id, start, orig, resize } => { - let d = (p - start) / geo.z; - let (cell, dx, dy, w, h) = orig; - let params = if resize { - json!({"kind": kind, "id": id, "width": (w + d.x).max(20.0), "height": (h + d.y).max(20.0)}) - } else { - // Re-anchor at the cell under the new top-left. - let x0 = sh.col_left(cell.col) as f32 + dx + d.x; - let y0 = sh.row_top(cell.row) as f32 + dy + d.y; - let nc = CellRef::new(sh.row_at(y0.max(0.0) as f64), sh.col_at(x0.max(0.0) as f64)); - json!({"kind": kind, "id": id, "at": nc.a1(), "dx": x0.max(0.0) - sh.col_left(nc.col) as f32, "dy": y0.max(0.0) - sh.row_top(nc.row) as f32}) - }; - let _ = app.session.run("object.move", params); - coalesce_last_undo(app); - } - Drag::None => {} - } - } - - // Release. - if resp.drag_stopped() || (!pointer.any_down() && app.grid.drag != Drag::None && !resp.dragged()) { - match std::mem::take(&mut app.grid.drag) { - Drag::Select => { - if resp.drag_stopped() - && let Some(p) = pos - { - select_to_pointer(app, geo, sh, wb, p); - } - format_painter_apply(app); - } - // Whole rows or columns clicked or dragged over by their headers. - Drag::Rows(_) | Drag::Cols(_) => format_painter_apply(app), - // Esc mid-drag lands here too (egui ends the drag on Esc): no menu, and the key is - // spent on the drag so the grid's Esc doesn't also drop the copy. - Drag::Fill { target: Some(t), menu: true } => { - if let Some(p) = pos - && t != sel.current() - && !ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) - { - app.grid.fill_menu = Some((sel.current(), t, geo.mirror_pos(p))); - } - } - Drag::Fill { target: Some(t), menu: false } => { - let src = sel.current(); - if t != src { - // Ctrl (Cmd on macOS) swaps copying and extending, as in Excel. - let mode = if mods.command { "alternate" } else { "series" }; - match app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) { - Ok(_) => set_fill_tag(app, src, t), - Err(e) => app.message = Some(("GridCraft".into(), crate::clean_error(&e))), - } - } - } - Drag::Freeze { cols, to: Some(to) } => { - let (rows_now, cols_now) = (geo.fr, geo.fc); - let (rows, cols) = if cols { (rows_now, to) } else { (to, cols_now) }; - if (rows, cols) != (rows_now, cols_now) { - // A1 (nothing left frozen) unfreezes. - app.run_or_alert("view.freezePanes", json!({"cell": CellRef::new(rows, cols).a1()})); - } - } - Drag::Move { to: Some(to), .. } => { - let src = sel.current(); - if to != src.start { - let _ = app.session.run("edit.cut", json!({"range": src.a1()})); - let _ = app.run("edit.paste", json!({"at": to.a1()})); - } - } - Drag::MoveCols { source, before: Some(before) } => { - if let Err(e) = app.run("sheet.moveColumns", json!({"cols": source.a1(), "before": before})) { - app.message = Some(("GridCraft".into(), crate::clean_error(&e))); - } - } - _ => {} - } - app.grid.drag_select = false; - } - - // Right-click: context menu. - if resp.secondary_clicked() - && let Some(p) = pos - { - if in_cells && !sel.contains(geo.cell_at(sh, p)) { - let _ = app.session.run("selection.set", json!({"cell": geo.cell_at(sh, p).a1()})); - } - if in_col_header || in_row_header { - let (rows, idx) = if in_row_header { (true, geo.row_at(sh, p.y)) } else { (false, geo.col_at(sh, p.x)) }; - let inside = sel.ranges.iter().any(|r| { - if rows { - r.is_full_rows() && idx >= r.start.row && idx <= r.end.row - } else { - r.is_full_cols() && idx >= r.start.col && idx <= r.end.col - } - }); - if !inside { - let range = if rows { RangeRef::rows(idx, idx) } else { RangeRef::cols(idx, idx) }; - let _ = app.session.run("selection.set", json!({"range": range.a1()})); - } - app.grid.header_menu = Some((geo.mirror_pos(p), rows)); - } else { - app.grid.context_menu = Some(geo.mirror_pos(p)); - app.grid.context_menu_frame = ui.ctx().cumulative_frame_nr(); - } - } - - if primary_clicked || resp.drag_started() { - resp.request_focus(); - } - keyboard(app, &ctx, resp, geo, sh); -} - -/// An armed Format Painter pastes onto the cells just clicked or dragged over (not while -/// pointing a formula reference, which also drags a Select). -fn format_painter_apply(app: &mut SheetApp) { - if app.session.format_painter.is_some() - && !app.editor.as_ref().is_some_and(|ed| ed.can_point()) - && !app.dialog.as_ref().is_some_and(|d| d.picks_references()) - { - let dest = app.session.active().map(|d| d.selection.current().a1()); - if let Some(dest) = dest - && let Err(e) = app.run("edit.formatPainter", json!({"apply": dest})) - { - app.message = Some(("GridCraft".into(), crate::clean_error(&e))); - } - } -} - -/// Point mode on a header: inserts a whole-row or whole-column reference instead of selecting. -fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef, extend: bool, add: bool) -> bool { - let Some(ed) = app.editor.as_mut().filter(|ed| ed.can_point()) else { return false }; - let edit_sheet = ed.sheet; - let active_sheet = wb.active_sheet(); - let range = if extend { - ed.point - .and_then(|point| crate::editor::formula_refs(&ed.text).into_iter().find(|(start, end, ..)| (*start, *end) == point)) - .and_then(|(_, _, previous, previous_sheet, _)| { - let previous_sheet = previous_sheet.as_deref().map(|name| wb.sheet_index(name)).unwrap_or(Some(edit_sheet)); - if previous_sheet != Some(active_sheet) { - return None; - } - if range.is_full_cols() && previous.is_full_cols() { - Some(RangeRef::cols(previous.start.col.min(range.start.col), previous.end.col.max(range.end.col))) - } else if range.is_full_rows() && previous.is_full_rows() { - Some(RangeRef::rows(previous.start.row.min(range.start.row), previous.end.row.max(range.end.row))) - } else { - None - } - }) - .unwrap_or(range) - } else { - range - }; - let reference = range.a1(); - let reference = - if ed.sheet != wb.active_sheet() { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference }; - if add { - ed.append_ref(&reference); - } else { - ed.insert_ref(&reference); - } - ed.point_anchor = None; - ed.point_cell = Some(range.start); - ed.request_focus = true; - true -} - -fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) { - let cell = CellRef::new( - geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)), - geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)), - ); - if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) { - d.pick_reference(cell, &sh.name, true); - } else if let Some(ed) = app.editor.as_mut() - && ed.point.is_some() - { - if let Some(start) = ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet()).map(|(_, cell)| cell).or(ed.point_cell) { - let range = RangeRef::new(start, cell); - let text = if range.is_single() { range.start.a1() } else { range.a1() }; - let text = if ed.sheet != wb.active_sheet() { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { text }; - ed.insert_ref(&text); - ed.point_cell = Some(cell); - ed.point_anchor = Some((wb.active_sheet(), start)); - } - } else if let Some(d) = app.session.active() { - // Read the current anchor: starting a drag may have just replaced the selection. - let anchor = d.selection.anchor; - let active = d.selection.active; - let mut ranges: Vec = d.selection.ranges.iter().map(|r| r.a1()).collect(); - ranges.pop(); - ranges.push(RangeRef::new(anchor, cell).a1()); - let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": active.a1()})); - if let Some(d) = app.session.active_mut() { - d.selection.anchor = anchor; - } - } -} - -fn coalesce_last_undo(app: &mut SheetApp) { - // Merge consecutive drag steps into one undo entry. - if let Some(d) = app.session.active_mut() - && d.undo.len() >= 2 - { - let n = d.undo.len(); - if d.undo[n - 1].label == d.undo[n - 2].label { - d.undo.remove(n - 1); - } - } -} - -fn scroll_by(app: &mut SheetApp, d: egui::Vec2) { - if d != egui::Vec2::ZERO - && let Some(v) = app.view_mut() - { - v.scroll = (v.scroll + d).max(egui::Vec2::ZERO); - } -} - -fn fill_target(src: RangeRef, pc: CellRef) -> RangeRef { - let below = pc.row as i64 - src.end.row as i64; - let above = src.start.row as i64 - pc.row as i64; - let right = pc.col as i64 - src.end.col as i64; - let left = src.start.col as i64 - pc.col as i64; - let vert = below.max(above); - let horiz = right.max(left); - if vert <= 0 && horiz <= 0 { - return src; - } - if vert >= horiz { - if below > 0 { - RangeRef::new(src.start, CellRef::new(pc.row, src.end.col)) - } else { - RangeRef::new(CellRef::new(pc.row, src.start.col), src.end) - } - } else if right > 0 { - RangeRef::new(src.start, CellRef::new(src.end.row, pc.col)) - } else { - RangeRef::new(CellRef::new(src.start.row, pc.col), src.end) - } -} - -fn on_border(r: Rect, p: Pos2) -> bool { - let outer = r.expand(3.0); - let inner = r.shrink(3.0); - outer.contains(p) && !inner.contains(p) -} - -/// The freeze handles, as in Excel for the web: a bar in the upper part of the column-header strip -/// on the frozen columns' right edge, and one in the left part of the row-header strip on the -/// frozen rows' bottom edge. With nothing frozen they sit on the corner's edges. The rest of the -/// header edge keeps resizing the column or row. -pub fn freeze_handles(geo: &Geo) -> (Rect, Rect) { - let x = geo.cells.left() + geo.frozen_w; - let y = geo.cells.top() + geo.frozen_h; - let cols = Rect::from_min_max(pos2(x - 3.0, geo.head_top()), pos2(x + 3.0, geo.head_top() + geo.header_h * 0.4)); - let rows = Rect::from_min_max(pos2(geo.head_left(), y - 3.0), pos2(geo.head_left() + geo.header_w * 0.4, y + 3.0)); - (cols, rows) -} - -/// How many columns (or rows) a freeze handle dropped at `p` freezes: the line boundary nearest -/// the pointer, limited so the frozen pane leaves room for the scrolling one. -fn freeze_boundary(geo: &Geo, sh: &Sheet, p: Pos2, cols: bool) -> u32 { - if cols { - if p.x <= geo.cells.left() { - return 0; - } - let c = geo.col_at(sh, p.x); - let (l, r) = (geo.x(sh, c), geo.x(sh, c.saturating_add(1).min(MAX_COLS - 1))); - let b = if p.x - l > r - p.x { c.saturating_add(1) } else { c }; - let room = geo.ci.at((((geo.cells.width() - 40.0) / geo.z).max(0.0)) as f64); - b.min(room).min(MAX_COLS - 1) - } else { - if p.y <= geo.cells.top() { - return 0; - } - let rr = geo.row_at(sh, p.y); - let (t, b2) = (geo.y(sh, rr), geo.y(sh, rr.saturating_add(1).min(MAX_ROWS - 1))); - let b = if p.y - t > b2 - p.y { rr.saturating_add(1) } else { rr }; - let room = geo.ri.at((((geo.cells.height() - 40.0) / geo.z).max(0.0)) as f64); - b.min(room).min(MAX_ROWS - 1) - } -} - -/// Paints the freeze handles and, while one is dragged, where the freeze line will go. -fn paint_freeze_handles(p: &Painter, geo: &Geo, sh: &Sheet, drag: &Drag, t: &Tokens) { - let (cols, rows) = freeze_handles(geo); - let bar = Color32::from_gray(150); - p.rect_filled(Rect::from_center_size(cols.center(), vec2(3.0, cols.height())), 1.0, bar); - p.rect_filled(Rect::from_center_size(rows.center(), vec2(rows.width(), 3.0)), 1.0, bar); - if let Drag::Freeze { cols, to: Some(to) } = *drag { - let stroke = Stroke::new(2.0, t.accent); - if cols { - let x = geo.cells.left() + sh.col_left(to) as f32 * geo.z; - p.line_segment([pos2(x, geo.head_top()), pos2(x, geo.cells.bottom())], stroke); - } else { - let y = geo.cells.top() + sh.row_top(to) as f32 * geo.z; - p.line_segment([pos2(geo.head_left(), y), pos2(geo.cells.right(), y)], stroke); - } - } -} - -fn col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option<(u32, f32)> { - let c = geo.col_at(sh, x); - for cc in [c, c.saturating_sub(1)] { - let edge = geo.x(sh, cc) + sh.col_width(cc) * geo.z; - if (x - edge).abs() <= 4.0 { - return Some((cc, edge)); - } - } - None -} - -fn row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option<(u32, f32)> { - let r = geo.row_at(sh, y); - for rr in [r, r.saturating_sub(1)] { - let edge = geo.y(sh, rr) + sh.row_height(rr) * geo.z; - if (y - edge).abs() <= 3.0 { - return Some((rr, edge)); - } - } - None -} - -/// The hidden column whose (zero-width) edge is at `x`: only just right of the boundary, as the -/// edge just left of it belongs to the visible column there. -fn hidden_col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option { - col_edge(geo, sh, x).map(|(c, _)| c).filter(|&c| sh.col_width(c) <= 0.0) -} - -fn hidden_row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option { - row_edge(geo, sh, y).map(|(r, _)| r).filter(|&r| sh.row_height(r) <= 0.0) -} - -/// A split cursor (two bars with arrows either side) painted in place of the OS cursor, which -/// has no such shape. Black on a white outline, like the system cursors. -fn paint_split_cursor(ctx: &egui::Context, at: Pos2, cols: bool) { - ctx.set_cursor_icon(CursorIcon::None); - let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("gridcraft.split_cursor"))); - // Drawn for columns (bars vertical); rows swap the axes. - let pt = |x: f32, y: f32| if cols { pos2(at.x + x, at.y + y) } else { pos2(at.x + y, at.y + x) }; - let arrow = |dir: f32| vec![pt(dir * 10.0, 0.0), pt(dir * 6.0, -4.0), pt(dir * 6.0, 4.0)]; - for (width, color) in [(3.0, Color32::WHITE), (1.0, Color32::BLACK)] { - let stroke = Stroke::new(width, color); - for bx in [-1.5, 1.5] { - p.line_segment([pt(bx, -8.0), pt(bx, 8.0)], stroke); - } - p.line_segment([pt(-7.0, 0.0), pt(7.0, 0.0)], stroke); - for dir in [-1.0, 1.0] { - p.add(egui::Shape::convex_polygon(arrow(dir), color, Stroke::new(width - 1.0, color))); - } - } -} - -fn is_filter_button(sh: &Sheet, geo: &Geo, p: Pos2) -> bool { - let c = geo.cell_at(sh, p); - sh.has_filter_button(c) && filter_button_rect(geo, sh, c).contains(p) -} - -fn object_anchor(sh: &Sheet, kind: &str, id: u32) -> Option { - match kind { - "chart" => sh.charts.iter().find(|c| c.id == id).map(|c| c.anchor), - "image" => sh.images.iter().find(|c| c.id == id).map(|c| c.anchor), - "slicer" => sh.slicers.iter().find(|c| c.id == id).map(|c| c.anchor), - _ => sh.shapes.iter().find(|c| c.id == id).map(|c| c.anchor), - } -} - -fn delete_selected_object(app: &mut SheetApp, sh: &Sheet) -> bool { - let Some(id) = app.selected_chart else { return false }; - let kind = if sh.charts.iter().any(|object| object.id == id) { - "chart" - } else if sh.images.iter().any(|object| object.id == id) { - "image" - } else if sh.shapes.iter().any(|object| object.id == id) { - "shape" - } else { - return false; - }; - app.run_or_alert("object.delete", json!({"kind": kind, "id": id})); - app.selected_chart = None; - true -} - -/// A click on a hyperlink. References select; web and e-mail addresses ask first (the dialog's -/// OK runs `link.open`); every other kind is refused with a message. The decision is the -/// engine's ([`gridcraft_engine::links::classify`]), the same one `link.open` applies. -pub fn follow_link(app: &mut SheetApp, target: &str) { - use gridcraft_engine::links::{LinkKind, classify}; - match classify(target) { - LinkKind::Internal(r) => { - let _ = app.session.run("edit.goTo", json!({"reference": r.trim_start_matches('#')})); - app.grid.ensure_visible = true; - } - LinkKind::External(url) => app.open_dialog("openLink", json!({"url": url})), - LinkKind::Refused(why) => app.message = Some(("Hyperlink".into(), why)), - } -} - -/// The areas after a click: with `add` (Ctrl) the clicked area is added, or removed when it is already -/// selected (unless it is the only one, so the selection never ends up empty). -fn toggled_ranges(mut ranges: Vec, clicked: String, add: bool) -> Vec { - if !add { - return vec![clicked]; - } - if ranges.len() > 1 && ranges.contains(&clicked) { - ranges.retain(|x| *x != clicked); - } else if !ranges.contains(&clicked) { - ranges.push(clicked); - } - ranges -} - -fn select_ranges(app: &mut SheetApp, r: RangeRef, add: bool, active: CellRef) { - let current: Vec = - if add { app.session.active().map(|d| d.selection.ranges.iter().map(|x| x.a1()).collect()).unwrap_or_default() } else { vec![] }; - let clicked = r.a1(); - let ranges = toggled_ranges(current, clicked.clone(), add); - // A removed area takes the active cell with it; `selection.set` then puts it at the start of the last area. - let params = - if ranges.contains(&clicked) { json!({"range": ranges.join(","), "active": active.a1()}) } else { json!({"range": ranges.join(",")}) }; - let _ = app.session.run("selection.set", params); -} - -/// Grid keyboard handling when no text field has focus. -fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: &Geo, sh: &Sheet) { - // Focus left on an editor that no longer exists goes back to the grid. - let stale = [egui::Id::unique("gridcraft.cell_editor"), egui::Id::unique("gridcraft.formula_bar"), crate::text_box::editor_id()]; - let editing = app.editor.is_some() || app.text_box_editor.is_some(); - if !editing && ctx.memory(|m| m.focused()).is_some_and(|f| stale.contains(&f)) { - resp.request_focus(); - } - let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && (editing || !stale.contains(&f))); - // An open filter menu owns the keyboard even when a click inside it left focus on nothing (#1516). - // So do the fill menu (its Esc closes it, not the copy) and the right-drag that opens it (an - // arrow key would move the selection the menu fills from). - if editing - || app.dialog.is_some() - || other_focus - || app.message.is_some() - || app.grid.filter_menu.is_some() - || app.grid.fill_menu.is_some() - || matches!(app.grid.drag, Drag::Fill { menu: true, .. }) - { - return; - } - if ctx.memory(|m| m.focused()).is_none() { - resp.request_focus(); - } - // The grid owns arrows, Tab and Escape (egui would otherwise move focus between widgets). - ctx.memory_mut(|m| { - m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }) - }); - let events = ctx.input(|i| i.events.clone()); - // A shortcut key that also types a character (Shift+Space sends Text(" ")) must not start an edit. - let shortcut_pressed = events.iter().any(|e| { - matches!(e, egui::Event::Key { pressed: true, modifiers: m, key, .. } - if !m.alt && (m.ctrl || m.command || (m.shift && *key == egui::Key::Space))) - }); - let page = ((geo.cells.height() / geo.z) / sh.default_row_height).floor().max(1.0) as i64; - // Alt shortcuts type their character too on Windows and Linux (Alt+= sends Text("=")): once - // a shortcut ran, that text must not start an edit over what it just did. - let mut shortcut_ran = false; - for ev in events { - match ev { - egui::Event::Copy => { - app.copy_to_clipboard(ctx, "edit.copy"); - } - egui::Event::Cut => { - app.copy_to_clipboard(ctx, "edit.cut"); - } - egui::Event::Paste(text) => app.paste_external(text), - // GridCraft has no image-clipboard pipeline and no cell-picture insert-from-clipboard - // command. Choosing to ignore `PasteImage` is explicit: inserting the bytes as a - // floating picture without a chosen anchor/size would be a new feature, not parity. - // A text representation, when the clipboard has one, still arrives as `Paste` above. - egui::Event::PasteImage(_) => {} - // PRIMARY middle-click paste belongs to egui's TextEdit; the grid has no caret to - // insert text at, so a middle click on a cell is deliberately not a paste. - egui::Event::MiddleClickPaste { .. } => {} - egui::Event::Text(_) if shortcut_pressed || shortcut_ran => {} - egui::Event::Text(text) if text.chars().all(|c| !c.is_control()) && !text.is_empty() => { - app.begin_edit(Some(text), false); - if let Some(ed) = app.editor.as_mut() { - ed.caret = ed.text.chars().count(); - } - return; - } - egui::Event::Key { key, physical_key, pressed: true, modifiers: m, .. } => { - use egui::Key; - if crate::keymap::dispatch(app, ctx, key, physical_key, m, geo, sh) { - shortcut_ran = true; - continue; - } - let cmd = m.command; - let shift = m.shift; - let mv = |app: &mut SheetApp, dr: i64, dc: i64| { - let from = app.session.active().map(|d| if shift { d.selection.moving_end() } else { d.selection.active }); - let _ = app.session.run("selection.move", json!({"dr": dr, "dc": dc, "extend": shift, "jump": cmd})); - app.grid.ensure_visible = true; - app.grid.reveal_moving_end = shift; - app.grid.reveal_from = from; - }; - match key { - Key::ArrowUp => mv(app, -1, 0), - Key::ArrowDown => mv(app, 1, 0), - // Arrows follow the screen: on a right-to-left sheet Left goes to the next column. - Key::ArrowLeft => mv(app, 0, if sh.right_to_left { 1 } else { -1 }), - Key::ArrowRight => mv(app, 0, if sh.right_to_left { -1 } else { 1 }), - Key::PageDown if !cmd => mv(app, page, 0), - Key::PageUp if !cmd => mv(app, -page, 0), - Key::PageDown => app.run_or_alert("sheet.next", json!({})), - Key::PageUp => app.run_or_alert("sheet.previous", json!({})), - Key::Home if cmd => { - let r = sh.freeze.map(|f| f.0).unwrap_or(0); - let c = sh.freeze.map(|f| f.1).unwrap_or(0); - let _ = app.session.run("selection.set", json!({"cell": CellRef::new(r, c).a1()})); - app.grid.ensure_visible = true; - } - Key::End if cmd => { - let end = sh.used_range().map(|u| u.end).unwrap_or_default(); - let _ = app.session.run("selection.set", json!({"cell": end.a1()})); - app.grid.ensure_visible = true; - } - Key::Home => { - let a = app.session.active().map(|d| d.selection.active).unwrap_or_default(); - let _ = app.session.run("selection.set", json!({"cell": CellRef::new(a.row, 0).a1()})); - app.grid.ensure_visible = true; - } - // Excel: Enter while a copied range is marked pastes it and ends copy mode. - Key::Enter if !m.alt && !m.ctrl && !shift && !cmd && app.session.clipboard.is_some() => { - app.run_or_alert("edit.paste", json!({})); - app.session.clipboard = None; - app.grid.ensure_visible = true; - } - Key::Enter => { - if m.alt || m.ctrl { - app.begin_edit(None, false); - } else { - app.move_after_enter(if shift { -1 } else { 1 }, 0); - } - } - Key::Tab => app.move_after_enter(0, if shift { -1 } else { 1 }), - Key::F2 => app.begin_edit(None, false), - Key::Delete if delete_selected_object(app, sh) => {} - Key::Delete => app.run_or_alert("edit.clearContents", json!({})), - Key::Backspace if delete_selected_object(app, sh) => {} - Key::Backspace => { - // Excel for Mac: Delete clears the cell and starts editing it. - app.run_or_alert("edit.clearContents", json!({})); - if app.session.active().is_some_and(|d| d.selection.is_single_cell()) { - app.begin_edit(Some(String::new()), false); - } - } - Key::Escape => { - app.session.clipboard = None; - app.session.format_painter = None; - } - Key::Space if !cmd && !shift && is_checkbox(sh, app) => app.run_or_alert("cell.toggleCheckbox", json!({})), - Key::F8 if m.alt => { - app.open_dialog("macros", json!({})); - } - Key::F9 => app.run_or_alert("formulas.calculateNow", json!({})), - Key::F4 if !cmd => {} - _ if cmd && !crate::keymap::altgr_text(ctx, m, ctx.os() == egui::os::OperatingSystem::Mac) => { - crate::ribbon::shortcut(app, key, m) - } - _ => {} - } - } - _ => {} - } - } -} - -/// Room the in-cell editor keeps right of its text. It is sized from the text of the last frame, so -/// the next typed character must still fit: a fixed 14 px was less than one glyph at high zoom, -/// and the field scrolled and snapped back on every key (#2336). `size` is the font size in px. -fn editor_slack(size: f32) -> f32 { - 14.0 + size -} - -/// Width of the one-line in-cell editor. While the text fits the cell (with the usual 14 px margin) the -/// editor is exactly the cell wide, as in Excel; once it overflows, the extra `editor_slack` applies. -fn editor_width(text_w: f32, cell_w: f32, size: f32) -> f32 { - if text_w + 14.0 <= cell_w { cell_w } else { text_w + editor_slack(size) } -} - -/// Draws the in-cell editor, growing and wrapping with its text, mirroring for right-to-left sheets, choosing contrasting caret ink and clipping cells hidden under frozen panes. -fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, wb: &Workbook) { - let Some(ed) = app.editor.as_ref() else { return }; - if app.view_key() != Some((ed.doc, ed.sheet)) { - return; - } - let cell = ed.cell; - let rect = match sh.merge_at(cell) { - Some(m) => geo.range_rect(sh, m), - None => geo.cell_rect(sh, cell), - }; - let st = wb.styles.get(sh.style_id(cell)).clone(); - 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; - let (w, h) = if wrap { - // 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 * 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. - let h = if lines > 1.0 { (size * 1.3 * lines + 4.0).max(rect.height()) } else { rect.height() }; - (editor_width(text_w, rect.width(), size).min(geo.cells.right() - rect.left()), h) - }; - // Grows to the left on a right-to-left sheet. - let erect = geo.mirror_rect(Rect::from_min_size(rect.min, vec2(w, h))); - let fill = match st.fill.pattern { - PatternType::Solid => st.fill.fg.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::WHITE), - _ => Color32::WHITE, - }; - let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h); - let quadrant = Rect::from_min_max( - pos2(if cell.col >= geo.fc { split.x } else { geo.cells.left() }, if cell.row >= geo.fr { split.y } else { geo.cells.top() }), - pos2(if cell.col >= geo.fc { geo.cells.right() } else { split.x }, if cell.row >= geo.fr { geo.cells.bottom() } else { split.y }), - ); - // A cell scrolled entirely under a frozen row/column keeps its edit (Escape still works), - // but neither the cell nor its helper may paint. Test the cell's own visible quadrant and - // give the shared editor an empty clip when the cell is hidden: a one-line cell editor's - // field can be larger than the cell (egui 0.37 gives text fields a minimum height), so its - // own max_rect is not a reliable "is the cell on screen" test at the frozen boundary. The - // pre-wrap cell rect must also be positive: a hidden row/column has zero height/width, but - // Wrap Text can expand the editor below/right of it, which must not paint over the next line. - let cell_visible = rect.width() > 0.0 && rect.height() > 0.0; - // `erect` and `ui.clip_rect()` are in screen coordinates; `quadrant` is logical until mirrored. - let screen_quadrant = geo.mirror_rect(quadrant); - let on_screen = cell_visible && erect.intersect(screen_quadrant).is_positive() && !geo.hidden_under_frozen(sh, cell); - let clip = if on_screen { ui.clip_rect().intersect(screen_quadrant) } else { Rect::NOTHING }; - let painter = ui.painter().with_clip_rect(clip); - painter.rect_filled(erect, 0.0, fill); - painter.rect_stroke(erect.expand(1.0), 0.0, Stroke::new(2.0, Tokens::get(ui.ctx()).sel_border), StrokeKind::Middle); - let id = egui::Id::unique("gridcraft.cell_editor"); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(erect.shrink2(vec2(2.0, 1.0)))); - child.set_clip_rect(clip); - // 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, erect.height() - 2.0); - ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("gridcraft.cell_editor.rect"), erect)); -} - -/// Test the popup itself, not whether the pointer is over any part of the app. -/// A trigger can sit outside its popup, so its opening click must be ignored. -fn popup_dismissed(response: &egui::Response, was_open: bool) -> bool { - response.ctx.input(|i| !i.focused || i.key_pressed(egui::Key::Escape)) || (was_open && response.clicked_elsewhere()) -} - -/// Black or white, whichever reads on `fill` (the threshold chart labels use). -fn ink_on(fill: Color32) -> Color32 { - let l = 0.299 * fill.r() as f32 + 0.587 * fill.g() as f32 + 0.114 * fill.b() as f32; - if l < 150.0 { Color32::WHITE } else { Color32::BLACK } -} - -/// The table whose header cell (`row`, `col`) was clicked, not just the first table in the column. -fn filter_table_range(sh: &Sheet, col: u32, row: u32) -> Option { - sh.tables.iter().find(|t| t.range.start.row == row && col >= t.range.start.col && col <= t.range.end.col).map(|t| t.range) -} - -/// Draws the filter-header popup: sort, search/select-all, apply/clear, a lazily installed table autofilter, new-open search focus and dismissal. -fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) { - let Some((col, row, at)) = app.grid.filter_menu else { return }; - let Some(d) = app.session.active() else { return }; - let wb = d.wb.clone(); - let si = wb.active_sheet(); - let Some(sh) = wb.sheet(si) else { return }; - let _ = geo; - // Tables get an autofilter on demand. - let table_range = filter_table_range(sh, col, row); - if sh.autofilter.is_none() - && let Some(r) = table_range - { - let _ = app.session.run("data.filter", json!({"range": r.a1()})); - let _ = app.session.run("data.filter", json!({"range": r.a1()})); - if let Some(sh2) = app.session.active().and_then(|d| d.wb.active()).map(|s| s.autofilter.is_some()) - && !sh2 - { - let d = app.session.active_mut(); - if let Some(d) = d { - let wbm = Arc::make_mut(&mut d.wb); - if let Some(s) = wbm.sheet_mut(si) { - s.autofilter = Some(gridcraft_engine::model::AutoFilter { range: r, criteria: vec![] }); - } - } - } - } - let wb = app.session.active().map(|d| d.wb.clone()).unwrap_or(wb); - let values = gridcraft_engine::cmd::data::filter_values(&wb, si, col); - let key = egui::Id::unique(("filter_sel", col)); - let search_key = egui::Id::unique(("filter_search", col)); - let buttons_key = egui::Id::unique(("filter_buttons", col)); - // Enter is the menu's OK, as in Excel: taken before the search box (which would only drop its - // focus) and the value checkboxes (which would toggle) see it. A focused button keeps it as its click. - let buttons: Vec = ui.ctx().data_mut(|d| d.get_temp(buttons_key)).unwrap_or_default(); - let on_button = ui.ctx().memory(|m| m.focused()).is_some_and(|f| buttons.contains(&f)); - let enter = !on_button && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); - let mut close = false; - let area = egui::Area::new(egui::Id::unique("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground); - let response = area.show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_width(230.0); - let ascending = ui.button("↑ Sort Ascending"); - if ascending.clicked() { - let _ = app.run("data.sortAscending", json!({"column": col_to_letters(col)})); - close = true; - } - let descending = ui.button("↓ Sort Descending"); - if descending.clicked() { - let _ = app.run("data.sortDescending", json!({"column": col_to_letters(col)})); - close = true; - } - ui.separator(); - ui.label(egui::RichText::new("Filter").strong()); - let mut checks: Vec<(String, bool)> = ui.ctx().data_mut(|d| d.get_temp::>(key)).unwrap_or(values.clone()); - // A search narrows the list; Select All and Apply then only cover the matches. - let mut search: String = ui.ctx().data_mut(|d| d.get_temp(search_key)).unwrap_or_default(); - let field = ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search").desired_width(f32::INFINITY)); - if !was_open { - field.request_focus(); - } - let needle = search.trim().to_lowercase(); - let matches = |v: &str| needle.is_empty() || v.to_lowercase().contains(&needle); - let all = checks.iter().filter(|(v, _)| matches(v)).all(|(_, b)| *b); - let mut all_new = all; - let label = if needle.is_empty() { "(Select All)" } else { "(Select All Search Results)" }; - if ui.checkbox(&mut all_new, label).changed() { - for c in checks.iter_mut().filter(|(v, _)| matches(v)) { - c.1 = all_new; - } - } - // Full width: shrunk to the longest value, the scroll bar sat mid-menu and the wheel only - // worked over the values themselves (#432). - egui::ScrollArea::vertical().max_height(220.0).auto_shrink([false, true]).show(ui, |ui| { - for (v, on) in checks.iter_mut().filter(|(v, _)| matches(v)) { - ui.checkbox(on, v.as_str()); - } - }); - ui.ctx().data_mut(|d| { - d.insert_temp(key, checks.clone()); - d.insert_temp(search_key, search.clone()); - }); - let checks: Vec<(String, bool)> = checks.iter().map(|(v, on)| (v.clone(), *on && matches(v))).collect(); - ui.separator(); - ui.horizontal(|ui| { - let clear = ui.button("Clear Filter"); - if clear.clicked() { - let _ = app.run("data.filterBy", json!({"column": col_to_letters(col), "clear": true})); - close = true; - } - // Nothing checked would hide every row, so Enter (unlike a click on Apply) does nothing then. - let any = checks.iter().any(|(_, on)| *on); - let apply = ui.button("Apply"); - ui.ctx().data_mut(|d| d.insert_temp(buttons_key, vec![ascending.id, descending.id, clear.id, apply.id])); - if apply.clicked() || (enter && any) { - let blanks = checks.iter().any(|(v, on)| v == "(Blanks)" && *on); - let vals: Vec = checks.iter().filter(|(v, on)| *on && v != "(Blanks)").map(|(v, _)| v.clone()).collect(); - let p = if checks.iter().all(|(_, b)| *b) { - json!({"column": col_to_letters(col), "clear": true}) - } else { - json!({"column": col_to_letters(col), "values": vals, "blanks": blanks}) - }; - let _ = app.run("data.filterBy", p); - close = true; - } - }); - }); - }); - if close || popup_dismissed(&response.response, was_open) { - ui.ctx().data_mut(|d| { - d.remove::>(key); - d.remove::(search_key); - d.remove::>(buttons_key); - }); - app.grid.filter_menu = None; - } -} - -/// Draws and dispatches the cell context menu, including Show Details when offered, clipboard and insert/format actions; closes on Esc or an outside press. -fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { - let Some(at) = app.grid.context_menu else { return }; - let mut close = false; - let resp = egui::Area::new(egui::Id::unique("cell_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(200.0); - let insert_copied = insert_copied_item(app); - let mut items: Vec<(&str, &str)> = vec![ - ("Cut", "edit.cut"), - ("Copy", "edit.copy"), - ("Paste", "edit.paste"), - ("Paste Special…", "ui:pasteSpecial"), - ("-", ""), - ("Insert…", "ui:insertCells"), - ("Delete…", "ui:deleteCells"), - ("Clear Contents", "edit.clearContents"), - ("-", ""), - ("Filter by Selected Cell's Value", "ui:filterValue"), - ("Sort A to Z", "data.sortAscending"), - ("Sort Z to A", "data.sortDescending"), - ("-", ""), - ("New Comment", "ui:newComment"), - ("New Note", "ui:newNote"), - ("-", ""), - ("Format Cells…", "ui:formatCells"), - ("Pick From Drop-down List…", "ui:pickList"), - ("Define Name…", "ui:defineName"), - ("Link…", "ui:insertLink"), - ]; - 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 == "-" { - ui.separator(); - continue; - } - if ui.add(egui::Button::new(lang.tr(label)).frame_when_inactive(false).min_size(vec2(190.0, 20.0))).clicked() { - close = true; - match id { - "ui:pasteSpecial" => app.open_dialog("pasteSpecial", json!({})), - "ui:insertPaste" => app.open_dialog("insertPaste", json!({})), - "ui:insertCells" => app.open_dialog("insertCells", json!({})), - "ui:deleteCells" => app.open_dialog("deleteCells", json!({})), - "ui:formatCells" => app.open_dialog("formatCells", json!({})), - "ui:newComment" => app.open_dialog("comment", json!({"threaded": true})), - "ui:newNote" => app.open_dialog("comment", json!({"threaded": false})), - "ui:defineName" => app.open_dialog("defineName", json!({})), - "ui:insertLink" => app.open_dialog("insertLink", json!({})), - "ui:pickList" => app.open_dialog("pickList", json!({})), - "ui:filterValue" => { - if let Some(d) = app.session.active() { - let a = d.selection.active; - let text = d.wb.active().map(|sh| gridcraft_engine::display::cell_text(&d.wb, sh, a)).unwrap_or_default(); - let has = d.wb.active().is_some_and(|sh| sh.autofilter.is_some()); - if !has { - let _ = app.run("data.filter", json!({})); - } - app.run_or_alert("data.filterBy", json!({"column": col_to_letters(a.col), "values": [text]})); - } - } - "edit.copy" | "edit.cut" => { - app.copy_to_clipboard(ui.ctx(), id); - } - "edit.paste" => app.paste_from_clipboard(), - other => app.run_or_alert(other, json!({})), - } - } - } - }); - }); - // A quick right-click presses and releases in one frame, and the menu's corner snaps to the - // nearest physical pixel, which can put the pointer just outside it: that press opened the - // menu, so it doesn't count as a click elsewhere. - let opened_now = app.grid.context_menu_frame == ui.ctx().cumulative_frame_nr(); - if close - || ui.input(|i| i.key_pressed(egui::Key::Escape)) - || (!opened_now - && ui.input(|i| i.pointer.any_pressed()) - && !resp.response.contains_pointer() - && !resp.response.hovered() - && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !resp.response.rect.contains(p))) - { - app.grid.context_menu = None; - } -} - -/// 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 = - app.session.active().map(|d| FillTag { src, target, uid: d.uid, sheet: d.wb.active_sheet(), revision: d.revision, undo_len: d.undo.len() }); -} - -/// 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, 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 }; - let current = app.session.active().is_some_and(|d| { - d.uid == tag.uid - && d.wb.active_sheet() == tag.sheet - && d.revision == tag.revision - && d.undo.len() == tag.undo_len - && d.selection.ranges.len() == 1 - && d.selection.ranges.first().is_some_and(|r| *r == tag.target) - }); - if !current { - app.grid.fill_tag = None; - return; - } - if app.editor.is_some() || app.dialog.is_some() || app.grid.drag != Drag::None { - return; - } - let size = vec2(18.0, 18.0); - let corner = geo.range_rect(sh, tag.target).right_bottom(); - let button = geo.mirror_rect(Rect::from_min_size(corner + vec2(2.0, 2.0), size)); - // `button` is in screen space, so compare it with the mirrored cells area. - if !geo.mirror_rect(geo.cells).contains_rect(button) { - return; - } - let lang = crate::i18n::current(ui.ctx()); - let resp = ui.interact(button, egui::Id::unique("gridcraft.fill_options_tag"), Sense::click()).on_hover_text(lang.tr("Auto Fill Options")); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, lang.tr("Auto Fill Options"))); - let t = Tokens::get(ui.ctx()); - let p = ui.painter(); - p.rect_filled(button, 3.0, if resp.hovered() { t.hover } else { t.window }); - p.rect_stroke(button, 3.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); - // Two small cells and a drop-down arrow. - let c = button.center(); - p.rect_stroke(Rect::from_min_size(pos2(c.x - 6.0, c.y - 6.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); - p.rect_stroke(Rect::from_min_size(pos2(c.x - 3.0, c.y - 3.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); - p.add(egui::Shape::convex_polygon( - vec![pos2(c.x + 1.0, c.y + 5.0), pos2(c.x + 7.0, c.y + 5.0), pos2(c.x + 4.0, c.y + 8.0)], - t.text, - 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 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(); - } - } - }); - if let Some(mode) = chosen { - if let Err(e) = app.run("edit.undo", json!({})) { - app.grid.fill_tag = None; - app.message = Some(("GridCraft".into(), crate::clean_error(&e))); - return; - } - match app.run("edit.autoFill", json!({"source": tag.src.a1(), "target": tag.target.a1(), "mode": mode})) { - Ok(_) => set_fill_tag(app, tag.src, tag.target), - Err(e) => { - app.grid.fill_tag = None; - app.message = Some(("GridCraft".into(), crate::clean_error(&e))); - } - } - } -} - -/// The menu a right-drag of the fill handle opens on release, as in Excel. -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::unique("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 lang = crate::i18n::current(ui.ctx()); - for (label, mode) in items { - if label == "-" { - ui.separator(); - continue; - } - if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() { - close = true; - if mode == "ui:series" { - let _ = app.session.run("selection.set", json!({"range": target.union(&src).a1(), "active": src.start.a1()})); - let direction = if target.width() != src.width() { "rows" } else { "columns" }; - app.open_dialog("series", json!({"direction": direction})); - } else if let Err(e) = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1(), "mode": mode})) { - app.message = Some(("GridCraft".into(), crate::clean_error(&e))); - } - } - } - }); - }); - if close || popup_dismissed(&response.response, was_open) { - app.grid.fill_menu = None; - } -} - -/// The right-click menu of a chart, picture, shape or slicer. -fn object_menu(app: &mut SheetApp, ui: &mut egui::Ui) { - let Some((at, kind, id)) = app.grid.object_menu else { return }; - let mut close = false; - let resp = egui::Area::new(egui::Id::unique("object_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(200.0); - let mut items = vec![("Delete", "object.delete")]; - if kind == "chart" { - items.extend([("-", ""), ("Select Data…", "ui:selectData"), ("Format Chart Area…", "ui:formatChart")]); - } - let lang = crate::i18n::current(ui.ctx()); - for (label, action) in items { - if label == "-" { - ui.separator(); - continue; - } - if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(190.0, 20.0))).clicked() { - close = true; - match action { - "ui:selectData" => app.open_dialog("selectData", json!({"chart": id})), - "ui:formatChart" => app.grid.pane = Some("formatChart".into()), - _ => { - app.run_or_alert("object.delete", json!({"kind": kind, "id": id})); - app.selected_chart = None; - } - } - } - } - }); - }); - if close - || ui.input(|i| i.key_pressed(egui::Key::Escape)) - || (ui.input(|i| i.pointer.any_pressed()) - && !resp.response.contains_pointer() - && !resp.response.hovered() - && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !resp.response.rect.contains(p))) - { - app.grid.object_menu = None; - } -} - -/// The warning button beside an active cell holding a number stored as text, as in Excel: its -/// menu names the problem and offers Convert to Number for the selection (#1994). -fn error_check_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) { - if app.editor.is_some() || app.dialog.is_some() || sh.show_formulas || app.grid.drag != Drag::None { - return; - } - let Some(active) = app.session.active().map(|d| d.selection.active) else { return }; - if sh.cell(active).and_then(gridcraft_engine::cmd::formulas::number_stored_as_text).is_none() { - return; - } - let cell = geo.cell_rect(sh, active); - let size = vec2(18.0, 18.0); - // Left of the cell, or right of it when that would cover the row headings. - let left = if cell.left() - size.x - 2.0 >= geo.cells.left() { cell.left() - size.x - 2.0 } else { cell.right() + 2.0 }; - let tag = geo.mirror_rect(Rect::from_min_size(pos2(left, cell.top()), size)); - if !geo.cells.intersects(tag) { - return; - } - let lang = crate::i18n::current(ui.ctx()); - let resp = ui.interact(tag, egui::Id::unique("gridcraft.error_check_tag"), Sense::click()).on_hover_text(lang.tr("Number Stored as Text")); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, lang.tr("Error checking options"))); - let t = Tokens::get(ui.ctx()); - let p = ui.painter(); - p.rect_filled(tag, 3.0, if resp.hovered() { t.hover } else { t.window }); - p.rect_stroke(tag, 3.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); - // A warning sign: an amber diamond with an exclamation mark. - let c = tag.center(); - let r = 6.0; - p.add(egui::Shape::convex_polygon( - vec![pos2(c.x, c.y - r), pos2(c.x + r, c.y), pos2(c.x, c.y + r), pos2(c.x - r, c.y)], - t.warning_fill, - Stroke::NONE, - )); - p.text(c, egui::Align2::CENTER_CENTER, "!", egui::FontId::proportional(10.0), t.warning_ink); - egui::Popup::menu(&resp).show(|ui| { - ui.label(egui::RichText::new(lang.tr("Number Stored as Text")).strong()); - ui.separator(); - if ui.button(lang.tr("Convert to Number")).clicked() { - app.run_or_alert("formulas.convertToNumber", json!({})); - ui.close(); - } - }); -} - -/// Used by `EditState` users to keep the active cell visible while pointing. -pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, extend: bool) { - let Some(d) = app.session.active() else { return }; - let from = ed.point_cell.unwrap_or(ed.cell); - let to = from.offset_clamped(dr, dc); - let anchor = if extend { - // The anchor is the corner opposite the moving end: the written range is normalized, so - // after extending up or left its first cell is the moving end, not the anchor. - let pointed = ed.point.and_then(|(s, e)| ed.text.get(s..e)).and_then(RangeRef::parse); - let inferred_anchor = pointed - .map(|r| { - CellRef::new( - if from.row == r.start.row { r.end.row } else { r.start.row }, - if from.col == r.start.col { r.end.col } else { r.start.col }, - ) - }) - .unwrap_or(from); - ed.point_anchor.filter(|(sheet, _)| *sheet == d.wb.active_sheet()).map(|(_, cell)| cell).unwrap_or(inferred_anchor) - } else { - to - }; - let text = if extend { RangeRef::new(anchor, to).a1() } else { to.a1() }; - let sheet = d.wb.active_sheet(); - ed.insert_ref(&text); - ed.point_cell = Some(to); - ed.point_anchor = Some((sheet, anchor)); - app.grid.ensure_visible = true; -} - -/// The dropdown arrow beside a cell with an in-cell list validation. -pub fn validation_arrow(sh: &Sheet, geo: &Geo, c: CellRef) -> Option { - let dv = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(c)))?; - if dv.kind != gridcraft_engine::model::ValidationKind::List || !dv.in_cell_dropdown || geo.hidden_under_frozen(sh, c) { - return None; - } - let r = geo.cell_rect(sh, c); - let s = r.height().clamp(14.0, 22.0); - Some(Rect::from_min_size(pos2(r.right() + 1.0, r.bottom() - s), vec2(s, s))) -} - -#[allow(clippy::too_many_arguments)] -/// Paints the validation arrow and input message, notes, the hyperlink tooltip and trace precedent/dependent arrows. -pub(crate) fn paint_overlays( - app: &mut SheetApp, - ui: &egui::Ui, - p: &Painter, - geo: &Geo, - wb: &Workbook, - si: usize, - sh: &Sheet, - sel: &gridcraft_engine::Selection, -) { - // Validation dropdown arrow for the active cell. - if app.editor.is_none() - && let Some(r) = validation_arrow(sh, geo, sel.active) - { - 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() - && app.grid.list_picker != Some(sel.active) - && let Some(dv) = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(sel.active))) - && dv.show_input - && !(dv.input_title.is_empty() && dv.input_message.is_empty()) - && !geo.hidden_under_frozen(sh, sel.active) - { - let r = geo.cell_rect(sh, sel.active); - let (at, pivot) = (geo.mirror_pos(pos2(r.left() + 8.0, r.bottom() + 6.0)), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP }); - egui::Area::new(egui::Id::unique("dv_input")).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show( - ui.ctx(), - |ui| { - egui::Frame::popup(ui.style()).fill(Color32::from_rgb(0xFF, 0xFF, 0xE1)).show(ui, |ui| { - ui.set_max_width(220.0); - if !dv.input_title.is_empty() { - ui.label(egui::RichText::new(&dv.input_title).strong().color(Color32::BLACK)); - } - ui.label(egui::RichText::new(&dv.input_message).color(Color32::BLACK)); - }); - }, - ); - } - // Notes: shown on hover (or always when marked visible). - let hover = app.grid.hover_cell; - let all_notes = app.session.show_all_notes; - for (c, cm) in &sh.comments { - let show = cm.visible || (all_notes && !cm.threaded) || hover == Some(*c); - if !show || app.grid.drag != Drag::None { - continue; - } - let r = geo.cell_rect(sh, *c); - if !p.clip_rect().intersects(r) || geo.hidden_under_frozen(sh, *c) { - continue; - } - let at = pos2(r.right() + 10.0, r.top()); - p.line_segment([r.right_top(), at + vec2(0.0, 6.0)], Stroke::new(1.0, Color32::from_gray(90))); - let (at, pivot) = (geo.mirror_pos(at), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP }); - egui::Area::new(egui::Id::unique(("note", c.row, c.col))).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show( - ui.ctx(), - |ui| { - egui::Frame::popup(ui.style()).fill(if cm.threaded { Color32::WHITE } else { Color32::from_rgb(0xFF, 0xFF, 0xE1) }).show(ui, |ui| { - ui.set_max_width(240.0); - ui.label(egui::RichText::new(format!("{}:", cm.author)).strong().color(Color32::BLACK)); - ui.label(egui::RichText::new(&cm.text).color(Color32::BLACK)); - for (a, t) in &cm.replies { - ui.separator(); - ui.label(egui::RichText::new(format!("{a}: {t}")).color(Color32::BLACK)); - } - }); - }, - ); - } - // Hyperlink tooltip. - if let Some(h) = hover.and_then(|c| sh.hyperlinks.get(&c)) - && app.editor.is_none() - { - let tip = h.tooltip.clone().unwrap_or_else(|| format!("{}\nClick once to follow. Click and hold to select this cell.", h.target)); - egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique("link_tip"), egui::PopupAnchor::Pointer).show(|ui| { - ui.label(tip); - }); - } - // Trace precedents / dependents arrows. - if let Some(trace) = &app.grid.trace - && app.session.active().is_some_and(|d| d.uid == trace.uid && d.revision == trace.revision) - && trace.sheet == si - && let Some(items) = trace.items.as_array() - { - let blue = Color32::from_rgb(0x1F, 0x5F, 0xC9); - let active = geo.cell_rect(sh, trace.cell).center(); - for it in items { - if it["sheet"].as_str().is_some_and(|s| !s.eq_ignore_ascii_case(&sh.name)) { - continue; - } - let target = it["range"].as_str().or(it["cell"].as_str()).and_then(RangeRef::parse); - let Some(t) = target else { continue }; - let tr = geo.range_rect(sh, t); - if !t.is_single() { - for pane in geo.range_panes(t) { - p.with_clip_rect(pane.intersect(p.clip_rect())).rect_stroke(tr, 0.0, Stroke::new(1.5, blue), StrokeKind::Inside); - } - } - let (from, to) = if it.get("range").is_some() { (tr.center(), active) } else { (active, tr.center()) }; - p.line_segment([from, to], Stroke::new(1.5, blue)); - p.circle_filled(from, 3.0, blue); - let d = (to - from).normalized(); - let n = vec2(-d.y, d.x); - p.add(egui::Shape::convex_polygon(vec![to, to - d * 9.0 + n * 4.0, to - d * 9.0 - n * 4.0], blue, Stroke::NONE)); - } - } - let _ = (wb, si); -} - -/// Rows the list-validation dropdown shows before it scrolls (Excel shows 8). -const LIST_PICKER_MAX_ROWS: usize = 8; -const LIST_PICKER_ROW_H: f32 = 18.0; -/// Popup frame around the rows: 2 px inner margin and the window stroke on each side, rounded up. -const LIST_PICKER_CHROME: f32 = 8.0; - -/// Popup height for `n` items: every item up to `LIST_PICKER_MAX_ROWS`, then a scrollbar. -fn list_picker_height(n: usize, row_h: f32, gap: f32) -> f32 { - let rows = n.clamp(1, LIST_PICKER_MAX_ROWS) as f32; - rows * row_h + (rows - 1.0) * gap -} - -/// Draws the validation list popup, choosing above/below and left/right placement for RTL, and applies the clicked item with `cell.set`. -fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) { - let Some(c) = app.grid.list_picker else { return }; - let Some(d) = app.session.active() else { return }; - let wb = d.wb.clone(); - let si = wb.active_sheet(); - let Some(sh) = wb.sheet(si) else { return }; - let Some(dv) = sh.validations.iter().find(|x| x.ranges.iter().any(|r| r.contains(c))).cloned() else { - app.grid.list_picker = None; - return; - }; - let items = gridcraft_engine::cmd::data::list_items(&wb, si, &dv); - let r = geo.cell_rect(sh, c); - // Fit the popup to its rows (up to LIST_PICKER_MAX_ROWS); without a minimum the scroll area collapses to its 64 px default. - let height = list_picker_height(items.len(), LIST_PICKER_ROW_H, ui.spacing().item_spacing.y); - // Open below the cell, or above it when the rows would run past the bottom of the window (as Excel does). - let screen = ui.ctx().content_rect(); - let above = r.bottom() + height + LIST_PICKER_CHROME > screen.bottom() && r.top() - height - LIST_PICKER_CHROME >= screen.top(); - let (at, pivot) = match (above, geo.rtl) { - (false, false) => (geo.mirror_pos(r.left_bottom()), Align2::LEFT_TOP), - (false, true) => (geo.mirror_pos(r.left_bottom()), Align2::RIGHT_TOP), - (true, false) => (geo.mirror_pos(r.left_top()), Align2::LEFT_BOTTOM), - (true, true) => (geo.mirror_pos(r.left_top()), Align2::RIGHT_BOTTOM), - }; - let mut close = false; - let response = egui::Area::new(egui::Id::unique("dv_list")).fixed_pos(at).pivot(pivot).order(egui::Order::Foreground).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).inner_margin(2.0).show(ui, |ui| { - ui.set_min_width(r.width().max(100.0)); - egui::ScrollArea::vertical().max_height(height).min_scrolled_height(height).show(ui, |ui| { - for it in &items { - if ui - .add(egui::Button::new(it.as_str()).frame_when_inactive(false).min_size(vec2(r.width().max(100.0), LIST_PICKER_ROW_H))) - .clicked() - { - app.run_or_alert("cell.set", json!({"cell": c.a1(), "input": it})); - close = true; - } - } - }); - }); - }); - if close || popup_dismissed(&response.response, was_open) { - app.grid.list_picker = None; - } -} - -/// Draws row/column header context menus with insert/delete, format, size, hide/unhide and group actions, plus Insert Copied Cells when available. -fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { - let Some((at, rows)) = app.grid.header_menu else { return }; - let mut close = false; - let response = egui::Area::new(egui::Id::unique("header_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 insert_copied = insert_copied_item(app); - let mut items: Vec<(&str, &str)> = if rows { - vec![ - ("Cut", "edit.cut"), - ("Copy", "edit.copy"), - ("Paste", "edit.paste"), - ("-", ""), - ("Insert", "home.insertRows"), - ("Delete", "home.deleteRows"), - ("Clear Contents", "edit.clearContents"), - ("-", ""), - ("Format Cells…", "ui:formatCells"), - ("Row Height…", "ui:rowHeight"), - ("AutoFit Row Height", "home.autofitRowHeight"), - ("Hide", "home.hideRows"), - ("Unhide", "home.unhideRows"), - ("Group", "data.group"), - ] - } else { - vec![ - ("Cut", "edit.cut"), - ("Copy", "edit.copy"), - ("Paste", "edit.paste"), - ("-", ""), - ("Insert", "home.insertColumns"), - ("Delete", "home.deleteColumns"), - ("Clear Contents", "edit.clearContents"), - ("-", ""), - ("Format Cells…", "ui:formatCells"), - ("Column Width…", "ui:columnWidth"), - ("AutoFit Column Width", "home.autofitColumnWidth"), - ("Hide", "home.hideColumns"), - ("Unhide", "home.unhideColumns"), - ("Sort A to Z", "data.sortAscending"), - ("Group", "data.group"), - ] - }; - if let Some(item) = insert_copied { - items.insert(5, item); - } - let lang = crate::i18n::current(ui.ctx()); - for (label, id) in items { - if label == "-" { - ui.separator(); - continue; - } - if ui.add(egui::Button::new(lang.tr(label)).frame_when_inactive(false).min_size(vec2(180.0, 20.0))).clicked() { - close = true; - match id { - "ui:insertPaste" => app.open_dialog("insertPaste", json!({})), - "ui:rowHeight" => app.open_dialog("rowHeight", json!({})), - "ui:formatCells" => app.open_dialog("formatCells", json!({})), - "ui:columnWidth" => app.open_dialog("columnWidth", json!({})), - "edit.copy" | "edit.cut" => { - app.copy_to_clipboard(ui.ctx(), id); - } - "edit.paste" => app.paste_from_clipboard(), - other => app.run_or_alert(other, json!({})), - } - } - } - }); - }); - if close || popup_dismissed(&response.response, was_open) { - app.grid.header_menu = None; - } -} - -/// The context-menu entry for Insert Copied Cells / Insert Cut Cells, when the clipboard holds a -/// copy or cut of this app: whole rows or columns insert at once, a block asks which way to shift -/// (Insert Paste). A cut block can only be pasted, so it gets no entry. -fn insert_copied_item(app: &SheetApp) -> Option<(&'static str, &'static str)> { - let clip = app.session.clipboard.as_ref()?; - let lines = clip.selected.is_full_rows() != clip.selected.is_full_cols(); - let cut = clip.cut && app.session.active().is_some_and(|d| d.uid == clip.doc_uid); - match (cut, lines) { - (true, true) => Some(("Insert Cut Cells", "home.insertCopiedCells")), - (true, false) => None, - (false, true) => Some(("Insert Copied Cells", "home.insertCopiedCells")), - (false, false) => Some(("Insert Copied Cells…", "ui:insertPaste")), - } -} - -fn is_checkbox(sh: &Sheet, app: &SheetApp) -> bool { - let Some(d) = app.session.active() else { return false }; - d.wb.styles.get(sh.style_id(d.selection.active)).num_fmt.as_str() == "checkbox" -} - -fn paint_pages(app: &mut SheetApp, p: &Painter, geo: &Geo, sh: &Sheet) { - let Some(d) = app.session.active() else { return }; - let key = (d.uid, d.revision, d.wb.active_sheet()); - if app.grid.pages.as_ref().is_none_or(|(k, _)| *k != key) { - let pages: Vec<(RangeRef, u32)> = app - .session - .run("file.printPreview", json!({})) - .ok() - .and_then(|v| { - v["pageList"].as_array().map(|a| { - a.iter() - .filter(|pg| pg["sheet"].as_str() == Some(sh.name.as_str())) - .filter_map(|pg| Some((RangeRef::parse(pg["range"].as_str()?)?, pg["page"].as_u64()? as u32))) - .collect() - }) - }) - .unwrap_or_default(); - app.grid.pages = Some((key, pages)); - } - let Some((_, pages)) = &app.grid.pages else { return }; - if pages.is_empty() { - return; - } - // Grey everything, then clear the printed pages. - let mut printed = pages[0].0; - for (r, _) in pages { - printed = printed.union(r); - } - let pr = geo.range_rect(sh, printed); - let grey = Color32::from_black_alpha(70); - let c = p.clip_rect(); - for r in [ - Rect::from_min_max(c.min, pos2(c.right(), pr.top())), - Rect::from_min_max(pos2(c.left(), pr.bottom()), c.max), - Rect::from_min_max(pos2(c.left(), pr.top()), pos2(pr.left(), pr.bottom())), - Rect::from_min_max(pos2(pr.right(), pr.top()), pos2(c.right(), pr.bottom())), - ] { - if r.is_positive() { - p.rect_filled(r, 0.0, grey); - } - } - let blue = Color32::from_rgb(0x2E, 0x6F, 0xD8); - for (r, n) in pages { - let rr = geo.range_rect(sh, *r); - if !rr.intersects(c) { - continue; - } - p.extend(egui::Shape::dashed_line( - &[rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()], - Stroke::new(2.0, blue), - 6.0, - 3.0, - )); - p.text( - rr.center(), - Align2::CENTER_CENTER, - format!("Page {n}"), - theme::ui_bold((rr.height().min(rr.width()) / 6.0).clamp(14.0, 72.0)), - Color32::from_black_alpha(45), - ); - } - p.rect_stroke(pr, 0.0, Stroke::new(3.0, blue), StrokeKind::Outside); -} - -#[cfg(test)] -mod selection_fill_tests { - use super::*; - - #[test] - fn rect_around_leaves_the_hole_and_never_overlaps() { - let area = Rect::from_min_max(pos2(100.0, 50.0), pos2(400.0, 250.0)); - let check = |hole: Rect, pieces: usize| { - let parts: Vec = rect_around(area, hole).collect(); - assert_eq!(parts.len(), pieces, "{hole:?}: {parts:?}"); - let cut = area.intersect(hole); - let cut_area = if cut.is_positive() { cut.area() } else { 0.0 }; - let shaded: f32 = parts.iter().map(Rect::area).sum(); - assert!((shaded - (area.area() - cut_area)).abs() < 0.5, "everything but the hole is shaded: {hole:?}"); - for (i, a) in parts.iter().enumerate() { - assert!(area.contains_rect(*a), "nothing outside the area: {a:?}"); - assert!(!a.shrink(0.01).intersects(hole), "the hole stays clear: {a:?} vs {hole:?}"); - // The fill is translucent: a part shaded twice would show darker. - assert!(parts.iter().skip(i + 1).all(|b| !a.shrink(0.01).intersects(*b)), "{parts:?}"); - } - }; - let cell = |x: f32, y: f32| Rect::from_min_size(pos2(x, y), vec2(64.0, 16.0)); - // In the middle, in each corner (where a drag began), along an edge. - check(cell(200.0, 100.0), 4); - check(cell(100.0, 50.0), 2); - check(cell(336.0, 50.0), 2); - check(cell(100.0, 234.0), 2); - check(cell(336.0, 234.0), 2); - check(cell(100.0, 100.0), 3); - // A merged cell as wide, or as tall, as the area; one that is all of it, or more. - check(Rect::from_min_max(pos2(100.0, 100.0), pos2(400.0, 132.0)), 2); - check(Rect::from_min_max(pos2(200.0, 50.0), pos2(264.0, 250.0)), 2); - check(area, 0); - check(area.expand(30.0), 0); - // The active cell is in another area, touches this one, or is only partly inside it. - check(cell(500.0, 300.0), 1); - check(cell(400.0, 50.0), 1); - check(cell(380.0, 240.0), 2); - } - - #[test] - fn rect_around_survives_hostile_numbers() { - let sane = Rect::from_min_max(pos2(0.0, 0.0), pos2(64.0, 16.0)); - let bad = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN, -1.0, 0.0]; - for a in bad { - for b in bad { - let weird = Rect::from_min_max(pos2(a, b), pos2(b, a)); - for (area, hole) in [(sane, weird), (weird, sane), (weird, weird)] { - for part in rect_around(area, hole) { - assert!(part.is_positive() && part.is_finite(), "{area:?} around {hole:?}: {part:?}"); - } - } - // A hole that makes no sense leaves the whole area shaded. - if !weird.intersects(sane) { - assert_eq!(rect_around(sane, weird).collect::>(), vec![sane]); - } - } - } - } -} - -/// The horizontal alignment to paint with. Cells are painted left to right and a right-to-left -/// sheet is mirrored afterwards, so an explicit Left or Right is swapped first to end up on the -/// side the user chose: Align Right stays on the right in either sheet direction, as in Excel. -/// General alignment is resolved from the value before this and follows the sheet direction. -fn painted_halign(h: HAlign, rtl: bool) -> HAlign { - match h { - HAlign::Left if rtl => HAlign::Right, - HAlign::Right if rtl => HAlign::Left, - h => h, - } -} - -/// Row and column heading labels grow with the zoom as the cells do, as in Excel (at 400% the -/// labels are four times their 100% size), and never get smaller than a readable 8.5 points. -pub(crate) fn heading_font_size(zoom: f32) -> f32 { - (11.5 * (zoom / DISPLAY_SCALE)).max(8.5) -} - -#[cfg(test)] -mod tests { - - #[test] - fn editor_matches_the_cell_while_the_text_fits() { - use super::editor_width; - assert_eq!(editor_width(10.0, 80.0, 7.0), 80.0); - assert_eq!(editor_width(66.0, 80.0, 7.0), 80.0); - assert_eq!(editor_width(67.0, 80.0, 7.0), 88.0); - } - - #[test] - fn editor_slack_fits_one_more_glyph_at_high_zoom() { - use super::editor_slack; - // A glyph is at most about one em wide; at 400% an em is ~60 px, far over the old fixed 14 px. - for size in [14.7_f32, 25.0, 60.0] { - assert!(editor_slack(size) - 4.0 >= size, "slack {} too small for a {size} px glyph", editor_slack(size)); - } - } - - #[test] - fn explicit_left_and_right_keep_their_side_on_a_right_to_left_sheet() { - use super::{HAlign, painted_halign}; - // Painted before the mirror: swapped, so they land where the user put them. - assert_eq!(painted_halign(HAlign::Right, true), HAlign::Left); - assert_eq!(painted_halign(HAlign::Left, true), HAlign::Right); - // Left-to-right sheets and the other alignments are painted as they are. - assert_eq!(painted_halign(HAlign::Right, false), HAlign::Right); - assert_eq!(painted_halign(HAlign::Left, false), HAlign::Left); - for h in [HAlign::Center, HAlign::Justify, HAlign::Fill] { - assert_eq!(painted_halign(h, true), h); - } - } - - #[test] - fn heading_labels_scale_with_the_zoom_without_a_cap() { - use super::{DISPLAY_SCALE, heading_font_size}; - let at = |percent: f32| heading_font_size(percent / 100.0 * DISPLAY_SCALE); - assert!((at(400.0) / at(100.0) - 4.0).abs() < 1e-4, "four times as large at 400%"); - assert!(at(200.0) > at(100.0) && at(400.0) > at(200.0)); - assert_eq!(at(10.0), 8.5, "never below a readable size"); - } - use super::*; - - #[test] - fn list_picker_fits_up_to_eight_rows_then_scrolls() { - assert_eq!(list_picker_height(5, 18.0, 3.0), 5.0 * 18.0 + 4.0 * 3.0); - assert_eq!(list_picker_height(8, 18.0, 3.0), 8.0 * 18.0 + 7.0 * 3.0); - assert_eq!(list_picker_height(30, 18.0, 3.0), list_picker_height(8, 18.0, 3.0)); - assert_eq!(list_picker_height(0, 18.0, 3.0), 18.0); - } - - #[test] - fn number_format_colors_resolve_named_and_indexed_rgb() { - use gridcraft_engine::numfmt::FormatColor as F; - assert_eq!(numfmt_color(F::Green), Color32::from_rgb(0, 255, 0)); - assert_eq!(numfmt_color(F::Indexed(1)), Color32::BLACK); - assert_eq!(numfmt_color(F::Indexed(10)), Color32::from_rgb(0, 128, 0)); - assert_eq!(numfmt_color(F::Indexed(46)), Color32::from_rgb(255, 102, 0)); - assert_eq!(numfmt_color(F::Indexed(56)), Color32::from_rgb(51, 51, 51)); - for index in [0, 57, 255] { - assert_eq!(numfmt_color(F::Indexed(index)), Color32::BLACK); - } - } - - #[test] - fn wheel_zoom_steps_along_the_ladder_and_stops_at_100() { - for (from, zoom_in, to) in [ - (100, true, 120), - (120, false, 100), - (100, false, 85), - // Between steps, the next one each way (Excel: 112% goes to 115% or 100%). - (112, true, 120), - (112, false, 100), - // A step less than 5% away is skipped… - (121, false, 100), - (86, false, 70), - (139, true, 170), - // …but never 100% or a limit. - (99, true, 100), - (101, false, 100), - (96, true, 100), - (385, true, 400), - (11, false, 10), - (400, true, 400), - (10, false, 10), - // Out-of-range zooms (from a file) come into range first. - (5, true, 12), - (1000, false, 340), - (0, false, 10), - (u16::MAX, true, 400), - ] { - assert_eq!(wheel_zoom_step(from, zoom_in), to, "{from}% {}", if zoom_in { "in" } else { "out" }); - } - // Every step on the ladder is 15–25% from the next, and a notch in then out returns. - // (The glide between steps is `zoom_glide_moves_by_ratio_and_always_arrives`.) - for w in WHEEL_ZOOM_LADDER.windows(2) { - let ratio = f32::from(w[1]) / f32::from(w[0]); - assert!((1.15..=1.25).contains(&ratio), "{w:?}"); - assert_eq!(wheel_zoom_step(w[0], true), w[1]); - assert_eq!(wheel_zoom_step(w[1], false), w[0]); - } - } - - #[test] - fn zoom_glide_moves_by_ratio_and_always_arrives() { - // A third of the way per frame, as at 60 frames a second. - for (from, to) in [(100, 120), (120, 100), (10, 12), (340, 400), (400, 10), (85, 100)] { - let (mut z, mut frames) = (from, 0); - while z != to { - let next = zoom_glide_step(z, to, 0.32); - assert!(next != z && next.abs_diff(to) < z.abs_diff(to), "{from} → {to}: {z} then {next}"); - z = next; - frames += 1; - assert!(frames < 40, "{from} → {to} arrives"); - } - assert!(frames >= 2, "{from} → {to} glides over more than one frame ({frames})"); - } - assert_eq!(zoom_glide_step(100, 120, 0.32), 106, "a third of the way by ratio"); - assert_eq!(zoom_glide_step(120, 100, 0.32), 113); - // No progress asked or nonsense: still a percent closer, or straight there. - assert_eq!(zoom_glide_step(100, 120, 0.0), 101); - assert_eq!(zoom_glide_step(120, 100, f32::NAN), 100); - assert_eq!(zoom_glide_step(100, 120, 5.0), 120); - assert_eq!(zoom_glide_step(120, 120, 0.5), 120); - assert_eq!(zoom_glide_step(0, 10, 0.5), 3); - } - - #[test] - fn center_across_run_stops_at_first_non_continuing_column() { - assert_eq!(center_across_end(0, 20, |c| c <= 2), 2); - assert_eq!(center_across_end(0, 20, |_| false), 0); - assert_eq!(center_across_end(0, 1, |_| true), 1); - } - - #[test] - fn conditional_number_format_reaches_the_cell_text() { - // #1163: a rule's number format replaces the cell's own; unmatched cells keep theirs. - let mut session = gridcraft_engine::Session::new(); - session.new_workbook(); - session.execute("range.setValues", json!({"range": "A1", "values": [[1], [3], [4]]})).unwrap(); - session.execute("home.numberFormat", json!({"range": "A3", "code": "0.0"})).unwrap(); - let rule = json!({"type": "cellIs", "operator": "greater", "value": "2", "style": {"num_fmt": "0.00"}}); - session.execute("home.conditionalFormat", json!({"range": "A1:A3", "rule": rule})).unwrap(); - let wb = session.active().unwrap().wb.clone(); - let sh = wb.sheet(0).unwrap(); - let mut cf = CfCache::new(); - let text = |row: u32, cf: &mut CfCache| { - let c = CellRef::new(row, 0); - let (lk, _) = look(&wb, 0, sh, c, cf); - display_text(&wb, sh, c, &sh.value(c), &lk.style, 200.0, 7.0).0 - }; - assert_eq!(text(0, &mut cf), "1"); - assert_eq!(text(1, &mut cf), "3.00"); - assert_eq!(text(2, &mut cf), "4.00"); - } - - #[test] - fn filter_menu_uses_the_table_whose_header_was_clicked() { - use gridcraft_engine::model::Table; - let table = |id: u32, r0: u32, r1: u32| Table { - id, - name: format!("T{id}"), - range: RangeRef::new(CellRef::new(r0, 0), CellRef::new(r1, 0)), - header_row: true, - totals_row: false, - columns: vec![], - style: String::new(), - banded_rows: true, - banded_cols: false, - first_col: false, - last_col: false, - filter_button: true, - filter: vec![], - table_type: None, - connection_id: None, - }; - let mut sh = Sheet::new("S"); - sh.tables.push(table(1, 0, 2)); - sh.tables.push(table(2, 4, 7)); - assert_eq!(filter_table_range(&sh, 0, 4), Some(sh.tables[1].range)); - assert_eq!(filter_table_range(&sh, 0, 0), Some(sh.tables[0].range)); - assert_eq!(filter_table_range(&sh, 1, 4), None); - } - - #[test] - fn cell_text_y_moves_monotonically_in_standard_and_tall_rows() { - let rect_std = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0)); - let gh = 18.5; - let z = 1.0; - - let y_top_std = cell_text_y(VAlign::Top, rect_std, gh, z); - let y_center_std = cell_text_y(VAlign::Center, rect_std, gh, z); - let y_bottom_std = cell_text_y(VAlign::Bottom, rect_std, gh, z); - - assert!(y_top_std < y_center_std, "Top ({y_top_std}) must be higher than Center ({y_center_std})"); - assert!(y_center_std < y_bottom_std, "Center ({y_center_std}) must be higher than Bottom ({y_bottom_std})"); - - // Verify tall row (e.g. 40px) - let rect_tall = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 40.0)); - let y_top_tall = cell_text_y(VAlign::Top, rect_tall, gh, z); - let y_center_tall = cell_text_y(VAlign::Center, rect_tall, gh, z); - let y_bottom_tall = cell_text_y(VAlign::Bottom, rect_tall, gh, z); - - assert!(y_top_tall < y_center_tall, "Top ({y_top_tall}) must be higher than Center ({y_center_tall})"); - assert!(y_center_tall < y_bottom_tall, "Center ({y_center_tall}) must be higher than Bottom ({y_bottom_tall})"); - } - - #[test] - fn wrapped_cell_text_y_positions_correctly_and_safeguards_overflow() { - let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 40.0)); - let gh = 30.0; - let z = 1.0; - - let y_top = wrapped_cell_text_y(VAlign::Top, rect, gh, z); - let y_center = wrapped_cell_text_y(VAlign::Center, rect, gh, z); - let y_bottom = wrapped_cell_text_y(VAlign::Bottom, rect, gh, z); - - assert!(y_top < y_center); - assert!(y_center < y_bottom); - - // Overflow case: gh exceeds rect height; Top still starts inside the cell. - let gh_overflow = 60.0; - let y_top_ovf = wrapped_cell_text_y(VAlign::Top, rect, gh_overflow, z); - assert!(y_top_ovf >= rect.top()); - } - - #[test] - fn wrapped_cell_text_y_keeps_vertical_alignment_when_text_is_taller_than_the_row() { - // A default-height row holding three wrapped lines. - let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0)); - let (gh, z) = (60.0, 1.0); - let y_top = wrapped_cell_text_y(VAlign::Top, rect, gh, z); - let y_center = wrapped_cell_text_y(VAlign::Center, rect, gh, z); - let y_bottom = wrapped_cell_text_y(VAlign::Bottom, rect, gh, z); - // Taller text overflows upward for Bottom and both ways for Center (the cell clips it). - assert!((y_top - 1.5).abs() < 0.01, "Top starts at the top edge, got {y_top}"); - assert!((y_center + gh / 2.0 - rect.center().y).abs() < 0.01, "Center is centred, got {y_center}"); - assert!((y_bottom + gh - (rect.bottom() - 1.5)).abs() < 0.01, "Bottom ends at the bottom edge, got {y_bottom}"); - } - - /// The screen box of a galley of `size` drawn at `pos` turned by `angle` about `pos`, as egui - /// draws a rotated `TextShape`. - fn rotated_box(pos: Pos2, size: egui::Vec2, angle: f32) -> Rect { - let rot = egui::emath::Rot2::from_angle(angle); - let pts = [vec2(0.0, 0.0), vec2(size.x, 0.0), vec2(0.0, size.y), size].map(|v| pos + rot * v); - Rect::from_points(&pts) - } - - #[test] - fn rotated_text_box_follows_the_cell_alignment() { - // A tall, narrow merged block (one 3-character column over four 60pt rows). - let rect = Rect::from_min_max(pos2(10.0, 20.0), pos2(40.0, 340.0)); - let size = vec2(90.0, 15.0); - let pad = 2.5; - let near = |a: f32, b: f32| (a - b).abs() < 0.01; - for deg in [90i16, -90, 45, -45] { - let angle = -(deg as f32).to_radians(); - for v in [VAlign::Top, VAlign::Center, VAlign::Bottom] { - for h in [HAlign::Left, HAlign::Center, HAlign::Right] { - let bb = rotated_box(rotated_text_pos(rect, size, angle, h, v, pad, 0.0), size, angle); - let what = format!("{deg}° {h:?}/{v:?}: {bb:?} in {rect:?}"); - assert!(near(bb.height(), rotated_box(Pos2::ZERO, size, angle).height()), "{what}"); - assert!(bb.top() >= rect.top() && bb.bottom() <= rect.bottom(), "{what}"); - // A narrow column can't hold a 45° line across: only check where it fits. - let fits_x = bb.width() <= rect.width() - 2.0 * pad; - match h { - HAlign::Left => assert!(near(bb.left(), rect.left() + pad), "{what}"), - HAlign::Right => assert!(near(bb.right(), rect.right() - pad), "{what}"), - _ => assert!(near(bb.center().x, rect.center().x), "{what}"), - } - if fits_x { - assert!(bb.left() >= rect.left() && bb.right() <= rect.right(), "{what}"); - } - match v { - VAlign::Top => assert!(near(bb.top(), rect.top() + pad), "{what}"), - VAlign::Center => assert!(near(bb.center().y, rect.center().y), "{what}"), - // Bottom is the default: downward text keeps starting at the top, as before. - _ if deg < 0 => assert!(near(bb.top(), rect.top() + pad), "{what}"), - _ => assert!(near(bb.bottom(), rect.bottom() - pad), "{what}"), - } - } - } - } - } - - #[test] - fn upright_text_with_default_alignment_keeps_its_place() { - let rect = Rect::from_min_max(pos2(10.0, 20.0), pos2(40.0, 340.0)); - let size = vec2(90.0, 15.0); - let pad = 2.5; - let near = |a: Pos2, b: Pos2| (a - b).length() < 0.01; - // Upward text starts at the bottom-left corner, downward text one block-height in from the top-left. - let up = rotated_text_pos(rect, size, -std::f32::consts::FRAC_PI_2, HAlign::General, VAlign::Bottom, pad, 0.0); - assert!(near(up, pos2(rect.left() + pad, rect.bottom() - pad)), "{up:?}"); - let down = rotated_text_pos(rect, size, std::f32::consts::FRAC_PI_2, HAlign::General, VAlign::Bottom, pad, 0.0); - assert!(near(down, pos2(rect.left() + pad + size.y, rect.top() + pad)), "{down:?}"); - } - - #[test] - fn upward_text_with_middle_align_is_centred_in_a_merged_block() { - // Issue #435: "Licht Strom (?)" turned up in A1:A4 with Middle Align was drawn at the bottom. - let rect = Rect::from_min_max(pos2(10.0, 20.0), pos2(40.0, 340.0)); - let size = vec2(90.0, 15.0); - let angle = -std::f32::consts::FRAC_PI_2; - let bb = rotated_box(rotated_text_pos(rect, size, angle, HAlign::Center, VAlign::Center, 2.5, 0.0), size, angle); - assert!((bb.center().y - rect.center().y).abs() < 0.01, "{bb:?} not centred in {rect:?}"); - assert!((bb.center().x - rect.center().x).abs() < 0.01, "{bb:?} not centred in {rect:?}"); - } +/// Row and column heading labels grow with the zoom as the cells do, as in Excel (at 400% the +/// labels are four times their 100% size), and never get smaller than a readable 8.5 points. +pub(crate) fn heading_font_size(zoom: f32) -> f32 { + (11.5 * (zoom / DISPLAY_SCALE)).max(8.5) } #[cfg(test)] -mod pane_tests { - use super::*; - - fn rng(r0: u32, c0: u32, r1: u32, c1: u32) -> RangeRef { - RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)) - } - - #[test] - fn selection_in_scrolling_pane_is_not_drawn_in_frozen_pane() { - // Frozen: 2 rows and 2 columns; the scrolling pane shows rows 2..=20 and columns 2..=10. - let frozen_cols = ((2, 20), (0, 1)); - let frozen_rows = ((0, 1), (2, 10)); - let corner = ((0, 1), (0, 1)); - let scrolled = ((2, 20), (2, 10)); - let sel = rng(5, 4, 7, 6); - assert!(range_in_pane(sel, scrolled.0, scrolled.1)); - assert!(!range_in_pane(sel, frozen_cols.0, frozen_cols.1)); - assert!(!range_in_pane(sel, frozen_rows.0, frozen_rows.1)); - assert!(!range_in_pane(sel, corner.0, corner.1)); - } - - #[test] - fn ctrl_click_on_a_selected_header_deselects_it() { - let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::>(); - assert_eq!(toggled_ranges(s(&["A:A", "C:C"]), "C:C".into(), true), s(&["A:A"])); - assert_eq!(toggled_ranges(s(&["A:A"]), "C:C".into(), true), s(&["A:A", "C:C"])); - assert_eq!(toggled_ranges(s(&["A:A"]), "A:A".into(), true), s(&["A:A"])); - assert_eq!(toggled_ranges(s(&["A1", "B2", "3:3"]), "B2".into(), true), s(&["A1", "3:3"])); - assert_eq!(toggled_ranges(s(&["A:A", "C:C"]), "C:C".into(), false), s(&["C:C"])); - } - - #[test] - fn selection_across_the_freeze_line_is_drawn_in_both_panes() { - let sel = rng(5, 0, 7, 6); - assert!(range_in_pane(sel, (2, 20), (0, 1))); - assert!(range_in_pane(sel, (2, 20), (2, 10))); - } - - #[test] - fn hidden_outline_symbols_take_no_room() { - let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); - let mut sh = Sheet::new("S"); - sh.rows.entry(3).or_default().outline = 1; - sh.cols.entry(2).or_default().outline = 2; - let shown = Geo::new(&sh, rect, vec2(0.0, 0.0)); - assert!(shown.band_w > 0.0 && shown.band_h > 0.0); - sh.show_outline_symbols = false; - let hidden = Geo::new(&sh, rect, vec2(0.0, 0.0)); - assert_eq!((hidden.band_w, hidden.band_h), (0.0, 0.0)); - assert!(hidden.cells.left() < shown.cells.left() && hidden.cells.top() < shown.cells.top()); - // Without headings too. - sh.show_headings = false; - let bare = Geo::new(&sh, rect, vec2(0.0, 0.0)); - assert_eq!((bare.band_w, bare.band_h), (0.0, 0.0)); - // No button answers a click on the empty bands' edges (the grid's left and top), where the - // level buttons would sit with no headings to hold them. - for geo in [&hidden, &bare] { - for i in 0..400 { - let t = i as f32 * 0.5; - for p in [pos2(geo.rect.left(), geo.rect.top() + t), pos2(geo.rect.left() + t, geo.rect.top())] { - assert_eq!(crate::outline::hit(geo, &sh, p), None, "{p:?}"); - } - } - } - } - - /// Two frozen rows and columns, scrolled right so that columns C:E lie under the frozen columns. - fn frozen_geo(sh: &mut Sheet) -> Geo { - sh.freeze = Some((2, 2)); - let scroll = vec2(3.0 * sh.default_col_width + 8.0, 0.0); - Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), scroll) - } - - #[test] - fn the_edge_just_past_a_hidden_line_unhides_it() { - let mut sh = Sheet::new("S"); - sh.cols.entry(11).or_default().hidden = true; // L - sh.rows.entry(26).or_default().hidden = true; // 27 - let geo = Geo::new(&sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(2000.0, 1200.0)), vec2(0.0, 0.0)); - let x = geo.x(&sh, 12); // between K and M - assert_eq!(hidden_col_edge(&geo, &sh, x + 2.0), Some(11)); - assert_eq!(hidden_col_edge(&geo, &sh, x - 2.0), None); // K's own edge - assert_eq!(hidden_col_edge(&geo, &sh, geo.x(&sh, 5)), None); - let y = geo.y(&sh, 27); // between 26 and 28 - assert_eq!(hidden_row_edge(&geo, &sh, y + 2.0), Some(26)); - assert_eq!(hidden_row_edge(&geo, &sh, y - 2.0), None); - } - - #[test] - fn headers_mark_the_edges_next_to_hidden_lines() { - let mut sh = Sheet::new("S"); - sh.cols.entry(0).or_default().hidden = true; // A - sh.cols.entry(11).or_default().hidden = true; // L - sh.rows.entry(26).or_default().hidden = true; // 27 - let col = |c| hidden_around(c, |i| sh.col_width(i)); - assert_eq!(col(1), (true, false)); // B, after hidden A - assert_eq!(col(10), (false, true)); // K - assert_eq!(col(12), (true, false)); // M - assert_eq!(col(5), (false, false)); - let row = |r| hidden_around(r, |i| sh.row_height(i)); - assert_eq!(row(25), (false, true)); - assert_eq!(row(27), (true, false)); - assert_eq!(row(0), (false, false)); - } - - #[test] - fn overlays_skip_cells_scrolled_under_the_frozen_columns() { - let mut sh = Sheet::new("S"); - let geo = frozen_geo(&mut sh); - assert!(geo.hidden_under_frozen(&sh, CellRef::new(5, 2))); - assert!(geo.hidden_under_frozen(&sh, CellRef::new(5, 4))); - assert!(!geo.hidden_under_frozen(&sh, CellRef::new(5, 5))); - assert!(!geo.hidden_under_frozen(&sh, CellRef::new(5, 0))); - let unscrolled = Geo::new(&sh, geo.rect, vec2(0.0, 0.0)); - assert!(!unscrolled.hidden_under_frozen(&sh, CellRef::new(5, 2))); - } - - #[test] - fn ranges_are_clipped_to_the_panes_that_hold_them() { - let mut sh = Sheet::new("S"); - let geo = frozen_geo(&mut sh); - let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h); - assert_eq!(geo.range_panes(rng(5, 3, 7, 6)), [Rect::from_min_max(split, geo.cells.max)]); - assert_eq!(geo.range_panes(rng(5, 0, 7, 6)).len(), 2); - assert_eq!(geo.range_panes(rng(0, 0, 7, 6)).len(), 4); - assert_eq!(geo.range_panes(rng(0, 0, 1, 1)), [Rect::from_min_max(geo.cells.min, split)]); - // The fill handle of D6:E7 sits over the frozen columns, where nothing of it is shown. - let handle = geo.range_rect(&sh, rng(5, 3, 6, 4)).right_bottom(); - assert!(handle.x < split.x); - assert!(!geo.range_panes(rng(6, 4, 6, 4)).iter().any(|r| r.contains(handle))); - } -} - -#[cfg(test)] -mod outline_band_tests { - use super::*; - - #[test] - fn whole_column_outline_keeps_left_and_right_sides_near_viewport() { - let clip = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0)); - let col = Rect::from_min_max(pos2(100.0, 40.0), pos2(180.0, 20_971_540.0)); - let sides = outline_bands(col, 2.0, clip); - // Left, right and top (in view); the bottom is 20 million points away. - assert_eq!(sides.len(), 3); - assert!(sides.iter().all(|s| s.bottom() <= clip.bottom() + 4.0 + f32::EPSILON)); - assert!(sides.iter().any(|s| s.left() < 100.0 && s.right() > 100.0 && s.height() > 500.0)); - assert!(sides.iter().any(|s| s.left() < 180.0 && s.right() > 180.0 && s.height() > 500.0)); - } - - #[test] - fn outline_outside_the_view_and_non_finite_draw_nothing() { - let clip = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0)); - assert!(outline_bands(Rect::from_min_max(pos2(900.0, 0.0), pos2(950.0, 20.0)), 2.0, clip).is_empty()); - assert!(outline_bands(Rect::from_min_max(pos2(f32::NAN, 0.0), pos2(1.0, 1.0)), 2.0, clip).is_empty()); - } -} - -#[cfg(test)] -mod trace_arrow_tests { - use super::*; - - fn app() -> SheetApp { - let mut s = gridcraft_engine::Session::new(); - s.new_workbook(); - SheetApp::new(s, Default::default()) - } - - fn select(app: &mut SheetApp, cell: &str) { - app.run("selection.set", json!({"cell": cell})).unwrap(); - } - - /// What the ribbon's Trace Precedents / Trace Dependents buttons do. - fn trace(app: &mut SheetApp, id: &str) { - let r = app.run(id, json!({})).unwrap(); - app.grid.trace = Trace::new(app, r); - } - - /// The arrows `paint_overlays` draws on the active sheet, as (tail, head) points. - fn arrows(app: &mut SheetApp) -> (Geo, Vec<(Pos2, Pos2)>) { - let d = app.session.active().unwrap(); - let (wb, si, sel) = (d.wb.clone(), d.wb.active_sheet(), d.selection.clone()); - let sh = wb.sheet(si).unwrap(); - let geo = Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0)), vec2(0.0, 0.0)); - let ctx = egui::Context::default(); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - let p = ui.painter().clone(); - paint_overlays(app, ui, &p, &geo, &wb, si, sh, &sel); - }); - // No renderer consumes the font atlas upload here. - out.textures_delta.clear(); - let blue = Color32::from_rgb(0x1F, 0x5F, 0xC9); - let mut lines = Vec::new(); - for s in out.shapes { - if let egui::Shape::LineSegment { points, stroke } = s.shape - && stroke.color == blue - { - lines.push((points[0], points[1])); - } - } - (geo, lines) - } - - fn center(app: &SheetApp, geo: &Geo, cell: &str) -> Pos2 { - let d = app.session.active().unwrap(); - let sh = d.wb.sheet(d.wb.active_sheet()).unwrap(); - geo.cell_rect(sh, CellRef::parse(cell).unwrap()).center() - } - - #[test] - fn precedent_arrows_stay_on_the_traced_cell_when_the_selection_moves() { - let mut app = app(); - for (c, v) in [("B2", "1"), ("B3", "2"), ("D2", "1"), ("D3", "2"), ("F7", "=B2+B3+SUM(D2:D3)")] { - app.run("cell.set", json!({"cell": c, "input": v})).unwrap(); - } - select(&mut app, "F7"); - trace(&mut app, "formulas.tracePrecedents"); - select(&mut app, "H7"); - let (geo, lines) = arrows(&mut app); - assert_eq!(lines.len(), 3, "B2, B3 and D2:D3: {lines:?}"); - let f7 = center(&app, &geo, "F7"); - assert!(lines.iter().all(|&(_, head)| head == f7), "every arrow points at F7, not at the active H7: {lines:?}"); - } - - #[test] - fn dependent_arrows_stay_on_the_traced_cell_when_the_selection_moves() { - let mut app = app(); - for (c, v) in [("B2", "1"), ("D4", "=B2*2"), ("E6", "=B2+1")] { - app.run("cell.set", json!({"cell": c, "input": v})).unwrap(); - } - select(&mut app, "B2"); - trace(&mut app, "formulas.traceDependents"); - select(&mut app, "C9"); - let (geo, lines) = arrows(&mut app); - assert_eq!(lines.len(), 2, "D4 and E6: {lines:?}"); - let b2 = center(&app, &geo, "B2"); - assert!(lines.iter().all(|&(tail, _)| tail == b2), "every arrow starts at B2, not at the active C9: {lines:?}"); - } - - #[test] - fn arrows_stay_on_the_sheet_of_the_traced_cell() { - let mut app = app(); - app.run("home.insertSheet", json!({"name": "Two"})).unwrap(); - app.run("cell.set", json!({"sheet": "Sheet1", "cell": "B2", "input": "1"})).unwrap(); - app.run("sheet.activate", json!({"sheet": "Two"})).unwrap(); - app.run("cell.set", json!({"cell": "F7", "input": "=Sheet1!B2"})).unwrap(); - select(&mut app, "F7"); - trace(&mut app, "formulas.tracePrecedents"); - // The precedent lives on Sheet1, but the arrow belongs to Two's F7. - app.run("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); - assert!(arrows(&mut app).1.is_empty(), "no arrow on Sheet1 from B2 to where F7 would be"); - } - - #[test] - fn inserting_a_row_removes_the_arrows() { - let mut app = app(); - app.run("cell.set", json!({"cell": "B2", "input": "1"})).unwrap(); - app.run("cell.set", json!({"cell": "F7", "input": "=B2"})).unwrap(); - select(&mut app, "F7"); - trace(&mut app, "formulas.tracePrecedents"); - assert_eq!(arrows(&mut app).1.len(), 1); - app.run("home.insertRows", json!({"rows": "4"})).unwrap(); - assert!(arrows(&mut app).1.is_empty(), "F7 is now F8: the old arrows would point at the wrong cell"); - } - - #[test] - fn arrows_stay_in_their_workbook() { - let mut app = app(); - app.session.new_workbook(); - // Two books with the same cells and the same revision: only the document tells them apart. - for book in [1, 0] { - app.session.set_active(book); - app.run("cell.set", json!({"cell": "B2", "input": "1"})).unwrap(); - app.run("cell.set", json!({"cell": "F7", "input": "=B2"})).unwrap(); - } - select(&mut app, "F7"); - trace(&mut app, "formulas.tracePrecedents"); - assert_eq!(arrows(&mut app).1.len(), 1); - app.session.set_active(1); - let revisions: Vec = app.session.documents().iter().map(|d| d.revision).collect(); - assert_eq!(revisions[0], revisions[1]); - assert!(arrows(&mut app).1.is_empty(), "Book1's arrows are not drawn on Book2"); - } -} +mod tests; diff --git a/crates/ui-egui/src/grid/cell_paint.rs b/crates/ui-egui/src/grid/cell_paint.rs new file mode 100644 index 0000000000..99ff2320e6 --- /dev/null +++ b/crates/ui-egui/src/grid/cell_paint.rs @@ -0,0 +1,310 @@ +use super::geo::Geo; +use super::*; + +/// Resolved look of a cell for painting. +pub(super) struct Look { + pub(super) style: Style, + pub(super) fill: Option, + pub(super) text_color: Color32, +} + +pub(super) fn col32(c: Color, wb: &Workbook, default: Color32) -> Color32 { + c.resolve(&wb.theme).map(theme::color32).unwrap_or(default) +} + +pub(super) fn numfmt_color(c: gridcraft_engine::numfmt::FormatColor) -> Color32 { + c.rgb().map(|[r, g, b]| Color32::from_rgb(r, g, b)).unwrap_or(Color32::BLACK) +} + +/// Last column of a Center Across Selection run starting at `start`: it extends over following columns (up to `limit`) while `continues` holds. +pub(super) fn center_across_end(start: u32, limit: u32, continues: impl Fn(u32) -> bool) -> u32 { + let mut end = start; + while end < limit && end + 1 < MAX_COLS && continues(end + 1) { + end += 1; + } + end +} + +pub(super) fn look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut CfCache) -> (Look, Option) { + let mut style = wb.styles.get(sh.style_id(c)).clone(); + if let Some(t) = sh.table_at(c) { + style = gridcraft_engine::tables::table_cell_style(wb, si, t, c); + } + let cfl = cf.look(wb, si, c); + if let Some(l) = &cfl + && let Some(st) = &l.style + { + if st.fill.pattern != PatternType::None { + style.fill = st.fill; + } + if st.font.color != Color::Auto { + style.font.color = st.font.color; + } + if st.font.bold { + style.font.bold = true; + } + if st.font.italic { + style.font.italic = true; + } + if st.font.underline != Underline::None { + style.font.underline = st.font.underline; + } + if st.font.strike { + style.font.strike = true; + } + // In a differential style General means "not set" (#1163). + if st.num_fmt.as_str() != "General" { + style.num_fmt = st.num_fmt.clone(); + } + for (dst, src) in [ + (&mut style.borders.left, st.borders.left), + (&mut style.borders.right, st.borders.right), + (&mut style.borders.top, st.borders.top), + (&mut style.borders.bottom, st.borders.bottom), + ] { + if !src.is_none() { + *dst = src; + } + } + } + let mut fill = match style.fill.pattern { + PatternType::None => None, + PatternType::Solid => style.fill.fg.resolve(&wb.theme).map(theme::color32), + _ => { + // Patterns: approximate with a blend of foreground and background. + let fg = style.fill.fg.resolve(&wb.theme).unwrap_or([0, 0, 0]); + let bg = style.fill.bg.resolve(&wb.theme).unwrap_or([255, 255, 255]); + Some(Color32::from_rgb( + ((fg[0] as u16 + bg[0] as u16 * 3) / 4) as u8, + ((fg[1] as u16 + bg[1] as u16 * 3) / 4) as u8, + ((fg[2] as u16 + bg[2] as u16 * 3) / 4) as u8, + )) + } + }; + if let Some(l) = &cfl + && let Some(rgb) = l.scale_fill + { + fill = Some(theme::color32(rgb)); + } + let text_color = col32(style.font.color, wb, Color32::BLACK); + (Look { style, fill, text_color }, cfl) +} + +/// Text for a value with General narrowing to the available width. +pub(super) fn display_text( + wb: &Workbook, + sh: &Sheet, + c: CellRef, + v: &Value, + st: &Style, + width_px: f32, + char_w: f32, +) -> (String, Option, bool, Option<(char, usize)>) { + if sh.show_formulas + && let Some(f) = sh.cell(c).and_then(|x| x.formula.as_ref()) + { + return (format!("={}", f.text), None, false, None); + } + if !sh.show_zeros && v.as_f64() == Some(0.0) { + return (String::new(), None, false, None); + } + let code = st.num_fmt.as_str(); + // A checkbox cell draws TRUE/FALSE (and empty) as a box; other values show as General. + if code == "checkbox" && matches!(v, Value::Bool(_) | Value::Empty) { + return (String::new(), None, false, None); + } + if code == "General" + && let Value::Number(n) = v + { + let max_chars = ((width_px - 4.0) / char_w).floor().max(1.0) as usize; + return match gridcraft_engine::numfmt::format_general_fit(*n, max_chars.min(11)) { + Some(t) => (t, None, true, None), + None => ("#".repeat(max_chars.max(1)), None, true, None), + }; + } + let f = gridcraft_engine::display::format(v, code, wb); + (f.text, f.color.map(numfmt_color), f.numeric, f.fill) +} + +pub(crate) fn wrapped_cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 { + let pad_v = 1.5 * z; + let y_top = rect.top() + pad_v; + // Not clamped to `y_top`: text taller than the row overflows upward (Bottom) or both ways + // (Center), and the cell's clip rect trims it, so Middle and Bottom stay distinct from Top. + let y_bottom = rect.bottom() - gh - pad_v; + match valign { + VAlign::Top => y_top, + VAlign::Center | VAlign::Justify | VAlign::Distributed => (y_top + y_bottom) / 2.0, + VAlign::Bottom => y_bottom, + } +} + +pub(crate) fn cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 { + let top_inset = 1.0 * z; + let y_top = rect.top() - top_inset; + let y_bottom = (rect.bottom() - gh - 0.5 * z).max(y_top); + match valign { + VAlign::Top => y_top, + VAlign::Center | VAlign::Justify | VAlign::Distributed => (y_top + y_bottom) / 2.0, + VAlign::Bottom => y_bottom, + } +} + +/// Where to put a `TextShape` holding a galley of `size` turned by `angle` (egui radians, so +/// negative for upward text) for it to sit in `rect` per the cell's alignment. egui turns the +/// galley about `pos`, its top-left corner, so the turned box is placed first and `pos` follows. +/// General keeps the text at the left. Bottom (the default) puts upward text at the bottom, but +/// downward text at the top, where it starts reading. +pub(crate) fn rotated_text_pos(rect: Rect, size: egui::Vec2, angle: f32, h: HAlign, v: VAlign, pad: f32, indent: f32) -> Pos2 { + let rot = egui::emath::Rot2::from_angle(angle); + let turned = Rect::from_points(&[vec2(0.0, 0.0), vec2(size.x, 0.0), vec2(0.0, size.y), size].map(|c| (rot * c).to_pos2())); + let x = match h { + HAlign::Center | HAlign::CenterAcross | HAlign::Distributed => rect.center().x - turned.width() / 2.0, + HAlign::Right => rect.right() - pad - indent - turned.width(), + _ => rect.left() + pad + indent, + }; + let y = match v { + VAlign::Center | VAlign::Justify | VAlign::Distributed => rect.center().y - turned.height() / 2.0, + VAlign::Bottom if angle < 0.0 => rect.bottom() - pad - turned.height(), + _ => rect.top() + pad, + }; + pos2(x, y) - turned.min.to_vec2() +} + +/// The outline segments of merged area `m` drawn at `rect`: the top and left edges come from the +/// first row's and first column's cells, the bottom and right edges from the last row's and last +/// column's cells, one segment per visible cell (limited to the visible `rows` × `cols`). +#[allow(clippy::too_many_arguments)] +pub(super) fn merged_edges( + wb: &Workbook, + si: usize, + sh: &Sheet, + geo: &Geo, + m: RangeRef, + rect: Rect, + rows: (u32, u32), + cols: (u32, u32), + cf: &mut CfCache, +) -> Vec<(BorderLine, [Pos2; 2])> { + let mut out = Vec::new(); + let (cs, ce) = (m.start.col.max(cols.0), m.end.col.min(cols.1)); + for col in cs..=ce { + if sh.col_width(col) == 0.0 { + continue; + } + let (x0, x1) = (geo.x(sh, col), geo.x(sh, col.saturating_add(1))); + let top = look(wb, si, sh, CellRef::new(m.start.row, col), cf).0.style.borders.top; + let bottom = look(wb, si, sh, CellRef::new(m.end.row, col), cf).0.style.borders.bottom; + out.push((top, [pos2(x0, rect.top()), pos2(x1, rect.top())])); + out.push((bottom, [pos2(x0, rect.bottom()), pos2(x1, rect.bottom())])); + } + let (rs, re) = (m.start.row.max(rows.0), m.end.row.min(rows.1)); + for row in rs..=re { + if sh.row_height(row) == 0.0 { + continue; + } + let (y0, y1) = (geo.y(sh, row), geo.y(sh, row.saturating_add(1))); + let left = look(wb, si, sh, CellRef::new(row, m.start.col), cf).0.style.borders.left; + let right = look(wb, si, sh, CellRef::new(row, m.end.col), cf).0.style.borders.right; + out.push((left, [pos2(rect.left(), y0), pos2(rect.left(), y1)])); + out.push((right, [pos2(rect.right(), y0), pos2(rect.right(), y1)])); + } + out +} + +/// The smaller font that makes `text` fit in `room` points of width (Shrink to fit), or `None` +/// when it already fits at its own size. +pub(super) fn shrink_font(p: &Painter, text: &str, font: &FontId, room: f32) -> Option { + let natural = p.layout_no_wrap(text.to_string(), font.clone(), Color32::PLACEHOLDER).size().x; + let room = room.max(1.0); + if natural.is_nan() || natural <= room { + return None; + } + // Glyph advances don't scale exactly linearly (rounding), so leave a little slack. + let size = (font.size * room / natural * 0.97).max(1.0); + Some(FontId::new(size, font.family.clone())) +} + +pub(crate) fn paint_border(p: &Painter, pts: [Pos2; 2], style: BorderStyle, col: Color32) { + let w = style.width(); + let (a, b) = (pts[0], pts[1]); + match style { + BorderStyle::Double => { + let horiz = (a.y - b.y).abs() < 0.5; + let off = if horiz { vec2(0.0, 1.5) } else { vec2(1.5, 0.0) }; + p.line_segment([a - off, b - off], Stroke::new(1.0, col)); + p.line_segment([a + off, b + off], Stroke::new(1.0, col)); + } + BorderStyle::Dashed + | BorderStyle::MediumDashed + | BorderStyle::DashDot + | BorderStyle::MediumDashDot + | BorderStyle::DashDotDot + | BorderStyle::MediumDashDotDot + | BorderStyle::SlantDashDot => { + p.extend(egui::Shape::dashed_line(&[a, b], Stroke::new(w, col), 4.0, 2.0)); + } + BorderStyle::Dotted | BorderStyle::Hair => { + p.extend(egui::Shape::dotted_line(&[a, b], col, 2.0, w * 0.5)); + } + _ => { + p.line_segment([a, b], Stroke::new(w, col)); + } + } +} + +pub(super) fn paint_cf_icon(p: &Painter, r: Rect, set: &str, idx: usize) { + let n = gridcraft_engine::cf::icon_count(set); + let palette3 = [Color32::from_rgb(0xD1, 0x3B, 0x2F), Color32::from_rgb(0xF2, 0xB8, 0x2E), Color32::from_rgb(0x2E, 0x9E, 0x4F)]; + let col = if n <= 3 { + palette3.get(idx).copied().unwrap_or(Color32::GRAY) + } else { + let t = idx as f32 / (n - 1).max(1) as f32; + Color32::from_rgb( + (0xD1 as f32 * (1.0 - t) + 0x2E as f32 * t) as u8, + (0x3B as f32 * (1.0 - t) + 0x9E as f32 * t) as u8, + (0x2F as f32 * (1.0 - t) + 0x4F as f32 * t) as u8, + ) + }; + let lower = set.to_ascii_lowercase(); + if lower.contains("arrow") { + let c = r.center(); + let s = r.width() / 2.0; + let pts = if idx + 1 == n { + vec![pos2(c.x, c.y - s), pos2(c.x + s, c.y + 0.1 * s), pos2(c.x - s, c.y + 0.1 * s)] + } else if idx == 0 { + vec![pos2(c.x, c.y + s), pos2(c.x - s, c.y - 0.1 * s), pos2(c.x + s, c.y - 0.1 * s)] + } else { + vec![pos2(c.x + s, c.y), pos2(c.x - 0.1 * s, c.y - s), pos2(c.x - 0.1 * s, c.y + s)] + }; + p.add(egui::Shape::convex_polygon(pts, col, Stroke::NONE)); + } else if lower.contains("rating") || lower.contains("quarter") { + p.circle_stroke(r.center(), r.width() / 2.0 - 0.5, Stroke::new(1.0, Color32::GRAY)); + let frac = idx as f32 / (n - 1).max(1) as f32; + p.rect_filled( + Rect::from_min_size(pos2(r.left() + 2.0, r.bottom() - 2.0 - (r.height() - 4.0) * frac), vec2(r.width() - 4.0, (r.height() - 4.0) * frac)), + 1.0, + Color32::from_rgb(0x2B, 0x7C, 0xD3), + ); + } else { + p.circle_filled(r.center(), r.width() / 2.0 - 0.5, col); + } +} + +/// Black or white, whichever reads on `fill` (the threshold chart labels use). +pub(super) fn ink_on(fill: Color32) -> Color32 { + let l = 0.299 * fill.r() as f32 + 0.587 * fill.g() as f32 + 0.114 * fill.b() as f32; + if l < 150.0 { Color32::WHITE } else { Color32::BLACK } +} + +/// The horizontal alignment to paint with. Cells are painted left to right and a right-to-left +/// sheet is mirrored afterwards, so an explicit Left or Right is swapped first to end up on the +/// side the user chose: Align Right stays on the right in either sheet direction, as in Excel. +/// General alignment is resolved from the value before this and follows the sheet direction. +pub(super) fn painted_halign(h: HAlign, rtl: bool) -> HAlign { + match h { + HAlign::Left if rtl => HAlign::Right, + HAlign::Right if rtl => HAlign::Left, + h => h, + } +} diff --git a/crates/ui-egui/src/grid/cursor.rs b/crates/ui-egui/src/grid/cursor.rs new file mode 100644 index 0000000000..b72f9e160f --- /dev/null +++ b/crates/ui-egui/src/grid/cursor.rs @@ -0,0 +1,138 @@ +use super::geo::Geo; +use super::interact::{OBJECT_RESIZE_RADIUS, col_edge, freeze_handles, hidden_col_edge, hidden_row_edge, on_border, row_edge}; +use super::*; + +/// Decide after interaction so drag start and release use the new state in the same frame. +pub(super) fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet) { + if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() { + return; + } + let ctx = ui.ctx(); + let pointer = ctx.input(|i| i.pointer.clone()); + let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p)); + let in_cells = pos.is_some_and(|p| geo.in_cells(p)); + let (freeze_cols, freeze_rows) = freeze_handles(geo); + // Hover bookkeeping also serves cell notes, independently of who owns the cursor. + if resp.hovered() + && let Some(p) = pos + && !freeze_cols.contains(p) + && !freeze_rows.contains(p) + && !matches!(app.grid.drag, Drag::Freeze { .. }) + { + app.grid.hover_cell = in_cells.then(|| geo.cell_at(sh, p)); + } + // Draw tools own the cursor over cells, including while recording a stroke. + if !app.session.draw_tool.is_empty() && in_cells { + ctx.set_cursor_icon(CursorIcon::Crosshair); + return; + } + // A held drag owns the cursor even outside the grid; a press below egui's threshold still + // has Drag::None and uses hover. A click can briefly leave a drag state after release. + if resp.dragged() + && pointer.primary_down() + && let Some(cursor) = drag_cursor(&app.grid.drag) + { + ctx.set_cursor_icon(cursor); + return; + } + if !resp.hovered() { + return; + } + let Some(p) = pos else { return }; + hover_cursor(app, ctx, resp, &pointer, geo, sh, p); +} + +/// The cursor a held drag shows, or `None` when the drag leaves it to hover. +fn drag_cursor(drag: &Drag) -> Option { + match drag { + Drag::None => None, + Drag::Select => Some(CursorIcon::Cell), + Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default), + Drag::Fill { .. } => Some(CursorIcon::Crosshair), + Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { resize: false, .. } => Some(CursorIcon::Move), + Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn), + Drag::ResizeRow { .. } => Some(CursorIcon::ResizeRow), + Drag::Freeze { cols, .. } => Some(if *cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }), + Drag::Object { resize: true, .. } => Some(CursorIcon::ResizeNwSe), + } +} + +/// The cursor over the grid when no drag owns it: freeze bars, header edges, the fill handle, cells. +fn hover_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, pointer: &egui::PointerState, geo: &Geo, sh: &Sheet, p: Pos2) { + let (freeze_cols, freeze_rows) = freeze_handles(geo); + if freeze_cols.contains(p) || freeze_rows.contains(p) { + freeze_cursor(app, ctx, resp, pointer, freeze_cols.contains(p)); + return; + } + let in_cells = geo.in_cells(p); + let in_col_header = p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left(); + let in_row_header = p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top(); + let near_col_edge = in_col_header && col_edge(geo, sh, p.x).is_some(); + let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default(); + let cur_rect = geo.range_rect(sh, sel.current()); + let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0)); + let handle_panes = geo.range_panes(RangeRef::cell(sel.current().end)); + let border_panes = geo.range_panes(sel.current()); + let on_handle = handle.contains(p) && handle_panes.iter().any(|r| r.contains(p)); + let on_cur_border = on_border(cur_rect, p) && border_panes.iter().any(|r| r.contains(p)); + let selected_object = crate::chartview::hit(app, geo, sh, p).filter(|&(_, id, _)| app.selected_chart == Some(id)); + let near_corner = selected_object.is_some_and(|(_, _, rect)| (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS); + if app.grid.drag == Drag::None && near_corner { + ctx.set_cursor_icon(CursorIcon::ResizeNwSe); + } else if near_col_edge || (in_row_header && row_edge(geo, sh, p.y).is_some()) { + header_edge_cursor(app, ctx, pointer, geo, sh, p, near_col_edge); + } else if on_handle && app.editor.is_none() { + ctx.set_cursor_icon(CursorIcon::Crosshair); + if app.grid.drag == Drag::None && !pointer.any_down() { + resp.clone().on_hover_text_at_pointer("Drag to fill cells. Drag from inside a cell to select a range."); + } + } else if in_cells && app.editor.is_none() && on_cur_border { + ctx.set_cursor_icon(CursorIcon::Move); + } else if in_cells { + ctx.set_cursor_icon(CursorIcon::Cell); + } else if in_col_header { + let col = geo.col_at(sh, p.x); + let selected = sel.ranges.len() == 1 && sel.current().is_full_cols() && sel.current().contains(CellRef::new(0, col)); + ctx.set_cursor_icon(if selected { CursorIcon::Move } else { CursorIcon::Default }); + } +} + +/// The resize cursor for dragging a freeze bar. +fn freeze_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, pointer: &egui::PointerState, cols: bool) { + ctx.set_cursor_icon(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }); + if app.grid.drag == Drag::None && !pointer.any_down() { + let tip = if cols { "Drag to freeze columns" } else { "Drag to freeze rows" }; + resp.clone().on_hover_text_at_pointer(crate::i18n::current(ctx).tr(tip)); + } +} + +/// The resize cursor over a column or row header edge. Just past a hidden column or row, Excel +/// shows a split cursor: dragging or double-clicking there unhides it. +fn header_edge_cursor(app: &SheetApp, ctx: &egui::Context, pointer: &egui::PointerState, geo: &Geo, sh: &Sheet, p: Pos2, near_col_edge: bool) { + let hidden = + app.grid.drag == Drag::None && if near_col_edge { hidden_col_edge(geo, sh, p.x).is_some() } else { hidden_row_edge(geo, sh, p.y).is_some() }; + match pointer.hover_pos() { + Some(at) if hidden => paint_split_cursor(ctx, at, near_col_edge), + _ => ctx.set_cursor_icon(if near_col_edge { CursorIcon::ResizeColumn } else { CursorIcon::ResizeRow }), + } +} + +/// A split cursor (two bars with arrows either side) painted in place of the OS cursor, which +/// has no such shape. Black on a white outline, like the system cursors. +fn paint_split_cursor(ctx: &egui::Context, at: Pos2, cols: bool) { + ctx.set_cursor_icon(CursorIcon::None); + let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("gridcraft.split_cursor"))); + // Drawn for columns (bars vertical); rows swap the axes. + let pt = |x: f32, y: f32| if cols { pos2(at.x + x, at.y + y) } else { pos2(at.x + y, at.y + x) }; + let arrow = |dir: f32| vec![pt(dir * 10.0, 0.0), pt(dir * 6.0, -4.0), pt(dir * 6.0, 4.0)]; + for (width, color) in [(3.0, Color32::WHITE), (1.0, Color32::BLACK)] { + let stroke = Stroke::new(width, color); + for bx in [-1.5, 1.5] { + p.line_segment([pt(bx, -8.0), pt(bx, 8.0)], stroke); + } + p.line_segment([pt(-7.0, 0.0), pt(7.0, 0.0)], stroke); + for dir in [-1.0, 1.0] { + p.add(egui::Shape::convex_polygon(arrow(dir), color, Stroke::new(width - 1.0, color))); + } + } +} diff --git a/crates/ui-egui/src/grid/editor.rs b/crates/ui-egui/src/grid/editor.rs new file mode 100644 index 0000000000..d87ec385a0 --- /dev/null +++ b/crates/ui-egui/src/grid/editor.rs @@ -0,0 +1,161 @@ +use super::cell_paint::ink_on; +use super::geo::Geo; +use super::*; + +/// Room the in-cell editor keeps right of its text. It is sized from the text of the last frame, so +/// the next typed character must still fit: a fixed 14 px was less than one glyph at high zoom, +/// and the field scrolled and snapped back on every key (#2336). `size` is the font size in px. +pub(super) fn editor_slack(size: f32) -> f32 { + 14.0 + size +} + +/// Width of the one-line in-cell editor. While the text fits the cell (with the usual 14 px margin) the +/// editor is exactly the cell wide, as in Excel; once it overflows, the extra `editor_slack` applies. +pub(super) fn editor_width(text_w: f32, cell_w: f32, size: f32) -> f32 { + if text_w + 14.0 <= cell_w { cell_w } else { text_w + editor_slack(size) } +} + +/// Draws the in-cell editor, growing and wrapping with its text, mirroring for right-to-left sheets, choosing contrasting caret ink and clipping cells hidden under frozen panes. +pub(super) fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, wb: &Workbook) { + let Some(ed) = app.editor.as_ref() else { return }; + if app.view_key() != Some((ed.doc, ed.sheet)) { + return; + } + let cell = ed.cell; + let rect = match sh.merge_at(cell) { + Some(m) => geo.range_rect(sh, m), + None => geo.cell_rect(sh, cell), + }; + let st = wb.styles.get(sh.style_id(cell)).clone(); + 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; + let (w, h) = if wrap { + // 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 * 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. + let h = if lines > 1.0 { (size * 1.3 * lines + 4.0).max(rect.height()) } else { rect.height() }; + (editor_width(text_w, rect.width(), size).min(geo.cells.right() - rect.left()), h) + }; + // Grows to the left on a right-to-left sheet. + let erect = geo.mirror_rect(Rect::from_min_size(rect.min, vec2(w, h))); + let fill = match st.fill.pattern { + PatternType::Solid => st.fill.fg.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::WHITE), + _ => Color32::WHITE, + }; + let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h); + let quadrant = Rect::from_min_max( + pos2(if cell.col >= geo.fc { split.x } else { geo.cells.left() }, if cell.row >= geo.fr { split.y } else { geo.cells.top() }), + pos2(if cell.col >= geo.fc { geo.cells.right() } else { split.x }, if cell.row >= geo.fr { geo.cells.bottom() } else { split.y }), + ); + // A cell scrolled entirely under a frozen row/column keeps its edit (Escape still works), + // but neither the cell nor its helper may paint. Test the cell's own visible quadrant and + // give the shared editor an empty clip when the cell is hidden: a one-line cell editor's + // field can be larger than the cell (egui 0.37 gives text fields a minimum height), so its + // own max_rect is not a reliable "is the cell on screen" test at the frozen boundary. The + // pre-wrap cell rect must also be positive: a hidden row/column has zero height/width, but + // Wrap Text can expand the editor below/right of it, which must not paint over the next line. + let cell_visible = rect.width() > 0.0 && rect.height() > 0.0; + // `erect` and `ui.clip_rect()` are in screen coordinates; `quadrant` is logical until mirrored. + let screen_quadrant = geo.mirror_rect(quadrant); + let on_screen = cell_visible && erect.intersect(screen_quadrant).is_positive() && !geo.hidden_under_frozen(sh, cell); + let clip = if on_screen { ui.clip_rect().intersect(screen_quadrant) } else { Rect::NOTHING }; + let painter = ui.painter().with_clip_rect(clip); + painter.rect_filled(erect, 0.0, fill); + painter.rect_stroke(erect.expand(1.0), 0.0, Stroke::new(2.0, Tokens::get(ui.ctx()).sel_border), StrokeKind::Middle); + let id = egui::Id::unique("gridcraft.cell_editor"); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(erect.shrink2(vec2(2.0, 1.0)))); + child.set_clip_rect(clip); + // 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, erect.height() - 2.0); + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("gridcraft.cell_editor.rect"), erect)); +} + +/// Test the popup itself, not whether the pointer is over any part of the app. +/// A trigger can sit outside its popup, so its opening click must be ignored. +pub(super) fn popup_dismissed(response: &egui::Response, was_open: bool) -> bool { + response.ctx.input(|i| !i.focused || i.key_pressed(egui::Key::Escape)) || (was_open && response.clicked_elsewhere()) +} + +/// The dropdown arrow beside a cell with an in-cell list validation. +pub fn validation_arrow(sh: &Sheet, geo: &Geo, c: CellRef) -> Option { + let dv = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(c)))?; + if dv.kind != gridcraft_engine::model::ValidationKind::List || !dv.in_cell_dropdown || geo.hidden_under_frozen(sh, c) { + return None; + } + let r = geo.cell_rect(sh, c); + let s = r.height().clamp(14.0, 22.0); + Some(Rect::from_min_size(pos2(r.right() + 1.0, r.bottom() - s), vec2(s, s))) +} + +/// Rows the list-validation dropdown shows before it scrolls (Excel shows 8). +const LIST_PICKER_MAX_ROWS: usize = 8; + +const LIST_PICKER_ROW_H: f32 = 18.0; + +/// Popup frame around the rows: 2 px inner margin and the window stroke on each side, rounded up. +const LIST_PICKER_CHROME: f32 = 8.0; + +/// Popup height for `n` items: every item up to `LIST_PICKER_MAX_ROWS`, then a scrollbar. +pub(super) fn list_picker_height(n: usize, row_h: f32, gap: f32) -> f32 { + let rows = n.clamp(1, LIST_PICKER_MAX_ROWS) as f32; + rows * row_h + (rows - 1.0) * gap +} + +/// Draws the validation list popup, choosing above/below and left/right placement for RTL, and applies the clicked item with `cell.set`. +pub(super) fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) { + let Some(c) = app.grid.list_picker else { return }; + let Some(d) = app.session.active() else { return }; + let wb = d.wb.clone(); + let si = wb.active_sheet(); + let Some(sh) = wb.sheet(si) else { return }; + let Some(dv) = sh.validations.iter().find(|x| x.ranges.iter().any(|r| r.contains(c))).cloned() else { + app.grid.list_picker = None; + return; + }; + let items = gridcraft_engine::cmd::data::list_items(&wb, si, &dv); + let r = geo.cell_rect(sh, c); + // Fit the popup to its rows (up to LIST_PICKER_MAX_ROWS); without a minimum the scroll area collapses to its 64 px default. + let height = list_picker_height(items.len(), LIST_PICKER_ROW_H, ui.spacing().item_spacing.y); + // Open below the cell, or above it when the rows would run past the bottom of the window (as Excel does). + let screen = ui.ctx().content_rect(); + let above = r.bottom() + height + LIST_PICKER_CHROME > screen.bottom() && r.top() - height - LIST_PICKER_CHROME >= screen.top(); + let (at, pivot) = match (above, geo.rtl) { + (false, false) => (geo.mirror_pos(r.left_bottom()), Align2::LEFT_TOP), + (false, true) => (geo.mirror_pos(r.left_bottom()), Align2::RIGHT_TOP), + (true, false) => (geo.mirror_pos(r.left_top()), Align2::LEFT_BOTTOM), + (true, true) => (geo.mirror_pos(r.left_top()), Align2::RIGHT_BOTTOM), + }; + let mut close = false; + let response = egui::Area::new(egui::Id::unique("dv_list")).fixed_pos(at).pivot(pivot).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).inner_margin(2.0).show(ui, |ui| { + ui.set_min_width(r.width().max(100.0)); + egui::ScrollArea::vertical().max_height(height).min_scrolled_height(height).show(ui, |ui| { + for it in &items { + if ui + .add(egui::Button::new(it.as_str()).frame_when_inactive(false).min_size(vec2(r.width().max(100.0), LIST_PICKER_ROW_H))) + .clicked() + { + app.run_or_alert("cell.set", json!({"cell": c.a1(), "input": it})); + close = true; + } + } + }); + }); + }); + if close || popup_dismissed(&response.response, was_open) { + app.grid.list_picker = None; + } +} diff --git a/crates/ui-egui/src/grid/geo.rs b/crates/ui-egui/src/grid/geo.rs new file mode 100644 index 0000000000..0c17e173a0 --- /dev/null +++ b/crates/ui-egui/src/grid/geo.rs @@ -0,0 +1,237 @@ +use super::*; + +/// Geometry of the grid for one frame (handles zoom and frozen panes). +#[derive(Clone)] +pub struct Geo { + pub rect: Rect, + pub cells: Rect, + pub z: f32, + pub header_w: f32, + pub header_h: f32, + /// The outline bands (grouped rows / columns): left of the row headers and above the column + /// headers; 0 without an outline (see `outline`). + pub band_w: f32, + pub band_h: f32, + pub row_outline: Arc, + pub col_outline: Arc, + pub fr: u32, + pub fc: u32, + pub frozen_w: f32, + pub frozen_h: f32, + pub scroll: egui::Vec2, + /// Sheet Right-to-Left: everything is laid out left-to-right as usual (these "logical" + /// coordinates are what `x`, `cell_rect`, `col_at`… return) and mirrored across `rect`'s + /// vertical centre line on screen: painted shapes by `mirror_painted`, widgets by + /// `mirror_rect`/`mirror_pos`, and pointer input back by `mirror_pos` (an involution). + pub rtl: bool, + /// Prefix sums over custom row heights / column widths, built once per frame. + pub(super) ri: std::sync::Arc, + pub(super) ci: std::sync::Arc, + /// Page Layout view: cells sit on pages (`scroll` is then in page-layout units). + pub paper: Option>, +} + +impl Geo { + pub fn new(sh: &Sheet, rect: Rect, scroll: egui::Vec2) -> Geo { + let zoom = (sh.zoom as f32 / 100.0).clamp(0.1, 4.0); + let z = zoom * DISPLAY_SCALE; + let header_h = if sh.show_headings { (COL_HEADER_H * zoom.max(0.75)).round() } else { 0.0 }; + let ri = std::sync::Arc::new(LineIndex::new(&sh.rows, sh.default_row_height, MAX_ROWS)); + let ci = std::sync::Arc::new(LineIndex::new(&sh.cols, sh.default_col_width, MAX_COLS)); + let last_row = ri.at((scroll.y + rect.height() / z) as f64 + 200.0) + 1; + let digits = (last_row as f32).log10().floor() as i32 + 1; + let header_w = if sh.show_headings { ((digits.max(2) as f32 * 7.0 + 14.0) * zoom.max(0.75)).round() } else { 0.0 }; + 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::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 }; + let mut band_h = if symbols { crate::outline::band(col_outline.levels, zoom) } else { 0.0 }; + // Without headings the level buttons still need a corner to sit in. + if symbols && row_outline.levels > 0 && header_h == 0.0 { + band_h = band_h.max(crate::outline::lane(zoom) + 3.0); + } + if symbols && col_outline.levels > 0 && header_w == 0.0 { + band_w = band_w.max(crate::outline::lane(zoom) + 3.0); + } + let cells = Rect::from_min_max(pos2(rect.left() + band_w + header_w, rect.top() + band_h + header_h), rect.max); + Geo { + rect, + cells, + z, + header_w, + header_h, + band_w, + band_h, + row_outline, + col_outline, + fr, + fc, + frozen_w, + frozen_h, + scroll, + rtl: sh.right_to_left, + ri, + ci, + paper: None, + } + } + /// Left edge of the row headers (right of the outline band). + pub fn head_left(&self) -> f32 { + self.cells.left() - self.header_w + } + /// Top edge of the column headers (below the outline band). + pub fn head_top(&self) -> f32 { + self.cells.top() - self.header_h + } + /// Page Layout view geometry: `rect` excludes the rulers; cells are drawn at the print scale + /// on pages laid out by `grid`; frozen panes don't apply (as in Excel). + pub fn paged(sh: &Sheet, rect: Rect, scroll: egui::Vec2, grid: Arc) -> Geo { + let mut g = Geo::new(sh, rect, scroll); + let zoom = (sh.zoom as f32 / 100.0).clamp(0.1, 4.0); + g.z = zoom * DISPLAY_SCALE * grid.scale.clamp(0.1, 4.0); + (g.fr, g.fc, g.frozen_w, g.frozen_h) = (0, 0, 0.0, 0.0); + g.paper = Some(grid); + g + } + /// The point is over cells (in Page Layout view: on a page, inside its margins). + pub fn in_cells(&self, p: Pos2) -> bool { + if !self.cells.contains(p) { + return false; + } + match &self.paper { + Some(pg) => pg.on_cells( + self.scroll.x as f64 + ((p.x - self.cells.left()) / self.z) as f64, + self.scroll.y as f64 + ((p.y - self.cells.top()) / self.z) as f64, + ), + None => true, + } + } + /// Scroll offset (in `scroll` units) at which `row` reaches the top of the scrolled area. + pub fn scroll_top_of(&self, sh: &Sheet, row: u32) -> f32 { + match &self.paper { + Some(pg) => pg.row_y(row) as f32, + None => (sh.row_top(row) - sh.row_top(self.fr)) as f32, + } + } + /// Scroll offset (in `scroll` units) at which `col` reaches the left of the scrolled area. + pub fn scroll_left_of(&self, sh: &Sheet, col: u32) -> f32 { + match &self.paper { + Some(pg) => pg.col_x(col) as f32, + None => (sh.col_left(col) - sh.col_left(self.fc)) as f32, + } + } + /// Screen x for a logical x (and back): identity unless the sheet is right-to-left. + pub fn mirror_x(&self, x: f32) -> f32 { + if self.rtl { self.rect.left() + self.rect.right() - x } else { x } + } + pub fn mirror_pos(&self, p: Pos2) -> Pos2 { + pos2(self.mirror_x(p.x), p.y) + } + pub fn mirror_rect(&self, r: Rect) -> Rect { + if self.rtl { Rect::from_min_max(pos2(self.mirror_x(r.right()), r.top()), pos2(self.mirror_x(r.left()), r.bottom())) } else { r } + } + /// On-screen rectangle of a cell (`cell_rect` mirrored for right-to-left sheets). + pub fn screen_cell_rect(&self, sh: &Sheet, c: CellRef) -> Rect { + self.mirror_rect(self.cell_rect(sh, c)) + } + pub fn x(&self, _sh: &Sheet, col: u32) -> f32 { + if let Some(pg) = &self.paper { + return self.cells.left() + (pg.col_x(col) - self.scroll.x as f64) as f32 * self.z; + } + if col < self.fc { + self.cells.left() + self.ci.start(col) as f32 * self.z + } else { + self.cells.left() + self.frozen_w + (self.ci.start(col) as f32 - self.ci.start(self.fc) as f32 - self.scroll.x) * self.z + } + } + pub fn y(&self, _sh: &Sheet, row: u32) -> f32 { + if let Some(pg) = &self.paper { + return self.cells.top() + (pg.row_y(row) - self.scroll.y as f64) as f32 * self.z; + } + if row < self.fr { + self.cells.top() + self.ri.start(row) as f32 * self.z + } else { + self.cells.top() + self.frozen_h + (self.ri.start(row) as f32 - self.ri.start(self.fr) as f32 - self.scroll.y) * self.z + } + } + pub fn cell_rect(&self, sh: &Sheet, c: CellRef) -> Rect { + Rect::from_min_max( + pos2(self.x(sh, c.col), self.y(sh, c.row)), + pos2(self.x(sh, c.col) + sh.col_width(c.col) * self.z, self.y(sh, c.row) + sh.row_height(c.row) * self.z), + ) + } + pub fn range_rect(&self, sh: &Sheet, r: RangeRef) -> Rect { + let a = self.cell_rect(sh, r.start); + let b = self.cell_rect(sh, r.end); + Rect::from_min_max(a.min, b.max) + } + pub fn col_at(&self, _sh: &Sheet, x: f32) -> u32 { + if let Some(pg) = &self.paper { + return pg.col_at(self.scroll.x as f64 + ((x - self.cells.left()) / self.z) as f64); + } + let dx = (x - self.cells.left()) / self.z; + if dx < self.frozen_w / self.z { + self.ci.at(dx.max(0.0) as f64) + } else { + self.ci.at(self.ci.start(self.fc) + self.scroll.x as f64 + (dx - self.frozen_w / self.z) as f64) + } + } + pub fn row_at(&self, _sh: &Sheet, y: f32) -> u32 { + if let Some(pg) = &self.paper { + return pg.row_at(self.scroll.y as f64 + ((y - self.cells.top()) / self.z) as f64); + } + let dy = (y - self.cells.top()) / self.z; + if dy < self.frozen_h / self.z { + self.ri.at(dy.max(0.0) as f64) + } else { + self.ri.at(self.ri.start(self.fr) + self.scroll.y as f64 + (dy - self.frozen_h / self.z) as f64) + } + } + pub fn cell_at(&self, sh: &Sheet, p: Pos2) -> CellRef { + CellRef::new(self.row_at(sh, p.y), self.col_at(sh, p.x)) + } + /// Visible scrolled rows (from the first visible row after the frozen ones). + pub(super) fn scroll_rows(&self, _sh: &Sheet) -> (u32, u32) { + let top = self.ri.start(self.fr) + self.scroll.y as f64; + let r0 = self.ri.at(top).max(self.fr); + let r1 = self.ri.at(top + ((self.cells.height() - self.frozen_h) / self.z) as f64).min(MAX_ROWS - 1); + (r0, r1) + } + pub(super) fn scroll_cols(&self, _sh: &Sheet) -> (u32, u32) { + let left = self.ci.start(self.fc) + self.scroll.x as f64; + let c0 = self.ci.at(left).max(self.fc); + let c1 = self.ci.at(left + ((self.cells.width() - self.frozen_w) / self.z) as f64).min(MAX_COLS - 1); + (c0, c1) + } + /// Screen areas of the panes (frozen rows/columns or the scrolling part) that hold part of `r`. + /// The panes share one coordinate system, so a range that is not clipped to these is also drawn + /// over the frozen rows/columns when it is scrolled under them. + pub fn range_panes(&self, r: RangeRef) -> Vec { + let split = pos2(self.cells.left() + self.frozen_w, self.cells.top() + self.frozen_h); + let mut xs = Vec::new(); + if r.start.col < self.fc { + xs.push((self.cells.left(), split.x)); + } + if r.end.col >= self.fc { + xs.push((split.x, self.cells.right())); + } + let mut ys = Vec::new(); + if r.start.row < self.fr { + ys.push((self.cells.top(), split.y)); + } + if r.end.row >= self.fr { + ys.push((split.y, self.cells.bottom())); + } + ys.iter().flat_map(|&(y0, y1)| xs.iter().map(move |&(x0, x1)| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)))).collect() + } + /// True when cell `c` is in the scrolling pane but scrolled entirely under the frozen rows or columns. + pub fn hidden_under_frozen(&self, sh: &Sheet, c: CellRef) -> bool { + let r = self.cell_rect(sh, c); + (self.fc > 0 && c.col >= self.fc && r.right() <= self.cells.left() + self.frozen_w) + || (self.fr > 0 && c.row >= self.fr && r.bottom() <= self.cells.top() + self.frozen_h) + } +} diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs new file mode 100644 index 0000000000..f59f367a7c --- /dev/null +++ b/crates/ui-egui/src/grid/interact.rs @@ -0,0 +1,904 @@ +use super::editor::validation_arrow; +use super::geo::Geo; +use super::keyboard::keyboard; +use super::menus::set_fill_tag; +use super::selection::select_ranges; +use super::tables::{is_filter_button, table_headings}; +use super::*; + +// ------------------------------------------------------------------ interaction + +/// How close (in points) a press must be to an object's bottom-right corner to resize it instead of moving it. +pub(super) const OBJECT_RESIZE_RADIUS: f32 = 10.0; + +pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) { + // Text selection and editing must not also move the object or edit worksheet cells. + if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() { + return; + } + let ctx = ui.ctx().clone(); + // Enter/Space synthesize egui clicks; only real pointer clicks use sheet coordinates. + let primary_clicked = resp.clicked_by(egui::PointerButton::Primary); + let pointer = ctx.input(|i| i.pointer.clone()); + let mods = ctx.input(|i| i.modifiers); + // Hit-testing works in logical coordinates (see `Geo::rtl`). + let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p)); + let in_cells = pos.is_some_and(|p| geo.in_cells(p)); + let in_col_header = pos.is_some_and(|p| p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left()); + let in_row_header = pos.is_some_and(|p| p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top()); + let in_corner = pos.is_some_and(|p| p.x < geo.cells.left() && p.y < geo.cells.top() && p.x >= geo.head_left() && p.y >= geo.head_top()); + let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default(); + let cur_rect = geo.range_rect(sh, sel.current()); + let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0)); + // The fill handle and the move border only react in the panes where they are painted, not + // over the frozen rows/columns that a scrolled selection lies under. + let handle_panes = geo.range_panes(RangeRef::cell(sel.current().end)); + let border_panes = geo.range_panes(sel.current()); + let on_handle = |p: Pos2| handle.contains(p) && handle_panes.iter().any(|r| r.contains(p)); + let on_cur_border = |p: Pos2| on_border(cur_rect, p) && border_panes.iter().any(|r| r.contains(p)); + + let (freeze_cols, freeze_rows) = freeze_handles(geo); + // Draw tab tools: the pen records a stroke, the eraser deletes ink under the pointer. + if !app.session.draw_tool.is_empty() && in_cells { + ctx.set_cursor_icon(CursorIcon::Crosshair); + if app.session.draw_tool == "pen" { + if let Some(p) = pos + && resp.dragged() + { + // Through the cell under the pointer, so frozen panes and pages map right. + let c = geo.cell_at(sh, p); + let sx = sh.col_left(c.col) as f32 + (p.x - geo.x(sh, c.col)) / geo.z; + let sy = sh.row_top(c.row) as f32 + (p.y - geo.y(sh, c.row)) / geo.z; + app.grid.ink.push([sx, sy]); + } + if !app.grid.ink.is_empty() { + // Live preview. + let pts: Vec = app + .grid + .ink + .iter() + .map(|q| { + let (col, row) = (sh.col_at(q[0].max(0.0) as f64), sh.row_at(q[1].max(0.0) as f64)); + geo.mirror_pos(pos2( + geo.x(sh, col) + (q[0] - sh.col_left(col) as f32) * geo.z, + geo.y(sh, row) + (q[1] - sh.row_top(row) as f32) * geo.z, + )) + }) + .collect(); + ui.painter().add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(2.0 * geo.z, Color32::from_rgb(0x1F, 0x5F, 0xC9)))); + } + if resp.drag_stopped() && app.grid.ink.len() >= 2 { + let pts: Vec<[f32; 2]> = std::mem::take(&mut app.grid.ink); + let _ = app.run("draw.stroke", json!({"points": pts})); + } + return; + } + if app.session.draw_tool == "eraser" && (primary_clicked || resp.dragged()) { + if let Some(p) = pos + && let Some(("shape", id, _)) = crate::chartview::hit(app, geo, sh, p) + && sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::Ink) + { + let _ = app.run("object.delete", json!({"kind": "shape", "id": id})); + } + return; + } + } + // A plain click on a slicer value tile toggles it (Ctrl/Cmd for multi-select); the clear + // button resets it. Runs before the generic object select/drag so tiles stay clickable. + if primary_clicked + && !resp.drag_started() + && let Some(p) = pos + && let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet(), p) + { + app.selected_chart = Some(id); + match act { + crate::chartview::SlicerAction::Toggle(value) => { + let _ = app.run("slicer.toggle", json!({"slicer": id, "value": value, "multi": mods.command || mods.ctrl})); + } + crate::chartview::SlicerAction::Clear => { + let _ = app.run("slicer.clear", json!({"slicer": id})); + } + } + return; + } + // Objects (charts, pictures, shapes, slicers) take clicks first. Only the primary button moves + // them: a right-click that wobbles into a drag still opens the object's menu (#2067). + let primary_drag = resp.drag_started_by(egui::PointerButton::Primary); + if primary_drag || primary_clicked { + // A drag is recognised only after the pointer has moved, so grab the object where the press began. + let at = if primary_drag { pointer.press_origin().or(pos) } else { pos }; + if let Some(p) = at + && let Some((kind, id, rect)) = crate::chartview::hit(app, geo, sh, p) + { + app.selected_chart = Some(id); + if kind == "shape" + && resp.double_clicked() + && sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::TextBox) + { + app.begin_text_box_edit(id); + return; + } + if kind == "chart" && resp.double_clicked() { + app.grid.pane = Some("formatChart".into()); + } + if primary_drag { + let a = object_anchor(sh, kind, id); + let resize = (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS; + if let Some(a) = a { + app.grid.drag = Drag::Object { kind, id, start: p, orig: (a.cell, a.dx, a.dy, a.width, a.height), resize }; + } + } + return; + } + app.selected_chart = None; + } + // A right-click on a chart, picture, shape or slicer opens the object's menu, not the cell's + // under it; a right press that wobbles into a drag counts too (#2067). + let secondary_drag = resp.drag_started_by(egui::PointerButton::Secondary); + if (resp.secondary_clicked() || secondary_drag) + && let Some(p) = if secondary_drag { pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) } else { pos } + && let Some((kind, id, _)) = crate::chartview::hit(app, geo, sh, p) + { + app.selected_chart = Some(id); + app.grid.object_menu = Some((geo.mirror_pos(p), kind, id)); + return; + } + + // A double-click is also a click: handle header AutoFit before resize setup returns. + if resp.double_clicked() + && let Some(p) = pos + { + let unhide = if in_col_header { + hidden_col_edge(geo, sh, p.x).map(|col| ("home.unhideColumns", json!({"cols": RangeRef::cols(col, col).a1()}))) + } else if in_row_header { + hidden_row_edge(geo, sh, p.y).map(|row| ("home.unhideRows", json!({"rows": RangeRef::rows(row, row).a1()}))) + } else { + None + }; + let autofit = if unhide.is_some() { + unhide + } else if in_col_header { + col_edge(geo, sh, p.x).map(|(col, _)| { + let selected = sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col); + ("home.autofitColumnWidth", if selected { json!({}) } else { json!({"cols": RangeRef::cols(col, col).a1()}) }) + }) + } else if in_row_header { + row_edge(geo, sh, p.y).map(|(row, _)| { + let selected = sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row); + ("home.autofitRowHeight", if selected { json!({}) } else { json!({"rows": RangeRef::rows(row, row).a1()}) }) + }) + } else { + None + }; + if let Some((command, params)) = autofit { + app.grid.drag = Drag::None; + app.grid.drag_select = false; + app.run_or_alert(command, params); + resp.request_focus(); + return; + } + } + + // Double-click the fill handle: a double-click is also a click, so run it before the handle press returns. + if resp.double_clicked() + && let Some(p) = pos + && on_handle(p) + { + // Fill down as far as the neighbouring column goes. + let src = sel.current(); + let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 }; + let cap = src.end.row.saturating_add(1_000_000).min(MAX_ROWS - 1); + let last = if src.end.row < cap { sh.last_contiguous_nonempty(neighbour, src.end.row + 1, cap) } else { src.end.row }; + if last > src.end.row { + let target = RangeRef::new(src.start, CellRef::new(last, src.end.col)); + let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()})); + } + app.grid.drag = Drag::None; + app.grid.drag_select = false; + resp.request_focus(); + return; + } + + // Press: decide the drag mode. + if resp.drag_started() || (primary_clicked && app.grid.drag == Drag::None) { + // Where the press began (a drag is only recognised after the pointer has moved). + let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) else { return }; + // The outline band's − / + and level buttons act on release, like buttons. + if let Some(action) = crate::outline::hit(geo, sh, p) { + if primary_clicked { + let (command, params) = action.command(); + app.run_or_alert(command, params); + } + return; + } + // Freeze handles sit on the header edges (and the corner's edges when nothing is frozen). + if freeze_cols.contains(p) || freeze_rows.contains(p) { + app.grid.drag = Drag::Freeze { cols: freeze_cols.contains(p), to: None }; + return; + } + if in_corner { + let _ = app.session.run("selection.set", json!({"range": RangeRef::all().a1()})); + return; + } + if in_col_header { + if let Some((c, x)) = col_edge(geo, sh, p.x) { + app.grid.drag = Drag::ResizeCol { col: c, start: p.x, orig: sh.col_width(c).max(0.0) * 0.0 + (x - geo.x(sh, c)) / geo.z }; + return; + } + let c = geo.col_at(sh, p.x); + // A Table heading's filter button, while the header names stand in for the letters. + if let Some(h) = table_headings(geo, sh, sel.active).into_iter().find(|h| h.col == c && h.button.is_some_and(|b| b.contains(p))) { + app.grid.filter_menu = Some((c, h.header_row, geo.mirror_pos(p))); + return; + } + if point_header(app, wb, sh, RangeRef::cols(c, c), mods.shift, mods.command) { + return; + } + // An armed Format Painter paints the columns dragged over instead of moving them. + if resp.drag_started() + && !mods.any() + && app.session.format_painter.is_none() + && sel.ranges.len() == 1 + && sel.current().is_full_cols() + && sel.current().contains(CellRef::new(0, c)) + { + app.grid.drag = Drag::MoveCols { source: sel.current(), before: None }; + return; + } + let range = if mods.shift { RangeRef::cols(sel.anchor.col.min(c), sel.anchor.col.max(c)) } else { RangeRef::cols(c, c) }; + select_ranges(app, range, mods.command, CellRef::new(geo.row_at(sh, geo.cells.top() + 1.0), c)); + app.grid.drag = Drag::Cols(if mods.shift { sel.anchor.col } else { c }); + return; + } + if in_row_header { + if let Some((r, y)) = row_edge(geo, sh, p.y) { + app.grid.drag = Drag::ResizeRow { row: r, start: p.y, orig: (y - geo.y(sh, r)) / geo.z }; + return; + } + let r = geo.row_at(sh, p.y); + if point_header(app, wb, sh, RangeRef::rows(r, r), mods.shift, mods.command) { + return; + } + let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) }; + select_ranges(app, range, mods.command, CellRef::new(r, geo.col_at(sh, geo.cells.left() + 1.0))); + app.grid.drag = Drag::Rows(if mods.shift { sel.anchor.row } else { r }); + return; + } + if !in_cells { + return; + } + let c = geo.cell_at(sh, p); + // Data validation list arrow next to the active cell. + if let Some(r) = validation_arrow(sh, geo, sel.active) + && r.contains(p) + { + app.grid.list_picker = Some(sel.active); + return; + } + // Filter dropdown buttons. + if is_filter_button(sh, geo, p) { + app.grid.filter_menu = Some((c.col, c.row, geo.mirror_pos(p))); + return; + } + // A dialog's formula box in point mode (New Formatting Rule) takes the reference; the + // selection, which the dialog acts on, stays. + if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) { + d.pick_reference(c, &sh.name, false); + app.grid.drag = Drag::Select; + app.grid.drag_select = true; + if resp.drag_started() + && let Some(p) = pos + { + select_to_pointer(app, geo, sh, wb, p); + } + return; + } + // Point mode while editing a formula: clicking inserts a reference. + if let Some(ed) = app.editor.as_mut() + && ed.can_point() + { + let anchor = if mods.shift && ed.point_at_caret() { + ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet()).map(|(_, cell)| cell).unwrap_or(c) + } else { + c + }; + let range = RangeRef::new(anchor, c); + let text = if range.is_single() { c.a1() } else { range.a1() }; + let sheet_name = (ed.sheet != wb.active_sheet()).then(|| sh.name.clone()); + let r = match sheet_name { + Some(n) => format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&n)), + None => text, + }; + if mods.command && !mods.shift { + ed.append_ref(&r); + } else { + ed.insert_ref(&r); + } + ed.request_focus = true; + ed.point_cell = Some(c); + ed.point_anchor = Some((wb.active_sheet(), anchor)); + app.grid.drag = Drag::Select; + app.grid.drag_select = true; + if resp.drag_started() + && let Some(p) = pos + { + select_to_pointer(app, geo, sh, wb, p); + } + return; + } + if app.editor.is_some() && !app.commit_edit(0, 0, false, false) { + return; + } + if on_handle(p) { + app.grid.drag = Drag::Fill { target: None, menu: resp.drag_started_by(egui::PointerButton::Secondary) }; + return; + } + if on_cur_border(p) && resp.drag_started() { + app.grid.drag = Drag::Move { grab: c, to: None }; + return; + } + // Double-click edits. + let now = crate::now_ms(); + if let Some((last, at)) = app.grid.last_click + && last == c + && now - at < 450.0 + && !mods.shift + { + app.grid.last_click = None; + // A PivotTable's value cell shows its source records on a new sheet (Excel's Show + // Details); its labels and headers still edit. + if sh.pivots.iter().any(|pt| pt.last_range.is_some_and(|r| r.contains(c))) { + let not_a_value_cell = + gridcraft_engine::EngineError::Disabled("pivot.showDetails".into(), gridcraft_engine::pivot::NOT_A_VALUE_CELL.into()).to_string(); + match app.run("pivot.showDetails", json!({"cell": c.a1()})) { + Ok(_) => return, + Err(e) if e == not_a_value_cell => {} + Err(e) => { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + return; + } + } + } + // Following a hyperlink requires a single click in Excel; double-click edits. + app.begin_edit(None, false); + return; + } + app.grid.last_click = Some((c, now)); + if mods.shift { + let _ = app.session.run("selection.set", json!({"range": RangeRef::new(sel.anchor, c).a1(), "active": sel.active.a1()})); + if let Some(d) = app.session.active_mut() { + d.selection.anchor = sel.anchor; + } + } else if mods.command { + // Ctrl+click (without Alt) on a hyperlink's text follows it instead of adding the cell to the selection. + if let Some(h) = sh.hyperlinks.get(&c).cloned() + && primary_clicked + && !mods.alt + && p.x < geo.cell_rect(sh, c).left() + geo.cell_rect(sh, c).width() * 0.8 + { + follow_link(app, &h.target); + } else { + select_ranges(app, RangeRef::cell(c), true, c); + } + } else { + let _ = app.session.run("selection.set", json!({"cell": c.a1()})); + if primary_clicked && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" { + let _ = app.run("cell.toggleCheckbox", json!({"cell": c.a1()})); + } + // A click on a hyperlink's text follows it (like Excel); elsewhere in the cell selects. + if let Some(h) = sh.hyperlinks.get(&c).cloned() + && primary_clicked + && !mods.any() + { + let r = geo.cell_rect(sh, c); + if p.x < r.left() + r.width() * 0.8 { + follow_link(app, &h.target); + } + } + } + app.grid.drag = Drag::Select; + app.grid.drag_select = true; + if resp.drag_started() + && let Some(p) = pos + { + // The first recognised drag already includes movement away from the press. + select_to_pointer(app, geo, sh, wb, p); + } + return; + } + + // Dragging. + if resp.dragged() + && let Some(p) = pos + { + // Auto-scroll near edges. + let edge = 24.0; + let mut auto = egui::Vec2::ZERO; + if p.y > geo.cells.bottom() - edge { + auto.y = 20.0; + } + if p.y < geo.cells.top() + edge * 0.5 && !matches!(app.grid.drag, Drag::ResizeRow { .. }) { + auto.y = -20.0; + } + if p.x > geo.cells.right() - edge { + auto.x = 30.0; + } + if p.x < geo.cells.left() + edge * 0.5 && !matches!(app.grid.drag, Drag::ResizeCol { .. }) { + auto.x = -30.0; + } + let pc = CellRef::new( + geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)), + geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)), + ); + match app.grid.drag.clone() { + Drag::Select => { + select_to_pointer(app, geo, sh, wb, p); + scroll_by(app, auto); + } + Drag::Rows(anchor) => { + let r = geo.row_at(sh, p.y); + let _ = + app.session.run("selection.set", json!({"range": RangeRef::rows(anchor.min(r), anchor.max(r)).a1(), "active": sel.active.a1()})); + scroll_by(app, vec2(0.0, auto.y)); + } + Drag::Cols(anchor) => { + let c = geo.col_at(sh, p.x); + let _ = + app.session.run("selection.set", json!({"range": RangeRef::cols(anchor.min(c), anchor.max(c)).a1(), "active": sel.active.a1()})); + scroll_by(app, vec2(auto.x, 0.0)); + } + Drag::MoveCols { source, .. } => { + let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)); + let midpoint = geo.x(sh, col) + sh.col_width(col) * geo.z * 0.5; + let before = (col + u32::from(p.x >= midpoint)).min(MAX_COLS - 1); + let before = (!(source.start.col..=source.end.col + 1).contains(&before)).then_some(before); + app.grid.drag = Drag::MoveCols { source, before }; + scroll_by(app, vec2(auto.x, 0.0)); + } + Drag::ResizeCol { col, start, orig } => { + let w = (orig + (p.x - start) / geo.z).max(0.0); + app.toast = + Some((format!("Width: {:.2} ({} pixels)", gridcraft_engine::cmd::format::points_to_chars(w as f64), w.round()), crate::now_ms())); + // Inside a selection of whole columns, every selected column takes the new width + // (Ctrl-selected areas included); otherwise only the dragged one. + let cols = if sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col) { + sel.ranges.iter().filter(|r| r.is_full_cols()).map(|r| r.a1()).collect::>().join(",") + } else { + RangeRef::cols(col, col).a1() + }; + let _ = app.session.run("home.columnWidth", json!({"cols": cols, "width": w.round()})); + coalesce_last_undo(app); + } + Drag::ResizeRow { row, start, orig } => { + let h = (orig + (p.y - start) / geo.z).max(0.0); + app.toast = Some((format!("Height: {:.2} ({} pixels)", h * 0.75, h.round()), crate::now_ms())); + let rows = if sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row) { + sel.ranges + .iter() + .filter(|r| r.is_full_rows()) + .map(|r| format!("{}:{}", r.start.row + 1, r.end.row + 1)) + .collect::>() + .join(",") + } else { + format!("{}:{}", row + 1, row + 1) + }; + let _ = app.session.run("home.rowHeight", json!({"rows": rows, "height": h.round()})); + coalesce_last_undo(app); + } + Drag::Freeze { cols, .. } => { + app.grid.drag = Drag::Freeze { cols, to: Some(freeze_boundary(geo, sh, p, cols)) }; + } + Drag::Fill { menu, .. } => { + let src = sel.current(); + let target = fill_target(src, pc); + app.grid.drag = Drag::Fill { target: Some(target), menu }; + scroll_by(app, auto); + } + Drag::Move { grab, .. } => { + let src = sel.current(); + let dr = pc.row as i64 - grab.row as i64; + let dc = pc.col as i64 - grab.col as i64; + app.grid.drag = Drag::Move { grab, to: Some(src.start.offset_clamped(dr, dc)) }; + scroll_by(app, auto); + } + Drag::Object { kind, id, start, orig, resize } => { + let d = (p - start) / geo.z; + let (cell, dx, dy, w, h) = orig; + let params = if resize { + json!({"kind": kind, "id": id, "width": (w + d.x).max(20.0), "height": (h + d.y).max(20.0)}) + } else { + // Re-anchor at the cell under the new top-left. + let x0 = sh.col_left(cell.col) as f32 + dx + d.x; + let y0 = sh.row_top(cell.row) as f32 + dy + d.y; + let nc = CellRef::new(sh.row_at(y0.max(0.0) as f64), sh.col_at(x0.max(0.0) as f64)); + json!({"kind": kind, "id": id, "at": nc.a1(), "dx": x0.max(0.0) - sh.col_left(nc.col) as f32, "dy": y0.max(0.0) - sh.row_top(nc.row) as f32}) + }; + let _ = app.session.run("object.move", params); + coalesce_last_undo(app); + } + Drag::None => {} + } + } + + // Release. + if resp.drag_stopped() || (!pointer.any_down() && app.grid.drag != Drag::None && !resp.dragged()) { + match std::mem::take(&mut app.grid.drag) { + Drag::Select => { + if resp.drag_stopped() + && let Some(p) = pos + { + select_to_pointer(app, geo, sh, wb, p); + } + format_painter_apply(app); + } + // Whole rows or columns clicked or dragged over by their headers. + Drag::Rows(_) | Drag::Cols(_) => format_painter_apply(app), + // Esc mid-drag lands here too (egui ends the drag on Esc): no menu, and the key is + // spent on the drag so the grid's Esc doesn't also drop the copy. + Drag::Fill { target: Some(t), menu: true } => { + if let Some(p) = pos + && t != sel.current() + && !ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) + { + app.grid.fill_menu = Some((sel.current(), t, geo.mirror_pos(p))); + } + } + Drag::Fill { target: Some(t), menu: false } => { + let src = sel.current(); + if t != src { + // Ctrl (Cmd on macOS) swaps copying and extending, as in Excel. + let mode = if mods.command { "alternate" } else { "series" }; + match app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) { + Ok(_) => set_fill_tag(app, src, t), + Err(e) => app.message = Some(("GridCraft".into(), crate::clean_error(&e))), + } + } + } + Drag::Freeze { cols, to: Some(to) } => { + let (rows_now, cols_now) = (geo.fr, geo.fc); + let (rows, cols) = if cols { (rows_now, to) } else { (to, cols_now) }; + if (rows, cols) != (rows_now, cols_now) { + // A1 (nothing left frozen) unfreezes. + app.run_or_alert("view.freezePanes", json!({"cell": CellRef::new(rows, cols).a1()})); + } + } + Drag::Move { to: Some(to), .. } => { + let src = sel.current(); + if to != src.start { + let _ = app.session.run("edit.cut", json!({"range": src.a1()})); + let _ = app.run("edit.paste", json!({"at": to.a1()})); + } + } + Drag::MoveCols { source, before: Some(before) } => { + if let Err(e) = app.run("sheet.moveColumns", json!({"cols": source.a1(), "before": before})) { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } + _ => {} + } + app.grid.drag_select = false; + } + + // Right-click: context menu. + if resp.secondary_clicked() + && let Some(p) = pos + { + if in_cells && !sel.contains(geo.cell_at(sh, p)) { + let _ = app.session.run("selection.set", json!({"cell": geo.cell_at(sh, p).a1()})); + } + if in_col_header || in_row_header { + let (rows, idx) = if in_row_header { (true, geo.row_at(sh, p.y)) } else { (false, geo.col_at(sh, p.x)) }; + let inside = sel.ranges.iter().any(|r| { + if rows { + r.is_full_rows() && idx >= r.start.row && idx <= r.end.row + } else { + r.is_full_cols() && idx >= r.start.col && idx <= r.end.col + } + }); + if !inside { + let range = if rows { RangeRef::rows(idx, idx) } else { RangeRef::cols(idx, idx) }; + let _ = app.session.run("selection.set", json!({"range": range.a1()})); + } + app.grid.header_menu = Some((geo.mirror_pos(p), rows)); + } else { + app.grid.context_menu = Some(geo.mirror_pos(p)); + app.grid.context_menu_frame = ui.ctx().cumulative_frame_nr(); + } + } + + if primary_clicked || resp.drag_started() { + resp.request_focus(); + } + keyboard(app, &ctx, resp, geo, sh); +} + +/// An armed Format Painter pastes onto the cells just clicked or dragged over (not while +/// pointing a formula reference, which also drags a Select). +fn format_painter_apply(app: &mut SheetApp) { + if app.session.format_painter.is_some() + && !app.editor.as_ref().is_some_and(|ed| ed.can_point()) + && !app.dialog.as_ref().is_some_and(|d| d.picks_references()) + { + let dest = app.session.active().map(|d| d.selection.current().a1()); + if let Some(dest) = dest + && let Err(e) = app.run("edit.formatPainter", json!({"apply": dest})) + { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } +} + +/// Point mode on a header: inserts a whole-row or whole-column reference instead of selecting. +fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef, extend: bool, add: bool) -> bool { + let Some(ed) = app.editor.as_mut().filter(|ed| ed.can_point()) else { return false }; + let edit_sheet = ed.sheet; + let active_sheet = wb.active_sheet(); + let range = if extend { + ed.point + .and_then(|point| crate::editor::formula_refs(&ed.text).into_iter().find(|(start, end, ..)| (*start, *end) == point)) + .and_then(|(_, _, previous, previous_sheet, _)| { + let previous_sheet = previous_sheet.as_deref().map(|name| wb.sheet_index(name)).unwrap_or(Some(edit_sheet)); + if previous_sheet != Some(active_sheet) { + return None; + } + if range.is_full_cols() && previous.is_full_cols() { + Some(RangeRef::cols(previous.start.col.min(range.start.col), previous.end.col.max(range.end.col))) + } else if range.is_full_rows() && previous.is_full_rows() { + Some(RangeRef::rows(previous.start.row.min(range.start.row), previous.end.row.max(range.end.row))) + } else { + None + } + }) + .unwrap_or(range) + } else { + range + }; + let reference = range.a1(); + let reference = + if ed.sheet != wb.active_sheet() { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference }; + if add { + ed.append_ref(&reference); + } else { + ed.insert_ref(&reference); + } + ed.point_anchor = None; + ed.point_cell = Some(range.start); + ed.request_focus = true; + true +} + +fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) { + let cell = CellRef::new( + geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)), + geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)), + ); + if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) { + d.pick_reference(cell, &sh.name, true); + } else if let Some(ed) = app.editor.as_mut() + && ed.point.is_some() + { + if let Some(start) = ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet()).map(|(_, cell)| cell).or(ed.point_cell) { + let range = RangeRef::new(start, cell); + let text = if range.is_single() { range.start.a1() } else { range.a1() }; + let text = if ed.sheet != wb.active_sheet() { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { text }; + ed.insert_ref(&text); + ed.point_cell = Some(cell); + ed.point_anchor = Some((wb.active_sheet(), start)); + } + } else if let Some(d) = app.session.active() { + // Read the current anchor: starting a drag may have just replaced the selection. + let anchor = d.selection.anchor; + let active = d.selection.active; + let mut ranges: Vec = d.selection.ranges.iter().map(|r| r.a1()).collect(); + ranges.pop(); + ranges.push(RangeRef::new(anchor, cell).a1()); + let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": active.a1()})); + if let Some(d) = app.session.active_mut() { + d.selection.anchor = anchor; + } + } +} + +fn coalesce_last_undo(app: &mut SheetApp) { + // Merge consecutive drag steps into one undo entry. + if let Some(d) = app.session.active_mut() + && d.undo.len() >= 2 + { + let n = d.undo.len(); + if d.undo[n - 1].label == d.undo[n - 2].label { + d.undo.remove(n - 1); + } + } +} + +fn scroll_by(app: &mut SheetApp, d: egui::Vec2) { + if d != egui::Vec2::ZERO + && let Some(v) = app.view_mut() + { + v.scroll = (v.scroll + d).max(egui::Vec2::ZERO); + } +} + +fn fill_target(src: RangeRef, pc: CellRef) -> RangeRef { + let below = pc.row as i64 - src.end.row as i64; + let above = src.start.row as i64 - pc.row as i64; + let right = pc.col as i64 - src.end.col as i64; + let left = src.start.col as i64 - pc.col as i64; + let vert = below.max(above); + let horiz = right.max(left); + if vert <= 0 && horiz <= 0 { + return src; + } + if vert >= horiz { + if below > 0 { + RangeRef::new(src.start, CellRef::new(pc.row, src.end.col)) + } else { + RangeRef::new(CellRef::new(pc.row, src.start.col), src.end) + } + } else if right > 0 { + RangeRef::new(src.start, CellRef::new(src.end.row, pc.col)) + } else { + RangeRef::new(CellRef::new(src.start.row, pc.col), src.end) + } +} + +pub(super) fn on_border(r: Rect, p: Pos2) -> bool { + let outer = r.expand(3.0); + let inner = r.shrink(3.0); + outer.contains(p) && !inner.contains(p) +} + +/// The freeze handles, as in Excel for the web: a bar in the upper part of the column-header strip +/// on the frozen columns' right edge, and one in the left part of the row-header strip on the +/// frozen rows' bottom edge. With nothing frozen they sit on the corner's edges. The rest of the +/// header edge keeps resizing the column or row. +pub fn freeze_handles(geo: &Geo) -> (Rect, Rect) { + let x = geo.cells.left() + geo.frozen_w; + let y = geo.cells.top() + geo.frozen_h; + let cols = Rect::from_min_max(pos2(x - 3.0, geo.head_top()), pos2(x + 3.0, geo.head_top() + geo.header_h * 0.4)); + let rows = Rect::from_min_max(pos2(geo.head_left(), y - 3.0), pos2(geo.head_left() + geo.header_w * 0.4, y + 3.0)); + (cols, rows) +} + +/// How many columns (or rows) a freeze handle dropped at `p` freezes: the line boundary nearest +/// the pointer, limited so the frozen pane leaves room for the scrolling one. +fn freeze_boundary(geo: &Geo, sh: &Sheet, p: Pos2, cols: bool) -> u32 { + if cols { + if p.x <= geo.cells.left() { + return 0; + } + let c = geo.col_at(sh, p.x); + let (l, r) = (geo.x(sh, c), geo.x(sh, c.saturating_add(1).min(MAX_COLS - 1))); + let b = if p.x - l > r - p.x { c.saturating_add(1) } else { c }; + let room = geo.ci.at((((geo.cells.width() - 40.0) / geo.z).max(0.0)) as f64); + b.min(room).min(MAX_COLS - 1) + } else { + if p.y <= geo.cells.top() { + return 0; + } + let rr = geo.row_at(sh, p.y); + let (t, b2) = (geo.y(sh, rr), geo.y(sh, rr.saturating_add(1).min(MAX_ROWS - 1))); + let b = if p.y - t > b2 - p.y { rr.saturating_add(1) } else { rr }; + let room = geo.ri.at((((geo.cells.height() - 40.0) / geo.z).max(0.0)) as f64); + b.min(room).min(MAX_ROWS - 1) + } +} + +/// Paints the freeze handles and, while one is dragged, where the freeze line will go. +pub(super) fn paint_freeze_handles(p: &Painter, geo: &Geo, sh: &Sheet, drag: &Drag, t: &Tokens) { + let (cols, rows) = freeze_handles(geo); + let bar = Color32::from_gray(150); + p.rect_filled(Rect::from_center_size(cols.center(), vec2(3.0, cols.height())), 1.0, bar); + p.rect_filled(Rect::from_center_size(rows.center(), vec2(rows.width(), 3.0)), 1.0, bar); + if let Drag::Freeze { cols, to: Some(to) } = *drag { + let stroke = Stroke::new(2.0, t.accent); + if cols { + let x = geo.cells.left() + sh.col_left(to) as f32 * geo.z; + p.line_segment([pos2(x, geo.head_top()), pos2(x, geo.cells.bottom())], stroke); + } else { + let y = geo.cells.top() + sh.row_top(to) as f32 * geo.z; + p.line_segment([pos2(geo.head_left(), y), pos2(geo.cells.right(), y)], stroke); + } + } +} + +pub(super) fn col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option<(u32, f32)> { + let c = geo.col_at(sh, x); + for cc in [c, c.saturating_sub(1)] { + let edge = geo.x(sh, cc) + sh.col_width(cc) * geo.z; + if (x - edge).abs() <= 4.0 { + return Some((cc, edge)); + } + } + None +} + +pub(super) fn row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option<(u32, f32)> { + let r = geo.row_at(sh, y); + for rr in [r, r.saturating_sub(1)] { + let edge = geo.y(sh, rr) + sh.row_height(rr) * geo.z; + if (y - edge).abs() <= 3.0 { + return Some((rr, edge)); + } + } + None +} + +/// The hidden column whose (zero-width) edge is at `x`: only just right of the boundary, as the +/// edge just left of it belongs to the visible column there. +pub(super) fn hidden_col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option { + col_edge(geo, sh, x).map(|(c, _)| c).filter(|&c| sh.col_width(c) <= 0.0) +} + +pub(super) fn hidden_row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option { + row_edge(geo, sh, y).map(|(r, _)| r).filter(|&r| sh.row_height(r) <= 0.0) +} + +fn object_anchor(sh: &Sheet, kind: &str, id: u32) -> Option { + match kind { + "chart" => sh.charts.iter().find(|c| c.id == id).map(|c| c.anchor), + "image" => sh.images.iter().find(|c| c.id == id).map(|c| c.anchor), + "slicer" => sh.slicers.iter().find(|c| c.id == id).map(|c| c.anchor), + _ => sh.shapes.iter().find(|c| c.id == id).map(|c| c.anchor), + } +} + +pub(super) fn delete_selected_object(app: &mut SheetApp, sh: &Sheet) -> bool { + let Some(id) = app.selected_chart else { return false }; + let kind = if sh.charts.iter().any(|object| object.id == id) { + "chart" + } else if sh.images.iter().any(|object| object.id == id) { + "image" + } else if sh.shapes.iter().any(|object| object.id == id) { + "shape" + } else { + return false; + }; + app.run_or_alert("object.delete", json!({"kind": kind, "id": id})); + app.selected_chart = None; + true +} + +/// A click on a hyperlink. References select; web and e-mail addresses ask first (the dialog's +/// OK runs `link.open`); every other kind is refused with a message. The decision is the +/// engine's ([`gridcraft_engine::links::classify`]), the same one `link.open` applies. +pub fn follow_link(app: &mut SheetApp, target: &str) { + use gridcraft_engine::links::{LinkKind, classify}; + match classify(target) { + LinkKind::Internal(r) => { + let _ = app.session.run("edit.goTo", json!({"reference": r.trim_start_matches('#')})); + app.grid.ensure_visible = true; + } + LinkKind::External(url) => app.open_dialog("openLink", json!({"url": url})), + LinkKind::Refused(why) => app.message = Some(("Hyperlink".into(), why)), + } +} + +/// Used by `EditState` users to keep the active cell visible while pointing. +pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, extend: bool) { + let Some(d) = app.session.active() else { return }; + let from = ed.point_cell.unwrap_or(ed.cell); + let to = from.offset_clamped(dr, dc); + let anchor = if extend { + // The anchor is the corner opposite the moving end: the written range is normalized, so + // after extending up or left its first cell is the moving end, not the anchor. + let pointed = ed.point.and_then(|(s, e)| ed.text.get(s..e)).and_then(RangeRef::parse); + let inferred_anchor = pointed + .map(|r| { + CellRef::new( + if from.row == r.start.row { r.end.row } else { r.start.row }, + if from.col == r.start.col { r.end.col } else { r.start.col }, + ) + }) + .unwrap_or(from); + ed.point_anchor.filter(|(sheet, _)| *sheet == d.wb.active_sheet()).map(|(_, cell)| cell).unwrap_or(inferred_anchor) + } else { + to + }; + let text = if extend { RangeRef::new(anchor, to).a1() } else { to.a1() }; + let sheet = d.wb.active_sheet(); + ed.insert_ref(&text); + ed.point_cell = Some(to); + ed.point_anchor = Some((sheet, anchor)); + app.grid.ensure_visible = true; +} diff --git a/crates/ui-egui/src/grid/keyboard.rs b/crates/ui-egui/src/grid/keyboard.rs new file mode 100644 index 0000000000..6cb4a8c9c2 --- /dev/null +++ b/crates/ui-egui/src/grid/keyboard.rs @@ -0,0 +1,155 @@ +use super::geo::Geo; +use super::interact::delete_selected_object; +use super::menus::is_checkbox; +use super::*; + +/// Grid keyboard handling when no text field has focus. +pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: &Geo, sh: &Sheet) { + // Focus left on an editor that no longer exists goes back to the grid. + let stale = [egui::Id::unique("gridcraft.cell_editor"), egui::Id::unique("gridcraft.formula_bar"), crate::text_box::editor_id()]; + let editing = app.editor.is_some() || app.text_box_editor.is_some(); + if !editing && ctx.memory(|m| m.focused()).is_some_and(|f| stale.contains(&f)) { + resp.request_focus(); + } + let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && (editing || !stale.contains(&f))); + // An open filter menu owns the keyboard even when a click inside it left focus on nothing (#1516). + // So do the fill menu (its Esc closes it, not the copy) and the right-drag that opens it (an + // arrow key would move the selection the menu fills from). + if editing + || app.dialog.is_some() + || other_focus + || app.message.is_some() + || app.grid.filter_menu.is_some() + || app.grid.fill_menu.is_some() + || matches!(app.grid.drag, Drag::Fill { menu: true, .. }) + { + return; + } + if ctx.memory(|m| m.focused()).is_none() { + resp.request_focus(); + } + // The grid owns arrows, Tab and Escape (egui would otherwise move focus between widgets). + ctx.memory_mut(|m| { + m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }) + }); + let events = ctx.input(|i| i.events.clone()); + // A shortcut key that also types a character (Shift+Space sends Text(" ")) must not start an edit. + let shortcut_pressed = events.iter().any(|e| { + matches!(e, egui::Event::Key { pressed: true, modifiers: m, key, .. } + if !m.alt && (m.ctrl || m.command || (m.shift && *key == egui::Key::Space))) + }); + let page = ((geo.cells.height() / geo.z) / sh.default_row_height).floor().max(1.0) as i64; + // Alt shortcuts type their character too on Windows and Linux (Alt+= sends Text("=")): once + // a shortcut ran, that text must not start an edit over what it just did. + let mut shortcut_ran = false; + for ev in events { + match ev { + egui::Event::Copy => { + app.copy_to_clipboard(ctx, "edit.copy"); + } + egui::Event::Cut => { + app.copy_to_clipboard(ctx, "edit.cut"); + } + egui::Event::Paste(text) => app.paste_external(text), + // GridCraft has no image-clipboard pipeline and no cell-picture insert-from-clipboard + // command. Choosing to ignore `PasteImage` is explicit: inserting the bytes as a + // floating picture without a chosen anchor/size would be a new feature, not parity. + // A text representation, when the clipboard has one, still arrives as `Paste` above. + egui::Event::PasteImage(_) => {} + // PRIMARY middle-click paste belongs to egui's TextEdit; the grid has no caret to + // insert text at, so a middle click on a cell is deliberately not a paste. + egui::Event::MiddleClickPaste { .. } => {} + egui::Event::Text(_) if shortcut_pressed || shortcut_ran => {} + egui::Event::Text(text) if text.chars().all(|c| !c.is_control()) && !text.is_empty() => { + app.begin_edit(Some(text), false); + if let Some(ed) = app.editor.as_mut() { + ed.caret = ed.text.chars().count(); + } + return; + } + egui::Event::Key { key, physical_key, pressed: true, modifiers: m, .. } => { + use egui::Key; + if crate::keymap::dispatch(app, ctx, key, physical_key, m, geo, sh) { + shortcut_ran = true; + continue; + } + let cmd = m.command; + let shift = m.shift; + let mv = |app: &mut SheetApp, dr: i64, dc: i64| { + let from = app.session.active().map(|d| if shift { d.selection.moving_end() } else { d.selection.active }); + let _ = app.session.run("selection.move", json!({"dr": dr, "dc": dc, "extend": shift, "jump": cmd})); + app.grid.ensure_visible = true; + app.grid.reveal_moving_end = shift; + app.grid.reveal_from = from; + }; + match key { + Key::ArrowUp => mv(app, -1, 0), + Key::ArrowDown => mv(app, 1, 0), + // Arrows follow the screen: on a right-to-left sheet Left goes to the next column. + Key::ArrowLeft => mv(app, 0, if sh.right_to_left { 1 } else { -1 }), + Key::ArrowRight => mv(app, 0, if sh.right_to_left { -1 } else { 1 }), + Key::PageDown if !cmd => mv(app, page, 0), + Key::PageUp if !cmd => mv(app, -page, 0), + Key::PageDown => app.run_or_alert("sheet.next", json!({})), + Key::PageUp => app.run_or_alert("sheet.previous", json!({})), + Key::Home if cmd => { + let r = sh.freeze.map(|f| f.0).unwrap_or(0); + let c = sh.freeze.map(|f| f.1).unwrap_or(0); + let _ = app.session.run("selection.set", json!({"cell": CellRef::new(r, c).a1()})); + app.grid.ensure_visible = true; + } + Key::End if cmd => { + let end = sh.used_range().map(|u| u.end).unwrap_or_default(); + let _ = app.session.run("selection.set", json!({"cell": end.a1()})); + app.grid.ensure_visible = true; + } + Key::Home => { + let a = app.session.active().map(|d| d.selection.active).unwrap_or_default(); + let _ = app.session.run("selection.set", json!({"cell": CellRef::new(a.row, 0).a1()})); + app.grid.ensure_visible = true; + } + // Excel: Enter while a copied range is marked pastes it and ends copy mode. + Key::Enter if !m.alt && !m.ctrl && !shift && !cmd && app.session.clipboard.is_some() => { + app.run_or_alert("edit.paste", json!({})); + app.session.clipboard = None; + app.grid.ensure_visible = true; + } + Key::Enter => { + if m.alt || m.ctrl { + app.begin_edit(None, false); + } else { + app.move_after_enter(if shift { -1 } else { 1 }, 0); + } + } + Key::Tab => app.move_after_enter(0, if shift { -1 } else { 1 }), + Key::F2 => app.begin_edit(None, false), + Key::Delete if delete_selected_object(app, sh) => {} + Key::Delete => app.run_or_alert("edit.clearContents", json!({})), + Key::Backspace if delete_selected_object(app, sh) => {} + Key::Backspace => { + // Excel for Mac: Delete clears the cell and starts editing it. + app.run_or_alert("edit.clearContents", json!({})); + if app.session.active().is_some_and(|d| d.selection.is_single_cell()) { + app.begin_edit(Some(String::new()), false); + } + } + Key::Escape => { + app.session.clipboard = None; + app.session.format_painter = None; + } + Key::Space if !cmd && !shift && is_checkbox(sh, app) => app.run_or_alert("cell.toggleCheckbox", json!({})), + Key::F8 if m.alt => { + app.open_dialog("macros", json!({})); + } + Key::F9 => app.run_or_alert("formulas.calculateNow", json!({})), + Key::F4 if !cmd => {} + _ if cmd && !crate::keymap::altgr_text(ctx, m, ctx.os() == egui::os::OperatingSystem::Mac) => { + crate::ribbon::shortcut(app, key, m) + } + _ => {} + } + } + _ => {} + } + } +} diff --git a/crates/ui-egui/src/grid/menus.rs b/crates/ui-egui/src/grid/menus.rs new file mode 100644 index 0000000000..b239c7eac9 --- /dev/null +++ b/crates/ui-egui/src/grid/menus.rs @@ -0,0 +1,551 @@ +use super::editor::popup_dismissed; +use super::geo::Geo; +use super::tables::filter_table_range; +use super::*; + +/// Draws the filter-header popup: sort, search/select-all, apply/clear, a lazily installed table autofilter, new-open search focus and dismissal. +pub(super) fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) { + let Some((col, row, at)) = app.grid.filter_menu else { return }; + let Some(d) = app.session.active() else { return }; + let wb = d.wb.clone(); + let si = wb.active_sheet(); + let Some(sh) = wb.sheet(si) else { return }; + let _ = geo; + // Tables get an autofilter on demand. + let table_range = filter_table_range(sh, col, row); + if sh.autofilter.is_none() + && let Some(r) = table_range + { + let _ = app.session.run("data.filter", json!({"range": r.a1()})); + let _ = app.session.run("data.filter", json!({"range": r.a1()})); + if let Some(sh2) = app.session.active().and_then(|d| d.wb.active()).map(|s| s.autofilter.is_some()) + && !sh2 + { + let d = app.session.active_mut(); + if let Some(d) = d { + let wbm = Arc::make_mut(&mut d.wb); + if let Some(s) = wbm.sheet_mut(si) { + s.autofilter = Some(gridcraft_engine::model::AutoFilter { range: r, criteria: vec![] }); + } + } + } + } + let wb = app.session.active().map(|d| d.wb.clone()).unwrap_or(wb); + let values = gridcraft_engine::cmd::data::filter_values(&wb, si, col); + let key = egui::Id::unique(("filter_sel", col)); + let search_key = egui::Id::unique(("filter_search", col)); + let buttons_key = egui::Id::unique(("filter_buttons", col)); + // Enter is the menu's OK, as in Excel: taken before the search box (which would only drop its + // focus) and the value checkboxes (which would toggle) see it. A focused button keeps it as its click. + let buttons: Vec = ui.ctx().data_mut(|d| d.get_temp(buttons_key)).unwrap_or_default(); + let on_button = ui.ctx().memory(|m| m.focused()).is_some_and(|f| buttons.contains(&f)); + let enter = !on_button && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + let mut close = false; + let area = egui::Area::new(egui::Id::unique("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground); + let response = area.show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_width(230.0); + let ascending = ui.button("↑ Sort Ascending"); + if ascending.clicked() { + let _ = app.run("data.sortAscending", json!({"column": col_to_letters(col)})); + close = true; + } + let descending = ui.button("↓ Sort Descending"); + if descending.clicked() { + let _ = app.run("data.sortDescending", json!({"column": col_to_letters(col)})); + close = true; + } + ui.separator(); + ui.label(egui::RichText::new("Filter").strong()); + let mut checks: Vec<(String, bool)> = ui.ctx().data_mut(|d| d.get_temp::>(key)).unwrap_or(values.clone()); + // A search narrows the list; Select All and Apply then only cover the matches. + let mut search: String = ui.ctx().data_mut(|d| d.get_temp(search_key)).unwrap_or_default(); + let field = ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search").desired_width(f32::INFINITY)); + if !was_open { + field.request_focus(); + } + let needle = search.trim().to_lowercase(); + let matches = |v: &str| needle.is_empty() || v.to_lowercase().contains(&needle); + let all = checks.iter().filter(|(v, _)| matches(v)).all(|(_, b)| *b); + let mut all_new = all; + let label = if needle.is_empty() { "(Select All)" } else { "(Select All Search Results)" }; + if ui.checkbox(&mut all_new, label).changed() { + for c in checks.iter_mut().filter(|(v, _)| matches(v)) { + c.1 = all_new; + } + } + // Full width: shrunk to the longest value, the scroll bar sat mid-menu and the wheel only + // worked over the values themselves (#432). + egui::ScrollArea::vertical().max_height(220.0).auto_shrink([false, true]).show(ui, |ui| { + for (v, on) in checks.iter_mut().filter(|(v, _)| matches(v)) { + ui.checkbox(on, v.as_str()); + } + }); + ui.ctx().data_mut(|d| { + d.insert_temp(key, checks.clone()); + d.insert_temp(search_key, search.clone()); + }); + let checks: Vec<(String, bool)> = checks.iter().map(|(v, on)| (v.clone(), *on && matches(v))).collect(); + ui.separator(); + ui.horizontal(|ui| { + let clear = ui.button("Clear Filter"); + if clear.clicked() { + let _ = app.run("data.filterBy", json!({"column": col_to_letters(col), "clear": true})); + close = true; + } + // Nothing checked would hide every row, so Enter (unlike a click on Apply) does nothing then. + let any = checks.iter().any(|(_, on)| *on); + let apply = ui.button("Apply"); + ui.ctx().data_mut(|d| d.insert_temp(buttons_key, vec![ascending.id, descending.id, clear.id, apply.id])); + if apply.clicked() || (enter && any) { + let blanks = checks.iter().any(|(v, on)| v == "(Blanks)" && *on); + let vals: Vec = checks.iter().filter(|(v, on)| *on && v != "(Blanks)").map(|(v, _)| v.clone()).collect(); + let p = if checks.iter().all(|(_, b)| *b) { + json!({"column": col_to_letters(col), "clear": true}) + } else { + json!({"column": col_to_letters(col), "values": vals, "blanks": blanks}) + }; + let _ = app.run("data.filterBy", p); + close = true; + } + }); + }); + }); + if close || popup_dismissed(&response.response, was_open) { + ui.ctx().data_mut(|d| { + d.remove::>(key); + d.remove::(search_key); + d.remove::>(buttons_key); + }); + app.grid.filter_menu = None; + } +} + +/// Draws and dispatches the cell context menu, including Show Details when offered, clipboard and insert/format actions; closes on Esc or an outside press. +pub(super) fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { + let Some(at) = app.grid.context_menu else { return }; + let mut close = false; + let resp = egui::Area::new(egui::Id::unique("cell_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(200.0); + let insert_copied = insert_copied_item(app); + let mut items: Vec<(&str, &str)> = vec![ + ("Cut", "edit.cut"), + ("Copy", "edit.copy"), + ("Paste", "edit.paste"), + ("Paste Special…", "ui:pasteSpecial"), + ("-", ""), + ("Insert…", "ui:insertCells"), + ("Delete…", "ui:deleteCells"), + ("Clear Contents", "edit.clearContents"), + ("-", ""), + ("Filter by Selected Cell's Value", "ui:filterValue"), + ("Sort A to Z", "data.sortAscending"), + ("Sort Z to A", "data.sortDescending"), + ("-", ""), + ("New Comment", "ui:newComment"), + ("New Note", "ui:newNote"), + ("-", ""), + ("Format Cells…", "ui:formatCells"), + ("Pick From Drop-down List…", "ui:pickList"), + ("Define Name…", "ui:defineName"), + ("Link…", "ui:insertLink"), + ]; + 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 == "-" { + ui.separator(); + continue; + } + if ui.add(egui::Button::new(lang.tr(label)).frame_when_inactive(false).min_size(vec2(190.0, 20.0))).clicked() { + close = true; + match id { + "ui:pasteSpecial" => app.open_dialog("pasteSpecial", json!({})), + "ui:insertPaste" => app.open_dialog("insertPaste", json!({})), + "ui:insertCells" => app.open_dialog("insertCells", json!({})), + "ui:deleteCells" => app.open_dialog("deleteCells", json!({})), + "ui:formatCells" => app.open_dialog("formatCells", json!({})), + "ui:newComment" => app.open_dialog("comment", json!({"threaded": true})), + "ui:newNote" => app.open_dialog("comment", json!({"threaded": false})), + "ui:defineName" => app.open_dialog("defineName", json!({})), + "ui:insertLink" => app.open_dialog("insertLink", json!({})), + "ui:pickList" => app.open_dialog("pickList", json!({})), + "ui:filterValue" => { + if let Some(d) = app.session.active() { + let a = d.selection.active; + let text = d.wb.active().map(|sh| gridcraft_engine::display::cell_text(&d.wb, sh, a)).unwrap_or_default(); + let has = d.wb.active().is_some_and(|sh| sh.autofilter.is_some()); + if !has { + let _ = app.run("data.filter", json!({})); + } + app.run_or_alert("data.filterBy", json!({"column": col_to_letters(a.col), "values": [text]})); + } + } + "edit.copy" | "edit.cut" => { + app.copy_to_clipboard(ui.ctx(), id); + } + "edit.paste" => app.paste_from_clipboard(), + other => app.run_or_alert(other, json!({})), + } + } + } + }); + }); + // A quick right-click presses and releases in one frame, and the menu's corner snaps to the + // nearest physical pixel, which can put the pointer just outside it: that press opened the + // menu, so it doesn't count as a click elsewhere. + let opened_now = app.grid.context_menu_frame == ui.ctx().cumulative_frame_nr(); + if close + || ui.input(|i| i.key_pressed(egui::Key::Escape)) + || (!opened_now + && ui.input(|i| i.pointer.any_pressed()) + && !resp.response.contains_pointer() + && !resp.response.hovered() + && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !resp.response.rect.contains(p))) + { + app.grid.context_menu = None; + } +} + +/// 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. +pub(super) fn set_fill_tag(app: &mut SheetApp, src: RangeRef, target: RangeRef) { + app.grid.fill_tag = + app.session.active().map(|d| FillTag { src, target, uid: d.uid, sheet: d.wb.active_sheet(), revision: d.revision, undo_len: d.undo.len() }); +} + +/// 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, 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. +pub(super) 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 }; + let current = app.session.active().is_some_and(|d| { + d.uid == tag.uid + && d.wb.active_sheet() == tag.sheet + && d.revision == tag.revision + && d.undo.len() == tag.undo_len + && d.selection.ranges.len() == 1 + && d.selection.ranges.first().is_some_and(|r| *r == tag.target) + }); + if !current { + app.grid.fill_tag = None; + return; + } + if app.editor.is_some() || app.dialog.is_some() || app.grid.drag != Drag::None { + return; + } + let size = vec2(18.0, 18.0); + let corner = geo.range_rect(sh, tag.target).right_bottom(); + let button = geo.mirror_rect(Rect::from_min_size(corner + vec2(2.0, 2.0), size)); + // `button` is in screen space, so compare it with the mirrored cells area. + if !geo.mirror_rect(geo.cells).contains_rect(button) { + return; + } + let lang = crate::i18n::current(ui.ctx()); + let resp = ui.interact(button, egui::Id::unique("gridcraft.fill_options_tag"), Sense::click()).on_hover_text(lang.tr("Auto Fill Options")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, lang.tr("Auto Fill Options"))); + let t = Tokens::get(ui.ctx()); + let p = ui.painter(); + p.rect_filled(button, 3.0, if resp.hovered() { t.hover } else { t.window }); + p.rect_stroke(button, 3.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); + // Two small cells and a drop-down arrow. + let c = button.center(); + p.rect_stroke(Rect::from_min_size(pos2(c.x - 6.0, c.y - 6.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); + p.rect_stroke(Rect::from_min_size(pos2(c.x - 3.0, c.y - 3.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); + p.add(egui::Shape::convex_polygon( + vec![pos2(c.x + 1.0, c.y + 5.0), pos2(c.x + 7.0, c.y + 5.0), pos2(c.x + 4.0, c.y + 8.0)], + t.text, + 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 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(); + } + } + }); + if let Some(mode) = chosen { + if let Err(e) = app.run("edit.undo", json!({})) { + app.grid.fill_tag = None; + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + return; + } + match app.run("edit.autoFill", json!({"source": tag.src.a1(), "target": tag.target.a1(), "mode": mode})) { + Ok(_) => set_fill_tag(app, tag.src, tag.target), + Err(e) => { + app.grid.fill_tag = None; + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } + } +} + +/// The menu a right-drag of the fill handle opens on release, as in Excel. +pub(super) 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::unique("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 lang = crate::i18n::current(ui.ctx()); + for (label, mode) in items { + if label == "-" { + ui.separator(); + continue; + } + if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() { + close = true; + if mode == "ui:series" { + let _ = app.session.run("selection.set", json!({"range": target.union(&src).a1(), "active": src.start.a1()})); + let direction = if target.width() != src.width() { "rows" } else { "columns" }; + app.open_dialog("series", json!({"direction": direction})); + } else if let Err(e) = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1(), "mode": mode})) { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } + } + }); + }); + if close || popup_dismissed(&response.response, was_open) { + app.grid.fill_menu = None; + } +} + +/// The right-click menu of a chart, picture, shape or slicer. +pub(super) fn object_menu(app: &mut SheetApp, ui: &mut egui::Ui) { + let Some((at, kind, id)) = app.grid.object_menu else { return }; + let mut close = false; + let resp = egui::Area::new(egui::Id::unique("object_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(200.0); + let mut items = vec![("Delete", "object.delete")]; + if kind == "chart" { + items.extend([("-", ""), ("Select Data…", "ui:selectData"), ("Format Chart Area…", "ui:formatChart")]); + } + let lang = crate::i18n::current(ui.ctx()); + for (label, action) in items { + if label == "-" { + ui.separator(); + continue; + } + if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(190.0, 20.0))).clicked() { + close = true; + match action { + "ui:selectData" => app.open_dialog("selectData", json!({"chart": id})), + "ui:formatChart" => app.grid.pane = Some("formatChart".into()), + _ => { + app.run_or_alert("object.delete", json!({"kind": kind, "id": id})); + app.selected_chart = None; + } + } + } + } + }); + }); + if close + || ui.input(|i| i.key_pressed(egui::Key::Escape)) + || (ui.input(|i| i.pointer.any_pressed()) + && !resp.response.contains_pointer() + && !resp.response.hovered() + && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !resp.response.rect.contains(p))) + { + app.grid.object_menu = None; + } +} + +/// The warning button beside an active cell holding a number stored as text, as in Excel: its +/// menu names the problem and offers Convert to Number for the selection (#1994). +pub(super) fn error_check_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) { + if app.editor.is_some() || app.dialog.is_some() || sh.show_formulas || app.grid.drag != Drag::None { + return; + } + let Some(active) = app.session.active().map(|d| d.selection.active) else { return }; + if sh.cell(active).and_then(gridcraft_engine::cmd::formulas::number_stored_as_text).is_none() { + return; + } + let cell = geo.cell_rect(sh, active); + let size = vec2(18.0, 18.0); + // Left of the cell, or right of it when that would cover the row headings. + let left = if cell.left() - size.x - 2.0 >= geo.cells.left() { cell.left() - size.x - 2.0 } else { cell.right() + 2.0 }; + let tag = geo.mirror_rect(Rect::from_min_size(pos2(left, cell.top()), size)); + if !geo.cells.intersects(tag) { + return; + } + let lang = crate::i18n::current(ui.ctx()); + let resp = ui.interact(tag, egui::Id::unique("gridcraft.error_check_tag"), Sense::click()).on_hover_text(lang.tr("Number Stored as Text")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, lang.tr("Error checking options"))); + let t = Tokens::get(ui.ctx()); + let p = ui.painter(); + p.rect_filled(tag, 3.0, if resp.hovered() { t.hover } else { t.window }); + p.rect_stroke(tag, 3.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); + // A warning sign: an amber diamond with an exclamation mark. + let c = tag.center(); + let r = 6.0; + p.add(egui::Shape::convex_polygon( + vec![pos2(c.x, c.y - r), pos2(c.x + r, c.y), pos2(c.x, c.y + r), pos2(c.x - r, c.y)], + t.warning_fill, + Stroke::NONE, + )); + p.text(c, egui::Align2::CENTER_CENTER, "!", egui::FontId::proportional(10.0), t.warning_ink); + egui::Popup::menu(&resp).show(|ui| { + ui.label(egui::RichText::new(lang.tr("Number Stored as Text")).strong()); + ui.separator(); + if ui.button(lang.tr("Convert to Number")).clicked() { + app.run_or_alert("formulas.convertToNumber", json!({})); + ui.close(); + } + }); +} + +/// Draws row/column header context menus with insert/delete, format, size, hide/unhide and group actions, plus Insert Copied Cells when available. +pub(super) fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { + let Some((at, rows)) = app.grid.header_menu else { return }; + let mut close = false; + let response = egui::Area::new(egui::Id::unique("header_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 insert_copied = insert_copied_item(app); + let mut items: Vec<(&str, &str)> = if rows { + vec![ + ("Cut", "edit.cut"), + ("Copy", "edit.copy"), + ("Paste", "edit.paste"), + ("-", ""), + ("Insert", "home.insertRows"), + ("Delete", "home.deleteRows"), + ("Clear Contents", "edit.clearContents"), + ("-", ""), + ("Format Cells…", "ui:formatCells"), + ("Row Height…", "ui:rowHeight"), + ("AutoFit Row Height", "home.autofitRowHeight"), + ("Hide", "home.hideRows"), + ("Unhide", "home.unhideRows"), + ("Group", "data.group"), + ] + } else { + vec![ + ("Cut", "edit.cut"), + ("Copy", "edit.copy"), + ("Paste", "edit.paste"), + ("-", ""), + ("Insert", "home.insertColumns"), + ("Delete", "home.deleteColumns"), + ("Clear Contents", "edit.clearContents"), + ("-", ""), + ("Format Cells…", "ui:formatCells"), + ("Column Width…", "ui:columnWidth"), + ("AutoFit Column Width", "home.autofitColumnWidth"), + ("Hide", "home.hideColumns"), + ("Unhide", "home.unhideColumns"), + ("Sort A to Z", "data.sortAscending"), + ("Group", "data.group"), + ] + }; + if let Some(item) = insert_copied { + items.insert(5, item); + } + let lang = crate::i18n::current(ui.ctx()); + for (label, id) in items { + if label == "-" { + ui.separator(); + continue; + } + if ui.add(egui::Button::new(lang.tr(label)).frame_when_inactive(false).min_size(vec2(180.0, 20.0))).clicked() { + close = true; + match id { + "ui:insertPaste" => app.open_dialog("insertPaste", json!({})), + "ui:rowHeight" => app.open_dialog("rowHeight", json!({})), + "ui:formatCells" => app.open_dialog("formatCells", json!({})), + "ui:columnWidth" => app.open_dialog("columnWidth", json!({})), + "edit.copy" | "edit.cut" => { + app.copy_to_clipboard(ui.ctx(), id); + } + "edit.paste" => app.paste_from_clipboard(), + other => app.run_or_alert(other, json!({})), + } + } + } + }); + }); + if close || popup_dismissed(&response.response, was_open) { + app.grid.header_menu = None; + } +} + +/// The context-menu entry for Insert Copied Cells / Insert Cut Cells, when the clipboard holds a +/// copy or cut of this app: whole rows or columns insert at once, a block asks which way to shift +/// (Insert Paste). A cut block can only be pasted, so it gets no entry. +fn insert_copied_item(app: &SheetApp) -> Option<(&'static str, &'static str)> { + let clip = app.session.clipboard.as_ref()?; + let lines = clip.selected.is_full_rows() != clip.selected.is_full_cols(); + let cut = clip.cut && app.session.active().is_some_and(|d| d.uid == clip.doc_uid); + match (cut, lines) { + (true, true) => Some(("Insert Cut Cells", "home.insertCopiedCells")), + (true, false) => None, + (false, true) => Some(("Insert Copied Cells", "home.insertCopiedCells")), + (false, false) => Some(("Insert Copied Cells…", "ui:insertPaste")), + } +} + +pub(super) fn is_checkbox(sh: &Sheet, app: &SheetApp) -> bool { + let Some(d) = app.session.active() else { return false }; + d.wb.styles.get(sh.style_id(d.selection.active)).num_fmt.as_str() == "checkbox" +} diff --git a/crates/ui-egui/src/grid/quadrant.rs b/crates/ui-egui/src/grid/quadrant.rs new file mode 100644 index 0000000000..aa9e6fc070 --- /dev/null +++ b/crates/ui-egui/src/grid/quadrant.rs @@ -0,0 +1,474 @@ +use super::cell_paint::{ + Look, cell_text_y, center_across_end, col32, display_text, look, merged_edges, paint_border, paint_cf_icon, painted_halign, rotated_text_pos, + shrink_font, wrapped_cell_text_y, +}; +use super::geo::Geo; +use super::tables::{filter_button_rect, paint_filter_button}; +use super::*; + +/// Mirror what the grid painted into `layer` (the shape indices in `range`) for a right-to-left +/// sheet. Geometry is reflected; text and meshes (glyphs, pictures) keep their reading direction +/// and only move so that their box lands mirrored. Floating objects (`objects`: shape index +/// ranges with their logical rect) move as a whole, so a chart keeps its axes and a picture +/// isn't flipped. +pub(super) fn mirror_painted( + ctx: &egui::Context, + layer: egui::LayerId, + range: std::ops::Range, + geo: &Geo, + objects: &[crate::chartview::PaintedObject], +) { + let axis = geo.rect.left() + geo.rect.right(); + ctx.graphics_mut(|g| { + let list = g.entry(layer); + for i in range { + let object = objects.iter().find(|o| o.shapes.contains(&i)); + list.mutate_shape(egui::layers::ShapeIdx(i), |cs| { + cs.clip_rect = geo.mirror_rect(cs.clip_rect); + match object { + Some(o) => cs.shape.translate(vec2(axis - o.rect.right() - o.rect.left(), 0.0)), + None => mirror_shape(&mut cs.shape, axis), + } + }); + } + }); +} + +/// Reflect one shape across the vertical line `x = axis / 2`. +fn mirror_shape(shape: &mut egui::Shape, axis: f32) { + use egui::Shape; + let flip = |p: &mut Pos2| p.x = axis - p.x; + match shape { + Shape::Noop | Shape::Callback(_) => {} + Shape::Vec(v) => v.iter_mut().for_each(|s| mirror_shape(s, axis)), + Shape::Circle(c) => flip(&mut c.center), + Shape::Ellipse(e) => flip(&mut e.center), + Shape::LineSegment { points, .. } => points.iter_mut().for_each(flip), + Shape::Path(p) => { + p.points.iter_mut().for_each(flip); + // Keep the preferred (clockwise) winding for filled polygons. + p.points.reverse(); + } + Shape::Rect(r) => { + r.rect = Rect::from_min_max(pos2(axis - r.rect.right(), r.rect.top()), pos2(axis - r.rect.left(), r.rect.bottom())); + let c = r.corner_radius; + r.corner_radius = egui::CornerRadius { nw: c.ne, ne: c.nw, sw: c.se, se: c.sw }; + } + Shape::QuadraticBezier(b) => b.points.iter_mut().for_each(flip), + Shape::CubicBezier(b) => b.points.iter_mut().for_each(flip), + _ => { + let b = shape.visual_bounding_rect(); + if b.is_finite() { + shape.translate(vec2(axis - b.right() - b.left(), 0.0)); + } + } + } +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn paint_quadrant( + p: &Painter, + geo: &Geo, + wb: &Workbook, + si: usize, + sh: &Sheet, + rows: (u32, u32), + cols: (u32, u32), + cf: &mut CfCache, + t: &Tokens, +) { + let z = geo.z; + let (r0, r1) = rows; + let (c0, c1) = cols; + let range = RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)); + // Merges are looked up 20 columns beyond the quadrant too: text overflowing in from off-screen cells stops at them. + let near = RangeRef::new(CellRef::new(r0, c0.saturating_sub(20)), CellRef::new(r1, c1.saturating_add(20).min(MAX_COLS - 1))); + let merges: Vec = sh.merges.iter().filter(|m| m.intersects(&near)).copied().collect(); + let in_merge = |c: CellRef| merges.iter().find(|m| m.contains(c)).copied(); + // Gridlines. + if sh.show_gridlines { + let stroke = Stroke::new(1.0, t.gridline); + let top = geo.y(sh, r0); + let bottom = geo.y(sh, r1 + 1); + let left = geo.x(sh, c0); + let right = geo.x(sh, c1 + 1); + for c in c0..=c1 + 1 { + if c <= c1 && sh.col_width(c) == 0.0 { + continue; + } + let x = geo.x(sh, c).round() - 0.5; + p.line_segment([pos2(x, top), pos2(x, bottom)], stroke); + } + for r in r0..=r1 + 1 { + if r <= r1 && sh.row_height(r) == 0.0 { + continue; + } + let y = geo.y(sh, r).round() - 0.5; + p.line_segment([pos2(left, y), pos2(right, y)], stroke); + } + } + // Merged areas: blank them (no inner gridlines) and paint as one cell. + let mut cells: Vec = Vec::new(); + for r in r0..=r1 { + if sh.row_height(r) == 0.0 { + continue; + } + for c in c0..=c1 { + if sh.col_width(c) == 0.0 { + continue; + } + cells.push(CellRef::new(r, c)); + } + } + let empty_ok = |c: CellRef| -> bool { sh.value_ref(c).is_none_or(Value::is_empty) && in_merge(c).is_none() }; + // Backgrounds. + let mut looks: Vec<(CellRef, Rect, Look, Option)> = Vec::with_capacity(cells.len().min(20_000)); + for &c in &cells { + let (rect, anchor) = match in_merge(c) { + Some(m) if m.start != c && !(c.row == r0.max(m.start.row) && c.col == c0.max(m.start.col)) => continue, + Some(m) => (geo.range_rect(sh, m), m.start), + None => (geo.cell_rect(sh, c), c), + }; + let (lk, cfl) = look(wb, si, sh, anchor, cf); + if let Some(f) = lk.fill { + p.rect_filled(rect.expand(0.0), 0.0, f); + } else if in_merge(c).is_some() { + p.rect_filled(rect.shrink(0.5), 0.0, Color32::WHITE); + } + looks.push((anchor, rect, lk, cfl)); + } + // Text cells just outside the quadrant whose text may overflow into it: the nearest non-empty + // cell left of `c0` and right of `c1` in each row (within the same frozen / scrolled pane). + let mut outside: Vec<(CellRef, Rect, Look, Option)> = Vec::new(); + let same_pane = |col: u32| (col < geo.fc) == (c0 < geo.fc); + for r in r0..=r1 { + if sh.row_height(r) == 0.0 { + continue; + } + let left = (c0.saturating_sub(20)..c0).rev().map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n)); + let right = (c1.saturating_add(1)..=c1.saturating_add(20).min(MAX_COLS - 1)).map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n)); + for n in [left, right].into_iter().flatten() { + if same_pane(n.col) && sh.col_width(n.col) > 0.0 && in_merge(n).is_none() { + let (lk, cfl) = look(wb, si, sh, n, cf); + outside.push((n, geo.cell_rect(sh, n), lk, cfl)); + } + } + } + // Text and per-cell decorations. + for (c, rect, lk, cfl) in looks.iter().chain(&outside) { + let c = *c; + if let Some(picture) = sh.cell_pictures.get(&c) { + crate::cell_pictures::paint(p, *rect, picture); + continue; + } + let v = sh.value(c); + if let Some(l) = cfl + && let Some((frac, rgb, gradient)) = l.bar + { + let bar = Rect::from_min_size(pos2(rect.left() + 2.0, rect.top() + 2.0), vec2((rect.width() - 4.0) * frac, rect.height() - 4.0)); + let col = theme::color32(rgb); + if gradient { + let mut mesh = egui::Mesh::default(); + let light = Color32::from_rgb( + ((rgb[0] as u16 + 255 * 3) / 4) as u8, + ((rgb[1] as u16 + 255 * 3) / 4) as u8, + ((rgb[2] as u16 + 255 * 3) / 4) as u8, + ); + let i = mesh.vertices.len() as u32; + for (pt, cc) in [(bar.left_top(), col), (bar.right_top(), light), (bar.right_bottom(), light), (bar.left_bottom(), col)] { + mesh.colored_vertex(pt, cc); + } + mesh.add_triangle(i, i + 1, i + 2); + mesh.add_triangle(i, i + 2, i + 3); + p.add(mesh); + p.rect_stroke(bar, 0.0, Stroke::new(1.0, col), StrokeKind::Inside); + } else { + p.rect_filled(bar, 0.0, col); + } + } + let mut text_left = rect.left(); + if let Some(l) = cfl + && let Some((set, idx)) = &l.icon + { + let r = Rect::from_min_size(pos2(rect.left() + 2.0, rect.center().y - 6.0 * z), vec2(12.0 * z, 12.0 * z)); + paint_cf_icon(p, r, set, *idx); + text_left += 15.0 * z; + } + if lk.style.num_fmt.as_str() == "checkbox" && matches!(v, Value::Bool(_) | Value::Empty) { + let s = (11.0 * z * 1.2).clamp(8.0, 24.0); + let b = Rect::from_center_size(rect.center(), vec2(s, s)); + let on = v == Value::Bool(true); + p.rect_filled(b, 2.0, if on { Color32::from_rgb(0x10, 0x7C, 0x41) } else { Color32::WHITE }); + p.rect_stroke( + b, + 2.0, + Stroke::new(1.0, if on { Color32::from_rgb(0x10, 0x7C, 0x41) } else { Color32::from_gray(110) }), + StrokeKind::Inside, + ); + if on { + crate::icons::paint_on_sheet(p, b.shrink(1.5), crate::icons::Icon::Check, Color32::WHITE); + } + continue; + } + if cfl.as_ref().is_some_and(|l| l.hide_value) || v.is_empty() { + continue; + } + // Align and clip header text inside the content area, away from the dropdown. + let filter_space = sh.filter_button_space(c) * z; + let text_rect = Rect::from_min_max(rect.min, pos2((rect.right() - filter_space).max(rect.left()), rect.bottom())); + let rect = &text_rect; + let st = &lk.style; + let size = st.font.size * 96.0 / 72.0 * z; + let fam = theme::cell_family(&st.font.name, st.font.bold, st.font.italic); + let font = FontId::new(size, fam); + // Digits fit by the cell's own font and size, not the default 11pt (9pt marks in narrow columns). + let char_w = p.layout_no_wrap("0".to_string(), font.clone(), Color32::PLACEHOLDER).size().x.max(1.0); + let avail_w = rect.right() - text_left; + let (mut text, ncolor, numeric, fill_char) = display_text(wb, sh, c, &v, st, avail_w / z.max(0.1) * z, char_w); + if sh.show_formulas && sh.cell(c).is_some_and(|cell| cell.formula.is_some()) { + text = crate::formula_locale::display(&text, crate::i18n::current(p.ctx()).formula_locale(), wb, si); + } + if text.is_empty() { + continue; + } + let color = ncolor.unwrap_or(lk.text_color); + let halign = match st.align.h { + HAlign::General => match v { + Value::Number(_) => HAlign::Right, + Value::Bool(_) | Value::Error(_) => HAlign::Center, + _ if numeric => HAlign::Right, + _ => HAlign::Left, + }, + h => painted_halign(h, geo.rtl), + }; + let indent = st.align.indent as f32 * 9.0 * z; + let pad = 2.5 * z; + let rotated = st.align.rotation != 0 && st.align.rotation != 255; + // Rotated text wraps along its own direction (below), not across the column width. + if !rotated && (st.align.wrap || st.align.h == HAlign::Justify) { + let wrap_w = (rect.width() - 2.0 * pad - indent).max(4.0); + let mut job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, wrap_w); + job.halign = match halign { + HAlign::Center | HAlign::CenterAcross => egui::Align::Center, + HAlign::Right => egui::Align::RIGHT, + _ => egui::Align::LEFT, + }; + let galley = p.layout_job(job); + let x = match halign { + HAlign::Center | HAlign::CenterAcross => rect.center().x, + HAlign::Right => rect.right() - pad, + _ => text_left + pad + indent, + }; + let y = wrapped_cell_text_y(st.align.v, *rect, galley.size().y, z); + p.with_clip_rect(rect.intersect(p.clip_rect())).galley(pos2(x, y), galley, color); + continue; + } + // Wrap Text off: Excel ignores the line breaks and draws the whole string on one line. + let first_line: String = text.chars().filter(|&ch| ch != '\n' && ch != '\r').collect(); + // The fill position indexes `text`; move it back by the breaks removed before it. + let fill_char = fill_char.map(|(ch, pos)| (ch, pos.saturating_sub(text.get(..pos).map_or(0, |s| s.matches(['\n', '\r']).count())))); + // Shrink to fit: too-wide content is drawn in a smaller font so it fits the cell (or the + // merged area): it neither spills, nor is clipped, nor turns into ####. + let small = (st.align.shrink && !rotated && st.align.h != HAlign::Fill && fill_char.is_none()) + .then(|| shrink_font(p, &first_line, &font, avail_w - 2.0 * pad - indent)) + .flatten(); + let shrunk = small.is_some(); + let font = small.unwrap_or(font); + let mut galley = p.layout_no_wrap(first_line.clone(), font.clone(), color); + let mut w = galley.size().x; + // Numbers and dates never overflow: they show #### when too narrow. + let hashed = !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill; + if hashed { + // Measured on `#` itself: it is wider than `0` in some fonts, and #### must fit its cell. + let hash_w = p.layout_no_wrap("#".to_string(), font.clone(), color).size().x.max(1.0); + let n = ((avail_w - 2.0 * pad) / hash_w).floor().max(1.0) as usize; + galley = p.layout_no_wrap("#".repeat(n), font.clone(), color); + w = galley.size().x; + } + // Repeat fill (`*` in number formats) / Fill alignment. A number already shown as #### keeps + // it: rebuilding it from the text would overflow and clip its leading digits. + if let Some((ch, pos)) = fill_char + && !hashed + && pos <= first_line.len() + { + let base_w = w; + let cw = p.layout_no_wrap(ch.to_string(), font.clone(), color).size().x.max(1.0); + let n = (((avail_w - 2.0 * pad) - base_w) / cw).floor().max(0.0) as usize; + let mut s = first_line.clone(); + if first_line.is_char_boundary(pos) { + s.insert_str(pos, &ch.to_string().repeat(n)); + } + galley = p.layout_no_wrap(s, font.clone(), color); + w = galley.size().x; + } + // Center Across Selection: the text is centred over this cell and the empty cells to its right that also use it. + let across_end = if halign == HAlign::CenterAcross && !rotated && in_merge(c).is_none() { + center_across_end(c.col, c1 + 20, |col| { + let n = CellRef::new(c.row, col); + empty_ok(n) && wb.styles.get(sh.style_id(n)).align.h == HAlign::CenterAcross + }) + } else { + c.col + }; + // Overflow into empty neighbours for text (not upright text, whose width is its line height). + let mut clip = *rect; + let overflows = !numeric && !matches!(v, Value::Number(_)) && st.align.rotation.abs() != 90 && w > rect.width() - 2.0 * pad; + if filter_space == 0.0 && (overflows || across_end > c.col) && in_merge(c).is_none() { + let mut right = rect.right(); + let mut left = rect.left(); + if across_end > c.col { + right = geo.x(sh, across_end + 1); + } + if matches!(halign, HAlign::Left | HAlign::Center | HAlign::Justify) { + let mut cc = c.col + 1; + while right - rect.left() < w + 2.0 * pad && cc < MAX_COLS && empty_ok(CellRef::new(c.row, cc)) && cc <= c1 + 20 { + right = geo.x(sh, cc + 1); + cc += 1; + } + } + if matches!(halign, HAlign::Right | HAlign::Center) { + let mut cc = c.col; + while rect.right() - left < w + 2.0 * pad && cc > 0 && empty_ok(CellRef::new(c.row, cc - 1)) && cc + 20 > c0 { + cc -= 1; + left = geo.x(sh, cc); + } + } + clip = Rect::from_min_max(pos2(left, rect.top()), pos2(right, rect.bottom())); + // Paint over the gridlines the text passes through. + if lk.fill.is_none() { + let inner = Rect::from_min_max(pos2(clip.left() + 1.0, clip.top() + 0.5), pos2(clip.right() - 1.0, clip.bottom() - 1.0)); + let extra_l = Rect::from_min_max(inner.min, pos2(rect.left() + 1.0, inner.bottom())); + let extra_r = Rect::from_min_max(pos2(rect.right() - 1.0, inner.top()), inner.max); + if extra_l.width() > 0.0 { + p.rect_filled(extra_l, 0.0, t.grid_bg); + } + if extra_r.width() > 0.0 { + p.rect_filled(extra_r, 0.0, t.grid_bg); + } + } + } + let x = match halign { + HAlign::CenterAcross => clip.center().x - w / 2.0, + HAlign::Center | HAlign::Distributed => rect.center().x - w / 2.0, + HAlign::Right => rect.right() - pad - w - indent, + _ => text_left + pad + indent, + }; + let gh = galley.size().y; + let y = cell_text_y(st.align.v, *rect, gh, z); + let cp = p.with_clip_rect(clip.intersect(p.clip_rect())); + if rotated { + let angle = -(st.align.rotation as f32).to_radians(); + // Upright (±90°) wrapped text wraps to the row height: lines then stack across the column. + let galley = if st.align.wrap && st.align.rotation.abs() == 90 { + let job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, (rect.height() - 2.0 * pad).max(4.0)); + p.layout_job(job) + } else { + galley.clone() + }; + let pos = rotated_text_pos(*rect, galley.size(), angle, painted_halign(st.align.h, geo.rtl), st.align.v, pad, indent); + let mut shape = egui::epaint::TextShape::new(pos, galley, color); + shape.angle = angle; + cp.add(shape); + } else { + cp.galley(pos2(x, y), galley.clone(), color); + } + let baseline = y + gh * 0.82; + match st.font.underline { + Underline::None => {} + Underline::Double | Underline::DoubleAccounting => { + cp.line_segment([pos2(x, baseline + 1.0), pos2(x + w, baseline + 1.0)], Stroke::new(1.0, color)); + cp.line_segment([pos2(x, baseline + 3.0), pos2(x + w, baseline + 3.0)], Stroke::new(1.0, color)); + } + _ => { + cp.line_segment([pos2(x, baseline + 1.0), pos2(x + w, baseline + 1.0)], Stroke::new(1.0, color)); + } + } + if st.font.strike { + cp.line_segment([pos2(x, y + gh * 0.55), pos2(x + w, y + gh * 0.55)], Stroke::new(1.0, color)); + } + } + // Borders (over fills and text). + for (c, rect, lk, _) in &looks { + let b = &lk.style.borders; + let merge = merges.iter().find(|m| m.start == *c); + let edges = match merge { + // A merged area's outline lives on the cells along each of its edges (Excel writes the + // right edge on the last column's cells and the bottom edge on the last row's), so each + // edge is drawn cell by cell from the cell it belongs to. + Some(m) => merged_edges(wb, si, sh, geo, *m, *rect, (r0, r1), (c0, c1), cf), + None => vec![ + (b.top, [rect.left_top(), rect.right_top()]), + (b.bottom, [rect.left_bottom(), rect.right_bottom()]), + (b.left, [rect.left_top(), rect.left_bottom()]), + (b.right, [rect.right_top(), rect.right_bottom()]), + ], + }; + for (line, pts) in edges { + if line.is_none() { + continue; + } + let col = col32(line.color, wb, Color32::BLACK); + paint_border(p, pts, line.style, col); + } + if !b.diag_down.is_none() { + paint_border(p, [rect.left_top(), rect.right_bottom()], b.diag_down.style, col32(b.diag_down.color, wb, Color32::BLACK)); + } + if !b.diag_up.is_none() { + paint_border(p, [rect.left_bottom(), rect.right_top()], b.diag_up.style, col32(b.diag_up.color, wb, Color32::BLACK)); + } + // Comment / note indicator. + if let Some(cm) = sh.comments.get(c) { + let tri_col = if cm.threaded { Color32::from_rgb(0x88, 0x55, 0xC8) } else { Color32::from_rgb(0xD0, 0x2E, 0x2E) }; + let s = 6.0 * z.max(0.7); + p.add(egui::Shape::convex_polygon( + vec![rect.right_top(), pos2(rect.right(), rect.top() + s), pos2(rect.right() - s, rect.top())], + tri_col, + Stroke::NONE, + )); + } + // Background error checking: a number stored as text gets Excel's green corner (#1994). + if !sh.show_formulas && sh.cell(*c).and_then(gridcraft_engine::cmd::formulas::number_stored_as_text).is_some() { + let s = 6.0 * z.max(0.7); + p.add(egui::Shape::convex_polygon( + vec![rect.left_top(), pos2(rect.left() + s, rect.top()), pos2(rect.left(), rect.top() + s)], + t.error_check_green, + Stroke::NONE, + )); + } + } + // Sparklines. + for sp in &sh.sparklines { + if !range.contains(sp.cell) { + continue; + } + let rect = geo.cell_rect(sh, sp.cell).shrink(3.0); + let vals: Vec> = match gridcraft_engine::calc::evaluate(wb, si, sp.cell, &sp.source) { + Value::Array(a) => a.data.iter().map(Value::as_f64).collect(), + v => vec![v.as_f64()], + }; + let col = sp.color.resolve(&wb.theme).unwrap_or([0x2B, 0x7C, 0xD3]); + let prims = gridcraft_chart::render_sparkline(sp.kind, &vals, [col[0], col[1], col[2], 255], sp.markers, rect.width(), rect.height()); + crate::chartview::paint_prims(p, rect.min, &prims, 1.0); + } + // Filter buttons. + let mut buttons: Vec<(CellRef, bool)> = Vec::new(); + if let Some(af) = &sh.autofilter { + for col in af.range.start.col..=af.range.end.col { + let active = af.criteria.iter().any(|(o, _)| af.range.start.col + o == col); + buttons.push((CellRef::new(af.range.start.row, col), active)); + } + } + for tb in &sh.tables { + if tb.filter_button && tb.header_row { + for col in tb.range.start.col..=tb.range.end.col { + buttons.push((CellRef::new(tb.range.start.row, col), false)); + } + } + } + for (c, active) in buttons { + if !range.contains(c) { + continue; + } + paint_filter_button(p, filter_button_rect(geo, sh, c), active); + } +} diff --git a/crates/ui-egui/src/grid/selection.rs b/crates/ui-egui/src/grid/selection.rs new file mode 100644 index 0000000000..3b5339f61e --- /dev/null +++ b/crates/ui-egui/src/grid/selection.rs @@ -0,0 +1,419 @@ +use super::geo::Geo; +#[cfg(test)] +use super::interact::{hidden_col_edge, hidden_row_edge}; +#[cfg(test)] +use super::show::hidden_around; +use super::*; + +/// Where the active cell is on screen: the whole merged cell when it is part of one. +fn active_rect(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection) -> Rect { + match sh.merge_at(sel.active) { + Some(m) => geo.range_rect(sh, m), + None => geo.cell_rect(sh, sel.active), + } +} + +/// The rectangles shaded for a selection: every area except the active cell, which stays clear so +/// it is plain where typing goes. A single selected cell has no fill. `outline` is where the +/// current area is drawn this frame: it glides to a new selection, and its fill with it. +pub fn selection_fill(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, outline: Rect) -> Vec { + selection_fill_where(geo, sh, sel, outline, |_| true) +} + +/// `selection_fill` for the areas `keep` accepts (a frozen pane draws only the areas it shows). +fn selection_fill_where(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, outline: Rect, keep: impl Fn(&RangeRef) -> bool) -> Vec { + if sel.is_single_cell() { + return vec![]; + } + let active = active_rect(geo, sh, sel); + let area = |r: &RangeRef| if *r == sel.current() { outline } else { geo.range_rect(sh, *r) }; + sel.ranges.iter().filter(|r| keep(r)).flat_map(|r| rect_around(area(r), active)).collect() +} + +/// `area` without `hole`: the strips above and below the hole, then what is left beside it. The +/// selection fill is translucent, so the active cell can't be cleared by painting over it; the +/// pieces never overlap for the same reason. Pieces with no size, or whose numbers aren't finite, +/// are dropped. +fn rect_around(area: Rect, hole: Rect) -> impl Iterator { + // `intersect` takes the sane side of a NaN, which would make a broken hole cover the area. + let hole = if hole.is_finite() { area.intersect(hole) } else { Rect::NOTHING }; + let parts = if hole.is_positive() { + [ + Rect::from_min_max(area.min, pos2(area.max.x, hole.min.y)), + Rect::from_min_max(pos2(area.min.x, hole.max.y), area.max), + Rect::from_min_max(pos2(area.min.x, hole.min.y), pos2(hole.min.x, hole.max.y)), + Rect::from_min_max(pos2(hole.max.x, hole.min.y), pos2(area.max.x, hole.max.y)), + ] + } else { + [area, Rect::NOTHING, Rect::NOTHING, Rect::NOTHING] + }; + parts.into_iter().filter(|r| r.is_positive() && r.is_finite()) +} + +/// True when `r` covers at least one cell of the pane that shows `rows` x `cols` (inclusive). +/// The panes of a frozen sheet share one coordinate system, so a range that lives only in the +/// scrolling pane would otherwise be drawn over the frozen pane as well. +fn range_in_pane(r: RangeRef, rows: (u32, u32), cols: (u32, u32)) -> bool { + r.start.row <= rows.1 && r.end.row >= rows.0 && r.start.col <= cols.1 && r.end.col >= cols.0 +} + +/// The four sides of a `width` wide outline centred on `rect`, cut to `clip` (a little beyond it). +/// +/// A stroked rectangle that spans a whole column (20 million points tall) loses its precision in +/// the tessellator and its left and right sides vanish; sides filled from rectangles that stay +/// near the viewport do not. Sides wholly outside `clip` are left out. +pub(crate) fn outline_bands(rect: Rect, width: f32, clip: Rect) -> Vec { + if !rect.is_finite() || !clip.is_finite() { + return Vec::new(); + } + let h = width / 2.0; + let clip = clip.expand(width * 2.0); + let sides = [ + Rect::from_min_max(pos2(rect.left() - h, rect.top() - h), pos2(rect.left() + h, rect.bottom() + h)), + Rect::from_min_max(pos2(rect.right() - h, rect.top() - h), pos2(rect.right() + h, rect.bottom() + h)), + Rect::from_min_max(pos2(rect.left() - h, rect.top() - h), pos2(rect.right() + h, rect.top() + h)), + Rect::from_min_max(pos2(rect.left() - h, rect.bottom() - h), pos2(rect.right() + h, rect.bottom() + h)), + ]; + sides.into_iter().map(|s| s.intersect(clip)).filter(|s| s.is_finite() && s.width() > 0.0 && s.height() > 0.0).collect() +} + +/// `outline` is where the current area's border is drawn this frame (see `selection_glide`). +#[allow(clippy::too_many_arguments)] +pub(crate) fn paint_selection( + p: &Painter, + geo: &Geo, + sh: &Sheet, + sel: &gridcraft_engine::Selection, + outline: Rect, + t: &Tokens, + app: &SheetApp, + rows: (u32, u32), + cols: (u32, u32), +) { + // Fill all areas except the active cell. + for rect in selection_fill_where(geo, sh, sel, outline, |r| range_in_pane(*r, rows, cols)) { + p.rect_filled(rect, 0.0, t.sel_fill); + } + let active_range = sh.merge_at(sel.active).unwrap_or_else(|| RangeRef::cell(sel.active)); + let active = active_rect(geo, sh, sel); + let active_here = range_in_pane(active_range, rows, cols); + let cur_here = range_in_pane(sel.current(), rows, cols); + // Editing a formula: outline its references instead of the selection. + if let Some(ed) = &app.editor + && ed.is_formula() + && app.session.active().is_some_and(|d| d.uid == ed.doc) + { + let origin = app.session.active().and_then(|d| d.wb.sheet(ed.sheet)).map_or(sh.name.as_str(), |o| o.name.as_str()); + for (_, _, r, sheet, idx) in crate::editor::formula_refs(&ed.text) { + if !crate::editor::ref_is_on_sheet(sheet.as_deref(), origin, &sh.name) { + continue; + } + if !range_in_pane(r, rows, cols) { + continue; + } + let rr = geo.range_rect(sh, r); + 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()] { + p.rect_filled(Rect::from_center_size(corner, vec2(4.0, 4.0)), 0.0, col); + } + } + } + if cur_here { + for side in outline_bands(outline.expand(0.5), 2.0, p.clip_rect()) { + p.rect_filled(side, 0.0, t.sel_border); + } + } + if active_here && sel.ranges.len() > 1 { + p.rect_stroke(active, 0.0, Stroke::new(1.0, t.sel_border), StrokeKind::Inside); + } + // Fill handle (bottom-right of the current area). + if app.editor.is_none() && range_in_pane(RangeRef::cell(sel.current().end), rows, cols) { + let h = Rect::from_center_size(outline.right_bottom(), vec2(6.0, 6.0)); + p.rect_filled(h.expand(1.0), 0.0, Color32::WHITE); + p.rect_filled(h, 0.0, t.sel_border); + } + // Fill-handle drag preview. + if let crate::grid::Drag::Fill { target: Some(tr), .. } = &app.grid.drag + && range_in_pane(*tr, rows, cols) + { + let rr = geo.range_rect(sh, *tr); + p.extend(egui::Shape::dashed_line( + &[rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()], + Stroke::new(1.5, Color32::from_gray(90)), + 4.0, + 3.0, + )); + } + if let crate::grid::Drag::Move { to: Some(to), .. } = &app.grid.drag { + let r = sel.current(); + let moved = RangeRef::new(*to, to.offset_clamped(r.height() as i64 - 1, r.width() as i64 - 1)); + if range_in_pane(moved, rows, cols) { + let rr = geo.range_rect(sh, moved); + p.rect_stroke(rr, 0.0, Stroke::new(2.0, Color32::from_gray(110)), StrokeKind::Middle); + } + } + if let crate::grid::Drag::MoveCols { before: Some(before), .. } = &app.grid.drag { + let x = geo.x(sh, *before); + p.line_segment([pos2(x, geo.rect.top()), pos2(x, geo.rect.bottom())], Stroke::new(3.0, t.accent)); + } + // Marching ants around the copied range. + if let Some(clip) = &app.session.clipboard + && app.session.active().is_some_and(|d| d.uid == clip.doc_uid && d.wb.active_sheet() == clip.sheet) + && range_in_pane(clip.range, rows, cols) + { + // A multi-area copy marks each area as selected. + let areas = if clip.areas.is_empty() { std::slice::from_ref(&clip.range) } else { clip.areas.as_slice() }; + for area in areas { + let rr = geo.range_rect(sh, *area).expand(0.5); + let phase = app.grid.marching_phase; + let pts = [rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()]; + p.extend(egui::Shape::dashed_line_with_offset(&pts, Stroke::new(2.0, t.sel_border), &[4.0], &[4.0], phase)); + } + } +} + +/// The areas after a click: with `add` (Ctrl) the clicked area is added, or removed when it is already +/// selected (unless it is the only one, so the selection never ends up empty). +fn toggled_ranges(mut ranges: Vec, clicked: String, add: bool) -> Vec { + if !add { + return vec![clicked]; + } + if ranges.len() > 1 && ranges.contains(&clicked) { + ranges.retain(|x| *x != clicked); + } else if !ranges.contains(&clicked) { + ranges.push(clicked); + } + ranges +} + +pub(super) fn select_ranges(app: &mut SheetApp, r: RangeRef, add: bool, active: CellRef) { + let current: Vec = + if add { app.session.active().map(|d| d.selection.ranges.iter().map(|x| x.a1()).collect()).unwrap_or_default() } else { vec![] }; + let clicked = r.a1(); + let ranges = toggled_ranges(current, clicked.clone(), add); + // A removed area takes the active cell with it; `selection.set` then puts it at the start of the last area. + let params = + if ranges.contains(&clicked) { json!({"range": ranges.join(","), "active": active.a1()}) } else { json!({"range": ranges.join(",")}) }; + let _ = app.session.run("selection.set", params); +} + +#[cfg(test)] +mod selection_fill_tests { + use super::*; + + #[test] + fn rect_around_leaves_the_hole_and_never_overlaps() { + let area = Rect::from_min_max(pos2(100.0, 50.0), pos2(400.0, 250.0)); + let check = |hole: Rect, pieces: usize| { + let parts: Vec = rect_around(area, hole).collect(); + assert_eq!(parts.len(), pieces, "{hole:?}: {parts:?}"); + let cut = area.intersect(hole); + let cut_area = if cut.is_positive() { cut.area() } else { 0.0 }; + let shaded: f32 = parts.iter().map(Rect::area).sum(); + assert!((shaded - (area.area() - cut_area)).abs() < 0.5, "everything but the hole is shaded: {hole:?}"); + for (i, a) in parts.iter().enumerate() { + assert!(area.contains_rect(*a), "nothing outside the area: {a:?}"); + assert!(!a.shrink(0.01).intersects(hole), "the hole stays clear: {a:?} vs {hole:?}"); + // The fill is translucent: a part shaded twice would show darker. + assert!(parts.iter().skip(i + 1).all(|b| !a.shrink(0.01).intersects(*b)), "{parts:?}"); + } + }; + let cell = |x: f32, y: f32| Rect::from_min_size(pos2(x, y), vec2(64.0, 16.0)); + // In the middle, in each corner (where a drag began), along an edge. + check(cell(200.0, 100.0), 4); + check(cell(100.0, 50.0), 2); + check(cell(336.0, 50.0), 2); + check(cell(100.0, 234.0), 2); + check(cell(336.0, 234.0), 2); + check(cell(100.0, 100.0), 3); + // A merged cell as wide, or as tall, as the area; one that is all of it, or more. + check(Rect::from_min_max(pos2(100.0, 100.0), pos2(400.0, 132.0)), 2); + check(Rect::from_min_max(pos2(200.0, 50.0), pos2(264.0, 250.0)), 2); + check(area, 0); + check(area.expand(30.0), 0); + // The active cell is in another area, touches this one, or is only partly inside it. + check(cell(500.0, 300.0), 1); + check(cell(400.0, 50.0), 1); + check(cell(380.0, 240.0), 2); + } + + #[test] + fn rect_around_survives_hostile_numbers() { + let sane = Rect::from_min_max(pos2(0.0, 0.0), pos2(64.0, 16.0)); + let bad = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN, -1.0, 0.0]; + for a in bad { + for b in bad { + let weird = Rect::from_min_max(pos2(a, b), pos2(b, a)); + for (area, hole) in [(sane, weird), (weird, sane), (weird, weird)] { + for part in rect_around(area, hole) { + assert!(part.is_positive() && part.is_finite(), "{area:?} around {hole:?}: {part:?}"); + } + } + // A hole that makes no sense leaves the whole area shaded. + if !weird.intersects(sane) { + assert_eq!(rect_around(sane, weird).collect::>(), vec![sane]); + } + } + } + } +} + +#[cfg(test)] +mod pane_tests { + use super::*; + + fn rng(r0: u32, c0: u32, r1: u32, c1: u32) -> RangeRef { + RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)) + } + + #[test] + fn selection_in_scrolling_pane_is_not_drawn_in_frozen_pane() { + // Frozen: 2 rows and 2 columns; the scrolling pane shows rows 2..=20 and columns 2..=10. + let frozen_cols = ((2, 20), (0, 1)); + let frozen_rows = ((0, 1), (2, 10)); + let corner = ((0, 1), (0, 1)); + let scrolled = ((2, 20), (2, 10)); + let sel = rng(5, 4, 7, 6); + assert!(range_in_pane(sel, scrolled.0, scrolled.1)); + assert!(!range_in_pane(sel, frozen_cols.0, frozen_cols.1)); + assert!(!range_in_pane(sel, frozen_rows.0, frozen_rows.1)); + assert!(!range_in_pane(sel, corner.0, corner.1)); + } + + #[test] + fn ctrl_click_on_a_selected_header_deselects_it() { + let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::>(); + assert_eq!(toggled_ranges(s(&["A:A", "C:C"]), "C:C".into(), true), s(&["A:A"])); + assert_eq!(toggled_ranges(s(&["A:A"]), "C:C".into(), true), s(&["A:A", "C:C"])); + assert_eq!(toggled_ranges(s(&["A:A"]), "A:A".into(), true), s(&["A:A"])); + assert_eq!(toggled_ranges(s(&["A1", "B2", "3:3"]), "B2".into(), true), s(&["A1", "3:3"])); + assert_eq!(toggled_ranges(s(&["A:A", "C:C"]), "C:C".into(), false), s(&["C:C"])); + } + + #[test] + fn selection_across_the_freeze_line_is_drawn_in_both_panes() { + let sel = rng(5, 0, 7, 6); + assert!(range_in_pane(sel, (2, 20), (0, 1))); + assert!(range_in_pane(sel, (2, 20), (2, 10))); + } + + #[test] + fn hidden_outline_symbols_take_no_room() { + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + let mut sh = Sheet::new("S"); + sh.rows.entry(3).or_default().outline = 1; + sh.cols.entry(2).or_default().outline = 2; + let shown = Geo::new(&sh, rect, vec2(0.0, 0.0)); + assert!(shown.band_w > 0.0 && shown.band_h > 0.0); + sh.show_outline_symbols = false; + let hidden = Geo::new(&sh, rect, vec2(0.0, 0.0)); + assert_eq!((hidden.band_w, hidden.band_h), (0.0, 0.0)); + assert!(hidden.cells.left() < shown.cells.left() && hidden.cells.top() < shown.cells.top()); + // Without headings too. + sh.show_headings = false; + let bare = Geo::new(&sh, rect, vec2(0.0, 0.0)); + assert_eq!((bare.band_w, bare.band_h), (0.0, 0.0)); + // No button answers a click on the empty bands' edges (the grid's left and top), where the + // level buttons would sit with no headings to hold them. + for geo in [&hidden, &bare] { + for i in 0..400 { + let t = i as f32 * 0.5; + for p in [pos2(geo.rect.left(), geo.rect.top() + t), pos2(geo.rect.left() + t, geo.rect.top())] { + assert_eq!(crate::outline::hit(geo, &sh, p), None, "{p:?}"); + } + } + } + } + + /// Two frozen rows and columns, scrolled right so that columns C:E lie under the frozen columns. + fn frozen_geo(sh: &mut Sheet) -> Geo { + sh.freeze = Some((2, 2)); + let scroll = vec2(3.0 * sh.default_col_width + 8.0, 0.0); + Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), scroll) + } + + #[test] + fn the_edge_just_past_a_hidden_line_unhides_it() { + let mut sh = Sheet::new("S"); + sh.cols.entry(11).or_default().hidden = true; // L + sh.rows.entry(26).or_default().hidden = true; // 27 + let geo = Geo::new(&sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(2000.0, 1200.0)), vec2(0.0, 0.0)); + let x = geo.x(&sh, 12); // between K and M + assert_eq!(hidden_col_edge(&geo, &sh, x + 2.0), Some(11)); + assert_eq!(hidden_col_edge(&geo, &sh, x - 2.0), None); // K's own edge + assert_eq!(hidden_col_edge(&geo, &sh, geo.x(&sh, 5)), None); + let y = geo.y(&sh, 27); // between 26 and 28 + assert_eq!(hidden_row_edge(&geo, &sh, y + 2.0), Some(26)); + assert_eq!(hidden_row_edge(&geo, &sh, y - 2.0), None); + } + + #[test] + fn headers_mark_the_edges_next_to_hidden_lines() { + let mut sh = Sheet::new("S"); + sh.cols.entry(0).or_default().hidden = true; // A + sh.cols.entry(11).or_default().hidden = true; // L + sh.rows.entry(26).or_default().hidden = true; // 27 + let col = |c| hidden_around(c, |i| sh.col_width(i)); + assert_eq!(col(1), (true, false)); // B, after hidden A + assert_eq!(col(10), (false, true)); // K + assert_eq!(col(12), (true, false)); // M + assert_eq!(col(5), (false, false)); + let row = |r| hidden_around(r, |i| sh.row_height(i)); + assert_eq!(row(25), (false, true)); + assert_eq!(row(27), (true, false)); + assert_eq!(row(0), (false, false)); + } + + #[test] + fn overlays_skip_cells_scrolled_under_the_frozen_columns() { + let mut sh = Sheet::new("S"); + let geo = frozen_geo(&mut sh); + assert!(geo.hidden_under_frozen(&sh, CellRef::new(5, 2))); + assert!(geo.hidden_under_frozen(&sh, CellRef::new(5, 4))); + assert!(!geo.hidden_under_frozen(&sh, CellRef::new(5, 5))); + assert!(!geo.hidden_under_frozen(&sh, CellRef::new(5, 0))); + let unscrolled = Geo::new(&sh, geo.rect, vec2(0.0, 0.0)); + assert!(!unscrolled.hidden_under_frozen(&sh, CellRef::new(5, 2))); + } + + #[test] + fn ranges_are_clipped_to_the_panes_that_hold_them() { + let mut sh = Sheet::new("S"); + let geo = frozen_geo(&mut sh); + let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h); + assert_eq!(geo.range_panes(rng(5, 3, 7, 6)), [Rect::from_min_max(split, geo.cells.max)]); + assert_eq!(geo.range_panes(rng(5, 0, 7, 6)).len(), 2); + assert_eq!(geo.range_panes(rng(0, 0, 7, 6)).len(), 4); + assert_eq!(geo.range_panes(rng(0, 0, 1, 1)), [Rect::from_min_max(geo.cells.min, split)]); + // The fill handle of D6:E7 sits over the frozen columns, where nothing of it is shown. + let handle = geo.range_rect(&sh, rng(5, 3, 6, 4)).right_bottom(); + assert!(handle.x < split.x); + assert!(!geo.range_panes(rng(6, 4, 6, 4)).iter().any(|r| r.contains(handle))); + } +} + +#[cfg(test)] +mod outline_band_tests { + use super::*; + + #[test] + fn whole_column_outline_keeps_left_and_right_sides_near_viewport() { + let clip = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0)); + let col = Rect::from_min_max(pos2(100.0, 40.0), pos2(180.0, 20_971_540.0)); + let sides = outline_bands(col, 2.0, clip); + // Left, right and top (in view); the bottom is 20 million points away. + assert_eq!(sides.len(), 3); + assert!(sides.iter().all(|s| s.bottom() <= clip.bottom() + 4.0 + f32::EPSILON)); + assert!(sides.iter().any(|s| s.left() < 100.0 && s.right() > 100.0 && s.height() > 500.0)); + assert!(sides.iter().any(|s| s.left() < 180.0 && s.right() > 180.0 && s.height() > 500.0)); + } + + #[test] + fn outline_outside_the_view_and_non_finite_draw_nothing() { + let clip = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0)); + assert!(outline_bands(Rect::from_min_max(pos2(900.0, 0.0), pos2(950.0, 20.0)), 2.0, clip).is_empty()); + assert!(outline_bands(Rect::from_min_max(pos2(f32::NAN, 0.0), pos2(1.0, 1.0)), 2.0, clip).is_empty()); + } +} diff --git a/crates/ui-egui/src/grid/show.rs b/crates/ui-egui/src/grid/show.rs new file mode 100644 index 0000000000..442a4722d6 --- /dev/null +++ b/crates/ui-egui/src/grid/show.rs @@ -0,0 +1,861 @@ +use super::cursor::update_cursor; +use super::editor::{in_cell_editor, list_picker, validation_arrow}; +use super::geo::Geo; +use super::interact::{interact, paint_freeze_handles}; +use super::menus::{context_menu, error_check_tag, fill_menu, fill_options_tag, filter_menu, header_menu, object_menu}; +use super::quadrant::{mirror_painted, paint_quadrant}; +use super::selection::paint_selection; +use super::tables::{TableHeading, paint_filter_button, table_headings}; +use super::*; + +pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let available = ui.available_rect_before_wrap(); + // A right-to-left sheet has its vertical scrollbar on the left. + let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left); + let bar = 14.0_f32.min(available.width()); + let rect = if rtl { + Rect::from_min_max(pos2(available.left() + bar, available.top()), available.max) + } else { + Rect::from_min_max(available.min, pos2(available.right() - bar, available.bottom())) + }; + let resp = ui.allocate_rect(rect, Sense::click_and_drag()); + app.grid.rect = Some(rect); + let Some(d) = app.session.active() else { return }; + let wb: Arc = d.wb.clone(); + let si = wb.active_sheet(); + let Some(sh) = wb.sheet(si) else { return }; + let sel = d.selection.clone(); + let place = (d.uid, si); + let mut view = app.view(); + // Page Layout view: pages on a desk, rulers above and left of the headings. + let paper = sh.view == gridcraft_engine::model::SheetViewMode::PageLayout; + let ruler = if paper && !app.session.hide_ruler { crate::paper_view::RULER } else { 0.0 }; + // Right-to-left sheets carry the vertical ruler on the right, beside the row headings. + let grid_rect = if rtl { + Rect::from_min_max(pos2(rect.left(), (rect.top() + ruler).min(rect.bottom())), pos2((rect.right() - ruler).max(rect.left()), rect.bottom())) + } else { + Rect::from_min_max(pos2((rect.left() + ruler).min(rect.right()), (rect.top() + ruler).min(rect.bottom())), rect.max) + }; + let make_geo = |app: &mut SheetApp, scroll: egui::Vec2| -> Geo { + if paper { + let probe = Geo::new(sh, grid_rect, scroll); + if let Some(g) = crate::paper_view::grid_for(app, &wb, si, sh, probe.cells.size(), scroll) { + let g = crate::paper_view::with_dragged_margin(app, &wb, si, g); + return Geo::paged(sh, grid_rect, scroll, g); + } + } + Geo::new(sh, rect, scroll) + }; + // Scrolling. + let (scroll_delta, zoom_delta, modifiers, now) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers, i.time)); + if (resp.hovered() || resp.contains_pointer()) && !crate::text_box::contains_pointer(app, ui.ctx()) { + if zoom_delta != 1.0 { + // egui's zoom factor is e^(speed × points); one notch is `line_scroll_speed` points. + let notch = ui.ctx().options(|o| o.input_options.scroll_zoom_speed * o.input_options.line_scroll_speed); + let turned = zoom_delta.ln() / notch; + let mut zooming = WheelZoom::at(app, place, sh.zoom, now); + if now - zooming.time > WHEEL_ZOOM_IDLE { + zooming.notches = 0.0; + } + if turned.is_finite() { + zooming.notches = (zooming.notches + turned).clamp(-32.0, 32.0); + } + zooming.time = now; + // A notch steps once 60% of it has turned, and the rest of it brings `notches` back to + // about 0. (Past half, so what's left after a step can't step back.) Steps count from + // where a glide is going, not from where it has got to. + let from = zooming.glide.map_or(sh.zoom, |(to, _)| to); + let mut z = from; + while zooming.notches >= 0.6 { + z = wheel_zoom_step(z, true); + zooming.notches -= 1.0; + } + while zooming.notches <= -0.6 { + z = wheel_zoom_step(z, false); + zooming.notches += 1.0; + } + if z != from { + zooming.glide = Some((z, sh.zoom)); + } + app.grid.wheel_zoom = Some(zooming); + } else if scroll_delta != egui::Vec2::ZERO { + let z = if paper { make_geo(app, view.scroll).z } else { sh.zoom as f32 / 100.0 * DISPLAY_SCALE }; + let mut d = scroll_delta; + // A horizontal swipe moves the content the way the fingers go, which is towards + // higher columns on a mirrored sheet. + if sh.right_to_left { + d.x = -d.x; + } + if modifiers.shift && d.x == 0.0 { + d = vec2(d.y, 0.0); + } + view.scroll -= d / z; + } + } + // A one-finger touch drag pans the sheet, the content following the finger; it doesn't select. + // (Two fingers pinch-zoom through `zoom_delta` above.) + if resp.drag_started() && ui.input(|i| i.any_touches()) { + app.grid.touch_pan = true; + } + if app.grid.touch_pan && resp.dragged() && ui.input(|i| i.multi_touch().is_none()) { + let z = if paper { make_geo(app, view.scroll).z } else { sh.zoom as f32 / 100.0 * DISPLAY_SCALE }; + let mut d = resp.drag_delta(); + if sh.right_to_left { + d.x = -d.x; + } + view.scroll -= d / z; + } + // The zoom glides to its step like egui eases a wheel notch (90% of the way in 0.1 s), or goes + // there at once without animations (as for the selection outline below: View › Animations + // off, egui's off in the headless harness, or a screenshot on its way). + let mut zooming = WheelZoom::at(app, place, sh.zoom, now); + if let Some((to, shown)) = zooming.glide { + let glides = app.ui.animations && ui.ctx().global_style().animation_time > 0.0 && !app.shots.in_flight(); + let share = egui::emath::exponential_smooth_factor(0.9, 0.1, ui.input(|i| i.stable_dt).min(0.1)); + let z = if glides { zoom_glide_step(shown, to, share) } else { to }; + let _ = app.session.run("view.zoom", json!({"percent": z})); + zooming.glide = (z != to).then_some((to, z)); + ui.ctx().request_repaint(); + } + // Store a glide's progress, or that it stopped. + if app.grid.wheel_zoom.is_some_and(|w| w.glide.is_some()) { + app.grid.wheel_zoom = Some(zooming); + } + view.scroll = view.scroll.max(egui::Vec2::ZERO); + let (mut max_x, mut max_y) = (sh.col_left(MAX_COLS - 20) as f32, sh.row_top(MAX_ROWS - 40) as f32); + if paper { + // Page layout units run past sheet units (margins and gaps); the end is known once the + // pages reach the last row / column. + let g = make_geo(app, view.scroll); + if let Some(pg) = &g.paper { + let (ex, ey) = pg.extent(); + max_x = if pg.cols_complete { (ex as f32 - g.cells.width() / g.z).max(0.0) } else { 1.0e9 }; + max_y = if pg.rows_complete { (ey as f32 - g.cells.height() / g.z).max(0.0) } else { 1.0e9 }; + } + } + view.scroll.x = view.scroll.x.min(max_x); + view.scroll.y = view.scroll.y.min(max_y); + let mut geo = make_geo(app, view.scroll); + // Keep the active cell (or the moving end of an extension) visible after keyboard moves. + if app.grid.ensure_visible { + app.grid.ensure_visible = false; + let moving = std::mem::take(&mut app.grid.reveal_moving_end); + let cell = if moving { sel.moving_end() } else { sel.active }; + let from = app.grid.reveal_from.take(); + let target = app.editor.as_ref().and_then(|e| e.point_cell).unwrap_or(cell); + // A whole column's moving end sits on the sheet's last row (a whole row's on its last + // column): when an extension moves it only across, the view keeps its place the other way. + // An extension that reaches the last row itself (Ctrl+Shift+Down past the data) still + // follows it. + let extended = (moving && target == cell).then(|| sel.current()); + let before = view.scroll; + if paper { + crate::paper_view::ensure_covers(app, &wb, si, target); + geo = make_geo(app, view.scroll); + } + let cr = geo.cell_rect(sh, target); + let area = Rect::from_min_max(pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h), geo.cells.max); + if target.row >= geo.fr { + if cr.top() < area.top() { + view.scroll.y -= (area.top() - cr.top()) / geo.z; + } else if cr.bottom() > area.bottom() - 2.0 { + view.scroll.y += (cr.bottom() - area.bottom() + 2.0) / geo.z; + } + } else if from.is_some_and(|f| f.row >= geo.fr) { + // A move from the scrolled rows into the frozen ones (Page Up or Ctrl+Up onto a frozen + // row 1) brings the scrolled rows back to their top; the rows it passed over would + // otherwise stay out of sight. + view.scroll.y = 0.0; + } + if target.col >= geo.fc { + if cr.left() < area.left() { + view.scroll.x -= (area.left() - cr.left()) / geo.z; + } else if cr.right() > area.right() - 2.0 { + view.scroll.x += ((cr.right() - area.right() + 2.0) / geo.z).min(cr.left() - area.left()); + } + } else if from.is_some_and(|f| f.col >= geo.fc) { + view.scroll.x = 0.0; + } + if extended.is_some_and(|r| r.is_full_cols()) && from.is_some_and(|f| f.row == cell.row) { + view.scroll.y = before.y; + } + if extended.is_some_and(|r| r.is_full_rows()) && from.is_some_and(|f| f.col == cell.col) { + view.scroll.x = before.x; + } + view.scroll = view.scroll.max(egui::Vec2::ZERO); + geo = make_geo(app, view.scroll); + } + // Snap scrolling to whole rows/columns at rest like Excel? Keep smooth (better on trackpads). + if let Some(v) = app.view_mut() { + *v = view; + } + app.grid.cells_rect = Some(geo.mirror_rect(geo.cells)); + // The selection outline glides to a new selection. It stays on its cells when Animations is + // off, when egui's animations are (the headless harness), and while a screenshot is on its way. + // A mouse button held down (a click or a drag selecting cells) moves it at once, as in Excel: + // a gliding outline trails the pointer and reads as lag. + let pointer_selecting = ui.input(|i| i.pointer.any_down()); + let outline = app.grid.glide.frame(crate::selection_glide::Frame { + place, + range: sel.current(), + target: geo.range_rect(sh, sel.current()), + view: geo.cells, + now, + frame_nr: ui.ctx().cumulative_frame_nr(), + animate: app.ui.animations && ui.ctx().global_style().animation_time > 0.0 && !app.shots.in_flight() && !pointer_selecting, + }); + if app.grid.glide.is_gliding() { + ui.ctx().request_repaint(); + } + app.grid.geo = Some(geo.clone()); + let painter = ui.painter_at(rect); + painter.rect_filled(rect, 0.0, t.grid_bg); + // Everything up to the headers is painted in logical (left-to-right) coordinates. + let mirror_from = painter.add(egui::Shape::Noop); + let mut cf = CfCache::new(); + let quads = if geo.paper.is_some() { + crate::paper_view::paint_pages(app, &painter, &geo, &wb, si, sh, &sel, outline, &mut cf, &t) + } else { + paint_panes(app, &painter, &geo, &wb, si, sh, &sel, outline, &mut cf, &t) + }; + // Objects above cells. A hidden object can't stay selected: Delete or the Format pane would act + // on something the user can't see. + if crate::chartview::objects_hidden(&wb) { + app.selected_chart = None; + } + let objects = crate::chartview::paint_objects(app, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh); + paint_overlays(app, ui, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh, &sel); + if sh.view == gridcraft_engine::model::SheetViewMode::PageBreakPreview { + paint_pages(app, &painter.with_clip_rect(geo.cells), &geo, sh); + } + if geo.paper.is_some() { + crate::paper_view::paint_headers_footers(app, ui, &painter, &geo, &wb, sh); + } + // Headers. + if sh.show_headings { + let headings = table_headings(&geo, sh, sel.active); + paint_headers(&painter, &geo, sh, &sel, &t, &quads, &headings); + paint_freeze_handles(&painter, &geo, sh, &app.grid.drag, &t); + } + crate::outline::paint(&painter, &geo, sh, &t); + if geo.rtl { + let mirror_to = painter.add(egui::Shape::Noop); + mirror_painted(ui.ctx(), painter.layer_id(), mirror_from.0..mirror_to.0, &geo, &objects); + } + if geo.paper.is_some() { + crate::paper_view::paint_rulers(app, &painter, rect, &geo, &t); + } + // Interaction. + let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu, app.grid.fill_menu); + // A pan the grid never saw end (it was hidden mid-drag) must not swallow the next press. + if app.grid.touch_pan && !resp.dragged() && !resp.drag_stopped() { + app.grid.touch_pan = false; + } + if app.grid.touch_pan { + // Skip the frames of a touch pan, its release included. + if !resp.dragged() { + app.grid.touch_pan = false; + } + } else { + crate::paper_view::interact(app, ui, &resp, &geo, sh, &wb); + interact(app, ui, &resp, &geo, sh, &wb); + } + update_cursor(app, ui, &resp, &geo, sh); + crate::text_box::show(app, ui, &geo, sh); + if app.editor.as_ref().is_some_and(|e| !e.from_formula_bar) { + in_cell_editor(app, ui, &geo, sh, &wb); + } + filter_menu(app, ui, &geo, previous_popups.0 == app.grid.filter_menu); + list_picker(app, ui, &geo, previous_popups.1 == app.grid.list_picker); + header_menu(app, ui, previous_popups.2 == app.grid.header_menu); + context_menu(app, ui); + fill_menu(app, ui, previous_popups.3 == app.grid.fill_menu); + fill_options_tag(app, ui, &geo, sh); + object_menu(app, ui); + error_check_tag(app, ui, &geo, sh); + let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) }; + // The track starts below the column headings and frozen rows; the bar's column beside them + // takes its colour too instead of showing a light square in dark mode. + ui.painter().rect_filled(Rect::from_min_max(pos2(track_x.0, rect.top()), pos2(track_x.1, rect.bottom())), 0.0, t.tab_bar); + vertical_scrollbar(app, ui, sh, &geo, track_x, max_y, &t); + if app.session.clipboard.is_some() { + // By the clock, not per frame: a gliding outline repaints more often than the ants step. + app.grid.marching_phase = crate::selection_glide::ants_phase(now); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(60)); + } +} + +/// Normal view: up to four panes (frozen rows/columns), each painted with its own clip. +#[allow(clippy::too_many_arguments)] +fn paint_panes( + app: &SheetApp, + painter: &Painter, + geo: &Geo, + wb: &Workbook, + si: usize, + sh: &Sheet, + sel: &gridcraft_engine::Selection, + outline: Rect, + cf: &mut CfCache, + t: &Tokens, +) -> Vec<((u32, u32), (u32, u32), Rect)> { + // Quadrants: (rows, cols, clip). + let (sr0, sr1) = geo.scroll_rows(sh); + let (sc0, sc1) = geo.scroll_cols(sh); + let fr_rows = (0, geo.fr.saturating_sub(1)); + let fr_cols = (0, geo.fc.saturating_sub(1)); + let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h); + let mut quads = vec![((sr0, sr1), (sc0, sc1), Rect::from_min_max(split, geo.cells.max))]; + if geo.fr > 0 { + quads.push((fr_rows, (sc0, sc1), Rect::from_min_max(pos2(split.x, geo.cells.top()), pos2(geo.cells.right(), split.y)))); + } + if geo.fc > 0 { + quads.push(((sr0, sr1), fr_cols, Rect::from_min_max(pos2(geo.cells.left(), split.y), pos2(split.x, geo.cells.bottom())))); + } + if geo.fr > 0 && geo.fc > 0 { + quads.push((fr_rows, fr_cols, Rect::from_min_max(geo.cells.min, split))); + } + for (rows, cols, clip) in &quads { + if rows.0 > rows.1 || cols.0 > cols.1 { + continue; + } + let p = painter.with_clip_rect(*clip); + paint_quadrant(&p, geo, wb, si, sh, *rows, *cols, cf, t); + paint_selection(&p, geo, sh, sel, outline, t, app, *rows, *cols); + } + // Freeze lines. + if geo.fr > 0 { + painter.line_segment([pos2(geo.cells.left(), split.y), pos2(geo.cells.right(), split.y)], Stroke::new(1.0, Color32::from_gray(150))); + } + if geo.fc > 0 { + painter.line_segment([pos2(split.x, geo.cells.top()), pos2(split.x, geo.cells.bottom())], Stroke::new(1.0, Color32::from_gray(150))); + } + quads +} + +/// The worksheet is painted virtually, so it needs its own scrollbar rather than a +/// ScrollArea containing all million rows. Keep the scroll extent stable during a drag. +fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &Geo, track_x: (f32, f32), limit: f32, t: &Tokens) { + let track = Rect::from_min_max(pos2(track_x.0, geo.cells.top() + geo.frozen_h), pos2(track_x.1, geo.cells.bottom())); + if track.height() <= 0.0 || track.width() <= 0.0 { + return; + } + let visible = track.height() / geo.z; + let scroll = app.view().scroll.y; + let last = sh.used_range().map(|r| r.end.row.saturating_add(10)).unwrap_or(100).clamp(100, MAX_ROWS - 1); + let content = geo.scroll_top_of(sh, last); + let extent = (content - visible).max(scroll).max(0.0).min(limit); + let id = ui.scope_id().with("vscroll"); + let resp = ui.interact(track, id, Sense::click_and_drag()); + let drag_id = id.with("extent"); + if resp.drag_started() { + ui.ctx().data_mut(|d| d.insert_temp(drag_id, extent)); + } + let extent = if resp.dragged() { ui.ctx().data(|d| d.get_temp::(drag_id)).unwrap_or(extent) } else { extent }; + let thumb_h = (track.height() * visible / (extent + visible)).clamp(24.0_f32.min(track.height()), track.height()); + let travel = track.height() - thumb_h; + let thumb_top = track.top() + if extent > 0.0 { travel * (scroll / extent).clamp(0.0, 1.0) } else { 0.0 }; + let thumb = Rect::from_min_size(pos2(track.left() + 3.0, thumb_top), vec2((track.width() - 6.0).max(0.0), thumb_h)); + ui.painter().rect_filled(track, 0.0, t.tab_bar); + ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled }); + let mut next = scroll; + if resp.dragged() && travel > 0.0 { + next += resp.drag_delta().y / travel * extent; + } else if resp.clicked() + && let Some(p) = resp.interact_pointer_pos() + { + if p.y < thumb.top() { + next -= visible; + } else if p.y > thumb.bottom() { + next += visible; + } + } + if resp.hovered() { + next -= ui.input(|i| i.smooth_scroll_delta.y) / geo.z; + } + next = next.clamp(0.0, limit); + if next != scroll { + if let Some(v) = app.view_mut() { + v.scroll.y = next; + } + ui.ctx().request_repaint(); + } +} + +/// The horizontal counterpart of `vertical_scrollbar`, drawn in the sheet tab bar as in Excel. The tab +/// bar is laid out before the grid, so this reads the last frame's geometry and leaves clamping the +/// far end to the grid, which runs later in the same frame. +pub(crate) fn horizontal_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, track: Rect, t: &Tokens) { + app.grid.hscroll = Some(track); + if track.height() <= 0.0 || track.width() <= 0.0 { + return; + } + let Some(geo) = app.grid.geo.clone() else { return }; + let Some(wb) = app.session.active().map(|d| d.wb.clone()) else { return }; + let Some(sh) = wb.active() else { return }; + let visible = ((geo.cells.width() - geo.frozen_w) / geo.z).max(0.0); + let scroll = app.view().scroll.x; + let last = sh.used_range().map(|r| r.end.col.saturating_add(10)).unwrap_or(26).clamp(26, MAX_COLS - 1); + let content = geo.scroll_left_of(sh, last); + let extent = (content - visible).max(scroll).max(0.0); + let id = ui.scope_id().with("hscroll"); + let resp = ui.interact(track, id, Sense::click_and_drag()); + let drag_id = id.with("extent"); + if resp.drag_started() { + ui.ctx().data_mut(|d| d.insert_temp(drag_id, extent)); + } + let extent = if resp.dragged() { ui.ctx().data(|d| d.get_temp::(drag_id)).unwrap_or(extent) } else { extent }; + let thumb_w = (track.width() * visible / (extent + visible).max(1.0)).clamp(24.0_f32.min(track.width()), track.width()); + let travel = track.width() - thumb_w; + let offset = if extent > 0.0 { travel * (scroll / extent).clamp(0.0, 1.0) } else { 0.0 }; + // A right-to-left sheet scrolls towards the left, so its thumb starts at the right end. + let dir = if geo.rtl { -1.0 } else { 1.0 }; + let thumb_left = if geo.rtl { track.right() - thumb_w - offset } else { track.left() + offset }; + let thumb = Rect::from_min_size(pos2(thumb_left, track.top() + 3.0), vec2(thumb_w, (track.height() - 6.0).max(0.0))); + ui.painter().rect_filled(track, 0.0, t.tab_bar); + ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled }); + let mut next = scroll; + if resp.dragged() && travel > 0.0 { + next += dir * resp.drag_delta().x / travel * extent; + } else if resp.clicked() + && let Some(p) = resp.interact_pointer_pos() + { + if p.x < thumb.left() { + next -= dir * visible; + } else if p.x > thumb.right() { + next += dir * visible; + } + } + if resp.hovered() { + let d = ui.input(|i| i.smooth_scroll_delta); + next -= (if d.x != 0.0 { dir * d.x } else { d.y }) / geo.z; + } + next = next.max(0.0); + if next != scroll { + if let Some(v) = app.view_mut() { + v.scroll.x = next; + } + ui.ctx().request_repaint(); + } +} + +pub(crate) fn paint_headers( + p: &Painter, + geo: &Geo, + sh: &Sheet, + sel: &gridcraft_engine::Selection, + t: &Tokens, + quads: &[((u32, u32), (u32, u32), Rect)], + headings: &[TableHeading], +) { + let font = theme::ui_font(heading_font_size(geo.z)); + // The headers' own area: inside the outline bands. + let r = Rect::from_min_max(pos2(geo.head_left(), geo.head_top()), geo.rect.max); + let top = Rect::from_min_max(pos2(geo.cells.left(), r.top()), pos2(r.right(), geo.cells.top())); + let left = Rect::from_min_max(pos2(r.left(), geo.cells.top()), pos2(geo.cells.left(), r.bottom())); + p.rect_filled(top, 0.0, t.header_bg); + p.rect_filled(left, 0.0, t.header_bg); + let bounds = sel.bounds(); + let col_sel = |c: u32| sel.ranges.iter().any(|x| c >= x.start.col && c <= x.end.col); + let row_sel = |rr: u32| sel.ranges.iter().any(|x| rr >= x.start.row && rr <= x.end.row); + let full_col = |c: u32| sel.ranges.iter().any(|x| x.is_full_cols() && c >= x.start.col && c <= x.end.col); + let full_row = |rr: u32| sel.ranges.iter().any(|x| x.is_full_rows() && rr >= x.start.row && rr <= x.end.row); + let _ = bounds; + // Hidden columns and rows leave a double divider where they sit, as in Excel. + let hidden_mark = Stroke::new(1.0, t.header_text.gamma_multiply(0.6)); + let mut done_cols = std::collections::HashSet::new(); + let mut done_rows = std::collections::HashSet::new(); + for (rows, cols, clip) in quads { + let pc = p.with_clip_rect(Rect::from_min_max(pos2(clip.left(), top.top()), pos2(clip.right(), top.bottom()))); + if cols.0 <= cols.1 { + for c in cols.0..=cols.1 { + if !done_cols.insert((c, clip.left() as i32)) { + continue; + } + let w = sh.col_width(c) * geo.z; + if w <= 0.0 { + continue; + } + let x = geo.x(sh, c); + let cr = Rect::from_min_size(pos2(x, top.top()), vec2(w, top.height())); + if col_sel(c) { + pc.rect_filled(cr, 0.0, if full_col(c) { t.header_all_bg } else { t.header_sel_bg }); + pc.line_segment([pos2(cr.left(), cr.bottom() - 1.0), pos2(cr.right(), cr.bottom() - 1.0)], Stroke::new(2.0, t.accent)); + } + pc.line_segment([pos2(cr.right() - 0.5, cr.top() + 3.0), pos2(cr.right() - 0.5, cr.bottom())], Stroke::new(1.0, t.header_line)); + let (before, after) = hidden_around(c, |i| sh.col_width(i)); + for x in [before.then_some(cr.left()), after.then_some(cr.right())].into_iter().flatten() { + for dx in [-1.5, 1.5] { + pc.line_segment([pos2(x + dx, cr.top() + 3.0), pos2(x + dx, cr.bottom())], hidden_mark); + } + } + let color = if col_sel(c) { t.header_sel_text } else { t.header_text }; + match headings.iter().find(|h| h.col == c) { + Some(h) => { + // The name is cut off at the button, as a cell's text is at its neighbour. + let end = h.button.map_or(cr.right(), |b| b.left()) - 2.0; + let room = Rect::from_min_max(cr.min, pos2(end.max(cr.left()), cr.bottom())).intersect(pc.clip_rect()); + let pad = 4.0 * (geo.z / DISPLAY_SCALE).max(0.75); + pc.with_clip_rect(room).text(pos2(cr.left() + pad, cr.center().y), Align2::LEFT_CENTER, &h.name, font.clone(), color); + if let Some(b) = h.button { + paint_filter_button(&pc, b, h.filtered); + } + } + None => { + pc.text(cr.center(), Align2::CENTER_CENTER, col_to_letters(c), font.clone(), color); + } + } + } + } + let pr = p.with_clip_rect(Rect::from_min_max(pos2(left.left(), clip.top()), pos2(left.right(), clip.bottom()))); + if rows.0 <= rows.1 { + for rr in rows.0..=rows.1 { + if !done_rows.insert((rr, clip.top() as i32)) { + continue; + } + let h = sh.row_height(rr) * geo.z; + if h <= 0.0 { + continue; + } + let y = geo.y(sh, rr); + let cr = Rect::from_min_size(pos2(left.left(), y), vec2(left.width(), h)); + if row_sel(rr) { + pr.rect_filled(cr, 0.0, if full_row(rr) { t.header_all_bg } else { t.header_sel_bg }); + pr.line_segment([pos2(cr.right() - 1.0, cr.top()), pos2(cr.right() - 1.0, cr.bottom())], Stroke::new(2.0, t.accent)); + } + pr.line_segment([pos2(cr.left() + 3.0, cr.bottom() - 0.5), pos2(cr.right(), cr.bottom() - 0.5)], Stroke::new(1.0, t.header_line)); + let (before, after) = hidden_around(rr, |i| sh.row_height(i)); + for y in [before.then_some(cr.top()), after.then_some(cr.bottom())].into_iter().flatten() { + for dy in [-1.5, 1.5] { + pr.line_segment([pos2(cr.left() + 3.0, y + dy), pos2(cr.right(), y + dy)], hidden_mark); + } + } + let color = if row_sel(rr) { t.header_sel_text } else { t.header_text }; + pr.text(cr.center(), Align2::CENTER_CENTER, (rr as u64 + 1).to_string(), font.clone(), color); + } + } + } + // Corner (select all) and edges. + let corner = Rect::from_min_max(r.min, geo.cells.min); + p.rect_filled(corner, 0.0, t.header_bg); + let tri = vec![ + pos2(corner.right() - 3.0, corner.top() + 5.0), + pos2(corner.right() - 3.0, corner.bottom() - 3.0), + pos2(corner.left() + 7.0, corner.bottom() - 3.0), + ]; + p.add(egui::Shape::convex_polygon(tri, Color32::from_gray(190), Stroke::NONE)); + p.line_segment([pos2(r.left(), geo.cells.top() - 0.5), pos2(r.right(), geo.cells.top() - 0.5)], Stroke::new(1.0, t.header_line)); + p.line_segment([pos2(geo.cells.left() - 0.5, r.top()), pos2(geo.cells.left() - 0.5, r.bottom())], Stroke::new(1.0, t.header_line)); +} + +/// Whether the line before / after visible line `i` is hidden (zero size), so its header edges +/// get the hidden marker. Line 0 has nothing before it. +pub(super) fn hidden_around(i: u32, size: impl Fn(u32) -> f32) -> (bool, bool) { + (i > 0 && size(i - 1) <= 0.0, i < u32::MAX && size(i + 1) <= 0.0) +} + +#[allow(clippy::too_many_arguments)] +/// Paints the validation arrow and input message, notes, the hyperlink tooltip and trace precedent/dependent arrows. +pub(crate) fn paint_overlays( + app: &mut SheetApp, + ui: &egui::Ui, + p: &Painter, + geo: &Geo, + wb: &Workbook, + si: usize, + sh: &Sheet, + sel: &gridcraft_engine::Selection, +) { + // Validation dropdown arrow for the active cell. + if app.editor.is_none() + && let Some(r) = validation_arrow(sh, geo, sel.active) + { + 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() + && app.grid.list_picker != Some(sel.active) + && let Some(dv) = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(sel.active))) + && dv.show_input + && !(dv.input_title.is_empty() && dv.input_message.is_empty()) + && !geo.hidden_under_frozen(sh, sel.active) + { + let r = geo.cell_rect(sh, sel.active); + let (at, pivot) = (geo.mirror_pos(pos2(r.left() + 8.0, r.bottom() + 6.0)), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP }); + egui::Area::new(egui::Id::unique("dv_input")).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show( + ui.ctx(), + |ui| { + egui::Frame::popup(ui.style()).fill(Color32::from_rgb(0xFF, 0xFF, 0xE1)).show(ui, |ui| { + ui.set_max_width(220.0); + if !dv.input_title.is_empty() { + ui.label(egui::RichText::new(&dv.input_title).strong().color(Color32::BLACK)); + } + ui.label(egui::RichText::new(&dv.input_message).color(Color32::BLACK)); + }); + }, + ); + } + // Notes: shown on hover (or always when marked visible). + let hover = app.grid.hover_cell; + let all_notes = app.session.show_all_notes; + for (c, cm) in &sh.comments { + let show = cm.visible || (all_notes && !cm.threaded) || hover == Some(*c); + if !show || app.grid.drag != Drag::None { + continue; + } + let r = geo.cell_rect(sh, *c); + if !p.clip_rect().intersects(r) || geo.hidden_under_frozen(sh, *c) { + continue; + } + let at = pos2(r.right() + 10.0, r.top()); + p.line_segment([r.right_top(), at + vec2(0.0, 6.0)], Stroke::new(1.0, Color32::from_gray(90))); + let (at, pivot) = (geo.mirror_pos(at), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP }); + egui::Area::new(egui::Id::unique(("note", c.row, c.col))).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show( + ui.ctx(), + |ui| { + egui::Frame::popup(ui.style()).fill(if cm.threaded { Color32::WHITE } else { Color32::from_rgb(0xFF, 0xFF, 0xE1) }).show(ui, |ui| { + ui.set_max_width(240.0); + ui.label(egui::RichText::new(format!("{}:", cm.author)).strong().color(Color32::BLACK)); + ui.label(egui::RichText::new(&cm.text).color(Color32::BLACK)); + for (a, t) in &cm.replies { + ui.separator(); + ui.label(egui::RichText::new(format!("{a}: {t}")).color(Color32::BLACK)); + } + }); + }, + ); + } + // Hyperlink tooltip. + if let Some(h) = hover.and_then(|c| sh.hyperlinks.get(&c)) + && app.editor.is_none() + { + let tip = h.tooltip.clone().unwrap_or_else(|| format!("{}\nClick once to follow. Click and hold to select this cell.", h.target)); + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique("link_tip"), egui::PopupAnchor::Pointer).show(|ui| { + ui.label(tip); + }); + } + // Trace precedents / dependents arrows. + if let Some(trace) = &app.grid.trace + && app.session.active().is_some_and(|d| d.uid == trace.uid && d.revision == trace.revision) + && trace.sheet == si + && let Some(items) = trace.items.as_array() + { + let blue = Color32::from_rgb(0x1F, 0x5F, 0xC9); + let active = geo.cell_rect(sh, trace.cell).center(); + for it in items { + if it["sheet"].as_str().is_some_and(|s| !s.eq_ignore_ascii_case(&sh.name)) { + continue; + } + let target = it["range"].as_str().or(it["cell"].as_str()).and_then(RangeRef::parse); + let Some(t) = target else { continue }; + let tr = geo.range_rect(sh, t); + if !t.is_single() { + for pane in geo.range_panes(t) { + p.with_clip_rect(pane.intersect(p.clip_rect())).rect_stroke(tr, 0.0, Stroke::new(1.5, blue), StrokeKind::Inside); + } + } + let (from, to) = if it.get("range").is_some() { (tr.center(), active) } else { (active, tr.center()) }; + p.line_segment([from, to], Stroke::new(1.5, blue)); + p.circle_filled(from, 3.0, blue); + let d = (to - from).normalized(); + let n = vec2(-d.y, d.x); + p.add(egui::Shape::convex_polygon(vec![to, to - d * 9.0 + n * 4.0, to - d * 9.0 - n * 4.0], blue, Stroke::NONE)); + } + } + let _ = (wb, si); +} + +fn paint_pages(app: &mut SheetApp, p: &Painter, geo: &Geo, sh: &Sheet) { + let Some(d) = app.session.active() else { return }; + let key = (d.uid, d.revision, d.wb.active_sheet()); + if app.grid.pages.as_ref().is_none_or(|(k, _)| *k != key) { + let pages: Vec<(RangeRef, u32)> = app + .session + .run("file.printPreview", json!({})) + .ok() + .and_then(|v| { + v["pageList"].as_array().map(|a| { + a.iter() + .filter(|pg| pg["sheet"].as_str() == Some(sh.name.as_str())) + .filter_map(|pg| Some((RangeRef::parse(pg["range"].as_str()?)?, pg["page"].as_u64()? as u32))) + .collect() + }) + }) + .unwrap_or_default(); + app.grid.pages = Some((key, pages)); + } + let Some((_, pages)) = &app.grid.pages else { return }; + if pages.is_empty() { + return; + } + // Grey everything, then clear the printed pages. + let mut printed = pages[0].0; + for (r, _) in pages { + printed = printed.union(r); + } + let pr = geo.range_rect(sh, printed); + let grey = Color32::from_black_alpha(70); + let c = p.clip_rect(); + for r in [ + Rect::from_min_max(c.min, pos2(c.right(), pr.top())), + Rect::from_min_max(pos2(c.left(), pr.bottom()), c.max), + Rect::from_min_max(pos2(c.left(), pr.top()), pos2(pr.left(), pr.bottom())), + Rect::from_min_max(pos2(pr.right(), pr.top()), pos2(c.right(), pr.bottom())), + ] { + if r.is_positive() { + p.rect_filled(r, 0.0, grey); + } + } + let blue = Color32::from_rgb(0x2E, 0x6F, 0xD8); + for (r, n) in pages { + let rr = geo.range_rect(sh, *r); + if !rr.intersects(c) { + continue; + } + p.extend(egui::Shape::dashed_line( + &[rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()], + Stroke::new(2.0, blue), + 6.0, + 3.0, + )); + p.text( + rr.center(), + Align2::CENTER_CENTER, + format!("Page {n}"), + theme::ui_bold((rr.height().min(rr.width()) / 6.0).clamp(14.0, 72.0)), + Color32::from_black_alpha(45), + ); + } + p.rect_stroke(pr, 0.0, Stroke::new(3.0, blue), StrokeKind::Outside); +} + +#[cfg(test)] +mod trace_arrow_tests { + use super::*; + + fn app() -> SheetApp { + let mut s = gridcraft_engine::Session::new(); + s.new_workbook(); + SheetApp::new(s, Default::default()) + } + + fn select(app: &mut SheetApp, cell: &str) { + app.run("selection.set", json!({"cell": cell})).unwrap(); + } + + /// What the ribbon's Trace Precedents / Trace Dependents buttons do. + fn trace(app: &mut SheetApp, id: &str) { + let r = app.run(id, json!({})).unwrap(); + app.grid.trace = Trace::new(app, r); + } + + /// The arrows `paint_overlays` draws on the active sheet, as (tail, head) points. + fn arrows(app: &mut SheetApp) -> (Geo, Vec<(Pos2, Pos2)>) { + let d = app.session.active().unwrap(); + let (wb, si, sel) = (d.wb.clone(), d.wb.active_sheet(), d.selection.clone()); + let sh = wb.sheet(si).unwrap(); + let geo = Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0)), vec2(0.0, 0.0)); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let p = ui.painter().clone(); + paint_overlays(app, ui, &p, &geo, &wb, si, sh, &sel); + }); + // No renderer consumes the font atlas upload here. + out.textures_delta.clear(); + let blue = Color32::from_rgb(0x1F, 0x5F, 0xC9); + let mut lines = Vec::new(); + for s in out.shapes { + if let egui::Shape::LineSegment { points, stroke } = s.shape + && stroke.color == blue + { + lines.push((points[0], points[1])); + } + } + (geo, lines) + } + + fn center(app: &SheetApp, geo: &Geo, cell: &str) -> Pos2 { + let d = app.session.active().unwrap(); + let sh = d.wb.sheet(d.wb.active_sheet()).unwrap(); + geo.cell_rect(sh, CellRef::parse(cell).unwrap()).center() + } + + #[test] + fn precedent_arrows_stay_on_the_traced_cell_when_the_selection_moves() { + let mut app = app(); + for (c, v) in [("B2", "1"), ("B3", "2"), ("D2", "1"), ("D3", "2"), ("F7", "=B2+B3+SUM(D2:D3)")] { + app.run("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + select(&mut app, "F7"); + trace(&mut app, "formulas.tracePrecedents"); + select(&mut app, "H7"); + let (geo, lines) = arrows(&mut app); + assert_eq!(lines.len(), 3, "B2, B3 and D2:D3: {lines:?}"); + let f7 = center(&app, &geo, "F7"); + assert!(lines.iter().all(|&(_, head)| head == f7), "every arrow points at F7, not at the active H7: {lines:?}"); + } + + #[test] + fn dependent_arrows_stay_on_the_traced_cell_when_the_selection_moves() { + let mut app = app(); + for (c, v) in [("B2", "1"), ("D4", "=B2*2"), ("E6", "=B2+1")] { + app.run("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + select(&mut app, "B2"); + trace(&mut app, "formulas.traceDependents"); + select(&mut app, "C9"); + let (geo, lines) = arrows(&mut app); + assert_eq!(lines.len(), 2, "D4 and E6: {lines:?}"); + let b2 = center(&app, &geo, "B2"); + assert!(lines.iter().all(|&(tail, _)| tail == b2), "every arrow starts at B2, not at the active C9: {lines:?}"); + } + + #[test] + fn arrows_stay_on_the_sheet_of_the_traced_cell() { + let mut app = app(); + app.run("home.insertSheet", json!({"name": "Two"})).unwrap(); + app.run("cell.set", json!({"sheet": "Sheet1", "cell": "B2", "input": "1"})).unwrap(); + app.run("sheet.activate", json!({"sheet": "Two"})).unwrap(); + app.run("cell.set", json!({"cell": "F7", "input": "=Sheet1!B2"})).unwrap(); + select(&mut app, "F7"); + trace(&mut app, "formulas.tracePrecedents"); + // The precedent lives on Sheet1, but the arrow belongs to Two's F7. + app.run("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + assert!(arrows(&mut app).1.is_empty(), "no arrow on Sheet1 from B2 to where F7 would be"); + } + + #[test] + fn inserting_a_row_removes_the_arrows() { + let mut app = app(); + app.run("cell.set", json!({"cell": "B2", "input": "1"})).unwrap(); + app.run("cell.set", json!({"cell": "F7", "input": "=B2"})).unwrap(); + select(&mut app, "F7"); + trace(&mut app, "formulas.tracePrecedents"); + assert_eq!(arrows(&mut app).1.len(), 1); + app.run("home.insertRows", json!({"rows": "4"})).unwrap(); + assert!(arrows(&mut app).1.is_empty(), "F7 is now F8: the old arrows would point at the wrong cell"); + } + + #[test] + fn arrows_stay_in_their_workbook() { + let mut app = app(); + app.session.new_workbook(); + // Two books with the same cells and the same revision: only the document tells them apart. + for book in [1, 0] { + app.session.set_active(book); + app.run("cell.set", json!({"cell": "B2", "input": "1"})).unwrap(); + app.run("cell.set", json!({"cell": "F7", "input": "=B2"})).unwrap(); + } + select(&mut app, "F7"); + trace(&mut app, "formulas.tracePrecedents"); + assert_eq!(arrows(&mut app).1.len(), 1); + app.session.set_active(1); + let revisions: Vec = app.session.documents().iter().map(|d| d.revision).collect(); + assert_eq!(revisions[0], revisions[1]); + assert!(arrows(&mut app).1.is_empty(), "Book1's arrows are not drawn on Book2"); + } +} diff --git a/crates/ui-egui/src/grid/tables.rs b/crates/ui-egui/src/grid/tables.rs new file mode 100644 index 0000000000..a283b2b22e --- /dev/null +++ b/crates/ui-egui/src/grid/tables.rs @@ -0,0 +1,87 @@ +use super::geo::Geo; +use super::*; + +pub fn filter_button_rect(geo: &Geo, sh: &Sheet, c: CellRef) -> Rect { + let rect = geo.cell_rect(sh, c); + // Keep the button within the same header slot that AutoFit reserves. + let pad = 2.0 * geo.z; + let s = (rect.height() - 4.0).clamp(10.0, 18.0).min((sh.filter_button_space(c) * geo.z - pad).max(0.0)); + Rect::from_min_size(pos2(rect.right() - s - pad, rect.bottom() - s - pad), vec2(s, s)) +} + +/// A Table column heading that shows the column's header name in place of its letters. +#[derive(Clone, Debug)] +pub struct TableHeading { + pub col: u32, + /// The table's header row, which the filter menu is keyed by. + pub header_row: u32, + pub name: String, + /// The heading slot over the column (logical coordinates). + pub rect: Rect, + /// The filter button, when the table shows filter buttons. + pub button: Option, + /// The column is filtered (the button shows the funnel). + pub filtered: bool, +} + +/// The Table whose header names replace the column letters: the one holding the active cell, +/// with its header row scrolled out of view and some of its rows still in view (Excel, #1423). +pub fn heading_table<'a>(geo: &Geo, sh: &'a Sheet, active: CellRef) -> Option<&'a gridcraft_engine::model::Table> { + // Page Layout view keeps the letters. + if geo.paper.is_some() || geo.header_h <= 0.0 { + return None; + } + let tb = sh.tables.iter().find(|t| t.header_row && t.range.contains(active))?; + let header = tb.range.start.row; + // A header row in the frozen rows is always on screen. + if header < geo.fr { + return None; + } + let top = geo.cells.top() + geo.frozen_h; + let bottom = |r: u32| geo.y(sh, r) + sh.row_height(r) * geo.z; + (bottom(header) <= top + 0.5 && bottom(tb.range.end.row) > top + 0.5).then_some(tb) +} + +/// The headings of `heading_table`'s visible columns. +pub fn table_headings(geo: &Geo, sh: &Sheet, active: CellRef) -> Vec { + let Some(tb) = heading_table(geo, sh, active) else { return Vec::new() }; + let af = sh.autofilter.as_ref().filter(|af| af.range == tb.range); + let mut out = Vec::new(); + for col in tb.range.start.col..=tb.range.end.col { + let w = sh.col_width(col) * geo.z; + let off = col - tb.range.start.col; + let Some(column) = tb.columns.get(off as usize) else { continue }; + if w <= 0.0 { + continue; + } + let rect = Rect::from_min_size(pos2(geo.x(sh, col), geo.head_top()), vec2(w, geo.header_h)); + // Clear of the 4-point column resize edge, and never wider than the column. + let s = (rect.height() - 6.0).clamp(10.0, 16.0).min(w - 7.0); + let button = (tb.filter_button && s > 0.0).then(|| Rect::from_min_size(pos2(rect.right() - s - 5.0, rect.center().y - s / 2.0), vec2(s, s))); + let filtered = tb.filter.iter().any(|(o, _)| *o == off) || af.is_some_and(|af| af.criteria.iter().any(|(o, _)| *o == off)); + out.push(TableHeading { col, header_row: tb.range.start.row, name: column.name.clone(), rect, button, filtered }); + } + out +} + +pub(super) fn paint_filter_button(p: &Painter, r: Rect, filtered: bool) { + 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, + ); +} + +pub(super) fn is_filter_button(sh: &Sheet, geo: &Geo, p: Pos2) -> bool { + let c = geo.cell_at(sh, p); + sh.has_filter_button(c) && filter_button_rect(geo, sh, c).contains(p) +} + +/// The table whose header cell (`row`, `col`) was clicked, not just the first table in the column. +pub(super) fn filter_table_range(sh: &Sheet, col: u32, row: u32) -> Option { + sh.tables.iter().find(|t| t.range.start.row == row && col >= t.range.start.col && col <= t.range.end.col).map(|t| t.range) +} diff --git a/crates/ui-egui/src/grid/tests.rs b/crates/ui-egui/src/grid/tests.rs new file mode 100644 index 0000000000..74abfc8618 --- /dev/null +++ b/crates/ui-egui/src/grid/tests.rs @@ -0,0 +1,306 @@ +#[test] +fn editor_matches_the_cell_while_the_text_fits() { + use super::editor_width; + assert_eq!(editor_width(10.0, 80.0, 7.0), 80.0); + assert_eq!(editor_width(66.0, 80.0, 7.0), 80.0); + assert_eq!(editor_width(67.0, 80.0, 7.0), 88.0); +} + +#[test] +fn editor_slack_fits_one_more_glyph_at_high_zoom() { + use super::editor_slack; + // A glyph is at most about one em wide; at 400% an em is ~60 px, far over the old fixed 14 px. + for size in [14.7_f32, 25.0, 60.0] { + assert!(editor_slack(size) - 4.0 >= size, "slack {} too small for a {size} px glyph", editor_slack(size)); + } +} + +#[test] +fn explicit_left_and_right_keep_their_side_on_a_right_to_left_sheet() { + use super::{HAlign, painted_halign}; + // Painted before the mirror: swapped, so they land where the user put them. + assert_eq!(painted_halign(HAlign::Right, true), HAlign::Left); + assert_eq!(painted_halign(HAlign::Left, true), HAlign::Right); + // Left-to-right sheets and the other alignments are painted as they are. + assert_eq!(painted_halign(HAlign::Right, false), HAlign::Right); + assert_eq!(painted_halign(HAlign::Left, false), HAlign::Left); + for h in [HAlign::Center, HAlign::Justify, HAlign::Fill] { + assert_eq!(painted_halign(h, true), h); + } +} + +#[test] +fn heading_labels_scale_with_the_zoom_without_a_cap() { + use super::{DISPLAY_SCALE, heading_font_size}; + let at = |percent: f32| heading_font_size(percent / 100.0 * DISPLAY_SCALE); + assert!((at(400.0) / at(100.0) - 4.0).abs() < 1e-4, "four times as large at 400%"); + assert!(at(200.0) > at(100.0) && at(400.0) > at(200.0)); + assert_eq!(at(10.0), 8.5, "never below a readable size"); +} +use super::*; + +#[test] +fn list_picker_fits_up_to_eight_rows_then_scrolls() { + assert_eq!(list_picker_height(5, 18.0, 3.0), 5.0 * 18.0 + 4.0 * 3.0); + assert_eq!(list_picker_height(8, 18.0, 3.0), 8.0 * 18.0 + 7.0 * 3.0); + assert_eq!(list_picker_height(30, 18.0, 3.0), list_picker_height(8, 18.0, 3.0)); + assert_eq!(list_picker_height(0, 18.0, 3.0), 18.0); +} + +#[test] +fn number_format_colors_resolve_named_and_indexed_rgb() { + use gridcraft_engine::numfmt::FormatColor as F; + assert_eq!(numfmt_color(F::Green), Color32::from_rgb(0, 255, 0)); + assert_eq!(numfmt_color(F::Indexed(1)), Color32::BLACK); + assert_eq!(numfmt_color(F::Indexed(10)), Color32::from_rgb(0, 128, 0)); + assert_eq!(numfmt_color(F::Indexed(46)), Color32::from_rgb(255, 102, 0)); + assert_eq!(numfmt_color(F::Indexed(56)), Color32::from_rgb(51, 51, 51)); + for index in [0, 57, 255] { + assert_eq!(numfmt_color(F::Indexed(index)), Color32::BLACK); + } +} + +#[test] +fn wheel_zoom_steps_along_the_ladder_and_stops_at_100() { + for (from, zoom_in, to) in [ + (100, true, 120), + (120, false, 100), + (100, false, 85), + // Between steps, the next one each way (Excel: 112% goes to 115% or 100%). + (112, true, 120), + (112, false, 100), + // A step less than 5% away is skipped… + (121, false, 100), + (86, false, 70), + (139, true, 170), + // …but never 100% or a limit. + (99, true, 100), + (101, false, 100), + (96, true, 100), + (385, true, 400), + (11, false, 10), + (400, true, 400), + (10, false, 10), + // Out-of-range zooms (from a file) come into range first. + (5, true, 12), + (1000, false, 340), + (0, false, 10), + (u16::MAX, true, 400), + ] { + assert_eq!(wheel_zoom_step(from, zoom_in), to, "{from}% {}", if zoom_in { "in" } else { "out" }); + } + // Every step on the ladder is 15–25% from the next, and a notch in then out returns. + // (The glide between steps is `zoom_glide_moves_by_ratio_and_always_arrives`.) + for w in WHEEL_ZOOM_LADDER.windows(2) { + let ratio = f32::from(w[1]) / f32::from(w[0]); + assert!((1.15..=1.25).contains(&ratio), "{w:?}"); + assert_eq!(wheel_zoom_step(w[0], true), w[1]); + assert_eq!(wheel_zoom_step(w[1], false), w[0]); + } +} + +#[test] +fn zoom_glide_moves_by_ratio_and_always_arrives() { + // A third of the way per frame, as at 60 frames a second. + for (from, to) in [(100, 120), (120, 100), (10, 12), (340, 400), (400, 10), (85, 100)] { + let (mut z, mut frames) = (from, 0); + while z != to { + let next = zoom_glide_step(z, to, 0.32); + assert!(next != z && next.abs_diff(to) < z.abs_diff(to), "{from} → {to}: {z} then {next}"); + z = next; + frames += 1; + assert!(frames < 40, "{from} → {to} arrives"); + } + assert!(frames >= 2, "{from} → {to} glides over more than one frame ({frames})"); + } + assert_eq!(zoom_glide_step(100, 120, 0.32), 106, "a third of the way by ratio"); + assert_eq!(zoom_glide_step(120, 100, 0.32), 113); + // No progress asked or nonsense: still a percent closer, or straight there. + assert_eq!(zoom_glide_step(100, 120, 0.0), 101); + assert_eq!(zoom_glide_step(120, 100, f32::NAN), 100); + assert_eq!(zoom_glide_step(100, 120, 5.0), 120); + assert_eq!(zoom_glide_step(120, 120, 0.5), 120); + assert_eq!(zoom_glide_step(0, 10, 0.5), 3); +} + +#[test] +fn center_across_run_stops_at_first_non_continuing_column() { + assert_eq!(center_across_end(0, 20, |c| c <= 2), 2); + assert_eq!(center_across_end(0, 20, |_| false), 0); + assert_eq!(center_across_end(0, 1, |_| true), 1); +} + +#[test] +fn conditional_number_format_reaches_the_cell_text() { + // #1163: a rule's number format replaces the cell's own; unmatched cells keep theirs. + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [[1], [3], [4]]})).unwrap(); + session.execute("home.numberFormat", json!({"range": "A3", "code": "0.0"})).unwrap(); + let rule = json!({"type": "cellIs", "operator": "greater", "value": "2", "style": {"num_fmt": "0.00"}}); + session.execute("home.conditionalFormat", json!({"range": "A1:A3", "rule": rule})).unwrap(); + let wb = session.active().unwrap().wb.clone(); + let sh = wb.sheet(0).unwrap(); + let mut cf = CfCache::new(); + let text = |row: u32, cf: &mut CfCache| { + let c = CellRef::new(row, 0); + let (lk, _) = look(&wb, 0, sh, c, cf); + display_text(&wb, sh, c, &sh.value(c), &lk.style, 200.0, 7.0).0 + }; + assert_eq!(text(0, &mut cf), "1"); + assert_eq!(text(1, &mut cf), "3.00"); + assert_eq!(text(2, &mut cf), "4.00"); +} + +#[test] +fn filter_menu_uses_the_table_whose_header_was_clicked() { + use gridcraft_engine::model::Table; + let table = |id: u32, r0: u32, r1: u32| Table { + id, + name: format!("T{id}"), + range: RangeRef::new(CellRef::new(r0, 0), CellRef::new(r1, 0)), + header_row: true, + totals_row: false, + columns: vec![], + style: String::new(), + banded_rows: true, + banded_cols: false, + first_col: false, + last_col: false, + filter_button: true, + filter: vec![], + table_type: None, + connection_id: None, + }; + let mut sh = Sheet::new("S"); + sh.tables.push(table(1, 0, 2)); + sh.tables.push(table(2, 4, 7)); + assert_eq!(filter_table_range(&sh, 0, 4), Some(sh.tables[1].range)); + assert_eq!(filter_table_range(&sh, 0, 0), Some(sh.tables[0].range)); + assert_eq!(filter_table_range(&sh, 1, 4), None); +} + +#[test] +fn cell_text_y_moves_monotonically_in_standard_and_tall_rows() { + let rect_std = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0)); + let gh = 18.5; + let z = 1.0; + + let y_top_std = cell_text_y(VAlign::Top, rect_std, gh, z); + let y_center_std = cell_text_y(VAlign::Center, rect_std, gh, z); + let y_bottom_std = cell_text_y(VAlign::Bottom, rect_std, gh, z); + + assert!(y_top_std < y_center_std, "Top ({y_top_std}) must be higher than Center ({y_center_std})"); + assert!(y_center_std < y_bottom_std, "Center ({y_center_std}) must be higher than Bottom ({y_bottom_std})"); + + // Verify tall row (e.g. 40px) + let rect_tall = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 40.0)); + let y_top_tall = cell_text_y(VAlign::Top, rect_tall, gh, z); + let y_center_tall = cell_text_y(VAlign::Center, rect_tall, gh, z); + let y_bottom_tall = cell_text_y(VAlign::Bottom, rect_tall, gh, z); + + assert!(y_top_tall < y_center_tall, "Top ({y_top_tall}) must be higher than Center ({y_center_tall})"); + assert!(y_center_tall < y_bottom_tall, "Center ({y_center_tall}) must be higher than Bottom ({y_bottom_tall})"); +} + +#[test] +fn wrapped_cell_text_y_positions_correctly_and_safeguards_overflow() { + let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 40.0)); + let gh = 30.0; + let z = 1.0; + + let y_top = wrapped_cell_text_y(VAlign::Top, rect, gh, z); + let y_center = wrapped_cell_text_y(VAlign::Center, rect, gh, z); + let y_bottom = wrapped_cell_text_y(VAlign::Bottom, rect, gh, z); + + assert!(y_top < y_center); + assert!(y_center < y_bottom); + + // Overflow case: gh exceeds rect height; Top still starts inside the cell. + let gh_overflow = 60.0; + let y_top_ovf = wrapped_cell_text_y(VAlign::Top, rect, gh_overflow, z); + assert!(y_top_ovf >= rect.top()); +} + +#[test] +fn wrapped_cell_text_y_keeps_vertical_alignment_when_text_is_taller_than_the_row() { + // A default-height row holding three wrapped lines. + let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0)); + let (gh, z) = (60.0, 1.0); + let y_top = wrapped_cell_text_y(VAlign::Top, rect, gh, z); + let y_center = wrapped_cell_text_y(VAlign::Center, rect, gh, z); + let y_bottom = wrapped_cell_text_y(VAlign::Bottom, rect, gh, z); + // Taller text overflows upward for Bottom and both ways for Center (the cell clips it). + assert!((y_top - 1.5).abs() < 0.01, "Top starts at the top edge, got {y_top}"); + assert!((y_center + gh / 2.0 - rect.center().y).abs() < 0.01, "Center is centred, got {y_center}"); + assert!((y_bottom + gh - (rect.bottom() - 1.5)).abs() < 0.01, "Bottom ends at the bottom edge, got {y_bottom}"); +} + +/// The screen box of a galley of `size` drawn at `pos` turned by `angle` about `pos`, as egui +/// draws a rotated `TextShape`. +fn rotated_box(pos: Pos2, size: egui::Vec2, angle: f32) -> Rect { + let rot = egui::emath::Rot2::from_angle(angle); + let pts = [vec2(0.0, 0.0), vec2(size.x, 0.0), vec2(0.0, size.y), size].map(|v| pos + rot * v); + Rect::from_points(&pts) +} + +#[test] +fn rotated_text_box_follows_the_cell_alignment() { + // A tall, narrow merged block (one 3-character column over four 60pt rows). + let rect = Rect::from_min_max(pos2(10.0, 20.0), pos2(40.0, 340.0)); + let size = vec2(90.0, 15.0); + let pad = 2.5; + let near = |a: f32, b: f32| (a - b).abs() < 0.01; + for deg in [90i16, -90, 45, -45] { + let angle = -(deg as f32).to_radians(); + for v in [VAlign::Top, VAlign::Center, VAlign::Bottom] { + for h in [HAlign::Left, HAlign::Center, HAlign::Right] { + let bb = rotated_box(rotated_text_pos(rect, size, angle, h, v, pad, 0.0), size, angle); + let what = format!("{deg}° {h:?}/{v:?}: {bb:?} in {rect:?}"); + assert!(near(bb.height(), rotated_box(Pos2::ZERO, size, angle).height()), "{what}"); + assert!(bb.top() >= rect.top() && bb.bottom() <= rect.bottom(), "{what}"); + // A narrow column can't hold a 45° line across: only check where it fits. + let fits_x = bb.width() <= rect.width() - 2.0 * pad; + match h { + HAlign::Left => assert!(near(bb.left(), rect.left() + pad), "{what}"), + HAlign::Right => assert!(near(bb.right(), rect.right() - pad), "{what}"), + _ => assert!(near(bb.center().x, rect.center().x), "{what}"), + } + if fits_x { + assert!(bb.left() >= rect.left() && bb.right() <= rect.right(), "{what}"); + } + match v { + VAlign::Top => assert!(near(bb.top(), rect.top() + pad), "{what}"), + VAlign::Center => assert!(near(bb.center().y, rect.center().y), "{what}"), + // Bottom is the default: downward text keeps starting at the top, as before. + _ if deg < 0 => assert!(near(bb.top(), rect.top() + pad), "{what}"), + _ => assert!(near(bb.bottom(), rect.bottom() - pad), "{what}"), + } + } + } + } +} + +#[test] +fn upright_text_with_default_alignment_keeps_its_place() { + let rect = Rect::from_min_max(pos2(10.0, 20.0), pos2(40.0, 340.0)); + let size = vec2(90.0, 15.0); + let pad = 2.5; + let near = |a: Pos2, b: Pos2| (a - b).length() < 0.01; + // Upward text starts at the bottom-left corner, downward text one block-height in from the top-left. + let up = rotated_text_pos(rect, size, -std::f32::consts::FRAC_PI_2, HAlign::General, VAlign::Bottom, pad, 0.0); + assert!(near(up, pos2(rect.left() + pad, rect.bottom() - pad)), "{up:?}"); + let down = rotated_text_pos(rect, size, std::f32::consts::FRAC_PI_2, HAlign::General, VAlign::Bottom, pad, 0.0); + assert!(near(down, pos2(rect.left() + pad + size.y, rect.top() + pad)), "{down:?}"); +} + +#[test] +fn upward_text_with_middle_align_is_centred_in_a_merged_block() { + // Issue #435: "Licht Strom (?)" turned up in A1:A4 with Middle Align was drawn at the bottom. + let rect = Rect::from_min_max(pos2(10.0, 20.0), pos2(40.0, 340.0)); + let size = vec2(90.0, 15.0); + let angle = -std::f32::consts::FRAC_PI_2; + let bb = rotated_box(rotated_text_pos(rect, size, angle, HAlign::Center, VAlign::Center, 2.5, 0.0), size, angle); + assert!((bb.center().y - rect.center().y).abs() < 0.01, "{bb:?} not centred in {rect:?}"); + assert!((bb.center().x - rect.center().x).abs() < 0.01, "{bb:?} not centred in {rect:?}"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1fbc98bd70..8618deb86f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -52,6 +52,15 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value as Json, json}; pub use control::{ControlRequest, ControlResponse}; + +mod app; +mod clock; +mod services; +mod state; + +pub use clock::{monotonic_ms, now_ms}; +pub use services::{Chrome, Inbox, Services}; +pub use state::{NoticeKind, OpenWindow, SaveAnswer, SheetView}; pub mod desktop_theme; /// Persisted UI preferences. @@ -160,19 +169,6 @@ impl UiState { } } -/// Per-sheet view state (scroll position in sheet points). -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub struct SheetView { - pub scroll: egui::Vec2, -} - -/// Severity of a notice shown in the info bar under the ribbon. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum NoticeKind { - Info, - Warning, -} - /// A dismissible bar under the ribbon, in the spirit of Excel's message bar. Used to tell the /// user what an opened file carried that GridCraft handled differently (e.g. preserved macros). #[derive(Clone, Debug, PartialEq)] @@ -183,74 +179,6 @@ pub struct Notice { pub action: Option<(String, String)>, } -/// An answer to the Save Changes question. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SaveAnswer { - Save, - DontSave, - Cancel, -} - -/// One open window, as View › Switch Windows and the macOS Window menu list it. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct OpenWindow { - /// The window's id, the `window` param of `view.switchWindows`. - pub window: u64, - /// Its workbook's `DocState::uid`. - pub doc: u64, - pub title: String, - pub dirty: bool, - pub active: bool, -} - -impl OpenWindow { - /// The title ("Book1", or "Book1:2" for a workbook's second window), with a bullet after it - /// while the workbook has unsaved changes. - pub fn label(&self) -> String { - if self.dirty { format!("{} •", self.title) } else { self.title.clone() } - } -} - -/// Host services the platform shell provides (file pickers, opening URLs). -#[derive(Default)] -pub struct Services { - /// Desktop: discover configured printers on a worker. - pub printers: Option, - /// Desktop: submit the PDF with the chosen settings on a worker. - pub print_native: Option, - pub pick_open: Option Option>>, - pub pick_save: Option Option>>, - /// Native picture picker (PNG/JPEG), separate from workbook opening. - pub pick_picture: Option Option>>, - /// Browser picture picker. Each dialog owns its inbox, so canceled picks cannot - /// insert into a subsequent dialog or open the image as a workbook. - pub pick_picture_async: Option>, - pub open_url: Option>, - /// Publish HTML and its plain-text alternative together. `Ok` means the host owns the - /// write (including any asynchronous fallback); an immediate error uses egui's text path. - pub copy_html: Option Result<(), String>>>, - /// Read the HTML flavour of the system clipboard when the user pastes text, so cells the - /// source spreadsheet recorded as numbers stay numbers ("Rp10,000.00" → 10000). `None` from - /// the host pastes the plain text alone. - pub paste_html: Option Option>>, - /// Read the system clipboard's plain text for the ribbon and menu Paste commands (Cmd/Ctrl+V - /// gets it from egui's `Event::Paste`). `None` from the host, or no service (web), pastes - /// GridCraft's own clipboard. - pub paste_text: Option Option>>, - /// Web: deliver a file to the user (name, bytes). - pub download: Option>, - /// Web: start an asynchronous file pick; the bytes arrive later in `inbox`. - pub open_async: Option>, - /// Files delivered asynchronously (name, bytes), opened on the next frame. - pub inbox: Option, - /// Web: hand PDF bytes to the browser's print flow directly (no download, - /// no intermediate file) — opens the system print dialog on that PDF. - pub print: Option>, -} - -/// Shared queue of files read asynchronously (browser file picker, drag and drop). -pub type Inbox = std::sync::Arc)>>>; - #[derive(Clone, Debug, Default)] pub struct Perf { pub frame_ms: f64, @@ -259,26 +187,6 @@ pub struct Perf { last: f64, } -/// Which parts of the app's own window chrome are drawn. Everything is on by default; a host -/// that embeds the sheet in its own window (and owns files itself) can turn off the title bar -/// and the File tab, whose Backstage view then never opens. Not persisted: it describes the -/// host, not a user preference. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Chrome { - /// The title bar: AutoSave, Home, Save, Undo/Redo, the file name, Comments, Share, Search and - /// the theme switch. - pub title_bar: bool, - /// The ribbon's File tab and the Backstage view it opens (also reached by keyboard and - /// commands such as `ui.backstage`). - pub file_tab: bool, -} - -impl Default for Chrome { - fn default() -> Self { - Chrome { title_bar: true, file_tab: true } - } -} - pub struct SheetApp { pub session: Session, pub ui: UiState, @@ -381,1520 +289,6 @@ pub struct SheetApp { updates_rt: updates_ui::Runtime, } -impl SheetApp { - /// Constructs a `SheetApp` with default UI, grid, printing, control and screenshot state and the caller's session and services. - pub fn new(session: Session, services: Services) -> SheetApp { - SheetApp { - session, - ui: UiState::default(), - chrome: Chrome::default(), - views: HashMap::new(), - editor: None, - text_box_editor: None, - dialog: None, - backstage: None, - message: None, - toast: None, - notice: None, - services, - printing: Default::default(), - print_feedback_seen: None, - synthetic: Default::default(), - synthetic_pending: false, - synthetic_injected_at_pass: None, - control_rx: None, - grid: grid::GridState { - last_border: "bottom".into(), - last_fill: "#FFFF00".into(), - last_font_color: "#C00000".into(), - ..Default::default() - }, - perf: Perf::default(), - fonts_ready: false, - fonts_set: false, - prepared_fonts: false, - fonts_bound: Vec::new(), - fonts_seen: (0, 0), - fonts_rebind: false, - fonts_han: None, - effective_dark: false, - desktop_theme: desktop_theme::DesktopTheme::default(), - desktop_dark: None, - name_box: None, - keytips: keytips::KeyTips::default(), - shots: control::Shots::default(), - capture_epoch: 0, - capture_geometry: None, - capture_geometry_from_raw: false, - selected_chart: None, - clipboard_text_at_copy: None, - cut_text: None, - tab_run: None, - started: now_ms(), - allow_close: false, - open_switch_windows: false, - ui_window: None, - parked: HashMap::new(), - native_windows: false, - arrange_windows: None, - #[cfg(not(target_arch = "wasm32"))] - updates_rt: Default::default(), - } - } - - /// One-time context setup: start cataloging the machine's fonts, then install the fonts and - /// visuals. Workbook-specific fonts are bound later, once the scan is done (see [`logic`]). - /// - /// [`logic`]: SheetApp::logic - pub fn setup_context(ctx: &egui::Context, dark: bool) { - fonts::start_scan(); - Self::setup_context_for_language(ctx, dark, i18n::Language::En); - } - - /// Sets up fonts and visuals for a specific persisted interface language. - pub fn setup_context_for_language(ctx: &egui::Context, dark: bool, language: i18n::Language) { - ctx.set_fonts(theme::font_definitions_for_language(language)); - theme::apply(ctx, dark); - } - - /// Test-harness setup: mark a deterministic font set (see - /// [`theme::pinned_font_definitions`]) as already installed, so the first `logic` pass neither - /// starts the host font scan nor replaces the harness's pinned set. Production uses - /// [`Self::setup_context`] instead. - #[doc(hidden)] - pub fn use_prepared_fonts(&mut self) { - self.prepared_fonts = true; - self.fonts_ready = true; - self.fonts_set = true; - self.fonts_rebind = false; - self.fonts_han = Some(theme::HanOrder::of(self.ui.language)); - self.fonts_bound = self.used_fonts(); - self.fonts_seen = self.fonts_key(); - } - - /// The font names the open workbooks use: every style's font plus the theme's heading/body - /// fonts. These are the families [`fonts`] binds an installed file for. Every workbook's, as - /// each may be showing in a window of its own. - pub fn used_fonts(&self) -> Vec { - let mut names: Vec = Vec::new(); - for d in self.session.documents() { - names.extend(d.wb.styles.iter().map(|(_, s)| s.font.name.clone())); - names.push(d.wb.theme.major_font.clone()); - names.push(d.wb.theme.minor_font.clone()); - // Text boxes and shapes name their own typefaces. - names.extend(d.wb.sheets.iter().flat_map(|sh| sh.shapes.iter().filter_map(|s| s.format.font.clone()))); - } - names - } - - /// The open workbooks' (uid, revision), folded: [`Self::used_fonts`] changes only with it. - fn fonts_key(&self) -> (u64, u64) { - self.session - .documents() - .iter() - .fold((0u64, 0u64), |(uid, rev), d| (uid.wrapping_mul(31).wrapping_add(d.uid), rev.wrapping_mul(31).wrapping_add(d.revision))) - } - - /// Runs an engine command (or a UI command) and handles UI requests it makes. - pub fn run(&mut self, id: &str, params: Json) -> Result { - let r = self.run_command(id, params); - // This copy leaves the system clipboard alone: note what it holds, so Paste can tell - // that GridCraft's copy is newer while that text is still there. - if matches!(id, "edit.copy" | "edit.cut") && r.is_ok() { - self.clipboard_text_at_copy = self.system_clipboard_text(); - self.cut_text = if id == "edit.cut" { self.clipboard_text_at_copy.clone() } else { None }; - } - r - } - - fn run_command(&mut self, id: &str, params: Json) -> Result { - let switches_window = id.starts_with("window.") - || matches!(id, "view.newWindow" | "view.hideWindow" | "view.unhideWindow") - || (id == "view.switchWindows" && ["window", "doc"].iter().any(|k| params.get(*k).is_some_and(|d| !d.is_null()))); - if id.starts_with("file.") || switches_window { - self.settle_drafts()?; - } - if matches!(id, "file.save" | "file.saveAs" | "file.saveBytes") { - self.store_view_state(); - } - if matches!( - id, - "sheet.activate" - | "sheet.next" - | "sheet.previous" - | "sheet.move" - | "sheet.hide" - | "sheet.unhide" - | "home.insertSheet" - | "home.deleteSheet" - | "edit.undo" - | "edit.redo" - | "object.delete" - | "object.move" - ) { - self.commit_text_box_edit()?; - } - if let Some(r) = self.run_ui_command(id, ¶ms) { - return r; - } - let before = self.session.active_window(); - let r = self.session.run(id, params); - if id.starts_with("file.") && self.session.active_window() != before { - // File › New or Open from the Backstage view: the window it was in goes back to its sheet. - self.backstage = None; - } - // Requests the command made (a dialog, a draft) belong to the window it left active. - self.follow_active_window(); - self.after_engine(); - if id == "view.arrangeAll" - && self.native_windows - && let Ok(r) = &r - { - let arrange = r.get("arrange").and_then(Json::as_str).unwrap_or("tiled").to_string(); - let windows = r.get("windows").and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_u64).collect()).unwrap_or_default(); - self.arrange_windows = Some((arrange, windows)); - } - // The new chart is selected, as after inserting any object, so Delete removes it, not its data. - if id == "insert.chart" - && let Ok(r) = &r - && let Some(chart) = r.get("chart").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()) - { - self.selected_chart = Some(chart); - } - if id == "file.exportPdf" - && let Ok(r) = &r - { - self.deliver_pdf(r); - } - r - } - - /// Copy or cut the selected cells to the system clipboard. Engine-only commands keep - /// their existing internal clipboard behavior and do not write to the host clipboard. - pub fn copy_to_clipboard(&mut self, ctx: &egui::Context, command: &str) { - // Only ask the engine for HTML when the host can publish it. - let result = match self.run_command(command, json!({"html": self.services.copy_html.is_some()})) { - Ok(result) => result, - Err(e) => return self.alert(e), - }; - let Some(text) = result.get("text").and_then(Json::as_str) else { return }; - // The system clipboard gets this copy, so its text decides what Paste pastes. - self.clipboard_text_at_copy = None; - self.cut_text = (command == "edit.cut").then(|| text.replace("\r\n", "\n")); - if let Some(html) = result.get("html").and_then(Json::as_str) - && let Some(copy_html) = &mut self.services.copy_html - { - match copy_html(html, text) { - Ok(()) => return, - Err(error) => log::warn!("HTML clipboard unavailable; copying plain text: {error}"), - } - } - ctx.copy_text(text.to_string()); - } - - /// Paste text that came from the system clipboard (Cmd/Ctrl+V, or the ribbon and menu Paste), - /// with the clipboard's HTML flavour when the host can read it. - /// - /// Excel pastes a cut once: after GridCraft's cut has been pasted, the system clipboard text - /// that came with it is not pasted again over the moved block; GridCraft's own (empty) - /// clipboard is pasted instead, which reports "Nothing to paste.". Other text means the - /// clipboard has moved on, so the cut's text is forgotten and pastes normally later. - pub fn paste_external(&mut self, text: String) { - if Self::same_clipboard_text(Some(&text), self.cut_text.as_deref()) { - if self.session.clipboard.is_none() { - return self.run_or_alert("edit.paste", json!({})); - } - } else { - self.cut_text = None; - } - let html = self.services.paste_html.as_mut().and_then(|read| read()); - let params = match html { - Some(html) => json!({"text": text, "html": html}), - None => json!({"text": text}), - }; - self.run_or_alert("edit.paste", params); - } - - /// Ribbon and menu Paste: paste the text on the system clipboard, as Cmd/Ctrl+V does. With no - /// text there, no host clipboard (web), or the text that was there when GridCraft last copied - /// without publishing, GridCraft's own clipboard is pasted. - pub fn paste_from_clipboard(&mut self) { - match self.system_clipboard_text() { - Some(text) if self.session.clipboard.is_none() || self.clipboard_text_at_copy.as_ref() != Some(&text) => self.paste_external(text), - _ => self.run_or_alert("edit.paste", json!({})), - } - } - - /// The system clipboard's plain text, with the line endings of egui's `Event::Paste` text. - /// `None` when it holds no text or the host can't read it. - fn system_clipboard_text(&mut self) -> Option { - self.services.paste_text.as_mut().and_then(|read| read()).filter(|t| !t.is_empty()).map(|t| t.replace("\r\n", "\n")) - } - - /// Whether two clipboard texts are the same copy: trailing line breaks don't count, as in the - /// engine's `edit.paste`, and CRLF matches LF (egui's `Event::Paste` text may keep the host's). - fn same_clipboard_text(a: Option<&str>, b: Option<&str>) -> bool { - fn chars(t: &str) -> impl Iterator + '_ { - t.trim_end_matches(['\r', '\n']).chars().filter(|c| *c != '\r') - } - match (a, b) { - (Some(a), Some(b)) => chars(a).eq(chars(b)), - _ => false, - } - } - - /// Web: `file.exportPdf` without a path returns the PDF as base64; hand it to the browser as a download. - fn deliver_pdf(&self, result: &Json) { - if result.get("path").is_some() { - return; - } - if let Some(dl) = &self.services.download - && let Some(b) = result.get("base64").and_then(Json::as_str).and_then(gridcraft_engine::io::base64_decode) - { - let title = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); - dl(&std::path::Path::new(&title).with_extension("pdf").to_string_lossy(), &b); - } - } - - /// Runs a command and shows its error in a message box (for menu/ribbon clicks). - /// Follow the desktop's light/dark choice while the app runs (Linux; the desktop app calls - /// this once — tests and embedders don't start the watcher thread). - pub fn watch_desktop_theme(&mut self) { - self.desktop_theme = desktop_theme::DesktopTheme::start(); - } - - pub fn run_or_alert(&mut self, id: &str, params: Json) { - // A menu/shortcut opens the settings first; the Print page's button submits explicitly. - if id == "file.print" { - if self.services.print_native.is_some() { - self.open_backstage(backstage::Page::Print); - return; - } - return self.print_now(params); - } - // Menu-style Export as PDF with no file named: ask where to save, like Save As does. - // Programmatic callers use `run` and keep getting the PDF bytes back. - if id == "file.exportPdf" && self.services.pick_save.is_some() && params.get("path").and_then(Json::as_str).is_none_or(str::is_empty) { - return self.export_pdf_with_picker(params); - } - // Recommended Charts with no chart type named (ribbon button, keytip, command search) asks - // which chart to insert; programmatic callers of `run` still get the first recommendation. - if id == "insert.recommendedCharts" && params.get("type").is_none() { - return self.open_dialog("recommendedCharts", json!({})); - } - // Switch Windows with no window named (key tip, command search) opens the View tab's - // list to choose from; programmatic callers of `run` get the list back. - if id == "view.switchWindows" && ["window", "doc"].iter().all(|k| params.get(*k).is_none_or(Json::is_null)) { - self.ui.ribbon_tab = "View".into(); - self.ui.ribbon_collapsed = false; - self.open_switch_windows = true; - return; - } - match self.run(id, params) { - // A refused cell draft already shows its own message (with the rule's title). - Err(e) if self.message.as_ref().is_some_and(|(_, m)| *m == e) => {} - Err(e) => self.alert(e), - Ok(_) => {} - } - } - - /// Asks for a `.pdf` path with the save picker, then re-runs `file.exportPdf` with it. Other - /// params (range, sheets, fitToPage) are kept. A cancelled picker does nothing. - fn export_pdf_with_picker(&mut self, mut params: Json) { - if !self.commit_edit(0, 0, false, false) { - return; - } - let title = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); - let stem = std::path::Path::new(&title).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book1").to_string(); - let Some(pick) = &self.services.pick_save else { return }; - let Some(path) = pick(&format!("{stem}.pdf")) else { return }; - // The picker may suggest a workbook extension; the export writes a PDF. - let path = std::path::Path::new(&path).with_extension("pdf").to_string_lossy().into_owned(); - match &mut params { - Json::Object(map) => { - map.insert("path".into(), Json::String(path)); - } - other => *other = json!({"path": path}), - } - if let Err(e) = self.run("file.exportPdf", params) { - self.alert(e); - } - } - - /// Shows a command error: a toast for an unavailable command, else a message box. - fn alert(&mut self, e: String) { - // Shown in the window the user ends up in, also after a command that switched windows. - self.follow_active_window(); - if e.contains("is not available right now") { - self.toast = Some((e, now_ms())); - } else { - self.message = Some(("GridCraft".into(), clean_error(&e))); - } - } - - /// Builds the selected sheets' print-ready PDF and hands it to the desktop worker or the - /// browser. Rendering errors use the usual alert; asynchronous spooler errors stay visible - /// on the Print page. The engine command still returns PDF bytes without submitting. - pub fn print_now(&mut self, params: Json) { - if let Err(error) = self.submit_print(params) { - self.alert(error); - } - } - - fn submit_print(&mut self, params: Json) -> Result { - if self.services.print_native.is_some() && !self.printing.ready() { - return Err("Select an available printer in File > Print before submitting.".into()); - } - let pdf = self.run("file.print", params)?; - let encoded = pdf.get("base64").and_then(Json::as_str).ok_or("The print command did not produce a PDF.")?; - let bytes = gridcraft_engine::io::base64_decode(encoded).ok_or("Couldn't decode the generated PDF.")?; - let len = bytes.len(); - if let Some(submit) = &self.services.print_native { - self.printing.begin(submit(bytes, self.printing.settings.clone())).map_err(|_| "A print job is already being submitted.".to_owned())?; - self.print_feedback_seen = None; - } else if let Some(print) = &self.services.print { - print(&bytes); - } else { - return Err("Printing is unavailable here; export a PDF instead.".into()); - } - Ok(json!({"printing": true, "bytes": len, "pending": self.services.print_native.is_some()})) - } - - /// UI-only commands (dialogs, view toggles that aren't saved in the file). - fn run_ui_command(&mut self, id: &str, p: &Json) -> Option> { - let r = match id { - "app.checkForUpdates" | "help.checkForUpdates" => { - self.check_for_updates(); - Ok(Json::Null) - } - "ui.printSettings" => printing::settings(&mut self.printing, p, self.services.printers.as_ref()), - "ui.printSubmit" => self.submit_print(p.clone()), - "ui.ribbonTab" => { - self.ui.ribbon_tab = p.get("tab").and_then(Json::as_str).unwrap_or("Home").to_string(); - Ok(Json::Null) - } - "view.collapseRibbon" => { - 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); - self.ui.system_theme = false; - Ok(json!({"on": self.ui.dark})) - } - "view.theme" => { - match p.get("mode").and_then(Json::as_str) { - Some("system") => self.ui.system_theme = true, - Some(mode @ ("light" | "dark")) => { - self.ui.dark = mode == "dark"; - self.ui.system_theme = false; - } - _ => return Some(Err("theme mode must be system, light, or dark".into())), - } - Ok(json!({"mode": self.ui.theme_mode()})) - } - "view.animations" => { - self.ui.animations = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.animations); - Ok(json!({"on": self.ui.animations})) - } - "options.autoComplete" => { - self.ui.autocomplete = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.autocomplete); - Ok(json!({"on": self.ui.autocomplete})) - } - "view.interfaceScale" => { - // `{"percent": 125}` or `{"percent": "auto"}`; out-of-range values are clamped. - let percent = match p.get("percent") { - Some(Json::String(s)) if s.eq_ignore_ascii_case("auto") => Some(100), - Some(v) => v.as_f64().and_then(clamp_interface_scale), - None => None, - }; - match percent { - Some(v) => { - self.ui.interface_scale = v; - Ok(json!({"percent": v})) - } - None => Err(format!("missing `percent` ({INTERFACE_SCALE_MIN}–{INTERFACE_SCALE_MAX}, or \"auto\")")), - } - } - "app.language.set" => { - // `{"language": "ja"}`; `code` is accepted as an alias. - let code = p.get("language").or_else(|| p.get("code")).and_then(Json::as_str).unwrap_or(""); - match i18n::Language::parse(code) { - Some(l) => { - self.ui.language = l; - Ok(json!({"language": l})) - } - None => Err(format!("unknown language {code:?} (use one of {})", i18n::Language::code_list(", "))), - } - } - "app.language.english" => { - self.ui.language = i18n::Language::En; - Ok(json!({"language": i18n::Language::En})) - } - "app.language.japanese" => { - self.ui.language = i18n::Language::Ja; - Ok(json!({"language": i18n::Language::Ja})) - } - "ui.backstage" => { - // `{"page": "info"}` opens File › Info; `{"page": "close"}` returns to the sheet. - match p.get("page").and_then(Json::as_str).map(str::trim) { - Some("close" | "none" | "") => self.backstage = None, - Some(id) => match backstage::Page::parse(id) { - Some(page) => self.open_backstage(page), - None => { - return Some(Err(format!( - "unknown File page {id:?} (use home, new, open, info, saveAs, print, export, options or close)" - ))); - } - }, - None => self.open_backstage(backstage::Page::Home), - } - Ok(json!({"page": self.backstage.as_ref().map(|b| b.page.id())})) - } - "ui.dialog" => { - let name = p.get("name").and_then(Json::as_str).unwrap_or(""); - self.open_dialog(name, p.clone()); - Ok(json!({"dialog": self.dialog.as_ref().map(|d| d.name())})) - } - "ui.message.dismiss" => { - self.message = None; - Ok(Json::Null) - } - "ui.notice" => Ok(match &self.notice { - Some(n) => json!({ - "text": n.text, - "kind": match n.kind { NoticeKind::Warning => "warning", NoticeKind::Info => "info" }, - "action": n.action, - }), - None => Json::Null, - }), - "ui.notice.dismiss" => { - self.notice = None; - Ok(Json::Null) - } - _ => return None, - }; - Some(r) - } - - /// Picks up requests commands made (dialogs, edit mode…). - pub fn after_engine(&mut self) { - // The requests belong to the window the command left active. - self.follow_active_window(); - for req in self.session.take_ui_requests() { - match req { - UiRequest::Dialog(name, params) => self.open_dialog(&name, params), - UiRequest::Message(m) => self.message = Some(("GridCraft".into(), m)), - UiRequest::EditCell(text) => { - let text = text.map(|text| { - self.session - .active() - .map(|d| formula_locale::display_template(&text, self.ui.language.formula_locale(), &d.wb, d.wb.active_sheet())) - .unwrap_or(text) - }); - 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); - } - } - } - } - } - - /// Whether the active sheet of the active document is protected (drives the Protect/Unprotect - /// Sheet labels and actions). - pub fn active_sheet_protected(&self) -> bool { - self.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false) - } - - pub fn open_dialog(&mut self, name: &str, params: Json) { - if let Err(e) = self.commit_text_box_edit() { - self.message = Some(("Text Box".into(), clean_error(&e))); - return; - } - // Every Protect Sheet entry point (ribbon menus, keytips, tab menu) becomes Unprotect Sheet - // on a protected sheet. A sheet without a password is unprotected straight away. - let name = if name == "protectSheet" && self.active_sheet_protected() { "unprotectSheet" } else { name }; - if name == "unprotectSheet" - && self - .session - .active() - .and_then(|d| d.wb.active()) - .and_then(|s| s.protection.as_ref()) - .is_some_and(|p| p.password.is_none() && p.legacy_password_hash.is_none()) - { - self.run_or_alert("review.unprotectSheet", json!({})); - return; - } - match name { - "open" => { - if let Some(start) = &self.services.open_async { - start(); - } else if let Some(pick) = &self.services.pick_open - && let Some(path) = pick() - { - self.open_path(&path); - } - } - "saveAs" => { - let suggested = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); - if let Some(pick) = &self.services.pick_save { - if let Some(path) = pick(&suggested) { - let path = if std::path::Path::new(&path).extension().is_none() { format!("{path}.xlsx") } else { path }; - self.run_or_alert("file.save", json!({"path": path})); - } - } else if self.services.download.is_some() - && let Ok(r) = self.run("file.saveBytes", json!({"format": "xlsx"})) - && let Some(dl) = &self.services.download - && let Some(b) = r.get("base64").and_then(Json::as_str).and_then(gridcraft_engine::io::base64_decode) - { - let name = r.get("name").and_then(Json::as_str).unwrap_or("Book.xlsx").to_string(); - dl(&name, &b); - } - } - // File › Options (`file.options`) and the File tab itself open the Backstage view. - "options" => self.open_backstage(backstage::Page::Options), - "backstage" => { - let page = params.get("page").and_then(Json::as_str).and_then(backstage::Page::parse).unwrap_or(backstage::Page::Home); - self.open_backstage(page); - } - other => self.dialog = dialogs::Dialog::open(self, other, params), - } - } - - /// Opens the File tab's Backstage view on `page`, committing a cell still being edited first - /// (File actions work on the whole workbook). A draft that can't be committed keeps the sheet. - pub fn open_backstage(&mut self, page: backstage::Page) { - if !self.chrome.file_tab { - return; - } - if !self.commit_edit(0, 0, false, false) { - return; - } - match &mut self.backstage { - Some(b) => b.page = page, - None => self.backstage = Some(backstage::Backstage::new(page)), - } - } - - pub fn open_path(&mut self, path: &str) { - match self.run("file.open", json!({"path": path})) { - Ok(r) => self.after_open(Some(path), &r), - // A password-protected workbook: ask for the password; the dialog opens it. - Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => self.open_dialog("openPassword", json!({"path": path})), - Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), - } - self.after_engine(); - } - - /// What follows a successful `file.open`: the workbook shows (not the Backstage), the Recent - /// list (for a file on disk) and the open warnings notice. Shared by File › Open, the - /// password dialog and the control channel. - pub fn after_open(&mut self, path: Option<&str>, r: &Json) { - self.backstage = None; - if let Some(path) = path { - self.ui.recent.retain(|p| p != path); - self.ui.recent.insert(0, path.to_string()); - self.ui.recent.truncate(20); - } - self.show_open_warnings(r); - } - - /// Turns the `warnings` a `file.open` result carries into an info-bar notice, choosing the - /// most important one (a preserved VBA project outranks, say, a skipped optional part). - /// Kept deliberately generic: any open-time warning can reach the user this way. - pub fn show_open_warnings(&mut self, r: &Json) { - // The opened workbook's window shows them, however it was opened (a script may have run - // `file.open` on the session directly). - self.follow_active_window(); - let Some(list) = r.get("warnings").and_then(Json::as_array) else { - self.notice = None; - return; - }; - let msgs: Vec<&str> = list.iter().filter_map(Json::as_str).collect(); - if msgs.is_empty() { - // A clean open clears any notice left over from the previous document. - self.notice = None; - return; - } - let vba: Vec<&str> = msgs.iter().copied().filter(|m| m.to_lowercase().contains("macro") || m.contains("VBA")).collect(); - if !vba.is_empty() { - // The reader says "dropped" when the VBA project was missing or unreadable; never promise to keep it then. - let dropped = vba.iter().any(|m| m.contains("dropped")); - let text = if dropped { - "This workbook's VBA macros could not be loaded and were dropped, so they will not be in the saved file. Keep the original file." - } else { - "This workbook contains VBA macros. GridCraft keeps them when you save as .xlsm, but does not run them." - }; - self.notice = Some(Notice { text: text.into(), kind: NoticeKind::Warning, action: Some(("What this means".into(), "macros".into())) }); - } else { - let text = if msgs.len() == 1 { - msgs[0].to_string() - } else { - format!("{} items in this file were handled differently. First: {}", msgs.len(), msgs[0]) - }; - self.notice = Some(Notice { text, kind: NoticeKind::Info, action: None }); - } - } - - /// The open windows in order, for the Switch Windows lists. - pub fn open_windows(&self) -> Vec { - let active = self.session.active_window(); - self.session - .windows() - .iter() - .filter_map(|w| { - let d = self.session.documents().iter().find(|d| d.uid == w.doc_uid)?; - let title = self.session.window_title(w.id)?; - Some(OpenWindow { window: w.id, doc: d.uid, title, dirty: d.is_dirty(), active: active == Some(w.id) }) - }) - .collect() - } - - /// The active workbook and sheet: what drafts, text boxes and picture targets belong to. - pub fn view_key(&self) -> Option<(u64, usize)> { - self.session.active().map(|d| (d.uid, d.wb.active_sheet())) - } - - /// The active window and sheet: each window scrolls on its own. - fn scroll_key(&self) -> Option<(u64, usize)> { - Some((self.session.active_window()?, self.session.active()?.wb.active_sheet())) - } - - pub fn view(&self) -> SheetView { - self.scroll_key().and_then(|k| self.views.get(&k).copied()).unwrap_or_else(|| self.saved_view()) - } - - pub fn view_mut(&mut self) -> Option<&mut SheetView> { - let k = self.scroll_key()?; - let saved = self.saved_view(); - Some(self.views.entry(k).or_insert(saved)) - } - - /// The view a sheet opens with: scrolled to the file's saved first visible cell (`topLeftCell`). - fn saved_view(&self) -> SheetView { - let Some(sh) = self.session.active().and_then(|d| d.wb.active()) else { return SheetView::default() }; - if sh.view != gridcraft_engine::model::SheetViewMode::Normal { - return SheetView::default(); - } - let (fr, fc) = sh.freeze.unwrap_or((0, 0)); - let top = sh.view_top_left; - let y = sh.row_top(top.row.max(fr)) - sh.row_top(fr); - let x = sh.col_left(top.col.max(fc)) - sh.col_left(fc); - SheetView { scroll: egui::vec2(x.max(0.0) as f32, y.max(0.0) as f32) } - } - - /// Hands the active document's scroll positions (first visible cell per sheet) and active - /// cell to the engine so a save writes them (`sheetView topLeftCell`). Normal view only: - /// Page Layout view scrolls over pages, not cells. - pub fn store_view_state(&mut self) { - let window = self.session.active_window(); - let Some(d) = self.session.active_mut() else { return }; - let mut tops = Vec::new(); - for (&(w, si), v) in &self.views { - let Some(sh) = d.wb.sheets.get(si).filter(|sh| Some(w) == window && sh.view == gridcraft_engine::model::SheetViewMode::Normal) else { - continue; - }; - let (fr, fc) = sh.freeze.unwrap_or((0, 0)); - // A hair past the edge so a row that starts exactly at the scroll offset is the one found. - let row = sh.row_at(sh.row_top(fr) + v.scroll.y.max(0.0) as f64 + 0.01); - let col = sh.col_at(sh.col_left(fc) + v.scroll.x.max(0.0) as f64 + 0.01); - tops.push((si, CellRef::new(row, col))); - } - d.store_view(&tops); - } - - /// Starts editing the active cell. `text` replaces the content (typing) or pre-fills it. - pub fn begin_edit(&mut self, text: Option, from_formula_bar: bool) { - // The source formula is display-only on spill children. Grid replacement keeps its - // existing behavior, but no formula-bar entry path may edit that displayed formula. - if from_formula_bar && formula_bar::active_spill_anchor(self).is_some() { - return; - } - if let Err(e) = self.commit_text_box_edit() { - self.message = Some(("Text Box".into(), clean_error(&e))); - return; - } - let Some(d) = self.session.active() else { return }; - let Some(sh) = d.wb.active() else { return }; - let at = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active); - // A picture has no editable scalar text. Selecting it, F2, or clicking - // the formula bar must not turn its fallback value into a text edit. - // Typing still starts an intentional replacement. - if text.is_none() && sh.cell_pictures.contains_key(&at) { - return; - } - let locale = self.ui.language.formula_locale(); - let current = if sh.cell_pictures.contains_key(&at) { - String::new() - } else { - match sh.cell(at) { - Some(c) if c.formula.is_some() => formula_locale::display(&c.input_text(), locale, &d.wb, d.wb.active_sheet()), - _ => gridcraft_engine::display::edit_text(&d.wb, sh, at), - } - }; - let (text, replace) = match text { - Some(t) => (t, true), - None => (current.clone(), false), - }; - let mut ed = editor::EditState::new(d.uid, d.wb.active_sheet(), at, text, replace, from_formula_bar); - ed.window = self.session.active_window(); - ed.locale = locale; - if replace && self.ui.autocomplete && !ed.is_formula() { - ed.completion = editor::column_completion(sh, at, &ed.text); - } - self.editor = Some(ed); - self.session.mode = if replace { gridcraft_engine::Mode::Enter } else { gridcraft_engine::Mode::Edit }; - } - - /// Commits the edit; `then` moves the selection (dr, dc) afterwards. - /// - /// The draft goes into the window and workbook it was typed in. If another window is - /// showing, that window stays active afterwards; a draft its own workbook refuses - /// (validation, a bad formula) brings its window back to the front with the draft still open. - pub fn commit_edit(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool { - let Some((doc, window)) = self.editor.as_ref().map(|ed| (ed.doc, ed.window)) else { return true }; - if !self.draft_elsewhere() { - return self.commit_edit_here(dr, dc, array, fill_selection); - } - // Its window, or (when that window closed) its workbook's most recent one. - let Some(owner) = window.filter(|w| self.session.window(*w).is_some()).or_else(|| self.session.doc_window(doc)) else { - self.cancel_edit(); - return true; - }; - let back = self.session.active_window(); - self.session.activate_window(owner); - if !self.commit_edit_here(dr, dc, array, fill_selection) { - // Refused: its window stays in front with the draft open and the reason shown. - return false; - } - if let Some(back) = back { - self.session.activate_window(back); - } - true - } - - /// The cell draft belongs to a window or workbook other than the one showing. - fn draft_elsewhere(&self) -> bool { - self.editor.as_ref().is_some_and(|ed| { - self.session.active().map(|d| d.uid) != Some(ed.doc) || ed.window.is_some_and(|w| self.session.active_window() != Some(w)) - }) - } - - /// Commits the cell and text-box drafts, as Enter would, before a command that can show - /// another workbook or write this one to a file, so a draft never lands in another workbook. - /// A draft its workbook refuses stays open with the reason shown, and the command doesn't run. - pub(crate) fn settle_drafts(&mut self) -> Result<(), String> { - if !self.commit_edit(0, 0, false, false) { - return Err(self.message.as_ref().map(|(_, m)| m.clone()).unwrap_or_else(|| "The cell being edited can't be entered.".into())); - } - self.commit_text_box_edit() - } - - fn commit_edit_here(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool { - let Some(ed) = self.editor.take() else { return true }; - self.session.mode = gridcraft_engine::Mode::Ready; - let text = match &ed.completion { - Some(full) if full.to_lowercase().starts_with(&ed.text.to_lowercase()) => full.clone(), - _ => ed.text.clone(), - }; - // F2 then Enter (or a file command) on an untouched draft: nothing to write. - if !fill_selection && !array && ed.original.as_ref() == Some(&text) { - if dr != 0 || dc != 0 { - self.move_after_enter(dr, dc); - } - return true; - } - let text = match self.session.active().map(|d| formula_locale::canonical(&text, ed.locale, &d.wb, ed.sheet, ed.cell)) { - Some(Ok(text)) => text, - Some(Err(error)) => { - self.message = Some(("GridCraft".into(), error.to_string())); - self.editor = Some(ed); - self.session.mode = gridcraft_engine::Mode::Edit; - return false; - } - None => text, - }; - // Data validation. - if let Some(d) = self.session.active() - && let Some((dv, msg)) = gridcraft_engine::cmd::data::check_validation(&d.wb, ed.sheet, ed.cell, &text) - { - if dv.error_style == gridcraft_engine::model::ErrorStyle::Stop { - let title = if dv.error_title.is_empty() { "GridCraft".to_string() } else { dv.error_title.clone() }; - self.message = Some((title, msg)); - self.editor = Some(ed); - self.session.mode = gridcraft_engine::Mode::Edit; - return false; - } - self.toast = Some((msg, now_ms())); - } - // Point mode can leave another sheet active; the edit belongs to the sheet it started on. - if self.session.active().is_some_and(|d| d.wb.active_sheet() != ed.sheet) - && let Err(e) = self.session.run("sheet.activate", json!({"sheet": ed.sheet})) - { - self.message = Some(("GridCraft".into(), clean_error(&e))); - self.editor = Some(ed); - self.session.mode = gridcraft_engine::Mode::Edit; - return false; - } - // Committing nothing into an empty cell changes nothing: no undo step (#1855, Backspace has - // already cleared the cell as its own step). - let unchanged = !fill_selection - && !array - && text.is_empty() - && self - .session - .active() - .and_then(|d| d.wb.sheet(ed.sheet)) - .is_some_and(|sh| sh.cell(ed.cell).is_none_or(|c| c.formula.is_none() && c.value == gridcraft_engine::core::Value::Empty)); - let r = if unchanged { - Ok(json!({})) - } else if fill_selection { - self.session.run("range.fill", json!({"input": text})) - } else { - self.session.run("cell.set", json!({"cell": ed.cell.a1(), "input": text, "array": array})) - }; - match r { - Ok(_) => { - if dr != 0 || dc != 0 { - self.move_after_enter(dr, dc); - } - self.after_engine(); - true - } - Err(e) => { - self.message = Some(("GridCraft".into(), clean_error(&e))); - self.editor = Some(ed); - self.session.mode = gridcraft_engine::Mode::Edit; - false - } - } - } - - /// Commits the edit with an arrow key. The arrow is an ordinary move, so it ends a run of Tabs - /// instead of continuing it (Right) or going back to its column like Enter (Down), as in Excel. - pub fn commit_edit_arrow(&mut self, dr: i64, dc: i64) -> bool { - let run = self.tab_run.take(); - let ok = self.commit_edit(dr, dc, false, false); - self.tab_run = if ok { None } else { run }; - ok - } - - pub fn cancel_edit(&mut self) { - self.editor = None; - self.session.mode = gridcraft_engine::Mode::Ready; - } - - /// Enter/Tab move inside a multi-cell selection, otherwise to the next cell. - pub fn move_after_enter(&mut self, dr: i64, dc: i64) { - let multi = self.session.active().is_some_and(|d| !d.selection.is_single_cell()); - // Any other move (arrows, clicks, another sheet) leaves the selection off the cell the - // last Tab landed on, which ends the run. - let here = self.session.active().map(|d| (d.uid, d.wb.active_sheet(), d.selection.active)); - let run = self.tab_run.take().filter(|&(uid, sheet, _, at)| here == Some((uid, sheet, at))); - if multi { - let fwd = dr > 0 || dc > 0; - let _ = self.session.run("selection.next", json!({"forward": fwd, "byRow": dc != 0})); - } else if let (Some((_, _, col, at)), 1, 0) = (run, dr, dc) { - let back = i64::from(col) - i64::from(at.col); - let _ = self.session.run("selection.move", json!({"dr": 1, "dc": back})); - } else { - let _ = self.session.run("selection.move", json!({"dr": dr, "dc": dc})); - if dr == 0 && dc != 0 { - let start = run.map(|r| r.2); - self.tab_run = self.session.active().and_then(|d| { - let (uid, sheet) = (d.uid, d.wb.active_sheet()); - let col = start.or(here.map(|h| h.2.col))?; - Some((uid, sheet, col, d.selection.active)) - }); - } - } - self.grid.ensure_visible = true; - } - - /// Terminal feedback also reaches the normal window when printing starts through control. - /// Polls printing feedback. Called once per frame from [`Self::logic`]; the direct calls in - /// `tests/native_print.rs` deliberately bypass the lifecycle to test this helper alone. - pub fn poll_printing(&mut self, ctx: &egui::Context) { - self.printing.poll(ctx); - if self.printing.message == self.print_feedback_seen { - return; - } - self.print_feedback_seen = self.printing.message.clone(); - let Some(message @ (printing::Feedback::Submitted(_) | printing::Feedback::Failed(_) | printing::Feedback::WorkerStopped)) = - self.printing.message.clone() - else { - return; - }; - let text = printing::feedback(&message, self.ui.language, |label| self.ui.language.tr(label).to_owned()); - if matches!(message, printing::Feedback::Submitted(_)) { - self.toast = Some((text, now_ms())); - } else { - self.alert(text); - } - } - - /// Holds back a window close (the close button, Alt+F4, the OS quitting the app) while a - /// workbook has unsaved changes, and asks about them first. - fn guard_window_close(&mut self, ctx: &egui::Context) { - if self.native_windows || self.allow_close || !ctx.input(|i| i.viewport().close_requested()) { - return; - } - // A cell still being edited is part of the workbook, as for File › Close. - if !self.commit_edit(0, 0, false, false) { - ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); - return; - } - if !self.session.documents().iter().any(|d| d.is_dirty()) { - return; - } - ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); - match self.dialog.as_mut() { - // Already asking about a workbook (File › Close): answering it now goes on to quit. - Some(d) if d.name() == "saveChanges" => { - d.values.insert("quit".into(), json!(true)); - } - _ => self.continue_quit(ctx), - } - } - - /// Asks about the next unsaved workbook before quitting, in its window, or closes the app - /// once none is left. - pub(crate) fn continue_quit(&mut self, ctx: &egui::Context) { - let dirty = self.session.documents().iter().find(|d| d.is_dirty()).map(|d| d.uid); - match dirty.and_then(|uid| self.session.doc_window(uid)) { - Some(window) => { - // Its window comes forward (the native window too: see `doc_windows`). - self.enter_window(window); - let title = self.session.active().map(|d| d.display_title()).unwrap_or_default(); - self.open_dialog("saveChanges", json!({"title": title, "quit": true})); - } - None => { - self.allow_close = true; - ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Close); - } - } - } - - /// Answers the Save Changes question about the active workbook (its buttons). While quitting, - /// the next unsaved workbook is asked about once this one is answered; Cancel stops the quit. - /// Returns whether the question is settled: Save keeps it open when the cell being edited - /// can't be entered. - pub fn answer_save_changes(&mut self, answer: SaveAnswer, quitting: bool, ctx: &egui::Context) -> bool { - match answer { - SaveAnswer::Cancel => {} - SaveAnswer::DontSave => { - self.run_or_alert("file.close", json!({"force": true, "document": true})); - if quitting { - self.continue_quit(ctx); - } - } - SaveAnswer::Save => { - // File operations include the cell still being edited, as File › Save does. - if !self.commit_edit(0, 0, false, false) { - return false; - } - self.run_or_alert("file.save", json!({})); - if self.session.active().is_some_and(|x| !x.is_dirty()) { - self.run_or_alert("file.close", json!({"force": true, "document": true})); - if quitting { - self.continue_quit(ctx); - } - } - } - } - true - } - - /// Quit (the macOS app menu, ⌘Q): commits every window's cell draft as Enter would, then asks - /// about each unsaved workbook in its window; Cancel on any of them stops the quit. A draft its - /// workbook refuses stops the quit too, with its window in front and the reason shown. - pub fn request_quit(&mut self, ctx: &egui::Context) { - while let Some(window) = self.parked_window_with_draft() { - self.enter_window(window); - if self.settle_drafts().is_err() { - return; - } - } - if self.settle_drafts().is_err() { - return; - } - if let Some(d) = self.dialog.as_mut().filter(|d| d.name() == "saveChanges") { - // Already asking about a workbook (File › Close): answering it goes on to quit. - d.values.insert("quit".into(), json!(true)); - return; - } - self.continue_quit(ctx); - } - - /// Per-frame logic (control channel, screenshots). Call before `ui`. - pub fn logic(&mut self, ctx: &egui::Context) { - self.ensure_text_width(ctx); - // A keytip control request lives one frame: the ribbon consumes it during `ui`, and - // anything that never reached a control (collapsed ribbon, tab switch) is dropped. - self.ui.keytip_invoke = None; - // Floating pictures' textures go with their workbook, even when no sheet is painted. - let open: Vec = self.session.documents().iter().map(|d| d.uid).collect(); - floating_pictures::forget_closed(ctx, &open); - self.apply_font_state(ctx); - if let Some(answer) = self.desktop_theme.take_change() { - self.desktop_dark = Some(answer); - } - self.sync_theme_preference(ctx); - self.sync_input_options(ctx); - // The geometry captures are bound to. Native frames recorded it from their fresh - // `RawInput` in `raw_input_hook` (including hidden logic-only ticks); the epoch is - // monotonic, so a resize back to the original values is still a new epoch and old pixels - // are never saved with new geometry. - let capture_geometry = control::current_capture_geometry(self, ctx); - control::note_capture_geometry(self, capture_geometry); - // Drain before admission: completed or timed-out captures release their slots and - // reservation first, so a new request is never refused for budget that is about to free. - // Screenshot results, deadlines and geometry validation belong to `logic`; `ui` only - // issues the capture command (after every widget, so a command cannot be missed). - control::drain_screenshots(self, ctx); - control::poll(self, ctx); - // Printing feedback is polled once per frame, here, so hidden windows and logic-only - // frames still see worker completion. `ui` and the desktop app no longer poll it. - self.poll_printing(ctx); - self.guard_window_close(ctx); - #[cfg(not(target_arch = "wasm32"))] - updates_ui::tick(self, ctx); - // Files read asynchronously (web file picker, dropped files). - self.poll_async_opens(); - self.autosave(ctx); - self.update_title(ctx); - self.request_hidden_paint(ctx); - // One window always shows a workbook, unless each workbook has a native window of its own - // (`doc_windows` decides what having none left means). - if self.session.active().is_none() && !self.native_windows { - self.session.new_workbook(); - } - self.follow_active_window(); - } - - /// Installs the text-width closure once, on the first frame. - fn ensure_text_width(&mut self, ctx: &egui::Context) { - if self.session.text_width.is_some() { - return; - } - let ctx = ctx.clone(); - self.session.text_width = Some(std::sync::Arc::new(move |text, font, exact| theme::cell_text_width(&ctx, text, font, exact))); - } - - /// Applies the font gates: a language Han-order change, startup/prepared fonts and workbook - /// font binding. Every branch keeps its one-frame paint gate. - fn apply_font_state(&mut self, ctx: &egui::Context) { - // A pending synthetic step owns the next real `ui` pass: `ui` returns at the font gate - // before it handles input, so a font change requested now would consume the step without - // applying it. Delay the change one frame; the hidden-paint request keeps it coming. - let defer_font_change = self.synthetic_pending || !self.synthetic.is_empty(); - if !defer_font_change && self.fonts_han.is_some_and(|han| han != theme::HanOrder::of(self.ui.language)) { - self.fonts_ready = false; - self.fonts_set = false; - } - if !self.fonts_ready { - self.start_font_setup(ctx); - } else if self.fonts_rebind { - // A font-set change made last frame is active now. - self.fonts_rebind = false; - } else if !defer_font_change && !self.prepared_fonts && fonts::scan_done() { - self.rebind_workbook_fonts(ctx); - } - } - - /// Applies the pending font set. New fonts apply from the next frame on: paint nothing until - /// then. Startup always proceeds: without it a queued step would be re-offered forever. - fn start_font_setup(&mut self, ctx: &egui::Context) { - if self.fonts_set { - self.fonts_ready = true; - return; - } - if self.prepared_fonts { - ctx.set_fonts(theme::pinned_font_definitions()); - theme::apply(ctx, self.ui.dark); - } else { - SheetApp::setup_context_for_language(ctx, self.ui.dark, self.ui.language); - } - self.fonts_set = true; - // The rebuilt set has no workbook fonts yet: bind them again once it is active. - self.fonts_bound.clear(); - self.fonts_seen = (0, 0); - self.fonts_han = Some(theme::HanOrder::of(self.ui.language)); - ctx.request_repaint(); - } - - /// Binds the workbook's installed fonts once the background scan has finished. Adding fonts - /// re-applies the font set, which egui activates at the next pass, so skip this frame's paint - /// (same one-frame cost as startup). Only when an open workbook changed. - fn rebind_workbook_fonts(&mut self, ctx: &egui::Context) { - let seen = self.fonts_key(); - if seen == self.fonts_seen { - return; - } - let used = self.used_fonts(); - self.fonts_seen = seen; - if used == self.fonts_bound { - return; - } - ctx.set_fonts(theme::font_definitions_with(self.ui.language, &used)); - self.fonts_bound = used; - self.fonts_rebind = true; - ctx.request_repaint(); - } - - /// Follows the desktop/system theme preference and records the effective theme. - fn sync_theme_preference(&mut self, ctx: &egui::Context) { - let preference = match ( - self.ui.system_theme, - ctx.system_theme().or_else(|| self.desktop_dark.map(|d| if d { egui::Theme::Dark } else { egui::Theme::Light })), - ) { - (true, Some(egui::Theme::Dark)) => egui::ThemePreference::Dark, - (true, Some(egui::Theme::Light)) => egui::ThemePreference::Light, - _ => self.ui.theme_preference(), - }; - if ctx.options(|o| o.theme_preference) != preference { - ctx.set_theme(preference); - ctx.request_repaint(); - } - self.effective_dark = ctx.theme() == egui::Theme::Dark; - } - - /// Keeps egui's own keyboard zoom and quit shortcuts off and applies GridCraft's zoom factor. - fn sync_input_options(&mut self, ctx: &egui::Context) { - // Interface size: the preference owns the zoom factor, so egui's own Ctrl/Cmd +/-/0 zoom - // keys stay off (they would also swallow GridCraft's shortcuts). - if ctx.options(|o| o.zoom_with_keyboard) { - ctx.options_mut(|o| o.zoom_with_keyboard = false); - } - // egui closes the window on Ctrl+Q (and with Shift or Alt held too) without asking to save - // (#1443). Excel for Windows has no such key (Ctrl+Q is Quick Analysis). With a native - // window per workbook egui's Cmd+Q would close only the window it was typed in: the menu - // bar's Quit item has the key instead (it asks about unsaved workbooks first). - if (ctx.os() != egui::os::OperatingSystem::Mac || self.native_windows) && ctx.options(|o| !o.quit_shortcuts.is_empty()) { - ctx.options_mut(|o| o.quit_shortcuts.clear()); - } - let zoom = self.ui.zoom_factor(); - if (ctx.zoom_factor() - zoom).abs() > 0.001 { - ctx.set_zoom_factor(zoom); - ctx.request_repaint(); - } - } - - /// Opens files that arrived asynchronously (web file picker, dropped files). - fn poll_async_opens(&mut self) { - let arrived: Vec<(String, Vec)> = self - .services - .inbox - .as_ref() - .map(|i| std::mem::take(&mut *i.lock().unwrap_or_else(std::sync::PoisonError::into_inner))) - .unwrap_or_default(); - for (name, bytes) in arrived { - let b64 = gridcraft_engine::io::base64_encode(&bytes); - match self.run("file.open", json!({"name": &name, "base64": &b64})) { - // A file picked from File › Open (web) arrives here: show it, not the Backstage. - Ok(r) => { - self.backstage = None; - self.show_open_warnings(&r); - } - Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => { - self.backstage = None; - self.open_dialog("openPassword", json!({"name": name, "base64": b64})); - } - Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), - } - self.after_engine(); - } - } - - /// AutoSave: save workbooks that have a file shortly after they change. This decision lives in - /// `logic` so a hidden or logic-only frame still saves; the monotonic timestamp check keeps a - /// forced hidden UI pass from saving the same change twice. - fn autosave(&mut self, ctx: &egui::Context) { - let dirty_xlsx = self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".xlsx"))); - let now = crate::monotonic_ms(); - let autosave_due = - self.ui.autosave && self.editor.is_none() && self.text_box_editor.is_none() && now - self.grid.last_autosave > 2000.0 && dirty_xlsx; - if autosave_due { - self.grid.last_autosave = now; - self.store_view_state(); - let _ = self.session.run("file.save", json!({})); - } else if self.ui.autosave && dirty_xlsx { - // Keep checking even when no input arrives while the window is hidden. - ctx.request_repaint_after(std::time::Duration::from_secs(2)); - } - } - - /// Look for a newer release once, in the background, as the desktop app starts (a no-op on the - /// web). A found release opens the update prompt; nothing is shown when GridCraft is current. - pub fn start_update_check(&mut self) { - #[cfg(not(target_arch = "wasm32"))] - updates_ui::start(self, false); - } - - /// Help ▸ Check for Updates…: look now, and say so when GridCraft is up to date or the check fails. - pub fn check_for_updates(&mut self) { - #[cfg(not(target_arch = "wasm32"))] - updates_ui::start(self, true); - #[cfg(target_arch = "wasm32")] - { - self.message = Some(("GridCraft".into(), "The web version is always the latest release.".into())); - } - } - - /// Window title, next to the close/autosave state: `ViewportCommand::Title` is valid outside a - /// pass and a control-channel rename while hidden updates it on return. - fn update_title(&mut self, ctx: &egui::Context) { - // Native windows get theirs from `doc_windows`. - if self.native_windows { - return; - } - let Some(d) = self.session.active() else { return }; - let title = format!("{}{}", d.display_title(), if d.is_dirty() { " — Edited" } else { "" }); - if self.grid.last_title.as_deref() != Some(title.as_str()) { - ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); - self.grid.last_title = Some(title); - } - } - - /// A queued/settling capture with a capturable size and an unapplied synthetic step both need - /// a real `ui` pass. Once a capture is issued, its `Screenshot` command's own `wants_paint` - /// paints the hidden window; after that a full hidden pass every frame only wastes CPU. A - /// minimized request has no size, so no hidden pass can reach issuance: the restore pass issues - /// it instead. Native eframe honours `RequestPaintWhileHidden`; the web runner does not (hidden - /// tabs only run `run_logic`), so a hidden web tab queues the work until it is shown again. - /// This is a shared eframe web follow-up, not a GridCraft-local workaround, and the control - /// protocol documents the limitation. - fn request_hidden_paint(&self, ctx: &egui::Context) { - if !(self.shots.needs_hidden_paint() || self.synthetic_pending || !self.synthetic.is_empty()) { - return; - } - // The web runner has no `RequestPaintWhileHidden`; sending it there only logs an - // unhandled command. A visible tab still gets the repaint below; a hidden tab keeps the - // work queued (see the shared eframe follow-up in docs/egui-037-local-followups.md). - #[cfg(not(target_arch = "wasm32"))] - ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); - ctx.request_repaint(); - } - - /// Records capture geometry from the fresh raw frame, then offers exactly one pending synthetic batch and tracks whether it was applied. - pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { - // Capture geometry from this frame's real input before any synthetic event is added. This - // runs on every native frame, including hidden/minimized logic-only ticks, so a resize, - // DPI/zoom change, Wayland frame without `inner_rect`, or minimize/restore is recorded - // even though egui's last UI pass still describes the old layout. - let geometry = control::CaptureGeometry::from_raw_input(raw, ctx); - control::note_capture_geometry(self, geometry); - self.capture_geometry_from_raw = true; - // One unapplied gesture step at a time. eframe retains an unconsumed `RawInput` for the - // next pass and for a hidden logic-only tick, so the batch is offered once and survives - // until a `ui` pass applies it (which pops it). Press and release stay distinct batches. - // - // A completed egui pass may have consumed the injected events without applying them: the - // font gate returns before input handling, but `begin_pass` has already taken the events. - // `cumulative_pass_nr` only advances when a pass ends, so a newer value with the batch - // still pending means exactly that, and the step is offered again for the next real pass. - if self.synthetic_pending { - let consumed_by_an_unapplied_pass = self.synthetic_injected_at_pass.is_some_and(|pass| ctx.cumulative_pass_nr() > pass); - if !consumed_by_an_unapplied_pass { - return; - } - } - if let Some(batch) = self.synthetic.front() { - raw.events.extend(batch.iter().cloned()); - self.synthetic_pending = true; - self.synthetic_injected_at_pass = Some(ctx.cumulative_pass_nr()); - } - } - - /// Flip AutoSave (title-bar switch / Options ▸ Save). Persisted with the other UI - /// preferences, so it stays as the user left it across restarts (#1901). - pub fn toggle_autosave(&mut self) { - self.ui.autosave = !self.ui.autosave; - } - - /// Lays out the whole window. - pub fn ui(&mut self, ui: &mut egui::Ui) { - let ctx = ui.ctx().clone(); - if !self.fonts_ready || self.fonts_rebind { - ctx.request_repaint(); - return; - } - let t0 = now_ms(); - // A script may have switched windows since the last frame. - self.follow_active_window(); - text_box::before_ui(self, &ctx); - // Another window came forward without going through a command (which settles drafts - // first): the draft goes into its own window instead of floating over this one. If its - // workbook refuses it, its window comes back to the front with the draft open. - if self.draft_elsewhere() { - self.commit_edit(0, 0, false, false); - } - // The language the widgets translate with this frame (see `i18n::current`). - i18n::set_current(&ctx, self.ui.language); - #[cfg(not(target_arch = "wasm32"))] - updates_ui::show(self, &ctx); - let t = theme::Tokens::get(&ctx); - // A host can turn the File tab off while Backstage is already open: close it. - if !self.chrome.file_tab { - self.backstage = None; - } - // Keytips opened the File view (Alt, F): its letters keep working there. - if self.backstage.is_none() || self.keytips.prefix().is_some() { - self.ribbon_keys(&ctx); - } - if self.chrome.title_bar { - ribbon::title_bar(self, ui); - } - if self.backstage.is_some() { - // File tab: the Backstage view replaces everything below the title bar. - backstage::show(self, ui); - } else { - ribbon::show(self, ui); - widgets::notice_bar(self, ui); - if self.session.prefs.formula_bar { - formula_bar::show(self, ui); - } - if self.ui.status_bar { - tabs::status_bar(self, ui); - } - tabs::sheet_tabs(self, ui); - pivot_pane::show(self, ui); - panes::show(self, ui); - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.grid_bg)).show(ui, |ui| { - let g0 = now_ms(); - grid::show(self, ui); - self.perf.grid_ms = now_ms() - g0; - }); - } - dialogs::show(self, &ctx); - widgets::message_box(self, &ctx); - widgets::toast(self, &ctx); - // Requests are admitted in `logic`, for the root viewport, so only its pass issues them. - if ctx.viewport_id() == egui::ViewportId::ROOT { - control::issue_screenshots(self, &ctx); - } - if !ctx.input(|i| i.focused) { - // Nothing else to do. - } - let now = now_ms(); - self.perf.frame_ms = now - t0; - if self.perf.last > 0.0 { - let dt = now - self.perf.last; - if dt > 0.0 { - self.perf.fps = self.perf.fps * 0.9 + 100.0 / dt; - } - } - self.perf.last = now; - // The step is applied only after a completed UI pass. A discarded or repeated pass of the - // same frame must not offer it again (grid sizing calls `ui` more than once per frame); - // provenance is the injected pass number, so only a pass that missed input handling is - // re-offered by `raw_input_hook`. - // Synthetic input reaches the root viewport only (`raw_input_hook`), so its pass consumes it. - if self.synthetic_pending && ctx.viewport_id() == egui::ViewportId::ROOT { - self.synthetic.pop_front(); - self.synthetic_pending = false; - self.synthetic_injected_at_pass = None; - } - } - - fn ribbon_keys(&mut self, ctx: &egui::Context) { - let before = self.keytips.prefix().map(str::to_string); - let enabled = self.editor.is_none() - && self.dialog.is_none() - && self.message.is_none() - && self.name_box.is_none() - && !ctx.text_edit_focused() - && self.grid.context_menu.is_none() - && self.grid.object_menu.is_none() - && self.grid.header_menu.is_none() - && self.grid.fill_menu.is_none() - && self.grid.filter_menu.is_none() - && self.grid.list_picker.is_none() - && self.grid.renaming_tab.is_none() - && (before.is_some() || !egui::Popup::is_any_open(ctx)); - let actions = self.keytips.process(ctx, enabled); - // Backing out of F to the tab letters, or finishing an F sequence, closes the File view - // that Alt, F opened: the command runs on the sheet, as it did without the view. - if self.backstage.is_some() && before.is_some() && (self.keytips.prefix() == Some("") || !actions.is_empty()) { - self.backstage = None; - } - if before.is_some() && before.as_deref() != self.keytips.prefix() && !ctx.input(|i| i.pointer.any_pressed()) { - // Leave pointer cancellation to the popup so clicking a menu item still works. - egui::Popup::close_all(ctx); - } - for action in actions { - match action { - keytips::Action::Tab("File") => self.open_backstage(backstage::Page::Home), - keytips::Action::Tab(tab) => { - self.run_or_alert("ui.ribbonTab", json!({"tab": tab})); - self.ui.ribbon_collapsed = false; - } - keytips::Action::Command(id @ ("edit.copy" | "edit.cut")) => self.copy_to_clipboard(ctx, id), - keytips::Action::Command("edit.paste") => self.paste_from_clipboard(), - keytips::Action::Command(id) => self.run_or_alert(id, json!({})), - keytips::Action::CommandWith(id, params) => { - let params: Json = serde_json::from_str(params).unwrap_or_else(|_| json!({})); - // A Borders preset picked by key tip becomes the split button's face, as a - // click on the same menu item does. - if id == "home.borders" - && let Some(preset) = params.get("preset").and_then(Json::as_str) - { - self.grid.last_border = preset.to_string(); - } - self.run_or_alert(id, params); - } - keytips::Action::Dialog(name) => self.open_dialog(name, json!({})), - keytips::Action::DialogWith(name, params) => self.open_dialog(name, serde_json::from_str(params).unwrap_or_else(|_| json!({}))), - // Reserved sequence: fires silently while its command is missing. When the - // command lands in the catalog it goes live with no table edit. - keytips::Action::Pending(id) => { - if gridcraft_engine::find_command(id).is_some() { - self.run_or_alert(id, json!({})); - } - } - // Route to the ribbon control that owns this keytip: it opens its dropdown or - // runs its click handler with the right params (the engine command alone would - // be missing them). - keytips::Action::Control(id) => self.ui.keytip_invoke = Some(id), - } - } - } - - /// Current selection's top-left cell text as A1 (for the Name Box). - pub fn name_box_text(&self) -> String { - let Some(d) = self.session.active() else { return String::new() }; - let sel = &d.selection; - let cur = sel.current(); - // A named range that exactly matches shows its name. - let sh = d.wb.active(); - if let Some(sh) = sh { - for n in &d.wb.names { - let target = n.formula.replace('$', ""); - let (sheet, body) = match target.rsplit_once('!') { - Some((s, b)) => (Some(gridcraft_engine::core::unquote_sheet(s)), b.to_string()), - None => (None, target.clone()), - }; - if sheet.as_deref().is_none_or(|s| s.eq_ignore_ascii_case(&sh.name)) && RangeRef::parse(&body) == Some(cur) { - return n.name.clone(); - } - } - } - if self.grid.drag_select && !cur.is_single() { - return format!("{}R x {}C", cur.height(), cur.width()); - } - sel.active.a1() - } -} - /// Strips the engine's error prefixes for message boxes. pub fn clean_error(e: &str) -> String { if let Some(rest) = e.split_once("`: ").map(|(_, r)| r) { @@ -1931,268 +325,6 @@ pub fn format_sync_time(ms: u64) -> String { } } -/// Wall-clock milliseconds since the Unix epoch, for user-visible timestamps (Backstage's "last -/// changed", toasts). It can jump when the system clock is adjusted, so it must not drive -/// deadlines or pacing; use [`monotonic_ms`] for those. -pub fn now_ms() -> f64 { - gridcraft_engine::core::date::unix_now_ms().unwrap_or(0.0) -} - -/// Monotonic milliseconds since the first call, for screenshot deadlines and AutoSave pacing. -/// -/// Deliberately separate from [`now_ms`]: a wall-clock adjustment must not extend (or expire) a -/// screenshot deadline or save a workbook twice. Native uses [`std::time::Instant`]; wasm uses the -/// browser's `performance.now()`, which is monotonic too. -#[cfg(not(target_arch = "wasm32"))] -#[allow(clippy::disallowed_methods)] // the std clock is read only off wasm -pub fn monotonic_ms() -> f64 { - use std::sync::OnceLock; - static START: OnceLock = OnceLock::new(); - START.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() * 1000.0 -} - -#[cfg(target_arch = "wasm32")] -/// Web monotonic clock: the browser's `performance.now()`, or 0 when it is unavailable. -pub fn monotonic_ms() -> f64 { - web_sys::window().and_then(|window| window.performance()).map_or(0.0, |performance| performance.now()) -} - pub fn cell(r: u32, c: u32) -> CellRef { CellRef::new(r, c) } - -#[cfg(test)] -mod lifecycle_tests { - use super::*; - - /// Builds a key-A press or release event without modifiers. - fn key_event(pressed: bool) -> egui::Event { - egui::Event::Key { key: egui::Key::A, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE } - } - - /// Returns a test app with a new workbook and default services. - fn app() -> SheetApp { - let mut session = Session::new(); - session.new_workbook(); - SheetApp::new(session, Services::default()) - } - - /// One headless pass; a test has no renderer for the texture atlas delta (0.37 panics when it - /// is dropped unapplied). - fn ui_pass(ctx: &egui::Context, raw: egui::RawInput, f: impl FnMut(&mut egui::Ui)) -> egui::FullOutput { - let mut out = ctx.run_ui(raw, f); - out.textures_delta.clear(); - out - } - - #[test] - /// Only one synthetic step is offered, is not duplicated by a hidden logic-only tick, and press/release remain distinct batches. - fn a_hidden_logic_tick_keeps_one_unapplied_synthetic_step() { - let ctx = egui::Context::default(); - let mut app = app(); - app.synthetic.push_back(vec![key_event(true)]); - app.synthetic.push_back(vec![key_event(false)]); - - let mut first = egui::RawInput::default(); - app.raw_input_hook(&ctx, &mut first); - assert_eq!(first.events.len(), 1, "only one step is offered"); - assert!(app.synthetic_pending); - assert_eq!(app.synthetic.len(), 2, "a logic-only frame does not consume the step"); - - // eframe carries the unconsumed `RawInput` into the next pass, so the hook offers the step - // only once; a hidden logic-only tick does not duplicate it or offer the release. - let mut second = egui::RawInput::default(); - app.raw_input_hook(&ctx, &mut second); - assert!(second.events.is_empty(), "an unapplied step is not injected a second time"); - assert_eq!(app.synthetic.len(), 2, "press and release stay distinct batches"); - } - - /// Returns raw input with a screen rect at the given size. - fn raw_with_size(size: egui::Vec2) -> egui::RawInput { - egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() } - } - - #[test] - /// A font-gated early-returning pass does not lose the queued step; the hook offers it again and a later real pass applies it. - fn a_font_gated_pass_offers_the_synthetic_step_again() { - let ctx = egui::Context::default(); - ctx.set_fonts(theme::font_definitions()); - let mut app = app(); - app.fonts_ready = true; - app.fonts_set = true; - // Binds `session.text_width`, as eframe's first frame does. - let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - app.synthetic.push_back(vec![key_event(true)]); - - // The font gate returns before input handling; `begin_pass` has still consumed the events. - app.fonts_rebind = true; - let mut raw = raw_with_size(egui::vec2(900.0, 700.0)); - app.raw_input_hook(&ctx, &mut raw); - assert_eq!(raw.events.len(), 1); - let _ = ui_pass(&ctx, raw, |ui| app.ui(ui)); - assert!(app.synthetic_pending, "the early-returning pass did not apply the step"); - assert_eq!(app.synthetic.len(), 1, "the step is still queued"); - - // A completed pass that did not apply it made the hook offer it again for a real pass. - app.fonts_rebind = false; - let mut raw = raw_with_size(egui::vec2(900.0, 700.0)); - app.raw_input_hook(&ctx, &mut raw); - assert_eq!(raw.events.len(), 1, "the step is not lost across the font gate"); - let _ = ui_pass(&ctx, raw, |ui| app.ui(ui)); - assert!(!app.synthetic_pending); - assert!(app.synthetic.is_empty(), "the real UI pass applied the step"); - } - - #[test] - /// A language-driven font rebuild waits while a synthetic step is pending and proceeds once it is clear. - fn a_pending_step_delays_a_font_change() { - let ctx = egui::Context::default(); - let mut app = app(); - app.fonts_ready = true; - app.fonts_han = Some(theme::HanOrder::Chinese); - app.ui.language = i18n::Language::Ja; - app.synthetic.push_back(vec![key_event(true)]); - app.synthetic_pending = true; - let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - assert!(app.fonts_ready, "the font rebuild waits for the pass that applies the step"); - app.synthetic.clear(); - app.synthetic_pending = false; - let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - assert!(!app.fonts_ready, "without pending input the language rebuild proceeds"); - } - - #[test] - /// Logic ticks see fresh viewport and DPI information; resizing back to the original size still advances the capture epoch. - fn a_hidden_logic_tick_sees_fresh_viewport_info_and_keeps_the_epoch_monotonic() { - /// Runs one logic tick with the requested width and native scale. - fn tick(ctx: &egui::Context, app: &mut SheetApp, width: f32, native_ppp: f32) { - let mut raw = - egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))), ..Default::default() }; - if let Some(viewport) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { - viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))); - viewport.native_pixels_per_point = Some(native_ppp); - } - let _ = ctx.run_logic(&raw, |ctx| app.logic(ctx)); - } - let ctx = egui::Context::default(); - let mut app = app(); - tick(&ctx, &mut app, 800.0, 2.0); - let first = app.capture_geometry.expect("the first logic tick records the geometry"); - assert_eq!(first.viewport, Some(egui::vec2(800.0, 400.0))); - assert_eq!(first.pixels_per_point, 2.0, "native scale times the interface zoom"); - tick(&ctx, &mut app, 900.0, 2.0); - let resized = app.capture_geometry.expect("geometry"); - assert_eq!(resized.viewport, Some(egui::vec2(900.0, 400.0)), "run_logic's ViewportInfo is the fresh size"); - let epoch = app.capture_epoch; - tick(&ctx, &mut app, 800.0, 2.0); - assert_eq!(app.capture_geometry.map(|g| g.viewport), Some(Some(egui::vec2(800.0, 400.0)))); - assert!(app.capture_epoch > epoch, "resizing back is still a new epoch: old pixels must not be saved"); - } - - #[test] - /// A resize and a resize back both advance the capture epoch. - fn the_capture_epoch_changes_even_when_the_geometry_returns() { - /// Runs one UI pass at the requested width. - fn tick(ctx: &egui::Context, app: &mut SheetApp, width: f32) { - let raw = - egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))), ..Default::default() }; - let _ = ui_pass(ctx, raw, |ui| app.logic(ui.ctx())); - } - let ctx = egui::Context::default(); - let mut app = app(); - tick(&ctx, &mut app, 800.0); - let base = app.capture_epoch; - tick(&ctx, &mut app, 900.0); - let changed = app.capture_epoch; - assert!(changed > base, "a resize increments the capture epoch"); - tick(&ctx, &mut app, 800.0); - assert!(app.capture_epoch > changed, "resizing back is still a new epoch: old pixels must not be saved"); - } - - /// Extracts title viewport commands from a logic output. - fn titles(out: &egui::LogicOutput) -> Vec { - out.viewport_commands - .values() - .flatten() - .filter_map(|c| match c { - egui::ViewportCommand::Title(title) => Some(title.clone()), - _ => None, - }) - .collect() - } - - #[test] - /// A hidden logic tick saves a dirty file-backed workbook once and clears its dirty state. - fn a_logic_only_tick_autosaves_a_dirty_workbook_once() { - let ctx = egui::Context::default(); - let mut app = app(); - let dir = std::env::temp_dir().join(format!("gridcraft-logic-autosave-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("autosave.xlsx"); - app.run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap(); - app.ui.autosave = true; - app.session.active_mut().unwrap().path = Some(path.to_string_lossy().into_owned()); - app.grid.last_autosave = monotonic_ms() - 3000.0; - - let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - assert!(path.exists(), "a hidden logic-only tick saves a dirty workbook with a file"); - assert!(!app.session.active().unwrap().is_dirty(), "the save clears the dirty state"); - let saved_at = app.grid.last_autosave; - let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - assert_eq!(app.grid.last_autosave, saved_at, "the same change is not saved a second time"); - - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - /// The title is sent on the first tick and after a rename, but not on every unchanged frame. - fn a_logic_only_tick_updates_the_title_once_per_change() { - let ctx = egui::Context::default(); - let mut app = app(); - let first = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - let title = app.session.active().unwrap().display_title().to_string(); - let expected = format!("{title}{}", if app.session.active().unwrap().is_dirty() { " — Edited" } else { "" }); - assert_eq!(titles(&first), [expected], "the first logic tick titles the window"); - let second = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - assert!(titles(&second).is_empty(), "an unchanged title is not sent every frame"); - - app.session.active_mut().unwrap().title = "Renamed".into(); - let third = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - let expected = format!("Renamed{}", if app.session.active().unwrap().is_dirty() { " — Edited" } else { "" }); - assert_eq!(titles(&third), [expected], "a rename while hidden updates the title"); - } - - #[test] - /// A hidden tick surfaces a failed print job in the app message. - fn a_logic_only_tick_polls_printing_feedback() { - let ctx = egui::Context::default(); - let mut app = app(); - app.printing.printers.push(crate::printing::Printer { name: "Queue".into(), label: String::new(), default: true }); - app.run("ui.printSettings", serde_json::json!({"printer": "Queue"})).unwrap(); - app.services.print_native = Some(Box::new(|_, _| { - let (sender, receiver) = std::sync::mpsc::channel(); - sender.send(Err("printer is offline".into())).unwrap(); - receiver - })); - app.run("ui.printSubmit", serde_json::json!({})).unwrap(); - let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); - assert_eq!(app.printing.message, Some(crate::printing::Feedback::Failed("printer is offline".into()))); - assert!(app.message.as_ref().is_some_and(|(_, text)| text.contains("printer is offline")), "logic surfaces the failure"); - } - - #[test] - /// Pending synthetic input makes logic request a real hidden paint. - fn a_pending_synthetic_step_requests_a_hidden_paint() { - let ctx = egui::Context::default(); - let mut app = app(); - app.synthetic.push_back(vec![key_event(true)]); - app.synthetic_pending = true; - let out = ui_pass(&ctx, egui::RawInput::default(), |ui| app.logic(ui.ctx())); - let painted = out - .viewport_output - .get(&ctx.viewport_id()) - .is_some_and(|output| output.commands.iter().any(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden))); - assert!(painted, "logic must ask for a real UI pass while a synthetic step is unapplied"); - } -} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index bcb1649496..49aba2b0e1 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -10,207 +10,39 @@ use crate::keytips::keytip_table; 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"]; - -/// macOS draws the window into the title bar (`with_fullsize_content_view`) and AppKit paints the -/// traffic lights itself, centred in its standard 28 pt title-bar band. Moving them would take -/// AppKit calls (`unsafe`, forbidden here), so on macOS the title bar is that band: the lights sit -/// vertically centred in it, with the controls beside them (#170). -const MAC: bool = cfg!(target_os = "macos"); -pub const MAC_TITLE_BAR: f32 = 28.0; -/// The traffic lights as AppKit lays them out: button diameter, centre-to-centre pitch and the -/// first button's left edge (8–10 pt depending on the macOS version; the larger value keeps the -/// gap after them from shrinking). -const MAC_LIGHT: f32 = 12.0; -const MAC_LIGHT_PITCH: f32 = 20.0; -const MAC_LIGHTS_LEFT: f32 = 10.0; -/// Where title-bar content starts on macOS: the end of the three lights plus the gap the lights -/// keep from the top of the window. -pub const MAC_CONTENT_LEFT: f32 = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT + (MAC_TITLE_BAR - MAC_LIGHT) / 2.0; +mod automate; +mod contextual; +mod controls; +mod data; +mod formulas; +mod galleries; +mod home; +mod insert; +mod page_layout; +mod review; +mod shortcuts; +mod title_bar; +mod view; + +use automate::automate; +use contextual::{chart_design, table_design}; +pub(crate) use controls::{ComboResult, editable_combo}; +use controls::{act, keytip}; +use data::data; +use formulas::formulas; +pub use home::FONTS; +use home::home; +use insert::{draw, insert}; +use page_layout::page_layout; +use review::review; +pub use shortcuts::shortcut; +pub use title_bar::{MAC_CONTENT_LEFT, MAC_TITLE_BAR, TITLE_LEFT_PAD, title_bar}; +use title_bar::{TITLE_BAR_H, TITLE_CONTROL_H}; +use view::view; -/// Leave room for the macOS traffic lights when the content extends into the title bar. -pub const TITLE_LEFT_PAD: f32 = if MAC { MAC_CONTENT_LEFT } else { 8.0 }; -const TITLE_BAR_H: f32 = if MAC { MAC_TITLE_BAR } else { 38.0 }; -/// The tallest title-bar controls, smaller on macOS to keep a margin in the shorter bar. -const TITLE_CONTROL_H: f32 = if MAC { 22.0 } else { 26.0 }; +pub const TABS: &[&str] = &["Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"]; const _: () = assert!(TITLE_CONTROL_H < TITLE_BAR_H, "title-bar controls keep a margin above and below"); -/// Draws the title bar: drag/maximize, AutoSave toggle, Home/Save/Undo/Redo/More, search, share, comments and theme controls, with a centered truncated title. -pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let ch = TITLE_CONTROL_H; - egui::Panel::top("title_bar").exact_size(TITLE_BAR_H).frame(egui::Frame::NONE.fill(t.window)).show(ui, |ui| { - let rect = ui.max_rect(); - // Dragging the empty title area moves the window. - let bg = ui.interact(rect, ui.scope_id().with("drag_title"), Sense::click_and_drag()); - if bg.drag_started() { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); - } - if bg.double_clicked() { - let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)); - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); - } - ui.horizontal_centered(|ui| { - ui.add_space(TITLE_LEFT_PAD); - // AutoSave toggle (saves after each change when the workbook has a file). - ui.label(egui::RichText::new(app.ui.language.tr("AutoSave")).font(theme::ui_font(12.5)).color(t.text_dim)); - let on = app.ui.autosave; - let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click()); - 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 { 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(); - } - ui.add_space(6.0); - let __k = icon_button(ui, Icon::Home, t.text_dim, "Home", vec2(26.0, ch)); - control_keytip(app, ui, &__k, "start"); - if __k.clicked() { - app.open_dialog("start", json!({})); - } - if icon_button(ui, Icon::Save, t.text_dim, "Save", vec2(26.0, ch)).clicked() - // File operations must include the cell the user is still editing (as the File > Save menu item already does). - && app.commit_edit(0, 0, false, false) - { - app.run_or_alert("file.save", json!({})); - } - // The commands' own availability: there must be a step, and no undo group may be open on - // the document (an action that spans several calls is still running). - let can_undo = gridcraft_engine::cmd::can_undo(&app.session).is_ok(); - let can_redo = gridcraft_engine::cmd::can_redo(&app.session).is_ok(); - // Named and marked enabled or not for assistive technology (and the UI tests). - let undo = icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo", vec2(26.0, ch)); - undo.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, can_undo, "Undo")); - if undo.clicked() && can_undo { - app.run_or_alert("edit.undo", json!({})); - } - let undo_list = icon_button(ui, Icon::Chevron, t.text_dim, "Undo list", vec2(14.0, ch)); - egui::Popup::menu(&undo_list).show(|ui| { - let labels: Vec = if can_undo { - app.session.active().map(|d| d.undo.iter().rev().take(20).map(|e| e.label.clone()).collect()).unwrap_or_default() - } else { - Vec::new() - }; - if labels.is_empty() { - ui.label("Can't Undo"); - } - for (i, l) in labels.iter().enumerate() { - if ui.button(format!("Undo {l}")).clicked() { - app.run_or_alert("edit.undo", json!({"steps": i + 1})); - } - } - }); - let redo = icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo", vec2(26.0, ch)); - redo.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, can_redo, "Redo")); - if redo.clicked() && can_redo { - app.run_or_alert("edit.redo", json!({})); - } - let more = icon_button(ui, Icon::More, t.text_dim, "More commands", vec2(26.0, ch)); - egui::Popup::menu(&more).show(|ui| { - for (label, id) in [ - ("New Workbook", "file.new"), - ("Open…", "file.open"), - ("Save As…", "file.saveAs"), - ("Sort A to Z", "data.sortAscending"), - ("Calculate Now", "formulas.calculateNow"), - ] { - if ui.button(label).clicked() { - app.run_or_alert(id, json!({})); - } - } - if ui.button("Print…").clicked() { - app.print_now(json!({})); - } - }); - let left_end = ui.min_rect().right(); - let right = ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add_space(8.0); - let __k = icon_button(ui, Icon::Search, t.text_dim, "Search commands", vec2(28.0, ch)); - control_keytip(app, ui, &__k, "commandSearch"); - if __k.clicked() { - app.open_dialog("commandSearch", json!({})); - } - let share = small_button(ui, Icon::Share, "Share", "Share: export or save a copy", true); - egui::Popup::menu(&share).show(|ui| { - if ui.button("Export as PDF…").clicked() { - app.run_or_alert("file.exportPdf", json!({})); - } - for (label, fmt) in - [("Save a Copy as Excel Workbook (.xlsx)…", "xlsx"), ("Export as CSV…", "csv"), ("Export as Web Page (.html)…", "html")] - { - if ui.button(label).clicked() { - app.open_dialog("saveCopy", json!({"format": fmt})); - } - } - }); - let __k = icon_button(ui, Icon::Comment, t.text_dim, "Comments", vec2(28.0, ch)); - control_keytip(app, ui, &__k, "comments"); - if __k.clicked() { - app.open_dialog("comments", json!({})); - } - // Quick light/dark switch (#194); right-click offers System as well. The same - // choice lives in File › Display theme and the View tab. - let lang = app.ui.language; - let theme = icon_button(ui, Icon::Appearance, t.text_dim, "Dark Mode", vec2(28.0, 26.0)); - let dark = ui.ctx().theme() == egui::Theme::Dark; - theme.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, dark, lang.tr("Dark Mode"))); - if theme.clicked() { - act(app, "view.darkMode", json!({})); - } - theme.context_menu(|ui| theme_choices(app, ui)); - ui.min_rect().left() - }); - // Centred title, cut with "…" so it never runs under the controls on either side. - let title = app.session.active().map(|d| d.display_title()).unwrap_or_default(); - let dirty = app.session.active().is_some_and(|d| d.is_dirty()); - let max_w = title_max_width(rect.center().x, left_end, right.inner); - paint_title(ui, rect.center(), &title, dirty, max_w, t.text); - }); - }); -} - -/// Lay out the title truncated to fit `max_w` together with the unsaved marker (when `dirty`). -fn title_galleys( - painter: &egui::Painter, - title: &str, - dirty: bool, - max_w: f32, - color: Color32, -) -> (std::sync::Arc, Option>) { - let font = theme::ui_bold(13.0); - let dot = dirty.then(|| painter.layout_no_wrap(" •".to_string(), font.clone(), color)); - let dot_w = dot.as_ref().map_or(0.0, |g| g.size().x); - let mut job = egui::text::LayoutJob::simple_singleline(title.to_string(), font, color); - job.wrap = egui::text::TextWrapping::truncate_at_width((max_w - dot_w).max(0.0)); - (painter.layout_job(job), dot) -} - -/// Gap kept between the title and the controls beside it. -const TITLE_GAP: f32 = 12.0; - -/// The widest the centred title may be without reaching the controls that end at `left_end` or -/// start at `right_start`. -fn title_max_width(center_x: f32, left_end: f32, right_start: f32) -> f32 { - let half = (center_x - left_end).min(right_start - center_x) - TITLE_GAP; - (2.0 * half).max(0.0) -} - -/// Paint the workbook title centred on `center`, truncated with "…" to `max_w`. The unsaved -/// marker stays visible however short the title gets. -fn paint_title(ui: &Ui, center: egui::Pos2, title: &str, dirty: bool, max_w: f32, color: Color32) { - let (galley, dot) = title_galleys(ui.painter(), title, dirty, max_w, color); - let dot_w = dot.as_ref().map_or(0.0, |g| g.size().x); - let w = galley.size().x + dot_w; - let left = center.x - w / 2.0; - let top = center.y - galley.size().y / 2.0; - let galley_w = galley.size().x; - ui.painter().galley(pos2(left, top), galley, color); - if let Some(dot) = dot { - ui.painter().galley(pos2(left + galley_w, top), dot, color); - } -} - /// Draws the ribbon tabs and the active tab's horizontally scrollable card, resetting an unavailable contextual tab to Home. pub fn show(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); @@ -459,2618 +291,3 @@ fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> { } v } - -fn sep(ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let (r, _) = ui.allocate_exact_size(vec2(13.0, 72.0), Sense::hover()); - ui.painter().line_segment([pos2(r.center().x, r.top() + 6.0), pos2(r.center().x, r.bottom() - 6.0)], Stroke::new(1.0, t.separator)); -} - -fn active_style(app: &SheetApp) -> Style { - app.session - .active() - .and_then(|d| { - let sh = d.wb.active()?; - Some(d.wb.styles.get(sh.style_id(d.selection.active)).clone()) - }) - .unwrap_or_default() -} - -fn theme_colors(app: &SheetApp) -> [u32; 12] { - app.session.active().map(|d| d.wb.theme.colors).unwrap_or(gridcraft_engine::model::Theme::default().colors) -} - -/// Runs a command from a ribbon click (menu-style: no params may open a dialog). -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 }; - let t = Tokens::get(ui.ctx()); - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("ribbon_keytips"))).with_clip_rect(ui.clip_rect()); - let text = painter.layout_no_wrap(rest.into(), theme::ui_bold(10.5), t.text); - let rect = Rect::from_center_size(at, text.size() + vec2(7.0, 3.0)); - painter.rect_filled(rect, 2.0, t.input_bg); - painter.rect_stroke(rect, 2.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside); - painter.galley(rect.min + vec2(3.5, 1.5), text, t.text); -} - -/// Paint the badge for a control that runs command `id`, when the armed prefix matches. -fn control_keytip(app: &SheetApp, ui: &Ui, response: &egui::Response, id: &str) { - if let Some(sequence) = keytip_table::control_badge(app.keytips.prefix().unwrap_or_default(), id) { - keytip(app, ui, sequence, response.rect.center_bottom()); - } -} - -/// Paint the single badge for a menu anchor whose popup items run `targets`. -fn anchor_keytip(app: &SheetApp, ui: &Ui, response: &egui::Response, targets: &[&str]) { - if let Some(sequence) = keytip_table::anchor_badge(app.keytips.prefix().unwrap_or_default(), targets) { - keytip(app, ui, sequence, response.rect.center_bottom()); - } -} - -/// The `on` value for a ribbon checkbox command. A click has already flipped the checkbox bool, so -/// it is sent as is; a key tip leaves the bool untouched, so the command gets the opposite. -fn checkbox_on(clicked: bool, shown: bool) -> bool { - shown == clicked -} - -/// Consume a one-shot keytip request for control `id` (set by `Action::Control`). -fn invoke(app: &mut SheetApp, id: &str) -> bool { - if app.ui.keytip_invoke == Some(id) { - app.ui.keytip_invoke = None; - true - } else { - false - } -} - -/// Force-open command for a popup: either a keytip invoke (open it) or a click (toggle). -fn popup_command(forced: bool, clicked: bool) -> Option { - if forced { Some(egui::SetOpenCommand::Bool(true)) } else { clicked.then_some(egui::SetOpenCommand::Toggle) } -} - -/// Per-control state of an [`editable_combo`]: the filter text and highlighted row. -#[derive(Clone, Default)] -struct ComboState { - text: String, - sel: i32, - was_open: bool, -} - -/// What the user did with an [`editable_combo`]. -pub(crate) enum ComboResult { - None, - /// A row was applied: clicked or Enter on the highlighted row. - Picked(String), -} - -/// Excel-style dropdown: a filter field (type to narrow the list) plus arrow/Enter selection. -/// `items` are `(value, display)`; the value is what comes back. When `forced` (keytip invoke) -/// or clicked, the popup opens, focuses the filter field, and selects its text. -pub(crate) fn editable_combo( - ui: &mut Ui, - anchor: &egui::Response, - salt: &'static str, - current_value: &str, - items: &[(&str, String)], - forced: bool, -) -> ComboResult { - combo_popup(ui, anchor, salt, current_value, items, forced, true, None) -} - -/// The dropdown of [`editable_combo`] without the filter field: the whole list, picked by click or -/// arrows and Enter. It drops directly below `under` and is as wide as it, like Excel's Font Size -/// list under its box. -fn list_combo(ui: &mut Ui, anchor: &egui::Response, under: Rect, salt: &'static str, current_value: &str, items: &[(&str, String)]) -> ComboResult { - combo_popup(ui, anchor, salt, current_value, items, false, false, Some(under)) -} - -fn combo_popup( - ui: &mut Ui, - anchor: &egui::Response, - salt: &'static str, - current_value: &str, - items: &[(&str, String)], - forced: bool, - filter: bool, - under: Option, -) -> ComboResult { - let popup_id = anchor.id.with("popup"); - let state_id = anchor.id.with(salt).with("__combo_state"); - let mut st = ui.data_mut(|d| d.get_temp::(state_id)).unwrap_or_default(); - let open_now = egui::Popup::is_id_open(ui.ctx(), popup_id); - if open_now && !st.was_open { - st.text = String::new(); - st.sel = items.iter().position(|(v, _)| *v == current_value).map(|i| i as i32).unwrap_or(0); - // The reset lands on the frame AFTER the popup opens, which usually has no input of - // its own — without a repaint request the stale filtered list stays on screen. - ui.ctx().request_repaint(); - } - st.was_open = open_now; - let mut result = ComboResult::None; - let mut popup = egui::Popup::menu(anchor).open_memory(popup_command(forced, anchor.clicked())); - if let Some(r) = under { - popup = popup.anchor(r).align(egui::RectAlign::BOTTOM_START).width(r.width()); - } - let row_w = under.map_or(220.0, |r| r.width() - 12.0); - popup.show(|ui| { - // Arrows and Enter navigate the list, not the filter field's cursor. Capture them and - // strip them from the input so the TextEdit never sees them. - let (mut down, mut up, mut enter) = (false, false, false); - ui.input(|i| { - down = i.key_pressed(egui::Key::ArrowDown); - up = i.key_pressed(egui::Key::ArrowUp); - enter = i.key_pressed(egui::Key::Enter); - }); - if down || up || enter { - ui.ctx().input_mut(|i| { - i.events.retain(|e| { - !matches!(e, egui::Event::Key { key: egui::Key::ArrowDown | egui::Key::ArrowUp | egui::Key::Enter, pressed: true, .. }) - }); - }); - } - if filter { - let text_id = anchor.id.with(salt).with("__combo_text"); - let te = ui.add(egui::TextEdit::singleline(&mut st.text).id(text_id).desired_width(220.0).hint_text("Type to filter…")); - if open_now && !te.has_focus() { - ui.ctx().memory_mut(|m| m.request_focus(text_id)); - // Select all once focus lands so typing replaces the filter. - if let Some(mut s) = egui::text_edit::TextEditState::load(ui.ctx(), text_id) { - let len = st.text.chars().count(); - s.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(len)))); - s.store(ui.ctx(), text_id); - } - } - } - let lower = st.text.to_lowercase(); - let filtered: Vec = items.iter().enumerate().filter(|(_, (_, d))| d.to_lowercase().contains(&lower)).map(|(i, _)| i).collect(); - if !filtered.is_empty() { - let n = filtered.len() as i32; - st.sel = st.sel.clamp(0, n - 1); - if down { - st.sel = (st.sel + 1).rem_euclid(n); - } - if up { - st.sel = (st.sel - 1).rem_euclid(n); - } - } - if enter && !filtered.is_empty() { - let idx = filtered[st.sel.clamp(0, filtered.len() as i32 - 1) as usize]; - result = ComboResult::Picked(items[idx].0.to_string()); - } - if filter { - ui.separator(); - } - let button_h = ui.spacing().interact_size.y.max(20.0); - let spacing = ui.spacing().item_spacing.y; - let n = filtered.len() as f32; - - let content_h = n * button_h + (n - 1.0).max(0.0) * spacing; - - let max_rows = 9.0_f32; - let max_h = max_rows * button_h + (max_rows - 1.0) * spacing; - - let desired_h = content_h.min(max_h); - - ui.set_min_height(desired_h); - - egui::ScrollArea::vertical().max_height(desired_h).show(ui, |ui| { - for (pos, i) in filtered.iter().enumerate() { - let (value, disp) = &items[*i]; - - let row = ui.add(egui::Button::selectable(pos as i32 == st.sel, egui::RichText::new(disp)).min_size(vec2(row_w, 20.0))); - - if (down || up) && pos as i32 == st.sel && !ui.clip_rect().contains_rect(row.rect) { - row.scroll_to_me(Some(if down { egui::Align::Max } else { egui::Align::Min })); - } - - if row.clicked() { - result = ComboResult::Picked(value.to_string()); - } - - if row.hovered() { - st.sel = pos as i32; - } - } - }); - if !matches!(result, ComboResult::None) { - ui.close(); - } - }); - ui.data_mut(|d| d.insert_temp(state_id, st)); - result -} - -/// The Accounting Number Format dropdown: label and the code Excel applies for that symbol. -const ACCOUNTING_SYMBOLS: &[(&str, &str)] = &[ - ("$ English (United States)", "_(\"$\"* #,##0.00_);_(\"$\"* \\(#,##0.00\\);_(\"$\"* \"-\"??_);_(@_)"), - ("£ English (United Kingdom)", "_-[$£-809]* #,##0.00_-;-[$£-809]* #,##0.00_-;_-[$£-809]* \"-\"??_-;_-@_-"), - ("€ Euro", "_-[$€-x-euro2] * #,##0.00_-;-[$€-x-euro2] * #,##0.00_-;_-[$€-x-euro2] * \"-\"??_-;_-@_-"), - ("¥ Japanese", "_-[$¥-411]* #,##0_-;-[$¥-411]* #,##0_-;_-[$¥-411]* \"-\"_-;_-@_-"), - ("¥ Chinese (PRC)", "_-[$¥-804]* #,##0.00_-;-[$¥-804]* #,##0.00_-;_-[$¥-804]* \"-\"??_-;_-@_-"), - ("₹ English (India)", "_-[$₹-4009]* #,##0.00_-;-[$₹-4009]* #,##0.00_-;_-[$₹-4009]* \"-\"??_-;_-@_-"), - ("CHF German (Switzerland)", "_-[$CHF-807]* #,##0.00_-;-[$CHF-807]* #,##0.00_-;_-[$CHF-807]* \"-\"??_-;_-@_-"), - ("Rp Indonesian", "_-[$Rp-421]* #,##0.00_-;-[$Rp-421]* #,##0.00_-;_-[$Rp-421]* \"-\"??_-;_-@_-"), - ("₩ Korean", "_-[$₩-412]* #,##0_-;-[$₩-412]* #,##0_-;_-[$₩-412]* \"-\"_-;_-@_-"), - ("R$ Portuguese (Brazil)", "_-[$R$-416]* #,##0.00_-;-[$R$-416]* #,##0.00_-;_-[$R$-416]* \"-\"??_-;_-@_-"), - ("₽ Russian", "_-[$₽-419]* #,##0.00_-;-[$₽-419]* #,##0.00_-;_-[$₽-419]* \"-\"??_-;_-@_-"), -]; - -fn menu_items(app: &mut SheetApp, ui: &mut Ui, items: &[(&str, &str, serde_json::Value)]) { - for (label, id, p) in items { - if *label == "-" { - ui.separator(); - continue; - } - let lang = app.ui.language; - let response = ui.add(egui::Button::new(lang.tr(label)).frame_when_inactive(false).min_size(vec2(220.0, 22.0))); - if let Some(sequence) = keytip_table::item_letter(app.keytips.prefix().unwrap_or_default(), id) { - keytip(app, ui, sequence, pos2(response.rect.right() - 10.0, response.rect.center().y)); - } - if response.clicked() { - if let Some(d) = id.strip_prefix("dialog:") { - app.open_dialog(d, p.clone()); - } else if *id == "edit.paste" { - app.paste_from_clipboard(); - } else { - act(app, id, p.clone()); - } - } - } -} - -/// Draws the Home tab: clipboard/paste menu, font, alignment, number, style, cell and editing commands with keytips. -fn home(app: &mut SheetApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let st = active_style(app); - // Clipboard - let paste = big_button(ui, Icon::Paste, "Paste", "Paste", true); - paste.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Paste")); - for sequence in - keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["edit.paste", "edit.pasteSpecial", "dialog:pasteSpecial"]) - { - keytip(app, ui, sequence, paste.rect.center_bottom()); - } - if paste.clicked() { - app.paste_from_clipboard(); - } - let paste_keys = matches!(app.keytips.prefix(), Some("HV" | "E")); - // Keep a keyboard-opened menu anchored to the button when a pointer click - // cancels keytips, so its items remain in place and can still be clicked. - let anchor_id = paste.id.with("keytip_anchor"); - // Query response state before taking the egui context write lock. - // Calling secondary_clicked() inside data_mut can deadlock. - let paste_secondary_clicked = paste.secondary_clicked(); - let anchored = ui.ctx().data_mut(|data| { - let anchored = if paste_keys { - true - } else if paste_secondary_clicked { - false - } else { - data.get_temp::(anchor_id).unwrap_or(false) - }; - data.insert_temp(anchor_id, anchored); - anchored - }); - let mut paste_popup = egui::Popup::context_menu(&paste); - if anchored { - paste_popup = paste_popup.anchor(&paste); - } - if paste_keys { - paste_popup = paste_popup.open_memory(egui::SetOpenCommand::Bool(true)); - } - paste_popup.show(|ui| { - menu_items( - app, - ui, - &[ - ("Paste", "edit.paste", json!({})), - ("Paste Values", "edit.pasteSpecial", json!({"what": "values"})), - ("Paste Formulas", "edit.pasteSpecial", json!({"what": "formulas"})), - ("Paste Formatting", "edit.pasteSpecial", json!({"what": "formats"})), - ("Transpose", "edit.pasteSpecial", json!({"what": "all", "transpose": true})), - ("Paste Link", "edit.pasteSpecial", json!({"what": "all", "link": true})), - ("-", "", json!(null)), - ("Paste Special…", "dialog:pasteSpecial", json!({})), - ], - ); - }); - ui.vertical(|ui| { - let __k = icon_button(ui, Icon::Cut, t.text, "Cut", vec2(26.0, 23.0)); - control_keytip(app, ui, &__k, "edit.cut"); - if __k.clicked() { - app.copy_to_clipboard(ui.ctx(), "edit.cut"); - } - let __k = icon_button(ui, Icon::Copy, t.text, "Copy", vec2(26.0, 23.0)); - control_keytip(app, ui, &__k, "edit.copy"); - if __k.clicked() { - app.copy_to_clipboard(ui.ctx(), "edit.copy"); - } - let fp_on = app.session.format_painter.is_some(); - let fp = toggle_button(ui, Icon::Brush, fp_on, "Format Painter (double-click to keep it on)"); - fp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), fp_on, "Format Painter")); - control_keytip(app, ui, &fp, "edit.formatPainter"); - if fp.double_clicked() { - act(app, "edit.formatPainter", json!({"sticky": true})); - } else if fp.clicked() { - if fp_on { - app.session.format_painter = None; - } else { - act(app, "edit.formatPainter", json!({})); - } - } - }); - sep(ui); - // Font - ui.vertical(|ui| { - ui.horizontal(|ui| { - font_combo(app, ui, &st); - size_combo(app, ui, &st); - let __k = icon_button(ui, Icon::Plus, t.text, "Increase Font Size", vec2(22.0, 23.0)); - control_keytip(app, ui, &__k, "home.increaseFontSize"); - if __k.clicked() { - act(app, "home.increaseFontSize", json!({})); - } - let __k = icon_button(ui, Icon::Minus, t.text, "Decrease Font Size", vec2(22.0, 23.0)); - control_keytip(app, ui, &__k, "home.decreaseFontSize"); - if __k.clicked() { - act(app, "home.decreaseFontSize", json!({})); - } - }); - ui.horizontal(|ui| { - let __k = toggle_button(ui, Icon::Bold, st.font.bold, "Bold"); - control_keytip(app, ui, &__k, "home.bold"); - if __k.clicked() { - act(app, "home.bold", json!({})); - } - let __k = toggle_button(ui, Icon::Italic, st.font.italic, "Italic"); - control_keytip(app, ui, &__k, "home.italic"); - if __k.clicked() { - act(app, "home.italic", json!({})); - } - let (u, ua) = split_button(ui, Icon::Underline, None, "Underline"); - control_keytip(app, ui, &ua, "home.underline"); - if u { - act(app, "home.underline", json!({})); - } - egui::Popup::menu(&ua).show(|ui| { - menu_items( - app, - ui, - &[ - ("Underline", "home.underline", json!({"style": "single"})), - ("Double Underline", "home.underline", json!({"style": "double"})), - ], - ); - }); - let __k = toggle_button(ui, Icon::Strike, st.font.strike, "Strikethrough"); - control_keytip(app, ui, &__k, "home.strikethrough"); - if __k.clicked() { - act(app, "home.strikethrough", json!({})); - } - let (b, ba) = split_button(ui, Icon::Borders, None, "Borders"); - control_keytip(app, ui, &ba, "home.borders"); - if b { - act(app, "home.borders", json!({"preset": app.grid.last_border.clone()})); - } - ba.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Border presets")); - egui::Popup::menu(&ba).show(|ui| { - for (label, preset) in [ - ("Bottom Border", "bottom"), - ("Top Border", "top"), - ("Left Border", "left"), - ("Right Border", "right"), - ("No Border", "none"), - ("All Borders", "all"), - ("Outside Borders", "outside"), - ("Thick Outside Borders", "thickOutside"), - ("Bottom Double Border", "doubleBottom"), - ("Thick Bottom Border", "thickBottom"), - ("Top and Bottom Border", "topBottom"), - ("Top and Thick Bottom Border", "topThickBottom"), - ("Top and Double Bottom Border", "topDoubleBottom"), - ("Inside Borders", "inside"), - ] { - if crate::border_preview::preset_button(ui, label, preset).clicked() { - app.grid.last_border = preset.to_string(); - act(app, "home.borders", json!({"preset": preset})); - } - } - ui.separator(); - if ui.button("More Borders…").clicked() { - app.open_dialog("formatCells", json!({"tab": "Border"})); - } - }); - let fill = app.grid.last_fill.clone(); - let fill_c = gridcraft_engine::model::Color::from_hex(&fill).and_then(|c| c.resolve(&Default::default())).map(theme::color32); - let (f, fa) = split_button(ui, Icon::Fill, fill_c, "Fill Color"); - control_keytip(app, ui, &fa, "home.fillColor"); - if f { - act(app, "home.fillColor", json!({"color": fill})); - } - let forced = invoke(app, "home.fillColor"); - egui::Popup::menu(&fa) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .open_memory(popup_command(forced, fa.clicked())) - .show(|ui| { - if let Some(c) = color_palette(ui, &theme_colors(app), "No Fill") { - if c != "none" { - app.grid.last_fill = c.clone(); - } - act(app, "home.fillColor", json!({"color": c})); - ui.close(); - } - }); - let fc = app.grid.last_font_color.clone(); - let fc_c = gridcraft_engine::model::Color::from_hex(&fc).and_then(|c| c.resolve(&Default::default())).map(theme::color32); - let (c, ca) = split_button(ui, Icon::FontColor, fc_c, "Font Color"); - control_keytip(app, ui, &ca, "home.fontColor"); - if c { - act(app, "home.fontColor", json!({"color": fc})); - } - let forced = invoke(app, "home.fontColor"); - egui::Popup::menu(&ca) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .open_memory(popup_command(forced, ca.clicked())) - .show(|ui| { - if let Some(c) = color_palette(ui, &theme_colors(app), "Automatic") { - if c != "none" { - app.grid.last_font_color = c.clone(); - } - act(app, "home.fontColor", json!({"color": if c == "none" { "auto".to_string() } else { c }})); - ui.close(); - } - }); - }); - }); - sep(ui); - // Alignment - ui.vertical(|ui| { - ui.horizontal(|ui| { - for (icon, v, id, tip) in [ - (Icon::AlignTop, VAlign::Top, "home.alignTop", "Top Align"), - (Icon::AlignMiddle, VAlign::Center, "home.alignMiddle", "Middle Align"), - (Icon::AlignBottom, VAlign::Bottom, "home.alignBottom", "Bottom Align"), - ] { - let __k = toggle_button(ui, icon, st.align.v == v, tip); - control_keytip(app, ui, &__k, id); - if __k.clicked() { - act(app, id, json!({})); - } - } - let o = small_button(ui, Icon::Orientation, "", "Orientation", true); - control_keytip(app, ui, &o, "home.orientation"); - let forced_o = invoke(app, "home.orientation"); - egui::Popup::menu(&o).open_memory(popup_command(forced_o, o.clicked())).show(|ui| { - menu_items( - app, - ui, - &[ - ("Angle Counterclockwise", "home.orientation", json!({"angle": "counterclockwise"})), - ("Angle Clockwise", "home.orientation", json!({"angle": "clockwise"})), - ("Vertical Text", "home.orientation", json!({"angle": "vertical"})), - ("Rotate Text Up", "home.orientation", json!({"angle": "up"})), - ("Rotate Text Down", "home.orientation", json!({"angle": "down"})), - ("Horizontal", "home.orientation", json!({"angle": 0})), - ("-", "", json!(null)), - ("Format Cell Alignment…", "dialog:formatCells", json!({"tab": "Alignment"})), - ], - ); - }); - let wrap = toggle_button(ui, Icon::Wrap, st.align.wrap, "Wrap Text"); - control_keytip(app, ui, &wrap, "home.wrapText"); - if wrap.clicked() { - act(app, "home.wrapText", json!({})); - } - ui.label(egui::RichText::new(app.ui.language.tr("Wrap Text")).font(theme::ui_font(12.5))); - }); - ui.horizontal(|ui| { - for (icon, h, id, tip) in [ - (Icon::AlignLeft, HAlign::Left, "home.alignLeft", "Align Left"), - (Icon::AlignCenter, HAlign::Center, "home.alignCenter", "Center"), - (Icon::AlignRight, HAlign::Right, "home.alignRight", "Align Right"), - ] { - let response = toggle_button(ui, icon, st.align.h == h, tip); - for sequence in keytip_table::control_badges(app.keytips.prefix().unwrap_or_default(), id) { - keytip(app, ui, sequence, response.rect.center_bottom()); - } - if response.clicked() { - act(app, id, json!({})); - } - } - let __k = icon_button(ui, Icon::IndentDec, t.text, "Decrease Indent", vec2(24.0, 23.0)); - control_keytip(app, ui, &__k, "home.decreaseIndent"); - if __k.clicked() { - act(app, "home.decreaseIndent", json!({})); - } - let __k = icon_button(ui, Icon::IndentInc, t.text, "Increase Indent", vec2(24.0, 23.0)); - control_keytip(app, ui, &__k, "home.increaseIndent"); - if __k.clicked() { - act(app, "home.increaseIndent", json!({})); - } - let (m, ma) = split_button(ui, Icon::Merge, None, "Merge & Center"); - control_keytip(app, ui, &ma, "home.mergeCenter"); - if m { - act(app, "home.mergeCenter", json!({})); - } - ui.label(egui::RichText::new(app.ui.language.tr("Merge & Center")).font(theme::ui_font(12.5))); - egui::Popup::menu(&ma).show(|ui| { - menu_items( - app, - ui, - &[ - ("Merge & Center", "home.mergeCenter", json!({})), - ("Merge Across", "home.mergeAcross", json!({})), - ("Merge Cells", "home.mergeCells", json!({})), - ("Unmerge Cells", "home.unmergeCells", json!({})), - ], - ); - }); - }); - }); - sep(ui); - // Number - ui.vertical(|ui| { - number_combo(app, ui, &st); - ui.horizontal(|ui| { - let (c, ca) = split_button(ui, Icon::Currency, None, "Accounting Number Format"); - control_keytip(app, ui, &ca, "home.accounting"); - if c { - act(app, "home.accounting", json!({})); - } - egui::Popup::menu(&ca).show(|ui| { - let mut items: Vec<_> = ACCOUNTING_SYMBOLS.iter().map(|(label, code)| (*label, "home.numberFormat", json!({"code": code}))).collect(); - items.push(("-", "", json!(null))); - items.push(("More Accounting Formats…", "dialog:formatCells", json!({"tab": "Number"}))); - menu_items(app, ui, &items); - }); - let __k = icon_button(ui, Icon::Percent, t.text, "Percent Style", vec2(24.0, 23.0)); - control_keytip(app, ui, &__k, "home.percent"); - if __k.clicked() { - act(app, "home.percent", json!({})); - } - let __k = icon_button(ui, Icon::Comma, t.text, "Comma Style", vec2(24.0, 23.0)); - control_keytip(app, ui, &__k, "home.comma"); - if __k.clicked() { - act(app, "home.comma", json!({})); - } - let __k = icon_button(ui, Icon::DecInc, t.text, "Increase Decimal", vec2(26.0, 23.0)); - control_keytip(app, ui, &__k, "home.increaseDecimal"); - if __k.clicked() { - act(app, "home.increaseDecimal", json!({})); - } - let __k = icon_button(ui, Icon::DecDec, t.text, "Decrease Decimal", vec2(26.0, 23.0)); - control_keytip(app, ui, &__k, "home.decreaseDecimal"); - if __k.clicked() { - act(app, "home.decreaseDecimal", json!({})); - } - }); - }); - sep(ui); - // Styles - let cf = big_button(ui, Icon::CondFormat, "Conditional\nFormatting", "Conditional Formatting", true); - anchor_keytip(app, ui, &cf, &["home.conditionalFormat", "home.manageRules", "home.clearRules"]); - egui::Popup::menu(&cf).show(|ui| cf_menu(app, ui)); - let ft = big_button(ui, Icon::FormatTable, "Format\nas Table", "Format as Table", true); - anchor_keytip(app, ui, &ft, &["table.style"]); - egui::Popup::menu(&ft).show(|ui| table_gallery(app, ui, "home.formatAsTable")); - let cs = big_button(ui, Icon::CellStyles, "Cell\nStyles", "Cell Styles", true); - anchor_keytip(app, ui, &cs, &["home.newCellStyle"]); - egui::Popup::menu(&cs).show(|ui| cell_style_gallery(app, ui)); - sep(ui); - // Cells - ui.vertical(|ui| { - let ins = small_button(ui, Icon::Insert, "Insert", "Insert", true); - for sequence in keytip_table::group_extension( - app.keytips.prefix().unwrap_or_default(), - &["dialog:insertCells", "home.insertRows", "home.insertColumns", "home.insertSheet"], - ) { - keytip(app, ui, sequence, ins.rect.center_bottom()); - } - egui::Popup::menu(&ins).show(|ui| { - menu_items( - app, - ui, - &[ - ("Insert Cells…", "dialog:insertCells", json!({})), - ("Insert Sheet Rows", "home.insertRows", json!({})), - ("Insert Sheet Columns", "home.insertColumns", json!({})), - ("Insert Sheet", "home.insertSheet", json!({})), - ], - ); - }); - let del = small_button(ui, Icon::Delete, "Delete", "Delete", true); - for sequence in keytip_table::group_extension( - app.keytips.prefix().unwrap_or_default(), - &["dialog:deleteCells", "home.deleteRows", "home.deleteColumns", "home.deleteSheet"], - ) { - keytip(app, ui, sequence, del.rect.center_bottom()); - } - egui::Popup::menu(&del).show(|ui| { - menu_items( - app, - ui, - &[ - ("Delete Cells…", "dialog:deleteCells", json!({})), - ("Delete Sheet Rows", "home.deleteRows", json!({})), - ("Delete Sheet Columns", "home.deleteColumns", json!({})), - ("Delete Sheet", "home.deleteSheet", json!({})), - ], - ); - }); - let fmt = small_button(ui, Icon::Format, "Format", "Format", true); - for sequence in keytip_table::group_extension( - app.keytips.prefix().unwrap_or_default(), - &[ - "dialog:rowHeight", - "home.autofitRowHeight", - "dialog:columnWidth", - "home.autofitColumnWidth", - "dialog:defaultWidth", - "home.hideRows", - "home.hideColumns", - "home.unhideRows", - "home.unhideColumns", - "sheet.hide", - "sheet.unhide", - "dialog:renameSheet", - "dialog:moveSheet", - "dialog:protectSheet", - "home.lockCell", - "dialog:formatCells", - ], - ) { - keytip(app, ui, sequence, fmt.rect.center_bottom()); - } - let format_keys = matches!(app.keytips.prefix(), Some("HO" | "OC")); - let mut format_popup = egui::Popup::menu(&fmt); - if format_keys { - format_popup = format_popup.open_memory(egui::SetOpenCommand::Bool(true)); - } - let protect_label = if app.active_sheet_protected() { "Unprotect Sheet…" } else { "Protect Sheet…" }; - format_popup.show(|ui| { - menu_items( - app, - ui, - &[ - ("Row Height…", "dialog:rowHeight", json!({})), - ("AutoFit Row Height", "home.autofitRowHeight", json!({})), - ("Column Width…", "dialog:columnWidth", json!({})), - ("AutoFit Column Width", "home.autofitColumnWidth", json!({})), - ("Default Width…", "dialog:defaultWidth", json!({})), - ("-", "", json!(null)), - ("Hide Rows", "home.hideRows", json!({})), - ("Hide Columns", "home.hideColumns", json!({})), - ("Unhide Rows", "home.unhideRows", json!({})), - ("Unhide Columns", "home.unhideColumns", json!({})), - ("Hide Sheet", "sheet.hide", json!({})), - ("Unhide Sheet…", "sheet.unhide", json!({})), - ("-", "", json!(null)), - ("Rename Sheet", "dialog:renameSheet", json!({})), - ("Move or Copy Sheet…", "dialog:moveSheet", json!({})), - ("-", "", json!(null)), - (protect_label, "dialog:protectSheet", json!({})), - ("Lock Cell", "home.lockCell", json!({})), - ("Format Cells…", "dialog:formatCells", json!({})), - ], - ); - }); - }); - sep(ui); - // Editing - ui.vertical(|ui| { - ui.horizontal(|ui| { - let (s, sa) = split_button(ui, Icon::Sum, None, "AutoSum"); - control_keytip(app, ui, &sa, "formulas.autoSum"); - if s { - act(app, "formulas.autoSum", 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( - app.keytips.prefix().unwrap_or_default(), - &["edit.fillDown", "edit.fillRight", "edit.fillUp", "edit.fillLeft", "dialog:series", "edit.flashFill"], - ) { - keytip(app, ui, sequence, fill.rect.center_bottom()); - } - egui::Popup::menu(&fill).show(|ui| { - menu_items( - app, - ui, - &[ - ("Down", "edit.fillDown", json!({})), - ("Right", "edit.fillRight", json!({})), - ("Up", "edit.fillUp", json!({})), - ("Left", "edit.fillLeft", json!({})), - ("Series…", "dialog:series", json!({})), - ("Flash Fill", "edit.flashFill", json!({})), - ], - ); - }); - let clear = small_button(ui, Icon::Eraser, "", "Clear", true); - for sequence in keytip_table::group_extension( - app.keytips.prefix().unwrap_or_default(), - &["edit.clearAll", "edit.clearFormats", "edit.clearContents", "edit.clearComments", "edit.clearHyperlinks"], - ) { - keytip(app, ui, sequence, clear.rect.center_bottom()); - } - egui::Popup::menu(&clear).show(|ui| { - menu_items( - app, - ui, - &[ - ("Clear All", "edit.clearAll", json!({})), - ("Clear Formats", "edit.clearFormats", json!({})), - ("Clear Contents", "edit.clearContents", json!({})), - ("Clear Comments and Notes", "edit.clearComments", json!({})), - ("Clear Hyperlinks", "edit.clearHyperlinks", json!({})), - ], - ); - }); - }); - let sf = big_button(ui, Icon::SortFilter, "Sort &\nFilter", "Sort & Filter", true); - for sequence in keytip_table::group_extension( - app.keytips.prefix().unwrap_or_default(), - &["data.sortAscending", "data.sortDescending", "dialog:sort", "data.filter", "data.clearFilter", "data.reapply"], - ) { - keytip(app, ui, sequence, sf.rect.center_bottom()); - } - egui::Popup::menu(&sf).show(|ui| { - menu_items( - app, - ui, - &[ - ("Sort A to Z", "data.sortAscending", json!({})), - ("Sort Z to A", "data.sortDescending", json!({})), - ("Custom Sort…", "dialog:sort", json!({})), - ("-", "", json!(null)), - ("Filter", "data.filter", json!({})), - ("Clear", "data.clearFilter", json!({})), - ("Reapply", "data.reapply", json!({})), - ], - ); - }); - let fs = big_button(ui, Icon::Find, "Find &\nSelect", "Find & Select", true); - for sequence in keytip_table::group_extension( - app.keytips.prefix().unwrap_or_default(), - &["dialog:find", "dialog:goTo", "dialog:goToSpecial", "edit.goToSpecial"], - ) { - keytip(app, ui, sequence, fs.rect.center_bottom()); - } - egui::Popup::menu(&fs).show(|ui| { - menu_items( - app, - ui, - &[ - ("Find…", "dialog:find", json!({})), - ("Replace…", "dialog:find", json!({"replace": true})), - ("Go To…", "dialog:goTo", json!({})), - ("Go To Special…", "dialog:goToSpecial", json!({})), - ("-", "", json!(null)), - ("Formulas", "edit.goToSpecial", json!({"kind": "formulas"})), - ("Notes", "edit.goToSpecial", json!({"kind": "notes"})), - ("Conditional Formatting", "edit.goToSpecial", json!({"kind": "conditionalFormats"})), - ("Constants", "edit.goToSpecial", json!({"kind": "constants"})), - ("Data Validation", "edit.goToSpecial", json!({"kind": "dataValidation"})), - ], - ); - }); -} - -/// 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)))); - control_keytip(app, ui, &resp, "home.fontName"); - let forced = invoke(app, "home.fontName"); - let items: Vec<(&str, String)> = FONTS.iter().map(|f| (*f, f.to_string())).collect(); - if let ComboResult::Picked(value) = editable_combo(ui, &resp, "font", &name, &items, forced) { - act(app, "home.fontName", json!({"name": value})); - } -} - -pub const FONTS: &[&str] = &[ - "Aptos", - "Aptos Narrow", - "Arial", - "Calibri", - "Cambria", - "Candara", - "Consolas", - "Courier New", - "Garamond", - "Georgia", - "Helvetica", - "Helvetica Neue", - "Segoe UI", - "Tahoma", - "Times New Roman", - "Trebuchet MS", - "Verdana", -]; - -/// Excel's size box: type a size and press Enter to apply exactly that number (the command refuses -/// sizes outside 1 to 409), Escape or clicking away reverts; the arrow opens the standard sizes. -fn size_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) { - let t = Tokens::get(ui.ctx()); - let size = st.font.size; - let current = if size.fract() == 0.0 { format!("{}", size as i32) } else { format!("{size}") }; - let field = egui::Id::unique("gridcraft.font_size_box"); - let draft = field.with("draft"); - let mut text = ui.data_mut(|d| d.get_temp::(draft)).unwrap_or_else(|| current.clone()); - // One frame like the Font name box beside it (same fill, border, height and corners), with the - // text field and the dropdown arrow inside it, as in Excel. - let (frame, _) = ui.allocate_exact_size(vec2(52.0, 24.0), egui::Sense::hover()); - // Painted once the field's focus and hover are known, but beneath the field and the arrow. - let bg = ui.painter().add(egui::Shape::Noop); - let text_rect = Rect::from_min_max(frame.min, egui::pos2(frame.right() - 16.0, frame.bottom())); - let arrow_rect = Rect::from_min_max(egui::pos2(frame.right() - 16.0, frame.top() + 1.0), egui::pos2(frame.right() - 1.0, frame.bottom() - 1.0)); - let mut field_ui = - ui.new_child(egui::UiBuilder::new().max_rect(text_rect.shrink2(vec2(6.0, 0.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); - let out = egui::TextEdit::singleline(&mut text) - .id(field) - .font(theme::ui_font(12.5)) - .frame(egui::Frame::NONE) - .desired_width(text_rect.width() - 12.0) - .show(&mut field_ui); - let resp = out.response; - ui.ctx().accesskit_node_builder(resp.id, |node| node.set_label(app.ui.language.tr("Font Size"))); - control_keytip(app, ui, &resp, "home.fontSize"); - if invoke(app, "home.fontSize") { - ui.memory_mut(|m| m.request_focus(field)); - } - if resp.gained_focus() { - // Typing replaces the size, as in Excel, whether the box was clicked or reached by keytip. - let mut state = out.state; - state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())))); - state.store(ui.ctx(), field); - } - if resp.lost_focus() { - ui.data_mut(|d| d.remove::(draft)); - // Consume the Enter so the grid behind does not also move the active cell down. - if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)) - && let Some(v) = text.trim().parse::().ok().filter(|v| v.is_finite()) - { - act(app, "home.fontSize", json!({"size": v})); - } - } else if resp.has_focus() { - ui.data_mut(|d| d.insert_temp(draft, text)); - } else { - ui.data_mut(|d| d.remove::(draft)); - } - let mut arrow_ui = ui.new_child(egui::UiBuilder::new().max_rect(arrow_rect)); - let arrow = icon_button(&mut arrow_ui, Icon::Chevron, t.text_dim, "Font Size", arrow_rect.size()); - let visuals = ui.style().visuals.clone(); - let looks = if resp.has_focus() || ui.rect_contains_pointer(frame) { &visuals.widgets.hovered } else { &visuals.widgets.inactive }; - let stroke = if resp.has_focus() { visuals.selection.stroke } else { looks.bg_stroke }; - ui.painter().set(bg, egui::epaint::RectShape::new(frame, looks.corner_radius, looks.weak_bg_fill, stroke, egui::StrokeKind::Inside)); - arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Font Size")); - let sizes = [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 36.0, 48.0, 72.0]; - let labels: Vec = sizes.iter().map(|s| format!("{}", *s as i32)).collect(); - let items: Vec<(&str, String)> = labels.iter().map(|s| (s.as_str(), s.clone())).collect(); - if let ComboResult::Picked(value) = list_combo(ui, &arrow, frame, "size", ¤t, &items) - && let Ok(v) = value.parse::() - { - act(app, "home.fontSize", json!({"size": v})); - } -} - -fn number_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) { - let code = st.num_fmt.as_str().to_string(); - let kind = gridcraft_engine::display::number_format(&code).kind(); - let current = format!("{kind:?}"); - let lang = app.ui.language; - let sample = app.session.active().and_then(|d| d.wb.active().map(|sh| sh.value(d.selection.active))).unwrap_or_default(); - let resp = ui.add_sized([150.0, 24.0], egui::Button::new(egui::RichText::new(lang.tr(¤t)).font(theme::ui_font(12.5)))); - control_keytip(app, ui, &resp, "home.numberFormat"); - let forced = invoke(app, "home.numberFormat"); - let names = ["General", "Number", "Currency", "Accounting", "Short Date", "Long Date", "Time", "Percentage", "Fraction", "Scientific", "Text"]; - let items: Vec<(&str, String)> = names - .iter() - .map(|n| { - let code = gridcraft_engine::cmd::format::format_code_for(n); - let preview = app.session.active().map(|d| gridcraft_engine::display::format(&sample, code, &d.wb).text).unwrap_or_default(); - (*n, format!("{:<12} {preview}", lang.tr(n))) - }) - .chain(std::iter::once(("__more", format!("{:>12}", lang.tr("More Number Formats…"))))) - .collect(); - if let ComboResult::Picked(value) = editable_combo(ui, &resp, "number", ¤t, &items, forced) { - match value.as_str() { - "__more" => app.open_dialog("formatCells", json!({"tab": "Number"})), - n => act(app, "home.numberFormat", json!({"format": n})), - } - } -} - -fn cf_menu(app: &mut SheetApp, ui: &mut Ui) { - ui.menu_button("Highlight Cells Rules", |ui| { - for (label, op) in [("Greater Than…", "greater"), ("Less Than…", "less"), ("Between…", "between"), ("Equal To…", "equal")] { - if ui.button(label).clicked() { - app.open_dialog("cfQuick", json!({"type": "cellIs", "operator": op, "title": label})); - } - } - if ui.button("Text that Contains…").clicked() { - app.open_dialog("cfQuick", json!({"type": "containsText", "title": "Text that Contains"})); - } - if ui.button("A Date Occurring…").clicked() { - app.open_dialog("cfQuick", json!({"type": "timePeriod", "title": "A Date Occurring"})); - } - if ui.button("Duplicate Values…").clicked() { - act(app, "home.conditionalFormat", json!({"rule": {"type": "duplicate"}})); - } - }); - ui.menu_button("Top/Bottom Rules", |ui| { - menu_items( - app, - ui, - &[ - ("Top 10 Items", "home.conditionalFormat", json!({"rule": {"type": "top10", "rank": 10}})), - ("Top 10%", "home.conditionalFormat", json!({"rule": {"type": "top10", "rank": 10, "percent": true}})), - ("Bottom 10 Items", "home.conditionalFormat", json!({"rule": {"type": "top10", "rank": 10, "bottom": true}})), - ("Bottom 10%", "home.conditionalFormat", json!({"rule": {"type": "top10", "rank": 10, "bottom": true, "percent": true}})), - ("Above Average", "home.conditionalFormat", json!({"rule": {"type": "aboveAverage"}})), - ("Below Average", "home.conditionalFormat", json!({"rule": {"type": "aboveAverage", "below": true}})), - ], - ); - }); - ui.menu_button("Data Bars", |ui| { - for (label, c) in - [("Blue", "#638EC6"), ("Green", "#63BE7B"), ("Red", "#F8696B"), ("Orange", "#FFB628"), ("Light Blue", "#008AEF"), ("Purple", "#D6007B")] - { - if ui.button(label).clicked() { - act(app, "home.conditionalFormat", json!({"rule": {"type": "dataBar", "color": c}})); - } - } - }); - ui.menu_button("Color Scales", |ui| { - for (label, cols) in [ - ("Green - Yellow - Red", vec!["#63BE7B", "#FFEB84", "#F8696B"]), - ("Red - Yellow - Green", vec!["#F8696B", "#FFEB84", "#63BE7B"]), - ("Green - White - Red", vec!["#63BE7B", "#FCFCFF", "#F8696B"]), - ("Blue - White - Red", vec!["#5A8AC6", "#FCFCFF", "#F8696B"]), - ("White - Green", vec!["#FCFCFF", "#63BE7B"]), - ("White - Red", vec!["#FCFCFF", "#F8696B"]), - ] { - if ui.button(label).clicked() { - act(app, "home.conditionalFormat", json!({"rule": {"type": "colorScale", "colors": cols}})); - } - } - }); - ui.menu_button("Icon Sets", |ui| { - for (label, set) in [ - ("3 Arrows", "3Arrows"), - ("3 Traffic Lights", "3TrafficLights1"), - ("3 Symbols", "3Symbols"), - ("4 Ratings", "4Rating"), - ("5 Arrows", "5Arrows"), - ("5 Quarters", "5Quarters"), - ] { - if ui.button(label).clicked() { - act(app, "home.conditionalFormat", json!({"rule": {"type": "iconSet", "set": set}})); - } - } - }); - ui.separator(); - if ui.button("New Rule…").clicked() { - app.open_dialog("cfQuick", json!({"type": "expression", "title": "New Formatting Rule"})); - } - ui.menu_button("Clear Rules", |ui| { - menu_items( - app, - ui, - &[ - ("Clear Rules from Selected Cells", "home.clearRules", json!({})), - ("Clear Rules from Entire Sheet", "home.clearRules", json!({"sheet": true})), - ], - ); - }); - if ui.button("Manage Rules…").clicked() { - app.open_dialog("manageRules", json!({})); - } -} - -/// A gallery of table styles drawn as mini tables. -fn table_gallery(app: &mut SheetApp, ui: &mut Ui, cmd: &str) { - ui.set_width(430.0); - let Some(wb) = app.session.active().map(|d| d.wb.clone()) else { return }; - for (fam, n) in [("Light", 21u32), ("Medium", 28), ("Dark", 11)] { - ui.label(egui::RichText::new(fam).strong()); - egui::Grid::new(("tg", fam)).spacing(vec2(4.0, 4.0)).show(ui, |ui| { - for i in 1..=n { - let name = format!("TableStyle{fam}{i}"); - 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, Tokens::get(ui.ctx()).cat(Cat::Orange)), StrokeKind::Outside); - } - if resp.on_hover_text(&name).clicked() { - act(app, cmd, json!({"style": name})); - ui.close(); - } - if i % 7 == 0 { - ui.end_row(); - } - } - }); - } -} - -fn paint_table_swatch(ui: &Ui, r: Rect, wb: &gridcraft_engine::model::Workbook, style: &str) { - let lk = gridcraft_engine::tables::look(style); - let res = |c: Option| c.and_then(|c| c.resolve(&wb.theme)).map(theme::color32); - let p = ui.painter(); - 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 { - let rr = Rect::from_min_size(pos2(r.left(), r.top() + i as f32 * rh), vec2(r.width(), rh)); - let fill = if i == 0 { - res(lk.header_fill) - } else if i % 2 == 1 { - res(lk.band_fill) - } else { - res(lk.body_fill) - }; - if let Some(f) = fill { - p.rect_filled(rr, 0.0, f); - } - if let Some(l) = res(lk.line) { - p.line_segment([rr.left_bottom(), rr.right_bottom()], Stroke::new(1.0, l)); - } - 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(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, 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])] = &[ - ("Good, Bad and Neutral", &["Normal", "Bad", "Good", "Neutral"]), - ("Data and Model", &["Calculation", "Check Cell", "Explanatory Text", "Input", "Linked Cell", "Note", "Output", "Warning Text"]), - ("Titles and Headings", &["Heading 1", "Heading 2", "Heading 3", "Heading 4", "Title", "Total"]), - ( - "Themed Cell Styles", - &[ - "20% - Accent1", - "20% - Accent2", - "20% - Accent3", - "20% - Accent4", - "20% - Accent5", - "20% - Accent6", - "40% - Accent1", - "40% - Accent2", - "40% - Accent3", - "40% - Accent4", - "40% - Accent5", - "40% - Accent6", - "60% - Accent1", - "60% - Accent2", - "60% - Accent3", - "60% - Accent4", - "60% - Accent5", - "60% - Accent6", - "Accent1", - "Accent2", - "Accent3", - "Accent4", - "Accent5", - "Accent6", - ], - ), - ("Number Format", &["Comma", "Comma [0]", "Currency", "Currency [0]", "Percent"]), - ]; - for (g, names) in groups { - ui.label(egui::RichText::new(*g).strong()); - egui::Grid::new(("csg", *g)).spacing(vec2(4.0, 4.0)).show(ui, |ui| { - for (i, n) in names.iter().enumerate() { - let st = gridcraft_engine::cmd::format::builtin_cell_style(n, &wb.theme).unwrap_or_default(); - let (r, resp) = ui.allocate_exact_size(vec2(82.0, 24.0), Sense::click()); - let fill = st - .fill - .fg - .resolve(&wb.theme) - .filter(|_| st.fill.pattern != gridcraft_engine::model::PatternType::None) - .map(theme::color32) - .unwrap_or(t.grid_bg); - ui.painter().rect_filled(r, 0.0, fill); - 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(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(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), - Align2::LEFT_CENTER, - *n, - egui::FontId::new((st.font.size).clamp(10.0, 15.0), fam), - col, - ); - if resp.hovered() { - 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})); - ui.close(); - } - if (i + 1) % 6 == 0 { - ui.end_row(); - } - } - }); - } -} - -/// Draws the Insert tab: PivotTable, Table, Pictures, Shapes, Icons, charts, slicer, sparklines, checkbox, links, comments, text box, header/footer and symbols. -fn insert(app: &mut SheetApp, ui: &mut Ui) { - let __k = big_button(ui, Icon::PivotTable, "PivotTable", "PivotTable", false); - control_keytip(app, ui, &__k, "insert.pivotTable"); - if __k.clicked() { - act(app, "insert.pivotTable", json!({})); - } - let __k = big_button(ui, Icon::Table, "Table", "Table", false); - control_keytip(app, ui, &__k, "insert.table"); - if __k.clicked() { - act(app, "insert.table", json!({})); - } - sep(ui); - let pic = big_button(ui, Icon::Picture, "Pictures", "Insert a picture", true); - anchor_keytip(app, ui, &pic, &["insert.picture"]); - pic.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Pictures")); - egui::Popup::menu(&pic).show(|ui| { - for (label, placement) in [("Place in Cell", "cell"), ("Place over Cells", "overCells")] { - if ui.button(label).clicked() { - app.open_dialog("insertPicture", json!({"placement": placement})); - ui.close(); - } - } - }); - let shapes = big_button(ui, Icon::Shapes, "Shapes", "Shapes", true); - anchor_keytip(app, ui, &shapes, &["insert.shape"]); - egui::Popup::menu(&shapes).show(|ui| { - for (label, kind) in [ - ("Rectangle", "rectangle"), - ("Rounded Rectangle", "roundedRectangle"), - ("Oval", "ellipse"), - ("Triangle", "triangle"), - ("Line", "line"), - ("Arrow", "arrow"), - ("Text Box", "textBox"), - ] { - if ui.button(label).clicked() { - act(app, "insert.shape", json!({"kind": kind})); - } - } - }); - let icn = big_button(ui, Icon::Theme, "Icons", "Insert an icon", true); - anchor_keytip(app, ui, &icn, &["insert.icons"]); - egui::Popup::menu(&icn).show(|ui| { - ui.set_width(260.0); - egui::Grid::new("icon_lib").show(ui, |ui| { - for (i, (name, icon)) in icons::LIBRARY.iter().enumerate() { - let (r, resp) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(r, 4.0, Tokens::get(ui.ctx()).hover); - } - icons::paint(ui.painter(), r.shrink(6.0), *icon, Tokens::get(ui.ctx()).text); - if resp.on_hover_text(*name).clicked() { - act(app, "insert.icons", json!({"name": name})); - ui.close(); - } - if (i + 1) % 6 == 0 { - ui.end_row(); - } - } - }); - }); - sep(ui); - let __k = big_button(ui, Icon::Chart, "Recommended\nCharts", "Recommended Charts", false); - control_keytip(app, ui, &__k, "insert.recommendedCharts"); - if __k.clicked() { - // Menu-style: with no chart type, run_or_alert opens the Insert Chart dialog. - act(app, "insert.recommendedCharts", json!({})); - } - ui.vertical(|ui| { - ui.horizontal(|ui| { - for (icon, tip, cid, subs) in [ - ( - Icon::ChartBar, - "Column or Bar", - "insert.chart.column", - vec![ - ("Clustered Column", "column", ""), - ("Stacked Column", "column", "stacked"), - ("100% Stacked Column", "column", "stacked100"), - ("Clustered Bar", "bar", ""), - ("Stacked Bar", "bar", "stacked"), - ("100% Stacked Bar", "bar", "stacked100"), - ], - ), - ( - Icon::ChartLine, - "Line or Area", - "insert.chart.line", - vec![ - ("Line", "line", ""), - ("Line with Markers", "line", "markers"), - ("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", "")]), - ] { - let r = small_button(ui, icon, "", tip, true); - control_keytip(app, ui, &r, cid); - let forced_c = invoke(app, cid); - egui::Popup::menu(&r).open_memory(popup_command(forced_c, r.clicked())).show(|ui| { - for (label, k, s) in &subs { - if ui.button(*label).clicked() { - act(app, "insert.chart", json!({"type": k, "subtype": s})); - } - } - }); - } - }); - ui.horizontal(|ui| { - for (icon, tip, cid, subs) in [ - ( - Icon::ChartScatter, - "Scatter or Bubble", - "insert.chart.scatter", - vec![("Scatter", "scatter", ""), ("Scatter with Lines", "scatter", "lines"), ("Bubble", "bubble", "")], - ), - (Icon::Chart, "Statistic", "insert.chart.statistic", vec![("Histogram", "histogram", ""), ("Box & Whisker", "boxWhisker", "")]), - ( - Icon::Theme, - "Hierarchy & more", - "insert.chart.hierarchy", - vec![ - ("Treemap", "treemap", ""), - ("Sunburst", "sunburst", ""), - ("Waterfall", "waterfall", ""), - ("Funnel", "funnel", ""), - ("Radar", "radar", ""), - ("Stock", "stock", ""), - ("Combo", "combo", ""), - ], - ), - ] { - let r = small_button(ui, icon, "", tip, true); - control_keytip(app, ui, &r, cid); - let forced_c = invoke(app, cid); - egui::Popup::menu(&r).open_memory(popup_command(forced_c, r.clicked())).show(|ui| { - for (label, k, s) in &subs { - if ui.button(*label).clicked() { - act(app, "insert.chart", json!({"type": k, "subtype": s})); - } - } - }); - } - }); - }); - sep(ui); - if big_button(ui, Icon::Filter, "Slicer", "Insert a slicer to filter a table", false).clicked() { - act(app, "insert.slicer", json!({})); - } - sep(ui); - let sp = big_button(ui, Icon::Sparkline, "Sparklines", "Sparklines", true); - anchor_keytip(app, ui, &sp, &["dialog:sparkline"]); - egui::Popup::menu(&sp).show(|ui| { - for (label, kind) in [("Line", "line"), ("Column", "column"), ("Win/Loss", "winLoss")] { - if ui.button(label).clicked() { - app.open_dialog("sparkline", json!({"type": kind})); - } - } - }); - sep(ui); - // Cell Controls. - if big_button(ui, Icon::Checkbox, "Checkbox", "Insert checkboxes in the selected cells", false).clicked() { - act(app, "insert.checkbox", json!({})); - } - sep(ui); - let __k = big_button(ui, Icon::Link, "Link", "Insert Link", false); - control_keytip(app, ui, &__k, "dialog:insertLink"); - if __k.clicked() { - app.open_dialog("insertLink", json!({})); - } - let __k = big_button(ui, Icon::Comment, "Comment", "New Comment", false); - control_keytip(app, ui, &__k, "dialog:comment"); - if __k.clicked() { - app.open_dialog("comment", json!({"threaded": true})); - } - sep(ui); - let __k = big_button(ui, Icon::TextBox, "Text\nBox", "Text Box", false); - control_keytip(app, ui, &__k, "insert.textBox"); - if __k.clicked() { - act(app, "insert.textBox", json!({})); - } - let __k = big_button(ui, Icon::PageLayout, "Header &\nFooter", "Header & Footer", false); - control_keytip(app, ui, &__k, "dialog:headerFooter"); - if __k.clicked() { - // As in Excel: Page Layout view, editing the header in place (the dialog is under Page Setup). - crate::paper_view::start_editing(app); - } - let sym = big_button(ui, Icon::Symbol, "Symbol", "Symbol", true); - control_keytip(app, ui, &sym, "insert.symbol"); - egui::Popup::menu(&sym).show(|ui| { - ui.set_width(260.0); - ui.horizontal_wrapped(|ui| { - for ch in [ - "€", "£", "¥", "©", "®", "™", "±", "≠", "≤", "≥", "÷", "×", "∞", "µ", "α", "β", "π", "Σ", "Ω", "°", "•", "…", "→", "←", "↑", "↓", - "✓", "★", "½", "¼", - ] { - // No bundled face has ✓: the button paints a code-drawn check instead of a tofu box. - if crate::widgets::glyph_button(ui, ch.chars().next().unwrap_or('?'), &format!("Insert {ch}")).clicked() { - act(app, "insert.symbol", json!({"char": ch})); - } - } - }); - }); -} - -fn draw(app: &mut SheetApp, ui: &mut Ui) { - let tool = app.session.draw_tool.clone(); - let __k = big_button(ui, Icon::Shapes, "Select\nObjects", "Select and move ink and shapes", false); - control_keytip(app, ui, &__k, "draw.lasso"); - if __k.clicked() { - act(app, "draw.lasso", json!({})); - } - sep(ui); - for (label, color) in [("Pen\nBlue", "#1F5FC9"), ("Pen\nBlack", "#000000"), ("Pen\nRed", "#D13B2F"), ("Highlighter", "#F2E33A")] { - let on = tool == "pen" && app.session.draw_color.eq_ignore_ascii_case(color); - let r = big_button(ui, Icon::Pen, label, "Draw with ink", false); - if on { - ui.painter().rect_stroke(r.rect, 5.0, Stroke::new(2.0, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); - } - if r.clicked() { - let width = if label == "Highlighter" { 12.0 } else { 2.0 }; - act(app, "draw.pen", json!({"on": !on, "color": color, "width": width})); - } - } - let er = big_button(ui, Icon::Eraser, "Eraser", "Erase ink strokes", false); - if tool == "eraser" { - ui.painter().rect_stroke(er.rect, 5.0, Stroke::new(2.0, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); - } - if er.clicked() { - act(app, "draw.eraser", json!({})); - } - sep(ui); - let __k = big_button(ui, Icon::Shapes, "Ink to\nShape", "Convert the last ink stroke to a shape", false); - control_keytip(app, ui, &__k, "draw.inkToShape"); - if __k.clicked() { - act(app, "draw.inkToShape", json!({})); - } -} - -fn page_layout(app: &mut SheetApp, ui: &mut Ui) { - let th = big_button(ui, Icon::Theme, "Themes", "Themes", true); - anchor_keytip(app, ui, &th, &["pageLayout.theme"]); - egui::Popup::menu(&th).show(|ui| { - for (name, colors) in gridcraft_engine::cmd::view::themes() { - ui.horizontal(|ui| { - for c in colors.iter().skip(4).take(6) { - let (r, _) = ui.allocate_exact_size(vec2(12.0, 12.0), Sense::hover()); - ui.painter().rect_filled(r, 1.0, Color32::from_rgb((c >> 16) as u8, (c >> 8) as u8, *c as u8)); - } - if ui.button(name).clicked() { - act(app, "pageLayout.theme", json!({"name": name})); - } - }); - } - }); - sep(ui); - let m = big_button(ui, Icon::Margins, "Margins", "Margins", true); - for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["pageLayout.margins", "dialog:pageSetup"]) { - keytip(app, ui, sequence, m.rect.center_bottom()); - } - egui::Popup::menu(&m).show(|ui| { - menu_items( - app, - ui, - &[ - ("Normal", "pageLayout.margins", json!({"preset": "normal"})), - ("Wide", "pageLayout.margins", json!({"preset": "wide"})), - ("Narrow", "pageLayout.margins", json!({"preset": "narrow"})), - ("Custom Margins…", "dialog:pageSetup", json!({})), - ], - ); - }); - let o = big_button(ui, Icon::Orient, "Page Layout|Orientation", "Page Layout|Orientation", true); - control_keytip(app, ui, &o, "pageLayout.orientation"); - egui::Popup::menu(&o).show(|ui| { - menu_items( - app, - ui, - &[ - ("Portrait", "pageLayout.orientation", json!({"orientation": "portrait"})), - ("Landscape", "pageLayout.orientation", json!({"orientation": "landscape"})), - ], - ); - }); - let s = big_button(ui, Icon::Paper, "Size", "Paper Size", true); - control_keytip(app, ui, &s, "pageLayout.size"); - egui::Popup::menu(&s).show(|ui| { - // B4/B5/B6 are the JIS sizes Japanese Excel uses; the ISO ones stay selectable by name. - for p in [ - "Letter", - "Legal", - "Tabloid", - "Executive", - "A3", - "A4", - "A5", - "A6", - "B4", - "B5", - "B6", - "Japanese Postcard", - "Japanese Double Postcard", - "Japanese Envelope #3", - "Japanese Envelope #4", - "Japanese Envelope Kaku #2", - "Japanese Envelope Kaku #3", - "B4 (ISO)", - "B5 (ISO)", - "B6 (ISO)", - ] { - if ui.button(p).clicked() { - act(app, "pageLayout.size", json!({"paper": p})); - } - } - }); - let pa = big_button(ui, Icon::PrintArea, "Print\nArea", "Print Area", true); - anchor_keytip(app, ui, &pa, &["pageLayout.printArea"]); - egui::Popup::menu(&pa).show(|ui| { - menu_items( - app, - ui, - &[("Set Print Area", "pageLayout.printArea", json!({})), ("Clear Print Area", "pageLayout.printArea", json!({"clear": true}))], - ); - }); - let br = big_button(ui, Icon::PageBreak, "Breaks", "Page Breaks", true); - for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["pageLayout.breaks"]) { - keytip(app, ui, sequence, br.rect.center_bottom()); - } - egui::Popup::menu(&br).show(|ui| { - menu_items( - app, - ui, - &[ - ("Insert Page Break", "pageLayout.breaks", json!({"insert": true})), - ("Remove Page Break", "pageLayout.breaks", json!({"remove": true})), - ("Reset All Page Breaks", "pageLayout.breaks", json!({"reset": true})), - ], - ); - }); - let __k = big_button(ui, Icon::Sheet, "Print\nTitles", "Print Titles", false); - control_keytip(app, ui, &__k, "pageLayout.printTitles"); - if __k.clicked() { - app.open_dialog("pageSetup", json!({"tab": "Sheet"})); - } - sep(ui); - let sh = app - .session - .active() - .and_then(|d| d.wb.active().map(|s| (s.show_gridlines, s.show_headings, s.print.gridlines, s.print.headings))) - .unwrap_or((true, true, false, false)); - // These two checkboxes mean the screen view vs. the printed page. English shows "View" (as in - // Excel), but the key is distinct from the "View" tab so languages can word them apart. - let screen = app.ui.language.tr("Sheet Options|View"); - let print = app.ui.language.tr("Print"); - ui.vertical(|ui| { - ui.label(egui::RichText::new(app.ui.language.tr("Gridlines")).strong().small()); - let mut v = sh.0; - let resp = ui.checkbox(&mut v, screen); - control_keytip(app, ui, &resp, "view.gridlines"); - if resp.changed() || invoke(app, "view.gridlines") { - act(app, "view.gridlines", json!({"on": checkbox_on(resp.changed(), v)})); - } - let mut p = sh.2; - let resp = ui.checkbox(&mut p, print); - control_keytip(app, ui, &resp, "pageLayout.printGridlines"); - if resp.changed() || invoke(app, "pageLayout.printGridlines") { - act(app, "pageLayout.printGridlines", json!({"on": checkbox_on(resp.changed(), p)})); - } - }); - ui.vertical(|ui| { - ui.label(egui::RichText::new(app.ui.language.tr("Headings")).strong().small()); - let mut v = sh.1; - let resp = ui.checkbox(&mut v, screen); - control_keytip(app, ui, &resp, "view.headings"); - if resp.changed() || invoke(app, "view.headings") { - act(app, "view.headings", json!({"on": checkbox_on(resp.changed(), v)})); - } - let mut p = sh.3; - let resp = ui.checkbox(&mut p, print); - control_keytip(app, ui, &resp, "pageLayout.printHeadings"); - if resp.changed() || invoke(app, "pageLayout.printHeadings") { - act(app, "pageLayout.printHeadings", json!({"on": checkbox_on(resp.changed(), p)})); - } - }); - let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left); - let r = big_button(ui, Icon::SheetRtl, "Sheet\nRight-to-Left", "Sheet Right-to-Left", false); - if rtl { - let t = Tokens::get(ui.ctx()); - ui.painter().rect_stroke(r.rect, 5.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); - } - if r.clicked() { - act(app, "view.rightToLeft", json!({"on": !rtl})); - } -} - -/// The Formulas tab: Insert Function, AutoSum, the function-category menus (names in the formula -/// language, descriptions in the interface language), defined names, auditing and calculation. -fn formulas(app: &mut SheetApp, ui: &mut Ui) { - let __k = big_button(ui, Icon::Function, "Insert\nFunction", "Insert Function (⇧F3)", false); - control_keytip(app, ui, &__k, "dialog:insertFunction"); - if __k.clicked() { - app.open_dialog("insertFunction", json!({})); - } - 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"), - ("Text", "Text", "formulas.text"), - ("Date &\nTime", "DateTime", "formulas.dateTime"), - ("Lookup &\nReference", "Lookup", "formulas.lookup"), - ("Math &\nTrig", "MathTrig", "formulas.mathTrig"), - ("More\nFunctions", "Statistical", "formulas.moreFunctions"), - ] { - let r = big_button(ui, Icon::Book, label, label, true); - control_keytip(app, ui, &r, cid); - let forced_c = invoke(app, cid); - egui::Popup::menu(&r).open_memory(popup_command(forced_c, r.clicked())).show(|ui| { - ui.set_max_height(420.0); - egui::ScrollArea::vertical().show(ui, |ui| { - for f in gridcraft_engine::cmd::formulas::function_list() { - if f["category"].as_str() == Some(cat) - && let Some(n) = f["name"].as_str() - { - let locale = app.ui.language.formula_locale(); - let name = - app.session.active().map(|d| crate::formula_locale::completion_name(n, locale, &d.wb, d.wb.active_sheet())).unwrap_or(n); - let desc = crate::formula_locale::description(n, locale, app.ui.language).unwrap_or_default(); - if ui.button(name).on_hover_text(desc).clicked() { - app.run_or_alert("formulas.insertFunction", json!({"name": n})); - ui.close(); - } - } - } - }); - }); - } - sep(ui); - let __k = big_button(ui, Icon::Name, "Name\nManager", "Name Manager", false); - control_keytip(app, ui, &__k, "dialog:nameManager"); - if __k.clicked() { - app.open_dialog("nameManager", json!({})); - } - ui.vertical(|ui| { - let __k = small_button(ui, Icon::Name, "Define Name", "Define Name", false); - control_keytip(app, ui, &__k, "dialog:defineName"); - if __k.clicked() { - 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, &["dialog:pasteName"]); - egui::Popup::menu(&use_in).show(|ui| { - // 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::unique("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>)>(cache)).filter(|(k, _)| *k == key); - let names = match cached { - Some((_, names)) => names, - None => { - let names: std::sync::Arc> = 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); - control_keytip(app, ui, &__k, "formulas.createFromSelection"); - if __k.clicked() { - act(app, "formulas.createFromSelection", json!({})); - } - }); - sep(ui); - ui.vertical(|ui| { - if small_button(ui, Icon::Trace, "Trace Precedents", "Trace Precedents", false).clicked() - && let Ok(r) = app.run("formulas.tracePrecedents", json!({})) - { - app.grid.trace = crate::grid::Trace::new(app, r); - } - if small_button(ui, Icon::Trace, "Trace Dependents", "Trace Dependents", false).clicked() - && let Ok(r) = app.run("formulas.traceDependents", json!({})) - { - app.grid.trace = crate::grid::Trace::new(app, r); - } - if small_button(ui, Icon::Close, "Remove Arrows", "Remove Arrows", false).clicked() { - app.grid.trace = None; - } - }); - ui.vertical(|ui| { - let __k = small_button(ui, Icon::Fx, "Show Formulas", "Show Formulas", false); - control_keytip(app, ui, &__k, "formulas.showFormulas"); - if __k.clicked() { - act(app, "formulas.showFormulas", json!({})); - } - 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() { - app.open_dialog("evaluateFormula", json!({})); - } - }); - if big_button(ui, Icon::Search, "Watch\nWindow", "Watch Window", false).clicked() { - app.grid.pane = Some("watch".into()); - } - sep(ui); - let co = big_button(ui, Icon::Calc, "Calculation\nOptions", "Calculation Options", true); - for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["formulas.calculationOptions"]) { - keytip(app, ui, sequence, co.rect.center_bottom()); - } - egui::Popup::menu(&co).show(|ui| { - menu_items( - app, - ui, - &[ - ("Automatic", "formulas.calculationOptions", json!({"mode": "automatic"})), - ("Automatic Except for Data Tables", "formulas.calculationOptions", json!({"mode": "automaticExceptTables"})), - ("Manual", "formulas.calculationOptions", json!({"mode": "manual"})), - ], - ); - }); - ui.vertical(|ui| { - let __k = small_button(ui, Icon::Calc, "Calculate Now", "Calculate Now (F9)", false); - control_keytip(app, ui, &__k, "formulas.calculateNow"); - if __k.clicked() { - act(app, "formulas.calculateNow", json!({})); - } - let __k = small_button(ui, Icon::Sheet, "Calculate Sheet", "Calculate Sheet (⇧F9)", false); - control_keytip(app, ui, &__k, "formulas.calculateSheet"); - if __k.clicked() { - act(app, "formulas.calculateSheet", json!({})); - } - }); -} - -/// The Data tab: Get Data imports (CSV, From Web), Queries & Connections, Refresh All, -/// sorting, filtering and the data tools. -fn data(app: &mut SheetApp, ui: &mut Ui) { - let gd = big_button(ui, Icon::Folder, "Get Data\n(Text/CSV)", "Import a text or CSV file", false); - control_keytip(app, ui, &gd, "data.getData"); - if gd.clicked() { - app.open_dialog("open", json!({})); - } - let web = big_button(ui, Icon::Table, "From\nWeb", "Import a table from a web page (From Web)", false); - control_keytip(app, ui, &web, "data.fromWeb"); - if web.clicked() { - app.open_dialog("fromWeb", json!({})); - } - ui.vertical(|ui| { - let qc = small_button(ui, Icon::Redo, "", "Queries & Connections (saved web tables, one-click Sync)", false); - control_keytip(app, ui, &qc, "data.queriesConnections"); - if qc.clicked() { - app.open_dialog("queriesConnections", json!({})); - } - let ra = small_button(ui, Icon::Redo, "", "Refresh All (re-sync every saved web table)", false); - control_keytip(app, ui, &ra, "data.webRefresh"); - if ra.clicked() { - act(app, "data.webRefresh", json!({})); - } - }); - sep(ui); - ui.vertical(|ui| { - let __k = small_button(ui, Icon::SortAsc, "", "Sort A to Z", false); - control_keytip(app, ui, &__k, "data.sortAscending"); - if __k.clicked() { - act(app, "data.sortAscending", json!({})); - } - let __k = small_button(ui, Icon::SortDesc, "", "Sort Z to A", false); - control_keytip(app, ui, &__k, "data.sortDescending"); - if __k.clicked() { - act(app, "data.sortDescending", json!({})); - } - }); - let __k = big_button(ui, Icon::SortFilter, "Sort", "Custom Sort", false); - control_keytip(app, ui, &__k, "dialog:sort"); - if __k.clicked() { - app.open_dialog("sort", json!({})); - } - let __k = big_button(ui, Icon::Filter, "Filter", "Filter", false); - control_keytip(app, ui, &__k, "data.filter"); - if __k.clicked() { - act(app, "data.filter", json!({})); - } - ui.vertical(|ui| { - let __k = small_button(ui, Icon::Eraser, "Clear", "Clear Filter", false); - control_keytip(app, ui, &__k, "data.clearFilter"); - if __k.clicked() { - act(app, "data.clearFilter", json!({})); - } - let __k = small_button(ui, Icon::Redo, "Reapply", "Reapply", false); - control_keytip(app, ui, &__k, "data.reapply"); - if __k.clicked() { - act(app, "data.reapply", json!({})); - } - let __k = small_button(ui, Icon::Filter, "Advanced", "Advanced Filter", false); - control_keytip(app, ui, &__k, "dialog:advancedFilter"); - if __k.clicked() { - app.open_dialog("advancedFilter", json!({})); - } - }); - sep(ui); - let __k = big_button(ui, Icon::TextColumns, "Text to\nColumns", "Text to Columns", false); - control_keytip(app, ui, &__k, "dialog:textToColumns"); - if __k.clicked() { - app.open_dialog("textToColumns", json!({})); - } - let __k = big_button(ui, Icon::FillDown, "Flash\nFill", "Flash Fill", false); - control_keytip(app, ui, &__k, "edit.flashFill"); - if __k.clicked() { - act(app, "edit.flashFill", json!({})); - } - let __k = big_button(ui, Icon::Duplicates, "Remove\nDuplicates", "Remove Duplicates", false); - control_keytip(app, ui, &__k, "dialog:removeDuplicates"); - if __k.clicked() { - app.open_dialog("removeDuplicates", json!({})); - } - let dv = big_button(ui, Icon::Validation, "Data\nValidation", "Data Validation", true); - for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["data.validation", "data.circleInvalid"]) { - keytip(app, ui, sequence, dv.rect.center_bottom()); - } - egui::Popup::menu(&dv).show(|ui| { - menu_items( - app, - ui, - &[ - ("Data Validation…", "dialog:dataValidation", json!({})), - ("Circle Invalid Data", "data.circleInvalid", json!({})), - ("Clear Validation", "data.validation", json!({"clear": true})), - ], - ); - }); - sep(ui); - let wi = big_button(ui, Icon::Calc, "What-If\nAnalysis", "What-If Analysis", true); - for sequence in - keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["dialog:goalSeek", "data.scenarioManager", "data.dataTable"]) - { - keytip(app, ui, sequence, wi.rect.center_bottom()); - } - egui::Popup::menu(&wi).show(|ui| { - menu_items( - app, - ui, - &[ - ("Goal Seek…", "dialog:goalSeek", json!({})), - ("Scenario Manager…", "data.scenarioManager", json!({})), - ("Data Table…", "data.dataTable", json!({})), - ], - ); - }); - if big_button(ui, Icon::ChartLine, "Forecast\nSheet", "Forecast Sheet", false).clicked() { - app.open_dialog("forecastSheet", json!({})); - } - sep(ui); - ui.vertical(|ui| { - let __k = small_button(ui, Icon::Group, "Group", "Group", false); - control_keytip(app, ui, &__k, "data.group"); - if __k.clicked() { - act(app, "data.group", json!({})); - } - let __k = small_button(ui, Icon::Ungroup, "Ungroup", "Ungroup", false); - control_keytip(app, ui, &__k, "data.ungroup"); - if __k.clicked() { - act(app, "data.ungroup", json!({})); - } - let __k = small_button(ui, Icon::Subtotal, "Subtotal", "Subtotal", false); - control_keytip(app, ui, &__k, "dialog:subtotal"); - if __k.clicked() { - app.open_dialog("subtotal", json!({})); - } - }); - ui.vertical(|ui| { - if small_button(ui, Icon::Plus, "Show Detail", "Show Detail", false).clicked() { - act(app, "data.showDetail", json!({})); - } - 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() { - app.open_dialog("analysisToolPak", json!({})); - } - sep(ui); - if big_button(ui, Icon::Function, "Solver", "Solver", false).clicked() { - app.open_dialog("solver", json!({})); - } -} - -fn review(app: &mut SheetApp, ui: &mut Ui) { - let __k = big_button(ui, Icon::Spell, "Spelling", "Spelling (F7)", false); - control_keytip(app, ui, &__k, "dialog:spelling"); - if __k.clicked() { - app.open_dialog("spelling", json!({})); - } - let __k = big_button(ui, Icon::Calc, "Workbook\nStatistics", "Workbook Statistics", false); - control_keytip(app, ui, &__k, "statistics"); - if __k.clicked() { - app.open_dialog("statistics", json!({})); - } - let __k = big_button(ui, Icon::Validation, "Check\nAccessibility", "Check Accessibility", false); - control_keytip(app, ui, &__k, "dialog:accessibility"); - if __k.clicked() { - app.open_dialog("accessibility", json!({})); - } - sep(ui); - let __k = big_button(ui, Icon::Comment, "New\nComment", "New Comment", false); - control_keytip(app, ui, &__k, "dialog:comment"); - if __k.clicked() { - app.open_dialog("comment", json!({"threaded": true})); - } - let __k = big_button(ui, Icon::Delete, "Delete", "Delete Comment", false); - control_keytip(app, ui, &__k, "review.deleteComment"); - if __k.clicked() { - act(app, "review.deleteComment", json!({})); - } - ui.vertical(|ui| { - for (icon, label, tip, id) in - [(Icon::Left, "Previous", "Previous Comment", "review.previousComment"), (Icon::Right, "Next", "Next Comment", "review.nextComment")] - { - if small_button(ui, icon, label, tip, false).clicked() { - act(app, id, json!({})); - app.grid.ensure_visible = true; - } - } - }); - let shown = app.session.comments_pane; - let r = big_button(ui, Icon::Comment, "Show\nComments", "Comments pane", false); - if shown { - ui.painter().rect_stroke(r.rect, 5.0, Stroke::new(2.0, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); - } - if r.clicked() { - act(app, "review.showComments", json!({"on": !shown})); - if app.session.comments_pane { - app.grid.pane = None; - } - } - let notes = big_button(ui, Icon::Note, "Notes", "Notes", true); - anchor_keytip(app, ui, ¬es, &["review.nextComment"]); - egui::Popup::menu(¬es).show(|ui| { - menu_items( - app, - ui, - &[ - ("New Note", "dialog:comment", json!({"threaded": false})), - ("Show/Hide Note", "review.showNote", json!({})), - ("Show All Notes", "review.showAllNotes", json!({})), - ("-", "", json!({})), - ("Convert to Comments", "review.convertToComments", json!({})), - ], - ); - }); - sep(ui); - let protected = app.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false); - let __k = big_button(ui, Icon::Lock, if protected { "Unprotect\nSheet" } else { "Protect\nSheet" }, "Protect Sheet", false); - control_keytip(app, ui, &__k, "dialog:protectSheet"); - if __k.clicked() { - app.open_dialog("protectSheet", json!({})); - } - let __k = big_button(ui, Icon::Book, "Protect\nWorkbook", "Protect Workbook structure", false); - control_keytip(app, ui, &__k, "review.protectWorkbook"); - if __k.clicked() { - crate::backstage::protect_workbook(app); - } - if big_button(ui, Icon::Validation, "Allow Edit\nRanges", "Allow Edit Ranges", false).clicked() { - app.open_dialog("allowEditRanges", json!({})); - } -} - -/// Draws the View tab: view modes, ruler/gridlines/formula-bar/headings toggles, zoom, freeze panes, theme, animations, interface size and language. -fn view(app: &mut SheetApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let _ = t; - for (icon, label, cmd) in [ - (Icon::Normal, "Normal", "view.normal"), - (Icon::PageBreak, "Page Break\nPreview", "view.pageBreakPreview"), - (Icon::PageLayout, "Page\nLayout", "view.pageLayout"), - ] { - let __k = big_button(ui, icon, label, label, false); - control_keytip(app, ui, &__k, cmd); - if __k.clicked() { - act(app, cmd, json!({})); - } - } - sep(ui); - let s = app.session.active().and_then(|d| d.wb.active().map(|s| (s.show_gridlines, s.show_headings))).unwrap_or((true, true)); - let lang = app.ui.language; - let page_layout = - app.session.active().and_then(|d| d.wb.active().map(|s| s.view == gridcraft_engine::model::SheetViewMode::PageLayout)).unwrap_or(false); - // Two columns of two, as in Excel's Show group: four stacked rows overflow the ribbon body. - ui.vertical(|ui| { - // Rulers exist only in Page Layout view. - let mut ruler = !app.session.hide_ruler; - let ruler_resp = ui.add_enabled(page_layout, egui::Checkbox::new(&mut ruler, lang.tr("Ruler"))); - control_keytip(app, ui, &ruler_resp, "view.ruler"); - if ruler_resp.changed() { - act(app, "view.ruler", json!({"on": ruler})); - } - let mut g = s.0; - let resp = ui.checkbox(&mut g, lang.tr("Gridlines")); - control_keytip(app, ui, &resp, "view.gridlines"); - if resp.changed() { - act(app, "view.gridlines", json!({"on": g})); - } - }); - ui.add_space(6.0); - ui.vertical(|ui| { - let mut fb = app.session.prefs.formula_bar; - let resp = ui.checkbox(&mut fb, lang.tr("Formula Bar")); - control_keytip(app, ui, &resp, "view.formulaBar"); - if resp.changed() || invoke(app, "view.formulaBar") { - act(app, "view.formulaBar", json!({"on": checkbox_on(resp.changed(), fb)})); - } - let mut h = s.1; - let resp = ui.checkbox(&mut h, lang.tr("Headings")); - control_keytip(app, ui, &resp, "view.headings"); - if resp.changed() { - act(app, "view.headings", json!({"on": h})); - } - }); - sep(ui); - let __k = big_button(ui, Icon::Zoom, "Zoom", "Zoom…", false); - control_keytip(app, ui, &__k, "dialog:zoom"); - if __k.clicked() { - app.open_dialog("zoom", json!({})); - } - let __k = big_button(ui, Icon::Search, "100%", "Zoom to 100%", false); - control_keytip(app, ui, &__k, "view.zoom"); - if __k.clicked() || invoke(app, "view.zoom") { - act(app, "view.zoom", json!({"percent": 100})); - } - let __k = big_button(ui, Icon::Zoom, "Zoom to\nSelection", "Zoom to Selection", false); - control_keytip(app, ui, &__k, "view.zoomToSelection"); - if __k.clicked() { - let r = app.grid.cells_rect.unwrap_or(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 700.0))); - act(app, "view.zoomToSelection", json!({"viewWidth": r.width(), "viewHeight": r.height()})); - } - sep(ui); - let __k = big_button(ui, Icon::NewFile, "New\nWindow", "New Window", false); - control_keytip(app, ui, &__k, "view.newWindow"); - if __k.clicked() { - act(app, "view.newWindow", json!({})); - } - let __k = big_button(ui, Icon::Table, "Arrange\nAll", "Arrange All", false); - control_keytip(app, ui, &__k, "view.arrangeAll"); - if __k.clicked() { - app.open_dialog("arrangeAll", json!({})); - } - let fp = big_button(ui, Icon::Freeze, "Freeze\nPanes", "Freeze Panes", true); - for sequence in - keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["view.freezePanes", "view.freezeTopRow", "view.freezeFirstColumn"]) - { - keytip(app, ui, sequence, fp.rect.center_bottom()); - } - egui::Popup::menu(&fp).show(|ui| { - menu_items( - app, - ui, - &[ - ("Freeze Panes", "view.freezePanes", json!({})), - ("Freeze Top Row", "view.freezeTopRow", json!({})), - ("Freeze First Column", "view.freezeFirstColumn", json!({})), - ("Unfreeze Panes", "view.unfreezePanes", json!({})), - ], - ); - }); - switch_windows(app, ui); - ui.vertical(|ui| { - let label_response = ui.label(lang.tr("Display theme")); - let mode = app.ui.theme_mode(); - let label = match mode { - "system" => "System", - "dark" => "Dark", - _ => "Light", - }; - egui::ComboBox::from_id_salt("display_theme") - .selected_text(lang.tr(label)) - .width(88.0) - .show_ui(ui, |ui| { - for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] { - if ui.selectable_label(mode == value, lang.tr(label)).clicked() { - act(app, "view.theme", json!({"mode": value})); - } - } - }) - .response - .labelled_by(label_response.id); - let mut animations = app.ui.animations; - let hint = lang.tr("Glide the selection outline to the cell you move to instead of jumping there"); - if ui.checkbox(&mut animations, lang.tr("Animations")).on_hover_text(hint).changed() { - act(app, "view.animations", json!({"on": animations})); - } - }); - sep(ui); - // Interface size (issue #230): scales the whole window on top of the desktop's scale. - ui.vertical(|ui| { - let label = ui.label(egui::RichText::new(lang.tr("Interface size")).font(theme::ui_font(11.5)).color(t.text_dim)); - let current = app.ui.interface_scale; - let text = |v: u16| if v == 100 { lang.tr("Auto").to_string() } else { format!("{v}%") }; - egui::ComboBox::from_id_salt("ui_interface_size") - .width(120.0) - .selected_text(egui::RichText::new(text(current)).font(theme::ui_font(12.5))) - .show_ui(ui, |ui| { - for v in [100, 75, 90, 110, 125, 150, 175, 200] { - if ui.selectable_label(v == current, text(v)).clicked() { - act(app, "view.interfaceScale", json!({"percent": v})); - } - } - }) - .response - .labelled_by(label.id); - }); - // Interface language: settings live in the View tab, like Excel's Options. The label follows - // the current language so it stays readable; the choices are always shown in their own script. - ui.vertical(|ui| { - let label = ui.label(egui::RichText::new(lang.tr("Interface language")).font(theme::ui_font(11.5)).color(t.text_dim)); - egui::ComboBox::from_id_salt("ui_language") - .width(120.0) - .selected_text(egui::RichText::new(lang.name()).font(theme::ui_font(12.5))) - .show_ui(ui, |ui| { - for l in crate::i18n::Language::ALL { - if ui.selectable_label(l == lang, l.name()).clicked() { - act(app, "app.language.set", json!({"language": l.code()})); - } - } - }) - .response - .labelled_by(label.id); - }); -} - -/// View › Switch Windows: the open workbooks, the active one checked and unsaved ones marked; -/// choosing one brings it forward. -fn switch_windows(app: &mut SheetApp, ui: &mut Ui) { - let anchor = big_button(ui, Icon::Book, "Switch\nWindows", "Switch Windows", true); - anchor.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Switch Windows")); - control_keytip(app, ui, &anchor, "view.switchWindows"); - let forced = std::mem::take(&mut app.open_switch_windows) | invoke(app, "view.switchWindows"); - egui::Popup::menu(&anchor).open_memory(popup_command(forced, anchor.clicked())).show(|ui| { - for w in app.open_windows() { - let label = theme::available_text(ui, &format!("{}{}", if w.active { "✓ " } else { " " }, w.label())); - if ui.add(egui::Button::selectable(w.active, label).min_size(vec2(220.0, 22.0))).clicked() { - act(app, "view.switchWindows", json!({"window": w.window})); - ui.close(); - } - } - }); -} - -fn automate(app: &mut SheetApp, ui: &mut Ui) { - if big_button(ui, Icon::Script, "Macros", "Macros and scripts in this workbook (Alt+F8)", false).clicked() { - app.open_dialog("macros", json!({})); - } - let __k = big_button(ui, Icon::Script, "Command\nPalette", "Search and run any command", false); - control_keytip(app, ui, &__k, "commandSearch"); - if __k.clicked() { - app.open_dialog("commandSearch", json!({})); - } - let __k = big_button(ui, Icon::Script, "Agent\nControl", "How agents drive GridCraft (MCP / control channel)", false); - control_keytip(app, ui, &__k, "agents"); - if __k.clicked() { - app.open_dialog("agents", json!({})); - } - let __k = big_button(ui, Icon::Script, "Action\nJournal", "Every command run in this session (replayable)", false); - control_keytip(app, ui, &__k, "journal"); - if __k.clicked() { - app.open_dialog("journal", json!({})); - } - let __k = big_button(ui, Icon::Script, "Check for\nUpdates…", "Look for a newer GridCraft release", false); - control_keytip(app, ui, &__k, "checkForUpdates"); - if __k.clicked() { - app.run_or_alert("app.checkForUpdates", json!({})); - } - let __k = big_button(ui, Icon::Script, "About\nGridCraft", "Version, contributors and the AI models that helped", false); - control_keytip(app, ui, &__k, "about"); - if __k.clicked() { - app.open_dialog("about", json!({})); - } -} - -/// Draws the Table Design tab: rename, resize, remove duplicates, convert to range, slicer, table options and the style strip. -fn table_design(app: &mut SheetApp, ui: &mut Ui) { - let Some((tname, header, totals, banded, banded_c, first, last, filter, style)) = app.session.active().and_then(|d| { - let sh = d.wb.active()?; - let t = sh.table_at(d.selection.active)?; - Some((t.name.clone(), t.header_row, t.totals_row, t.banded_rows, t.banded_cols, t.first_col, t.last_col, t.filter_button, t.style.clone())) - }) else { - return; - }; - ui.vertical(|ui| { - ui.label(egui::RichText::new("Table Name:").small()); - let field = egui::Id::unique(("gridcraft.table_name", &tname)); - if let Some(name) = crate::widgets::committed_text(ui, field, &tname, Some(120.0)) - && name != tname - { - act(app, "table.rename", json!({"table": tname, "name": name})); - } - }); - let __k = big_button(ui, Icon::Table, "Resize\nTable", "Resize Table", false); - control_keytip(app, ui, &__k, "dialog:tableResize"); - if __k.clicked() { - app.open_dialog("tableResize", json!({})); - } - sep(ui); - let __k = big_button(ui, Icon::Duplicates, "Remove\nDuplicates", "Remove Duplicates", false); - control_keytip(app, ui, &__k, "data.removeDuplicates"); - if __k.clicked() { - act(app, "data.removeDuplicates", json!({})); - } - let __k = big_button(ui, Icon::Table, "Convert\nto Range", "Convert to Range", false); - control_keytip(app, ui, &__k, "table.convertToRange"); - if __k.clicked() { - act(app, "table.convertToRange", json!({"table": tname})); - } - sep(ui); - if big_button(ui, Icon::Filter, "Insert\nSlicer", "Insert Slicer", false).clicked() { - act(app, "table.insertSlicer", json!({"table": tname})); - } - sep(ui); - ui.vertical(|ui| { - for (label, cmd, v) in [ - ("Header Row", "table.headerRow", header && filter), - ("Total Row", "table.totalRow", totals), - ("Banded Rows", "table.bandedRows", banded), - ] { - let mut on = v; - if ui.checkbox(&mut on, label).changed() { - act(app, cmd, json!({"table": tname, "on": on})); - } - } - }); - ui.vertical(|ui| { - for (label, cmd, v) in [ - ("First Column", "table.firstColumn", first), - ("Last Column", "table.lastColumn", last), - ("Banded Columns", "table.bandedColumns", banded_c), - ] { - let mut on = v; - if ui.checkbox(&mut on, label).changed() { - act(app, cmd, json!({"table": tname, "on": on})); - } - } - }); - sep(ui); - table_style_strip(app, ui, &tname, &style); -} - -/// The first page of Excel's Table Styles gallery, drawn in the ribbon. The More arrow opens the -/// full gallery (Light, Medium, Dark) as a popup. -const RIBBON_TABLE_STYLES: [&str; 7] = - ["TableStyleLight1", "TableStyleLight2", "TableStyleLight3", "TableStyleLight4", "TableStyleLight5", "TableStyleLight6", "TableStyleLight7"]; - -fn table_style_strip(app: &mut SheetApp, ui: &mut Ui, tname: &str, current: &str) { - let t = Tokens::get(ui.ctx()); - ui.add_space(12.0); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 3.0; - // Borrow the workbook only while painting; the session must be free for `act` afterwards. - let clicked = { - let Some(d) = app.session.active() else { return }; - let wb = &d.wb; - let mut clicked = None; - for name in RIBBON_TABLE_STYLES { - let (r, resp) = ui.allocate_exact_size(vec2(52.0, 40.0), Sense::click()); - paint_table_swatch(ui, r, wb, name); - if name == current { - ui.painter().rect_stroke(r.expand(2.5), 2.0, Stroke::new(2.0, t.accent), StrokeKind::Outside); - } - if resp.hovered() { - ui.painter().rect_stroke(r.expand(1.0), 2.0, Stroke::new(2.0, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside); - } - if resp.on_hover_text(name).clicked() { - clicked = Some(name); - } - } - clicked - }; - if let Some(name) = clicked { - act(app, "table.style", json!({"table": tname, "style": name})); - } - let more = icon_button(ui, Icon::Chevron, t.text_dim, "More Table Styles", vec2(18.0, 40.0)); - anchor_keytip(app, ui, &more, &["table.style"]); - egui::Popup::menu(&more).show(|ui| table_gallery(app, ui, "table.style")); - }); -} - -fn chart_design(app: &mut SheetApp, ui: &mut Ui) { - let Some(id) = app.selected_chart else { return }; - let ae = big_button(ui, Icon::Chart, "Add Chart\nElement", "Add Chart Element", true); - for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["chart.set"]) { - keytip(app, ui, sequence, ae.rect.center_bottom()); - } - egui::Popup::menu(&ae).show(|ui| { - menu_items( - app, - ui, - &[ - ("Legend: Bottom", "chart.set", json!({"chart": id, "legend": "bottom"})), - ("Legend: Right", "chart.set", json!({"chart": id, "legend": "right"})), - ("Legend: Top", "chart.set", json!({"chart": id, "legend": "top"})), - ("Legend: None", "chart.set", json!({"chart": id, "legend": "none"})), - ("-", "", json!(null)), - ("Data Labels: Show", "chart.set", json!({"chart": id, "dataLabels": true})), - ("Data Labels: None", "chart.set", json!({"chart": id, "dataLabels": false})), - ("Gridlines: Show", "chart.set", json!({"chart": id, "gridlines": true})), - ("Gridlines: None", "chart.set", json!({"chart": id, "gridlines": false})), - ], - ); - }); - let __k = big_button(ui, Icon::Redo, "Switch\nRow/Column", "Switch Row/Column", false); - control_keytip(app, ui, &__k, "chart.switchRowColumn"); - if __k.clicked() { - act(app, "chart.switchRowColumn", json!({"chart": id})); - } - if big_button(ui, Icon::Table, "Select\nData", "Select Data", false).clicked() { - app.open_dialog("selectData", json!({"chart": id})); - } - let ct = big_button(ui, Icon::ChartBar, "Change\nChart Type", "Change Chart Type", true); - for sequence in keytip_table::group_extension( - app.keytips.prefix().unwrap_or_default(), - &["column", "bar", "line", "area", "pie", "doughnut", "scatter", "radar", "waterfall", "funnel", "treemap"], - ) { - keytip(app, ui, sequence, ct.rect.center_bottom()); - } - egui::Popup::menu(&ct).show(|ui| { - for (label, k, s) in [ - ("Clustered Column", "column", ""), - ("Stacked Column", "column", "stacked"), - ("Bar", "bar", ""), - ("Line", "line", "markers"), - ("Area", "area", ""), - ("Pie", "pie", ""), - ("Doughnut", "doughnut", ""), - ("Scatter", "scatter", ""), - ("Radar", "radar", ""), - ("Waterfall", "waterfall", ""), - ("Funnel", "funnel", ""), - ("Treemap", "treemap", ""), - ] { - if ui.button(label).clicked() { - act(app, "chart.set", json!({"chart": id, "type": k, "subtype": s})); - } - } - }); - if big_button(ui, Icon::Settings, "Format\nPane", "Format Chart pane", false).clicked() { - app.grid.pane = Some("formatChart".into()); - } - let __k = big_button(ui, Icon::TextBox, "Chart\nTitle", "Edit chart title", false); - control_keytip(app, ui, &__k, "chartTitle"); - if __k.clicked() { - app.open_dialog("chartTitle", json!({"chart": id})); - } - let __k = big_button(ui, Icon::Delete, "Delete\nChart", "Delete chart", false); - control_keytip(app, ui, &__k, "chart.delete"); - if __k.clicked() { - act(app, "chart.delete", json!({"chart": id})); - app.selected_chart = None; - } -} - -/// Command-key shortcuts when the grid has focus. -pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) { - let shift = m.shift; - let id: Option<(&str, serde_json::Value)> = match key { - Key::B => Some(("home.bold", json!({}))), - Key::I => Some(("home.italic", json!({}))), - Key::U => Some(("home.underline", json!({}))), - Key::Z if shift => Some(("edit.redo", json!({}))), - Key::Z => Some(("edit.undo", json!({}))), - Key::Y => Some(("edit.redo", json!({}))), - Key::A => Some(("edit.selectAll", json!({}))), - Key::D => Some(("edit.fillDown", json!({}))), - Key::R => Some(("edit.fillRight", json!({}))), - Key::S if shift => Some(("file.saveAs", json!({}))), - Key::S => Some(("file.save", json!({}))), - Key::O => Some(("file.open", json!({}))), - Key::N => Some(("file.new", json!({}))), - Key::W => Some(("file.close", json!({}))), - Key::P => { - app.print_now(json!({})); - None - } - Key::K => { - app.open_dialog("insertLink", json!({})); - None - } - Key::F if shift => Some(("data.filter", json!({}))), - Key::F => { - app.open_dialog("find", json!({})); - None - } - Key::H => { - app.open_dialog("find", json!({"replace": true})); - None - } - Key::G => { - app.open_dialog("goTo", json!({})); - None - } - Key::T if shift => Some(("formulas.autoSum", json!({}))), - Key::T => Some(("insert.table", json!({}))), - Key::E => Some(("edit.flashFill", json!({}))), - Key::Num1 => { - app.open_dialog("formatCells", json!({})); - None - } - Key::Num9 if shift => Some(("home.unhideRows", json!({}))), - Key::Num9 => Some(("home.hideRows", json!({}))), - Key::Num0 if shift => Some(("home.unhideColumns", json!({}))), - Key::Num0 => Some(("home.hideColumns", json!({}))), - // As in Excel: with whole rows or whole columns selected, Ctrl+Shift+= / Ctrl+- insert or - // delete them at once; any other selection asks which way to shift. - Key::Minus => match whole_lines(app) { - Some(true) => Some(("home.deleteRows", json!({}))), - Some(false) => Some(("home.deleteColumns", json!({}))), - None => { - app.open_dialog("deleteCells", json!({})); - None - } - }, - // Insert works on one area at a time, so several areas still go through the dialog. - Key::Plus | Key::Equals if shift => match whole_lines(app).filter(|_| single_area(app)) { - Some(true) => Some(("home.insertRows", json!({}))), - Some(false) => Some(("home.insertColumns", json!({}))), - None => { - app.open_dialog("insertCells", json!({})); - None - } - }, - Key::Semicolon => { - // Insert today's date. - let text = app - .session - .active() - .map(|d| { - let today = gridcraft_engine::core::date::serial_in_system(d.wb.date_system, gridcraft_engine::calc::now_serial()).floor(); - gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(today), "m/d/yyyy", &d.wb).text - }) - .unwrap_or_default(); - app.begin_edit(Some(text), false); - None - } - Key::Backtick => Some(("formulas.showFormulas", json!({}))), - _ => None, - }; - if let Some((id, p)) = id { - app.run_or_alert(id, p); - app.grid.ensure_visible = true; - } -} - -#[cfg(test)] -mod title_bar_tests { - use super::*; - - #[test] - fn mac_title_bar_centres_and_clears_the_traffic_lights() { - // The lights are centred in the bar, and content keeps the same gap after them. - let margin = (MAC_TITLE_BAR - MAC_LIGHT) / 2.0; - let lights_end = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT; - assert_eq!(MAC_CONTENT_LEFT - lights_end, margin); - } - - #[test] - fn title_fits_between_the_controls() { - // Room limited by the nearer side, symmetric about the centre. - assert_eq!(title_max_width(500.0, 300.0, 900.0), 2.0 * (200.0 - TITLE_GAP)); - assert_eq!(title_max_width(500.0, 100.0, 650.0), 2.0 * (150.0 - TITLE_GAP)); - // Controls overlapping the centre leave no room rather than a negative width. - assert_eq!(title_max_width(500.0, 520.0, 900.0), 0.0); - } - - /// Lay out a title in a real egui frame, returning (title text, title width, dot width). - fn layout_title(title: &str, dirty: bool, max_w: f32) -> (String, f32, f32) { - let ctx = egui::Context::default(); - ctx.set_fonts(theme::font_definitions()); - let mut out = (String::new(), 0.0, 0.0); - let mut frame = ctx.run_ui(egui::RawInput::default(), |ui| { - let (galley, dot) = title_galleys(ui.painter(), title, dirty, max_w, Color32::WHITE); - // The glyphs actually drawn: `galley.text()` is the untruncated source. - let shown = galley.rows.iter().flat_map(|r| r.glyphs.iter().map(|g| g.chr)).collect(); - out = (shown, galley.size().x, dot.map_or(0.0, |d| d.size().x)); - }); - frame.textures_delta.clear(); // nothing consumes the atlas in a headless test - out - } - - #[test] - fn long_title_is_truncated_with_an_ellipsis() { - let long = format!("upload-{}-cleaned (1).xlsx", "3FMyNTYg2gDaANiAAACq20AAAAQMBqug".repeat(4)); - let (text, w, _) = layout_title(&long, false, 300.0); - assert!(text.ends_with('…'), "truncated title ends with an ellipsis: {text:?}"); - assert!(text.chars().count() < long.chars().count()); - assert!(w <= 300.0, "title stays within its room: {w}"); - } - - #[test] - fn short_title_is_left_whole() { - let (text, _, _) = layout_title("Book1.xlsx", false, 300.0); - assert_eq!(text, "Book1.xlsx"); - } - - #[test] - fn unsaved_dot_survives_truncation() { - let long = "a very long workbook name ".repeat(20); - let (text, w, dot_w) = layout_title(&long, true, 200.0); - assert!(dot_w > 0.0, "the unsaved marker is laid out"); - assert!(text.ends_with('…')); - assert!(w + dot_w <= 200.0, "title and marker fit together: {}", w + dot_w); - } - - #[test] - fn no_room_draws_nothing_but_does_not_crash() { - let (_, w, _) = layout_title("Book1.xlsx", true, 0.0); - assert!(w <= 20.0, "a zero-width slot leaves at most the ellipsis: {w}"); - } -} - -#[cfg(test)] -mod checkbox_on_tests { - use super::*; - - #[test] - fn click_sends_the_new_checkbox_value_and_key_tip_the_opposite() { - // Click: the checkbox bool already holds the new choice. - assert!(!checkbox_on(true, false)); - assert!(checkbox_on(true, true)); - // Key tip: the bool still holds the current setting, so toggle it. - assert!(checkbox_on(false, false)); - assert!(!checkbox_on(false, true)); - } -} - -/// Whether the selection is a single area. -fn single_area(app: &SheetApp) -> bool { - app.session.active().is_some_and(|d| d.selection.ranges.len() == 1) -} - -/// `Some(true)` when every selected area is whole rows, `Some(false)` when every one is whole -/// columns, `None` otherwise (including the whole sheet). -fn whole_lines(app: &SheetApp) -> Option { - let sel = &app.session.active()?.selection; - let rows = sel.ranges.iter().all(|r| r.is_full_rows() && !r.is_full_cols()); - let cols = sel.ranges.iter().all(|r| r.is_full_cols() && !r.is_full_rows()); - if rows { - Some(true) - } else if cols { - Some(false) - } else { - None - } -} - -#[cfg(test)] -mod accounting_symbol_tests { - use super::*; - use gridcraft_engine::core::{DateSystem, Value}; - use gridcraft_engine::numfmt::{NumberFormat, format_value}; - - #[test] - fn accounting_dropdown_offers_the_issue_currencies_with_excel_codes() { - // #1063: label, Excel's [$symbol-LCID] tag, and 1234.5 as Excel shows it (fill and the - // `_-` padding aside). The yen and won have no minor unit, so their codes show no decimals. - for (label, tag, shown) in [ - ("Rp Indonesian", "[$Rp-421]", "Rp1,234.50"), - ("₹ English (India)", "[$₹-4009]", "₹1,234.50"), - ("₩ Korean", "[$₩-412]", "₩1,235"), - ("¥ Chinese (PRC)", "[$¥-804]", "¥1,234.50"), - ("¥ Japanese", "[$¥-411]", "¥1,235"), - ] { - let code = ACCOUNTING_SYMBOLS.iter().find(|(l, _)| *l == label).map(|(_, c)| *c); - let Some(code) = code else { panic!("{label} is not in the Accounting dropdown") }; - assert!(code.split(';').take(3).all(|section| section.contains(tag)), "{label}: {code}"); - let out = format_value(&Value::Number(1234.5), &NumberFormat::parse(code), DateSystem::D1900); - assert_eq!(out.text.trim(), shown, "{label}"); - let symbol = tag.trim_start_matches("[$").split('-').next().unwrap_or_default(); - assert_eq!(out.fill.map(|(c, at)| (c, out.text[..at].trim_start())), Some((' ', symbol)), "{label}"); - } - } -} diff --git a/crates/ui-egui/src/ribbon/automate.rs b/crates/ui-egui/src/ribbon/automate.rs new file mode 100644 index 0000000000..5a9de6b503 --- /dev/null +++ b/crates/ui-egui/src/ribbon/automate.rs @@ -0,0 +1,33 @@ +use super::controls::control_keytip; +use super::*; + +pub(super) fn automate(app: &mut SheetApp, ui: &mut Ui) { + if big_button(ui, Icon::Script, "Macros", "Macros and scripts in this workbook (Alt+F8)", false).clicked() { + app.open_dialog("macros", json!({})); + } + let __k = big_button(ui, Icon::Script, "Command\nPalette", "Search and run any command", false); + control_keytip(app, ui, &__k, "commandSearch"); + if __k.clicked() { + app.open_dialog("commandSearch", json!({})); + } + let __k = big_button(ui, Icon::Script, "Agent\nControl", "How agents drive GridCraft (MCP / control channel)", false); + control_keytip(app, ui, &__k, "agents"); + if __k.clicked() { + app.open_dialog("agents", json!({})); + } + let __k = big_button(ui, Icon::Script, "Action\nJournal", "Every command run in this session (replayable)", false); + control_keytip(app, ui, &__k, "journal"); + if __k.clicked() { + app.open_dialog("journal", json!({})); + } + let __k = big_button(ui, Icon::Script, "Check for\nUpdates…", "Look for a newer GridCraft release", false); + control_keytip(app, ui, &__k, "checkForUpdates"); + if __k.clicked() { + app.run_or_alert("app.checkForUpdates", json!({})); + } + let __k = big_button(ui, Icon::Script, "About\nGridCraft", "Version, contributors and the AI models that helped", false); + control_keytip(app, ui, &__k, "about"); + if __k.clicked() { + app.open_dialog("about", json!({})); + } +} diff --git a/crates/ui-egui/src/ribbon/contextual.rs b/crates/ui-egui/src/ribbon/contextual.rs new file mode 100644 index 0000000000..4974801910 --- /dev/null +++ b/crates/ui-egui/src/ribbon/contextual.rs @@ -0,0 +1,183 @@ +use super::controls::{act, anchor_keytip, control_keytip, keytip, menu_items, sep}; +use super::galleries::{paint_table_swatch, table_gallery}; +use super::*; + +/// Draws the Table Design tab: rename, resize, remove duplicates, convert to range, slicer, table options and the style strip. +pub(super) fn table_design(app: &mut SheetApp, ui: &mut Ui) { + let Some((tname, header, totals, banded, banded_c, first, last, filter, style)) = app.session.active().and_then(|d| { + let sh = d.wb.active()?; + let t = sh.table_at(d.selection.active)?; + Some((t.name.clone(), t.header_row, t.totals_row, t.banded_rows, t.banded_cols, t.first_col, t.last_col, t.filter_button, t.style.clone())) + }) else { + return; + }; + ui.vertical(|ui| { + ui.label(egui::RichText::new("Table Name:").small()); + let field = egui::Id::unique(("gridcraft.table_name", &tname)); + if let Some(name) = crate::widgets::committed_text(ui, field, &tname, Some(120.0)) + && name != tname + { + act(app, "table.rename", json!({"table": tname, "name": name})); + } + }); + let __k = big_button(ui, Icon::Table, "Resize\nTable", "Resize Table", false); + control_keytip(app, ui, &__k, "dialog:tableResize"); + if __k.clicked() { + app.open_dialog("tableResize", json!({})); + } + sep(ui); + let __k = big_button(ui, Icon::Duplicates, "Remove\nDuplicates", "Remove Duplicates", false); + control_keytip(app, ui, &__k, "data.removeDuplicates"); + if __k.clicked() { + act(app, "data.removeDuplicates", json!({})); + } + let __k = big_button(ui, Icon::Table, "Convert\nto Range", "Convert to Range", false); + control_keytip(app, ui, &__k, "table.convertToRange"); + if __k.clicked() { + act(app, "table.convertToRange", json!({"table": tname})); + } + sep(ui); + if big_button(ui, Icon::Filter, "Insert\nSlicer", "Insert Slicer", false).clicked() { + act(app, "table.insertSlicer", json!({"table": tname})); + } + sep(ui); + ui.vertical(|ui| { + for (label, cmd, v) in [ + ("Header Row", "table.headerRow", header && filter), + ("Total Row", "table.totalRow", totals), + ("Banded Rows", "table.bandedRows", banded), + ] { + let mut on = v; + if ui.checkbox(&mut on, label).changed() { + act(app, cmd, json!({"table": tname, "on": on})); + } + } + }); + ui.vertical(|ui| { + for (label, cmd, v) in [ + ("First Column", "table.firstColumn", first), + ("Last Column", "table.lastColumn", last), + ("Banded Columns", "table.bandedColumns", banded_c), + ] { + let mut on = v; + if ui.checkbox(&mut on, label).changed() { + act(app, cmd, json!({"table": tname, "on": on})); + } + } + }); + sep(ui); + table_style_strip(app, ui, &tname, &style); +} + +/// The first page of Excel's Table Styles gallery, drawn in the ribbon. The More arrow opens the +/// full gallery (Light, Medium, Dark) as a popup. +const RIBBON_TABLE_STYLES: [&str; 7] = + ["TableStyleLight1", "TableStyleLight2", "TableStyleLight3", "TableStyleLight4", "TableStyleLight5", "TableStyleLight6", "TableStyleLight7"]; + +fn table_style_strip(app: &mut SheetApp, ui: &mut Ui, tname: &str, current: &str) { + let t = Tokens::get(ui.ctx()); + ui.add_space(12.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 3.0; + // Borrow the workbook only while painting; the session must be free for `act` afterwards. + let clicked = { + let Some(d) = app.session.active() else { return }; + let wb = &d.wb; + let mut clicked = None; + for name in RIBBON_TABLE_STYLES { + let (r, resp) = ui.allocate_exact_size(vec2(52.0, 40.0), Sense::click()); + paint_table_swatch(ui, r, wb, name); + if name == current { + ui.painter().rect_stroke(r.expand(2.5), 2.0, Stroke::new(2.0, t.accent), StrokeKind::Outside); + } + if resp.hovered() { + ui.painter().rect_stroke(r.expand(1.0), 2.0, Stroke::new(2.0, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside); + } + if resp.on_hover_text(name).clicked() { + clicked = Some(name); + } + } + clicked + }; + if let Some(name) = clicked { + act(app, "table.style", json!({"table": tname, "style": name})); + } + let more = icon_button(ui, Icon::Chevron, t.text_dim, "More Table Styles", vec2(18.0, 40.0)); + anchor_keytip(app, ui, &more, &["table.style"]); + egui::Popup::menu(&more).show(|ui| table_gallery(app, ui, "table.style")); + }); +} + +pub(super) fn chart_design(app: &mut SheetApp, ui: &mut Ui) { + let Some(id) = app.selected_chart else { return }; + let ae = big_button(ui, Icon::Chart, "Add Chart\nElement", "Add Chart Element", true); + for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["chart.set"]) { + keytip(app, ui, sequence, ae.rect.center_bottom()); + } + egui::Popup::menu(&ae).show(|ui| { + menu_items( + app, + ui, + &[ + ("Legend: Bottom", "chart.set", json!({"chart": id, "legend": "bottom"})), + ("Legend: Right", "chart.set", json!({"chart": id, "legend": "right"})), + ("Legend: Top", "chart.set", json!({"chart": id, "legend": "top"})), + ("Legend: None", "chart.set", json!({"chart": id, "legend": "none"})), + ("-", "", json!(null)), + ("Data Labels: Show", "chart.set", json!({"chart": id, "dataLabels": true})), + ("Data Labels: None", "chart.set", json!({"chart": id, "dataLabels": false})), + ("Gridlines: Show", "chart.set", json!({"chart": id, "gridlines": true})), + ("Gridlines: None", "chart.set", json!({"chart": id, "gridlines": false})), + ], + ); + }); + let __k = big_button(ui, Icon::Redo, "Switch\nRow/Column", "Switch Row/Column", false); + control_keytip(app, ui, &__k, "chart.switchRowColumn"); + if __k.clicked() { + act(app, "chart.switchRowColumn", json!({"chart": id})); + } + if big_button(ui, Icon::Table, "Select\nData", "Select Data", false).clicked() { + app.open_dialog("selectData", json!({"chart": id})); + } + let ct = big_button(ui, Icon::ChartBar, "Change\nChart Type", "Change Chart Type", true); + for sequence in keytip_table::group_extension( + app.keytips.prefix().unwrap_or_default(), + &["column", "bar", "line", "area", "pie", "doughnut", "scatter", "radar", "waterfall", "funnel", "treemap"], + ) { + keytip(app, ui, sequence, ct.rect.center_bottom()); + } + egui::Popup::menu(&ct).show(|ui| { + for (label, k, s) in [ + ("Clustered Column", "column", ""), + ("Stacked Column", "column", "stacked"), + ("Bar", "bar", ""), + ("Line", "line", "markers"), + ("Area", "area", ""), + ("Pie", "pie", ""), + ("Doughnut", "doughnut", ""), + ("Scatter", "scatter", ""), + ("Radar", "radar", ""), + ("Waterfall", "waterfall", ""), + ("Funnel", "funnel", ""), + ("Treemap", "treemap", ""), + ] { + if ui.button(label).clicked() { + act(app, "chart.set", json!({"chart": id, "type": k, "subtype": s})); + } + } + }); + if big_button(ui, Icon::Settings, "Format\nPane", "Format Chart pane", false).clicked() { + app.grid.pane = Some("formatChart".into()); + } + let __k = big_button(ui, Icon::TextBox, "Chart\nTitle", "Edit chart title", false); + control_keytip(app, ui, &__k, "chartTitle"); + if __k.clicked() { + app.open_dialog("chartTitle", json!({"chart": id})); + } + let __k = big_button(ui, Icon::Delete, "Delete\nChart", "Delete chart", false); + control_keytip(app, ui, &__k, "chart.delete"); + if __k.clicked() { + act(app, "chart.delete", json!({"chart": id})); + app.selected_chart = None; + } +} diff --git a/crates/ui-egui/src/ribbon/controls.rs b/crates/ui-egui/src/ribbon/controls.rs new file mode 100644 index 0000000000..e77e583a68 --- /dev/null +++ b/crates/ui-egui/src/ribbon/controls.rs @@ -0,0 +1,270 @@ +use super::*; + +pub(super) fn sep(ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(13.0, 72.0), Sense::hover()); + ui.painter().line_segment([pos2(r.center().x, r.top() + 6.0), pos2(r.center().x, r.bottom() - 6.0)], Stroke::new(1.0, t.separator)); +} + +pub(super) fn active_style(app: &SheetApp) -> Style { + app.session + .active() + .and_then(|d| { + let sh = d.wb.active()?; + Some(d.wb.styles.get(sh.style_id(d.selection.active)).clone()) + }) + .unwrap_or_default() +} + +pub(super) fn theme_colors(app: &SheetApp) -> [u32; 12] { + app.session.active().map(|d| d.wb.theme.colors).unwrap_or(gridcraft_engine::model::Theme::default().colors) +} + +/// Runs a command from a ribbon click (menu-style: no params may open a dialog). +pub(super) fn act(app: &mut SheetApp, id: &str, params: serde_json::Value) { + app.run_or_alert(id, params); +} + +/// Keytips are painted, not focusable widgets: the grid retains keyboard focus. +pub(super) fn keytip(app: &SheetApp, ui: &Ui, sequence: &str, at: egui::Pos2) { + let Some(rest) = app.keytips.prefix().and_then(|prefix| sequence.strip_prefix(prefix)).filter(|s| !s.is_empty()) else { return }; + let t = Tokens::get(ui.ctx()); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("ribbon_keytips"))).with_clip_rect(ui.clip_rect()); + let text = painter.layout_no_wrap(rest.into(), theme::ui_bold(10.5), t.text); + let rect = Rect::from_center_size(at, text.size() + vec2(7.0, 3.0)); + painter.rect_filled(rect, 2.0, t.input_bg); + painter.rect_stroke(rect, 2.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside); + painter.galley(rect.min + vec2(3.5, 1.5), text, t.text); +} + +/// Paint the badge for a control that runs command `id`, when the armed prefix matches. +pub(super) fn control_keytip(app: &SheetApp, ui: &Ui, response: &egui::Response, id: &str) { + if let Some(sequence) = keytip_table::control_badge(app.keytips.prefix().unwrap_or_default(), id) { + keytip(app, ui, sequence, response.rect.center_bottom()); + } +} + +/// Paint the single badge for a menu anchor whose popup items run `targets`. +pub(super) fn anchor_keytip(app: &SheetApp, ui: &Ui, response: &egui::Response, targets: &[&str]) { + if let Some(sequence) = keytip_table::anchor_badge(app.keytips.prefix().unwrap_or_default(), targets) { + keytip(app, ui, sequence, response.rect.center_bottom()); + } +} + +/// The `on` value for a ribbon checkbox command. A click has already flipped the checkbox bool, so +/// it is sent as is; a key tip leaves the bool untouched, so the command gets the opposite. +pub(super) fn checkbox_on(clicked: bool, shown: bool) -> bool { + shown == clicked +} + +/// Consume a one-shot keytip request for control `id` (set by `Action::Control`). +pub(super) fn invoke(app: &mut SheetApp, id: &str) -> bool { + if app.ui.keytip_invoke == Some(id) { + app.ui.keytip_invoke = None; + true + } else { + false + } +} + +/// Force-open command for a popup: either a keytip invoke (open it) or a click (toggle). +pub(super) fn popup_command(forced: bool, clicked: bool) -> Option { + if forced { Some(egui::SetOpenCommand::Bool(true)) } else { clicked.then_some(egui::SetOpenCommand::Toggle) } +} + +/// Per-control state of an [`editable_combo`]: the filter text and highlighted row. +#[derive(Clone, Default)] +struct ComboState { + text: String, + sel: i32, + was_open: bool, +} + +/// What the user did with an [`editable_combo`]. +pub(crate) enum ComboResult { + None, + /// A row was applied: clicked or Enter on the highlighted row. + Picked(String), +} + +/// Excel-style dropdown: a filter field (type to narrow the list) plus arrow/Enter selection. +/// `items` are `(value, display)`; the value is what comes back. When `forced` (keytip invoke) +/// or clicked, the popup opens, focuses the filter field, and selects its text. +pub(crate) fn editable_combo( + ui: &mut Ui, + anchor: &egui::Response, + salt: &'static str, + current_value: &str, + items: &[(&str, String)], + forced: bool, +) -> ComboResult { + combo_popup(ui, anchor, salt, current_value, items, forced, true, None) +} + +/// The dropdown of [`editable_combo`] without the filter field: the whole list, picked by click or +/// arrows and Enter. It drops directly below `under` and is as wide as it, like Excel's Font Size +/// list under its box. +pub(super) fn list_combo( + ui: &mut Ui, + anchor: &egui::Response, + under: Rect, + salt: &'static str, + current_value: &str, + items: &[(&str, String)], +) -> ComboResult { + combo_popup(ui, anchor, salt, current_value, items, false, false, Some(under)) +} + +fn combo_popup( + ui: &mut Ui, + anchor: &egui::Response, + salt: &'static str, + current_value: &str, + items: &[(&str, String)], + forced: bool, + filter: bool, + under: Option, +) -> ComboResult { + let popup_id = anchor.id.with("popup"); + let state_id = anchor.id.with(salt).with("__combo_state"); + let mut st = ui.data_mut(|d| d.get_temp::(state_id)).unwrap_or_default(); + let open_now = egui::Popup::is_id_open(ui.ctx(), popup_id); + if open_now && !st.was_open { + st.text = String::new(); + st.sel = items.iter().position(|(v, _)| *v == current_value).map(|i| i as i32).unwrap_or(0); + // The reset lands on the frame AFTER the popup opens, which usually has no input of + // its own — without a repaint request the stale filtered list stays on screen. + ui.ctx().request_repaint(); + } + st.was_open = open_now; + let mut result = ComboResult::None; + let mut popup = egui::Popup::menu(anchor).open_memory(popup_command(forced, anchor.clicked())); + if let Some(r) = under { + popup = popup.anchor(r).align(egui::RectAlign::BOTTOM_START).width(r.width()); + } + let row_w = under.map_or(220.0, |r| r.width() - 12.0); + popup.show(|ui| { + // Arrows and Enter navigate the list, not the filter field's cursor. Capture them and + // strip them from the input so the TextEdit never sees them. + let (mut down, mut up, mut enter) = (false, false, false); + ui.input(|i| { + down = i.key_pressed(egui::Key::ArrowDown); + up = i.key_pressed(egui::Key::ArrowUp); + enter = i.key_pressed(egui::Key::Enter); + }); + if down || up || enter { + ui.ctx().input_mut(|i| { + i.events.retain(|e| { + !matches!(e, egui::Event::Key { key: egui::Key::ArrowDown | egui::Key::ArrowUp | egui::Key::Enter, pressed: true, .. }) + }); + }); + } + if filter { + let text_id = anchor.id.with(salt).with("__combo_text"); + let te = ui.add(egui::TextEdit::singleline(&mut st.text).id(text_id).desired_width(220.0).hint_text("Type to filter…")); + if open_now && !te.has_focus() { + ui.ctx().memory_mut(|m| m.request_focus(text_id)); + // Select all once focus lands so typing replaces the filter. + if let Some(mut s) = egui::text_edit::TextEditState::load(ui.ctx(), text_id) { + let len = st.text.chars().count(); + s.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(len)))); + s.store(ui.ctx(), text_id); + } + } + } + let lower = st.text.to_lowercase(); + let filtered: Vec = items.iter().enumerate().filter(|(_, (_, d))| d.to_lowercase().contains(&lower)).map(|(i, _)| i).collect(); + if !filtered.is_empty() { + let n = filtered.len() as i32; + st.sel = st.sel.clamp(0, n - 1); + if down { + st.sel = (st.sel + 1).rem_euclid(n); + } + if up { + st.sel = (st.sel - 1).rem_euclid(n); + } + } + if enter && !filtered.is_empty() { + let idx = filtered[st.sel.clamp(0, filtered.len() as i32 - 1) as usize]; + result = ComboResult::Picked(items[idx].0.to_string()); + } + if filter { + ui.separator(); + } + let button_h = ui.spacing().interact_size.y.max(20.0); + let spacing = ui.spacing().item_spacing.y; + let n = filtered.len() as f32; + + let content_h = n * button_h + (n - 1.0).max(0.0) * spacing; + + let max_rows = 9.0_f32; + let max_h = max_rows * button_h + (max_rows - 1.0) * spacing; + + let desired_h = content_h.min(max_h); + + ui.set_min_height(desired_h); + + egui::ScrollArea::vertical().max_height(desired_h).show(ui, |ui| { + for (pos, i) in filtered.iter().enumerate() { + let (value, disp) = &items[*i]; + + let row = ui.add(egui::Button::selectable(pos as i32 == st.sel, egui::RichText::new(disp)).min_size(vec2(row_w, 20.0))); + + if (down || up) && pos as i32 == st.sel && !ui.clip_rect().contains_rect(row.rect) { + row.scroll_to_me(Some(if down { egui::Align::Max } else { egui::Align::Min })); + } + + if row.clicked() { + result = ComboResult::Picked(value.to_string()); + } + + if row.hovered() { + st.sel = pos as i32; + } + } + }); + if !matches!(result, ComboResult::None) { + ui.close(); + } + }); + ui.data_mut(|d| d.insert_temp(state_id, st)); + result +} + +pub(super) fn menu_items(app: &mut SheetApp, ui: &mut Ui, items: &[(&str, &str, serde_json::Value)]) { + for (label, id, p) in items { + if *label == "-" { + ui.separator(); + continue; + } + let lang = app.ui.language; + let response = ui.add(egui::Button::new(lang.tr(label)).frame_when_inactive(false).min_size(vec2(220.0, 22.0))); + if let Some(sequence) = keytip_table::item_letter(app.keytips.prefix().unwrap_or_default(), id) { + keytip(app, ui, sequence, pos2(response.rect.right() - 10.0, response.rect.center().y)); + } + if response.clicked() { + if let Some(d) = id.strip_prefix("dialog:") { + app.open_dialog(d, p.clone()); + } else if *id == "edit.paste" { + app.paste_from_clipboard(); + } else { + act(app, id, p.clone()); + } + } + } +} + +#[cfg(test)] +mod checkbox_on_tests { + use super::*; + + #[test] + fn click_sends_the_new_checkbox_value_and_key_tip_the_opposite() { + // Click: the checkbox bool already holds the new choice. + assert!(!checkbox_on(true, false)); + assert!(checkbox_on(true, true)); + // Key tip: the bool still holds the current setting, so toggle it. + assert!(checkbox_on(false, false)); + assert!(!checkbox_on(false, true)); + } +} diff --git a/crates/ui-egui/src/ribbon/data.rs b/crates/ui-egui/src/ribbon/data.rs new file mode 100644 index 0000000000..93043a97bc --- /dev/null +++ b/crates/ui-egui/src/ribbon/data.rs @@ -0,0 +1,156 @@ +use super::controls::{act, control_keytip, keytip, menu_items, sep}; +use super::*; + +pub(super) fn data(app: &mut SheetApp, ui: &mut Ui) { + let gd = big_button(ui, Icon::Folder, "Get Data\n(Text/CSV)", "Import a text or CSV file", false); + control_keytip(app, ui, &gd, "data.getData"); + if gd.clicked() { + app.open_dialog("open", json!({})); + } + let web = big_button(ui, Icon::Table, "From\nWEB", "Import a table from a web page (From Web)", false); + control_keytip(app, ui, &web, "data.fromWeb"); + if web.clicked() { + app.open_dialog("fromWeb", json!({})); + } + ui.vertical(|ui| { + let qc = small_button(ui, Icon::Redo, "", "Queries & Connections (saved web tables, one-click Sync)", false); + control_keytip(app, ui, &qc, "data.queriesConnections"); + if qc.clicked() { + app.open_dialog("queriesConnections", json!({})); + } + let ra = small_button(ui, Icon::Redo, "", "Refresh All (re-sync every saved web table)", false); + control_keytip(app, ui, &ra, "data.webRefresh"); + if ra.clicked() { + act(app, "data.webRefresh", json!({})); + } + }); + sep(ui); + ui.vertical(|ui| { + let __k = small_button(ui, Icon::SortAsc, "", "Sort A to Z", false); + control_keytip(app, ui, &__k, "data.sortAscending"); + if __k.clicked() { + act(app, "data.sortAscending", json!({})); + } + let __k = small_button(ui, Icon::SortDesc, "", "Sort Z to A", false); + control_keytip(app, ui, &__k, "data.sortDescending"); + if __k.clicked() { + act(app, "data.sortDescending", json!({})); + } + }); + let __k = big_button(ui, Icon::SortFilter, "Sort", "Custom Sort", false); + control_keytip(app, ui, &__k, "dialog:sort"); + if __k.clicked() { + app.open_dialog("sort", json!({})); + } + let __k = big_button(ui, Icon::Filter, "Filter", "Filter", false); + control_keytip(app, ui, &__k, "data.filter"); + if __k.clicked() { + act(app, "data.filter", json!({})); + } + ui.vertical(|ui| { + let __k = small_button(ui, Icon::Eraser, "Clear", "Clear Filter", false); + control_keytip(app, ui, &__k, "data.clearFilter"); + if __k.clicked() { + act(app, "data.clearFilter", json!({})); + } + let __k = small_button(ui, Icon::Redo, "Reapply", "Reapply", false); + control_keytip(app, ui, &__k, "data.reapply"); + if __k.clicked() { + act(app, "data.reapply", json!({})); + } + let __k = small_button(ui, Icon::Filter, "Advanced", "Advanced Filter", false); + control_keytip(app, ui, &__k, "dialog:advancedFilter"); + if __k.clicked() { + app.open_dialog("advancedFilter", json!({})); + } + }); + sep(ui); + let __k = big_button(ui, Icon::TextColumns, "Text to\nColumns", "Text to Columns", false); + control_keytip(app, ui, &__k, "dialog:textToColumns"); + if __k.clicked() { + app.open_dialog("textToColumns", json!({})); + } + let __k = big_button(ui, Icon::FillDown, "Flash\nFill", "Flash Fill", false); + control_keytip(app, ui, &__k, "edit.flashFill"); + if __k.clicked() { + act(app, "edit.flashFill", json!({})); + } + let __k = big_button(ui, Icon::Duplicates, "Remove\nDuplicates", "Remove Duplicates", false); + control_keytip(app, ui, &__k, "dialog:removeDuplicates"); + if __k.clicked() { + app.open_dialog("removeDuplicates", json!({})); + } + let dv = big_button(ui, Icon::Validation, "Data\nValidation", "Data Validation", true); + for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["data.validation", "data.circleInvalid"]) { + keytip(app, ui, sequence, dv.rect.center_bottom()); + } + egui::Popup::menu(&dv).show(|ui| { + menu_items( + app, + ui, + &[ + ("Data Validation…", "dialog:dataValidation", json!({})), + ("Circle Invalid Data", "data.circleInvalid", json!({})), + ("Clear Validation", "data.validation", json!({"clear": true})), + ], + ); + }); + sep(ui); + let wi = big_button(ui, Icon::Calc, "What-If\nAnalysis", "What-If Analysis", true); + for sequence in + keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["dialog:goalSeek", "data.scenarioManager", "data.dataTable"]) + { + keytip(app, ui, sequence, wi.rect.center_bottom()); + } + egui::Popup::menu(&wi).show(|ui| { + menu_items( + app, + ui, + &[ + ("Goal Seek…", "dialog:goalSeek", json!({})), + ("Scenario Manager…", "data.scenarioManager", json!({})), + ("Data Table…", "data.dataTable", json!({})), + ], + ); + }); + if big_button(ui, Icon::ChartLine, "Forecast\nSheet", "Forecast Sheet", false).clicked() { + app.open_dialog("forecastSheet", json!({})); + } + sep(ui); + ui.vertical(|ui| { + let __k = small_button(ui, Icon::Group, "Group", "Group", false); + control_keytip(app, ui, &__k, "data.group"); + if __k.clicked() { + act(app, "data.group", json!({})); + } + let __k = small_button(ui, Icon::Ungroup, "Ungroup", "Ungroup", false); + control_keytip(app, ui, &__k, "data.ungroup"); + if __k.clicked() { + act(app, "data.ungroup", json!({})); + } + let __k = small_button(ui, Icon::Subtotal, "Subtotal", "Subtotal", false); + control_keytip(app, ui, &__k, "dialog:subtotal"); + if __k.clicked() { + app.open_dialog("subtotal", json!({})); + } + }); + ui.vertical(|ui| { + if small_button(ui, Icon::Plus, "Show Detail", "Show Detail", false).clicked() { + act(app, "data.showDetail", json!({})); + } + 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() { + app.open_dialog("analysisToolPak", json!({})); + } + sep(ui); + if big_button(ui, Icon::Function, "Solver", "Solver", false).clicked() { + app.open_dialog("solver", json!({})); + } +} diff --git a/crates/ui-egui/src/ribbon/formulas.rs b/crates/ui-egui/src/ribbon/formulas.rs new file mode 100644 index 0000000000..caac2da015 --- /dev/null +++ b/crates/ui-egui/src/ribbon/formulas.rs @@ -0,0 +1,202 @@ +use super::controls::{act, anchor_keytip, control_keytip, invoke, keytip, menu_items, popup_command, sep}; +use super::home::autosum_menu; +use super::*; + +/// 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() +} + +/// The Formulas tab: Insert Function, AutoSum, the function-category menus (names in the formula +/// language, descriptions in the interface language), defined names, auditing and calculation. +pub(super) fn formulas(app: &mut SheetApp, ui: &mut Ui) { + let __k = big_button(ui, Icon::Function, "Insert\nFunction", "Insert Function (⇧F3)", false); + control_keytip(app, ui, &__k, "dialog:insertFunction"); + if __k.clicked() { + app.open_dialog("insertFunction", json!({})); + } + 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"), + ("Text", "Text", "formulas.text"), + ("Date &\nTime", "DateTime", "formulas.dateTime"), + ("Lookup &\nReference", "Lookup", "formulas.lookup"), + ("Math &\nTrig", "MathTrig", "formulas.mathTrig"), + ("More\nFunctions", "Statistical", "formulas.moreFunctions"), + ] { + let r = big_button(ui, Icon::Book, label, label, true); + control_keytip(app, ui, &r, cid); + let forced_c = invoke(app, cid); + egui::Popup::menu(&r).open_memory(popup_command(forced_c, r.clicked())).show(|ui| { + ui.set_max_height(420.0); + egui::ScrollArea::vertical().show(ui, |ui| { + for f in gridcraft_engine::cmd::formulas::function_list() { + if f["category"].as_str() == Some(cat) + && let Some(n) = f["name"].as_str() + { + let locale = app.ui.language.formula_locale(); + let name = + app.session.active().map(|d| crate::formula_locale::completion_name(n, locale, &d.wb, d.wb.active_sheet())).unwrap_or(n); + let desc = crate::formula_locale::description(n, locale, app.ui.language).unwrap_or_default(); + if ui.button(name).on_hover_text(desc).clicked() { + app.run_or_alert("formulas.insertFunction", json!({"name": n})); + ui.close(); + } + } + } + }); + }); + } + sep(ui); + let __k = big_button(ui, Icon::Name, "Name\nManager", "Name Manager", false); + control_keytip(app, ui, &__k, "dialog:nameManager"); + if __k.clicked() { + app.open_dialog("nameManager", json!({})); + } + ui.vertical(|ui| { + let __k = small_button(ui, Icon::Name, "Define Name", "Define Name", false); + control_keytip(app, ui, &__k, "dialog:defineName"); + if __k.clicked() { + 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, &["dialog:pasteName"]); + egui::Popup::menu(&use_in).show(|ui| { + // 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::unique("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>)>(cache)).filter(|(k, _)| *k == key); + let names = match cached { + Some((_, names)) => names, + None => { + let names: std::sync::Arc> = 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); + control_keytip(app, ui, &__k, "formulas.createFromSelection"); + if __k.clicked() { + act(app, "formulas.createFromSelection", json!({})); + } + }); + sep(ui); + ui.vertical(|ui| { + if small_button(ui, Icon::Trace, "Trace Precedents", "Trace Precedents", false).clicked() + && let Ok(r) = app.run("formulas.tracePrecedents", json!({})) + { + app.grid.trace = crate::grid::Trace::new(app, r); + } + if small_button(ui, Icon::Trace, "Trace Dependents", "Trace Dependents", false).clicked() + && let Ok(r) = app.run("formulas.traceDependents", json!({})) + { + app.grid.trace = crate::grid::Trace::new(app, r); + } + if small_button(ui, Icon::Close, "Remove Arrows", "Remove Arrows", false).clicked() { + app.grid.trace = None; + } + }); + ui.vertical(|ui| { + let __k = small_button(ui, Icon::Fx, "Show Formulas", "Show Formulas", false); + control_keytip(app, ui, &__k, "formulas.showFormulas"); + if __k.clicked() { + act(app, "formulas.showFormulas", json!({})); + } + 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() { + app.open_dialog("evaluateFormula", json!({})); + } + }); + if big_button(ui, Icon::Search, "Watch\nWindow", "Watch Window", false).clicked() { + app.grid.pane = Some("watch".into()); + } + sep(ui); + let co = big_button(ui, Icon::Calc, "Calculation\nOptions", "Calculation Options", true); + for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["formulas.calculationOptions"]) { + keytip(app, ui, sequence, co.rect.center_bottom()); + } + egui::Popup::menu(&co).show(|ui| { + menu_items( + app, + ui, + &[ + ("Automatic", "formulas.calculationOptions", json!({"mode": "automatic"})), + ("Automatic Except for Data Tables", "formulas.calculationOptions", json!({"mode": "automaticExceptTables"})), + ("Manual", "formulas.calculationOptions", json!({"mode": "manual"})), + ], + ); + }); + ui.vertical(|ui| { + let __k = small_button(ui, Icon::Calc, "Calculate Now", "Calculate Now (F9)", false); + control_keytip(app, ui, &__k, "formulas.calculateNow"); + if __k.clicked() { + act(app, "formulas.calculateNow", json!({})); + } + let __k = small_button(ui, Icon::Sheet, "Calculate Sheet", "Calculate Sheet (⇧F9)", false); + control_keytip(app, ui, &__k, "formulas.calculateSheet"); + if __k.clicked() { + act(app, "formulas.calculateSheet", json!({})); + } + }); +} diff --git a/crates/ui-egui/src/ribbon/galleries.rs b/crates/ui-egui/src/ribbon/galleries.rs new file mode 100644 index 0000000000..085ba0dc5b --- /dev/null +++ b/crates/ui-egui/src/ribbon/galleries.rs @@ -0,0 +1,145 @@ +use super::controls::act; +use super::*; + +/// A gallery of table styles drawn as mini tables. +pub(super) fn table_gallery(app: &mut SheetApp, ui: &mut Ui, cmd: &str) { + ui.set_width(430.0); + let Some(wb) = app.session.active().map(|d| d.wb.clone()) else { return }; + for (fam, n) in [("Light", 21u32), ("Medium", 28), ("Dark", 11)] { + ui.label(egui::RichText::new(fam).strong()); + egui::Grid::new(("tg", fam)).spacing(vec2(4.0, 4.0)).show(ui, |ui| { + for i in 1..=n { + let name = format!("TableStyle{fam}{i}"); + 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, Tokens::get(ui.ctx()).cat(Cat::Orange)), StrokeKind::Outside); + } + if resp.on_hover_text(&name).clicked() { + act(app, cmd, json!({"style": name})); + ui.close(); + } + if i % 7 == 0 { + ui.end_row(); + } + } + }); + } +} + +pub(super) fn paint_table_swatch(ui: &Ui, r: Rect, wb: &gridcraft_engine::model::Workbook, style: &str) { + let lk = gridcraft_engine::tables::look(style); + let res = |c: Option| c.and_then(|c| c.resolve(&wb.theme)).map(theme::color32); + let p = ui.painter(); + 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 { + let rr = Rect::from_min_size(pos2(r.left(), r.top() + i as f32 * rh), vec2(r.width(), rh)); + let fill = if i == 0 { + res(lk.header_fill) + } else if i % 2 == 1 { + res(lk.band_fill) + } else { + res(lk.body_fill) + }; + if let Some(f) = fill { + p.rect_filled(rr, 0.0, f); + } + if let Some(l) = res(lk.line) { + p.line_segment([rr.left_bottom(), rr.right_bottom()], Stroke::new(1.0, l)); + } + 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(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, t.separator), StrokeKind::Inside); +} + +pub(super) 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])] = &[ + ("Good, Bad and Neutral", &["Normal", "Bad", "Good", "Neutral"]), + ("Data and Model", &["Calculation", "Check Cell", "Explanatory Text", "Input", "Linked Cell", "Note", "Output", "Warning Text"]), + ("Titles and Headings", &["Heading 1", "Heading 2", "Heading 3", "Heading 4", "Title", "Total"]), + ( + "Themed Cell Styles", + &[ + "20% - Accent1", + "20% - Accent2", + "20% - Accent3", + "20% - Accent4", + "20% - Accent5", + "20% - Accent6", + "40% - Accent1", + "40% - Accent2", + "40% - Accent3", + "40% - Accent4", + "40% - Accent5", + "40% - Accent6", + "60% - Accent1", + "60% - Accent2", + "60% - Accent3", + "60% - Accent4", + "60% - Accent5", + "60% - Accent6", + "Accent1", + "Accent2", + "Accent3", + "Accent4", + "Accent5", + "Accent6", + ], + ), + ("Number Format", &["Comma", "Comma [0]", "Currency", "Currency [0]", "Percent"]), + ]; + for (g, names) in groups { + ui.label(egui::RichText::new(*g).strong()); + egui::Grid::new(("csg", *g)).spacing(vec2(4.0, 4.0)).show(ui, |ui| { + for (i, n) in names.iter().enumerate() { + let st = gridcraft_engine::cmd::format::builtin_cell_style(n, &wb.theme).unwrap_or_default(); + let (r, resp) = ui.allocate_exact_size(vec2(82.0, 24.0), Sense::click()); + let fill = st + .fill + .fg + .resolve(&wb.theme) + .filter(|_| st.fill.pattern != gridcraft_engine::model::PatternType::None) + .map(theme::color32) + .unwrap_or(t.grid_bg); + ui.painter().rect_filled(r, 0.0, fill); + 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(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(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), + Align2::LEFT_CENTER, + *n, + egui::FontId::new((st.font.size).clamp(10.0, 15.0), fam), + col, + ); + if resp.hovered() { + 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})); + ui.close(); + } + if (i + 1) % 6 == 0 { + ui.end_row(); + } + } + }); + } +} diff --git a/crates/ui-egui/src/ribbon/home.rs b/crates/ui-egui/src/ribbon/home.rs new file mode 100644 index 0000000000..b6886b02cd --- /dev/null +++ b/crates/ui-egui/src/ribbon/home.rs @@ -0,0 +1,813 @@ +use super::controls::{ + ComboResult, act, active_style, anchor_keytip, control_keytip, editable_combo, invoke, keytip, list_combo, menu_items, popup_command, sep, + theme_colors, +}; +use super::galleries::{cell_style_gallery, table_gallery}; +use super::*; + +/// The Accounting Number Format dropdown: label and the code Excel applies for that symbol. +const ACCOUNTING_SYMBOLS: &[(&str, &str)] = &[ + ("$ English (United States)", "_(\"$\"* #,##0.00_);_(\"$\"* \\(#,##0.00\\);_(\"$\"* \"-\"??_);_(@_)"), + ("£ English (United Kingdom)", "_-[$£-809]* #,##0.00_-;-[$£-809]* #,##0.00_-;_-[$£-809]* \"-\"??_-;_-@_-"), + ("€ Euro", "_-[$€-x-euro2] * #,##0.00_-;-[$€-x-euro2] * #,##0.00_-;_-[$€-x-euro2] * \"-\"??_-;_-@_-"), + ("¥ Japanese", "_-[$¥-411]* #,##0_-;-[$¥-411]* #,##0_-;_-[$¥-411]* \"-\"_-;_-@_-"), + ("¥ Chinese (PRC)", "_-[$¥-804]* #,##0.00_-;-[$¥-804]* #,##0.00_-;_-[$¥-804]* \"-\"??_-;_-@_-"), + ("₹ English (India)", "_-[$₹-4009]* #,##0.00_-;-[$₹-4009]* #,##0.00_-;_-[$₹-4009]* \"-\"??_-;_-@_-"), + ("CHF German (Switzerland)", "_-[$CHF-807]* #,##0.00_-;-[$CHF-807]* #,##0.00_-;_-[$CHF-807]* \"-\"??_-;_-@_-"), + ("Rp Indonesian", "_-[$Rp-421]* #,##0.00_-;-[$Rp-421]* #,##0.00_-;_-[$Rp-421]* \"-\"??_-;_-@_-"), + ("₩ Korean", "_-[$₩-412]* #,##0_-;-[$₩-412]* #,##0_-;_-[$₩-412]* \"-\"_-;_-@_-"), + ("R$ Portuguese (Brazil)", "_-[$R$-416]* #,##0.00_-;-[$R$-416]* #,##0.00_-;_-[$R$-416]* \"-\"??_-;_-@_-"), + ("₽ Russian", "_-[$₽-419]* #,##0.00_-;-[$₽-419]* #,##0.00_-;_-[$₽-419]* \"-\"??_-;_-@_-"), +]; + +/// Draws the Home tab: clipboard/paste menu, font, alignment, number, style, cell and editing commands with keytips. +pub(super) fn home(app: &mut SheetApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let st = active_style(app); + // Clipboard + let paste = big_button(ui, Icon::Paste, "Paste", "Paste", true); + paste.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Paste")); + for sequence in + keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["edit.paste", "edit.pasteSpecial", "dialog:pasteSpecial"]) + { + keytip(app, ui, sequence, paste.rect.center_bottom()); + } + if paste.clicked() { + app.paste_from_clipboard(); + } + let paste_keys = matches!(app.keytips.prefix(), Some("HV" | "E")); + // Keep a keyboard-opened menu anchored to the button when a pointer click + // cancels keytips, so its items remain in place and can still be clicked. + let anchor_id = paste.id.with("keytip_anchor"); + // Query response state before taking the egui context write lock. + // Calling secondary_clicked() inside data_mut can deadlock. + let paste_secondary_clicked = paste.secondary_clicked(); + let anchored = ui.ctx().data_mut(|data| { + let anchored = if paste_keys { + true + } else if paste_secondary_clicked { + false + } else { + data.get_temp::(anchor_id).unwrap_or(false) + }; + data.insert_temp(anchor_id, anchored); + anchored + }); + let mut paste_popup = egui::Popup::context_menu(&paste); + if anchored { + paste_popup = paste_popup.anchor(&paste); + } + if paste_keys { + paste_popup = paste_popup.open_memory(egui::SetOpenCommand::Bool(true)); + } + paste_popup.show(|ui| { + menu_items( + app, + ui, + &[ + ("Paste", "edit.paste", json!({})), + ("Paste Values", "edit.pasteSpecial", json!({"what": "values"})), + ("Paste Formulas", "edit.pasteSpecial", json!({"what": "formulas"})), + ("Paste Formatting", "edit.pasteSpecial", json!({"what": "formats"})), + ("Transpose", "edit.pasteSpecial", json!({"what": "all", "transpose": true})), + ("Paste Link", "edit.pasteSpecial", json!({"what": "all", "link": true})), + ("-", "", json!(null)), + ("Paste Special…", "dialog:pasteSpecial", json!({})), + ], + ); + }); + ui.vertical(|ui| { + let __k = icon_button(ui, Icon::Cut, t.text, "Cut", vec2(26.0, 23.0)); + control_keytip(app, ui, &__k, "edit.cut"); + if __k.clicked() { + app.copy_to_clipboard(ui.ctx(), "edit.cut"); + } + let __k = icon_button(ui, Icon::Copy, t.text, "Copy", vec2(26.0, 23.0)); + control_keytip(app, ui, &__k, "edit.copy"); + if __k.clicked() { + app.copy_to_clipboard(ui.ctx(), "edit.copy"); + } + let fp_on = app.session.format_painter.is_some(); + let fp = toggle_button(ui, Icon::Brush, fp_on, "Format Painter (double-click to keep it on)"); + fp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), fp_on, "Format Painter")); + control_keytip(app, ui, &fp, "edit.formatPainter"); + if fp.double_clicked() { + act(app, "edit.formatPainter", json!({"sticky": true})); + } else if fp.clicked() { + if fp_on { + app.session.format_painter = None; + } else { + act(app, "edit.formatPainter", json!({})); + } + } + }); + sep(ui); + // Font + ui.vertical(|ui| { + ui.horizontal(|ui| { + font_combo(app, ui, &st); + size_combo(app, ui, &st); + let __k = icon_button(ui, Icon::Plus, t.text, "Increase Font Size", vec2(22.0, 23.0)); + control_keytip(app, ui, &__k, "home.increaseFontSize"); + if __k.clicked() { + act(app, "home.increaseFontSize", json!({})); + } + let __k = icon_button(ui, Icon::Minus, t.text, "Decrease Font Size", vec2(22.0, 23.0)); + control_keytip(app, ui, &__k, "home.decreaseFontSize"); + if __k.clicked() { + act(app, "home.decreaseFontSize", json!({})); + } + }); + ui.horizontal(|ui| { + let __k = toggle_button(ui, Icon::Bold, st.font.bold, "Bold"); + control_keytip(app, ui, &__k, "home.bold"); + if __k.clicked() { + act(app, "home.bold", json!({})); + } + let __k = toggle_button(ui, Icon::Italic, st.font.italic, "Italic"); + control_keytip(app, ui, &__k, "home.italic"); + if __k.clicked() { + act(app, "home.italic", json!({})); + } + let (u, ua) = split_button(ui, Icon::Underline, None, "Underline"); + control_keytip(app, ui, &ua, "home.underline"); + if u { + act(app, "home.underline", json!({})); + } + egui::Popup::menu(&ua).show(|ui| { + menu_items( + app, + ui, + &[ + ("Underline", "home.underline", json!({"style": "single"})), + ("Double Underline", "home.underline", json!({"style": "double"})), + ], + ); + }); + let __k = toggle_button(ui, Icon::Strike, st.font.strike, "Strikethrough"); + control_keytip(app, ui, &__k, "home.strikethrough"); + if __k.clicked() { + act(app, "home.strikethrough", json!({})); + } + let (b, ba) = split_button(ui, Icon::Borders, None, "Borders"); + control_keytip(app, ui, &ba, "home.borders"); + if b { + act(app, "home.borders", json!({"preset": app.grid.last_border.clone()})); + } + ba.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Border presets")); + egui::Popup::menu(&ba).show(|ui| { + for (label, preset) in [ + ("Bottom Border", "bottom"), + ("Top Border", "top"), + ("Left Border", "left"), + ("Right Border", "right"), + ("No Border", "none"), + ("All Borders", "all"), + ("Outside Borders", "outside"), + ("Thick Outside Borders", "thickOutside"), + ("Bottom Double Border", "doubleBottom"), + ("Thick Bottom Border", "thickBottom"), + ("Top and Bottom Border", "topBottom"), + ("Top and Thick Bottom Border", "topThickBottom"), + ("Top and Double Bottom Border", "topDoubleBottom"), + ("Inside Borders", "inside"), + ] { + if crate::border_preview::preset_button(ui, label, preset).clicked() { + app.grid.last_border = preset.to_string(); + act(app, "home.borders", json!({"preset": preset})); + } + } + ui.separator(); + if ui.button("More Borders…").clicked() { + app.open_dialog("formatCells", json!({"tab": "Border"})); + } + }); + let fill = app.grid.last_fill.clone(); + let fill_c = gridcraft_engine::model::Color::from_hex(&fill).and_then(|c| c.resolve(&Default::default())).map(theme::color32); + let (f, fa) = split_button(ui, Icon::Fill, fill_c, "Fill Color"); + control_keytip(app, ui, &fa, "home.fillColor"); + if f { + act(app, "home.fillColor", json!({"color": fill})); + } + let forced = invoke(app, "home.fillColor"); + egui::Popup::menu(&fa) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .open_memory(popup_command(forced, fa.clicked())) + .show(|ui| { + if let Some(c) = color_palette(ui, &theme_colors(app), "No Fill") { + if c != "none" { + app.grid.last_fill = c.clone(); + } + act(app, "home.fillColor", json!({"color": c})); + ui.close(); + } + }); + let fc = app.grid.last_font_color.clone(); + let fc_c = gridcraft_engine::model::Color::from_hex(&fc).and_then(|c| c.resolve(&Default::default())).map(theme::color32); + let (c, ca) = split_button(ui, Icon::FontColor, fc_c, "Font Color"); + control_keytip(app, ui, &ca, "home.fontColor"); + if c { + act(app, "home.fontColor", json!({"color": fc})); + } + let forced = invoke(app, "home.fontColor"); + egui::Popup::menu(&ca) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .open_memory(popup_command(forced, ca.clicked())) + .show(|ui| { + if let Some(c) = color_palette(ui, &theme_colors(app), "Automatic") { + if c != "none" { + app.grid.last_font_color = c.clone(); + } + act(app, "home.fontColor", json!({"color": if c == "none" { "auto".to_string() } else { c }})); + ui.close(); + } + }); + }); + }); + sep(ui); + // Alignment + ui.vertical(|ui| { + ui.horizontal(|ui| { + for (icon, v, id, tip) in [ + (Icon::AlignTop, VAlign::Top, "home.alignTop", "Top Align"), + (Icon::AlignMiddle, VAlign::Center, "home.alignMiddle", "Middle Align"), + (Icon::AlignBottom, VAlign::Bottom, "home.alignBottom", "Bottom Align"), + ] { + let __k = toggle_button(ui, icon, st.align.v == v, tip); + control_keytip(app, ui, &__k, id); + if __k.clicked() { + act(app, id, json!({})); + } + } + let o = small_button(ui, Icon::Orientation, "", "Orientation", true); + control_keytip(app, ui, &o, "home.orientation"); + let forced_o = invoke(app, "home.orientation"); + egui::Popup::menu(&o).open_memory(popup_command(forced_o, o.clicked())).show(|ui| { + menu_items( + app, + ui, + &[ + ("Angle Counterclockwise", "home.orientation", json!({"angle": "counterclockwise"})), + ("Angle Clockwise", "home.orientation", json!({"angle": "clockwise"})), + ("Vertical Text", "home.orientation", json!({"angle": "vertical"})), + ("Rotate Text Up", "home.orientation", json!({"angle": "up"})), + ("Rotate Text Down", "home.orientation", json!({"angle": "down"})), + ("Horizontal", "home.orientation", json!({"angle": 0})), + ("-", "", json!(null)), + ("Format Cell Alignment…", "dialog:formatCells", json!({"tab": "Alignment"})), + ], + ); + }); + let wrap = toggle_button(ui, Icon::Wrap, st.align.wrap, "Wrap Text"); + control_keytip(app, ui, &wrap, "home.wrapText"); + if wrap.clicked() { + act(app, "home.wrapText", json!({})); + } + ui.label(egui::RichText::new(app.ui.language.tr("Wrap Text")).font(theme::ui_font(12.5))); + }); + ui.horizontal(|ui| { + for (icon, h, id, tip) in [ + (Icon::AlignLeft, HAlign::Left, "home.alignLeft", "Align Left"), + (Icon::AlignCenter, HAlign::Center, "home.alignCenter", "Center"), + (Icon::AlignRight, HAlign::Right, "home.alignRight", "Align Right"), + ] { + let response = toggle_button(ui, icon, st.align.h == h, tip); + for sequence in keytip_table::control_badges(app.keytips.prefix().unwrap_or_default(), id) { + keytip(app, ui, sequence, response.rect.center_bottom()); + } + if response.clicked() { + act(app, id, json!({})); + } + } + let __k = icon_button(ui, Icon::IndentDec, t.text, "Decrease Indent", vec2(24.0, 23.0)); + control_keytip(app, ui, &__k, "home.decreaseIndent"); + if __k.clicked() { + act(app, "home.decreaseIndent", json!({})); + } + let __k = icon_button(ui, Icon::IndentInc, t.text, "Increase Indent", vec2(24.0, 23.0)); + control_keytip(app, ui, &__k, "home.increaseIndent"); + if __k.clicked() { + act(app, "home.increaseIndent", json!({})); + } + let (m, ma) = split_button(ui, Icon::Merge, None, "Merge & Center"); + control_keytip(app, ui, &ma, "home.mergeCenter"); + if m { + act(app, "home.mergeCenter", json!({})); + } + ui.label(egui::RichText::new(app.ui.language.tr("Merge & Center")).font(theme::ui_font(12.5))); + egui::Popup::menu(&ma).show(|ui| { + menu_items( + app, + ui, + &[ + ("Merge & Center", "home.mergeCenter", json!({})), + ("Merge Across", "home.mergeAcross", json!({})), + ("Merge Cells", "home.mergeCells", json!({})), + ("Unmerge Cells", "home.unmergeCells", json!({})), + ], + ); + }); + }); + }); + sep(ui); + // Number + ui.vertical(|ui| { + number_combo(app, ui, &st); + ui.horizontal(|ui| { + let (c, ca) = split_button(ui, Icon::Currency, None, "Accounting Number Format"); + control_keytip(app, ui, &ca, "home.accounting"); + if c { + act(app, "home.accounting", json!({})); + } + egui::Popup::menu(&ca).show(|ui| { + let mut items: Vec<_> = ACCOUNTING_SYMBOLS.iter().map(|(label, code)| (*label, "home.numberFormat", json!({"code": code}))).collect(); + items.push(("-", "", json!(null))); + items.push(("More Accounting Formats…", "dialog:formatCells", json!({"tab": "Number"}))); + menu_items(app, ui, &items); + }); + let __k = icon_button(ui, Icon::Percent, t.text, "Percent Style", vec2(24.0, 23.0)); + control_keytip(app, ui, &__k, "home.percent"); + if __k.clicked() { + act(app, "home.percent", json!({})); + } + let __k = icon_button(ui, Icon::Comma, t.text, "Comma Style", vec2(24.0, 23.0)); + control_keytip(app, ui, &__k, "home.comma"); + if __k.clicked() { + act(app, "home.comma", json!({})); + } + let __k = icon_button(ui, Icon::DecInc, t.text, "Increase Decimal", vec2(26.0, 23.0)); + control_keytip(app, ui, &__k, "home.increaseDecimal"); + if __k.clicked() { + act(app, "home.increaseDecimal", json!({})); + } + let __k = icon_button(ui, Icon::DecDec, t.text, "Decrease Decimal", vec2(26.0, 23.0)); + control_keytip(app, ui, &__k, "home.decreaseDecimal"); + if __k.clicked() { + act(app, "home.decreaseDecimal", json!({})); + } + }); + }); + sep(ui); + // Styles + let cf = big_button(ui, Icon::CondFormat, "Conditional\nFormatting", "Conditional Formatting", true); + anchor_keytip(app, ui, &cf, &["home.conditionalFormat", "home.manageRules", "home.clearRules"]); + egui::Popup::menu(&cf).show(|ui| cf_menu(app, ui)); + let ft = big_button(ui, Icon::FormatTable, "Format\nas Table", "Format as Table", true); + anchor_keytip(app, ui, &ft, &["table.style"]); + egui::Popup::menu(&ft).show(|ui| table_gallery(app, ui, "home.formatAsTable")); + let cs = big_button(ui, Icon::CellStyles, "Cell\nStyles", "Cell Styles", true); + anchor_keytip(app, ui, &cs, &["home.newCellStyle"]); + egui::Popup::menu(&cs).show(|ui| cell_style_gallery(app, ui)); + sep(ui); + // Cells + ui.vertical(|ui| { + let ins = small_button(ui, Icon::Insert, "Insert", "Insert", true); + for sequence in keytip_table::group_extension( + app.keytips.prefix().unwrap_or_default(), + &["dialog:insertCells", "home.insertRows", "home.insertColumns", "home.insertSheet"], + ) { + keytip(app, ui, sequence, ins.rect.center_bottom()); + } + egui::Popup::menu(&ins).show(|ui| { + menu_items( + app, + ui, + &[ + ("Insert Cells…", "dialog:insertCells", json!({})), + ("Insert Sheet Rows", "home.insertRows", json!({})), + ("Insert Sheet Columns", "home.insertColumns", json!({})), + ("Insert Sheet", "home.insertSheet", json!({})), + ], + ); + }); + let del = small_button(ui, Icon::Delete, "Delete", "Delete", true); + for sequence in keytip_table::group_extension( + app.keytips.prefix().unwrap_or_default(), + &["dialog:deleteCells", "home.deleteRows", "home.deleteColumns", "home.deleteSheet"], + ) { + keytip(app, ui, sequence, del.rect.center_bottom()); + } + egui::Popup::menu(&del).show(|ui| { + menu_items( + app, + ui, + &[ + ("Delete Cells…", "dialog:deleteCells", json!({})), + ("Delete Sheet Rows", "home.deleteRows", json!({})), + ("Delete Sheet Columns", "home.deleteColumns", json!({})), + ("Delete Sheet", "home.deleteSheet", json!({})), + ], + ); + }); + let fmt = small_button(ui, Icon::Format, "Format", "Format", true); + for sequence in keytip_table::group_extension( + app.keytips.prefix().unwrap_or_default(), + &[ + "dialog:rowHeight", + "home.autofitRowHeight", + "dialog:columnWidth", + "home.autofitColumnWidth", + "dialog:defaultWidth", + "home.hideRows", + "home.hideColumns", + "home.unhideRows", + "home.unhideColumns", + "sheet.hide", + "sheet.unhide", + "dialog:renameSheet", + "dialog:moveSheet", + "dialog:protectSheet", + "home.lockCell", + "dialog:formatCells", + ], + ) { + keytip(app, ui, sequence, fmt.rect.center_bottom()); + } + let format_keys = matches!(app.keytips.prefix(), Some("HO" | "OC")); + let mut format_popup = egui::Popup::menu(&fmt); + if format_keys { + format_popup = format_popup.open_memory(egui::SetOpenCommand::Bool(true)); + } + let protect_label = if app.active_sheet_protected() { "Unprotect Sheet…" } else { "Protect Sheet…" }; + format_popup.show(|ui| { + menu_items( + app, + ui, + &[ + ("Row Height…", "dialog:rowHeight", json!({})), + ("AutoFit Row Height", "home.autofitRowHeight", json!({})), + ("Column Width…", "dialog:columnWidth", json!({})), + ("AutoFit Column Width", "home.autofitColumnWidth", json!({})), + ("Default Width…", "dialog:defaultWidth", json!({})), + ("-", "", json!(null)), + ("Hide Rows", "home.hideRows", json!({})), + ("Hide Columns", "home.hideColumns", json!({})), + ("Unhide Rows", "home.unhideRows", json!({})), + ("Unhide Columns", "home.unhideColumns", json!({})), + ("Hide Sheet", "sheet.hide", json!({})), + ("Unhide Sheet…", "sheet.unhide", json!({})), + ("-", "", json!(null)), + ("Rename Sheet", "dialog:renameSheet", json!({})), + ("Move or Copy Sheet…", "dialog:moveSheet", json!({})), + ("-", "", json!(null)), + (protect_label, "dialog:protectSheet", json!({})), + ("Lock Cell", "home.lockCell", json!({})), + ("Format Cells…", "dialog:formatCells", json!({})), + ], + ); + }); + }); + sep(ui); + // Editing + ui.vertical(|ui| { + ui.horizontal(|ui| { + let (s, sa) = split_button(ui, Icon::Sum, None, "AutoSum"); + control_keytip(app, ui, &sa, "formulas.autoSum"); + if s { + act(app, "formulas.autoSum", 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( + app.keytips.prefix().unwrap_or_default(), + &["edit.fillDown", "edit.fillRight", "edit.fillUp", "edit.fillLeft", "dialog:series", "edit.flashFill"], + ) { + keytip(app, ui, sequence, fill.rect.center_bottom()); + } + egui::Popup::menu(&fill).show(|ui| { + menu_items( + app, + ui, + &[ + ("Down", "edit.fillDown", json!({})), + ("Right", "edit.fillRight", json!({})), + ("Up", "edit.fillUp", json!({})), + ("Left", "edit.fillLeft", json!({})), + ("Series…", "dialog:series", json!({})), + ("Flash Fill", "edit.flashFill", json!({})), + ], + ); + }); + let clear = small_button(ui, Icon::Eraser, "", "Clear", true); + for sequence in keytip_table::group_extension( + app.keytips.prefix().unwrap_or_default(), + &["edit.clearAll", "edit.clearFormats", "edit.clearContents", "edit.clearComments", "edit.clearHyperlinks"], + ) { + keytip(app, ui, sequence, clear.rect.center_bottom()); + } + egui::Popup::menu(&clear).show(|ui| { + menu_items( + app, + ui, + &[ + ("Clear All", "edit.clearAll", json!({})), + ("Clear Formats", "edit.clearFormats", json!({})), + ("Clear Contents", "edit.clearContents", json!({})), + ("Clear Comments and Notes", "edit.clearComments", json!({})), + ("Clear Hyperlinks", "edit.clearHyperlinks", json!({})), + ], + ); + }); + }); + let sf = big_button(ui, Icon::SortFilter, "Sort &\nFilter", "Sort & Filter", true); + for sequence in keytip_table::group_extension( + app.keytips.prefix().unwrap_or_default(), + &["data.sortAscending", "data.sortDescending", "dialog:sort", "data.filter", "data.clearFilter", "data.reapply"], + ) { + keytip(app, ui, sequence, sf.rect.center_bottom()); + } + egui::Popup::menu(&sf).show(|ui| { + menu_items( + app, + ui, + &[ + ("Sort A to Z", "data.sortAscending", json!({})), + ("Sort Z to A", "data.sortDescending", json!({})), + ("Custom Sort…", "dialog:sort", json!({})), + ("-", "", json!(null)), + ("Filter", "data.filter", json!({})), + ("Clear", "data.clearFilter", json!({})), + ("Reapply", "data.reapply", json!({})), + ], + ); + }); + let fs = big_button(ui, Icon::Find, "Find &\nSelect", "Find & Select", true); + for sequence in keytip_table::group_extension( + app.keytips.prefix().unwrap_or_default(), + &["dialog:find", "dialog:goTo", "dialog:goToSpecial", "edit.goToSpecial"], + ) { + keytip(app, ui, sequence, fs.rect.center_bottom()); + } + egui::Popup::menu(&fs).show(|ui| { + menu_items( + app, + ui, + &[ + ("Find…", "dialog:find", json!({})), + ("Replace…", "dialog:find", json!({"replace": true})), + ("Go To…", "dialog:goTo", json!({})), + ("Go To Special…", "dialog:goToSpecial", json!({})), + ("-", "", json!(null)), + ("Formulas", "edit.goToSpecial", json!({"kind": "formulas"})), + ("Notes", "edit.goToSpecial", json!({"kind": "notes"})), + ("Conditional Formatting", "edit.goToSpecial", json!({"kind": "conditionalFormats"})), + ("Constants", "edit.goToSpecial", json!({"kind": "constants"})), + ("Data Validation", "edit.goToSpecial", json!({"kind": "dataValidation"})), + ], + ); + }); +} + +/// The AutoSum menu (Home › Editing and Formulas › Function Library): a function, or the Insert +/// Function dialog. +pub(super) 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)))); + control_keytip(app, ui, &resp, "home.fontName"); + let forced = invoke(app, "home.fontName"); + let items: Vec<(&str, String)> = FONTS.iter().map(|f| (*f, f.to_string())).collect(); + if let ComboResult::Picked(value) = editable_combo(ui, &resp, "font", &name, &items, forced) { + act(app, "home.fontName", json!({"name": value})); + } +} + +pub const FONTS: &[&str] = &[ + "Aptos", + "Aptos Narrow", + "Arial", + "Calibri", + "Cambria", + "Candara", + "Consolas", + "Courier New", + "Garamond", + "Georgia", + "Helvetica", + "Helvetica Neue", + "Segoe UI", + "Tahoma", + "Times New Roman", + "Trebuchet MS", + "Verdana", +]; + +/// Excel's size box: type a size and press Enter to apply exactly that number (the command refuses +/// sizes outside 1 to 409), Escape or clicking away reverts; the arrow opens the standard sizes. +fn size_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) { + let t = Tokens::get(ui.ctx()); + let size = st.font.size; + let current = if size.fract() == 0.0 { format!("{}", size as i32) } else { format!("{size}") }; + let field = egui::Id::unique("gridcraft.font_size_box"); + let draft = field.with("draft"); + let mut text = ui.data_mut(|d| d.get_temp::(draft)).unwrap_or_else(|| current.clone()); + // One frame like the Font name box beside it (same fill, border, height and corners), with the + // text field and the dropdown arrow inside it, as in Excel. + let (frame, _) = ui.allocate_exact_size(vec2(52.0, 24.0), egui::Sense::hover()); + // Painted once the field's focus and hover are known, but beneath the field and the arrow. + let bg = ui.painter().add(egui::Shape::Noop); + let text_rect = Rect::from_min_max(frame.min, egui::pos2(frame.right() - 16.0, frame.bottom())); + let arrow_rect = Rect::from_min_max(egui::pos2(frame.right() - 16.0, frame.top() + 1.0), egui::pos2(frame.right() - 1.0, frame.bottom() - 1.0)); + let mut field_ui = + ui.new_child(egui::UiBuilder::new().max_rect(text_rect.shrink2(vec2(6.0, 0.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + let out = egui::TextEdit::singleline(&mut text) + .id(field) + .font(theme::ui_font(12.5)) + .frame(egui::Frame::NONE) + .desired_width(text_rect.width() - 12.0) + .show(&mut field_ui); + let resp = out.response; + ui.ctx().accesskit_node_builder(resp.id, |node| node.set_label(app.ui.language.tr("Font Size"))); + control_keytip(app, ui, &resp, "home.fontSize"); + if invoke(app, "home.fontSize") { + ui.memory_mut(|m| m.request_focus(field)); + } + if resp.gained_focus() { + // Typing replaces the size, as in Excel, whether the box was clicked or reached by keytip. + let mut state = out.state; + state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())))); + state.store(ui.ctx(), field); + } + if resp.lost_focus() { + ui.data_mut(|d| d.remove::(draft)); + // Consume the Enter so the grid behind does not also move the active cell down. + if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)) + && let Some(v) = text.trim().parse::().ok().filter(|v| v.is_finite()) + { + act(app, "home.fontSize", json!({"size": v})); + } + } else if resp.has_focus() { + ui.data_mut(|d| d.insert_temp(draft, text)); + } else { + ui.data_mut(|d| d.remove::(draft)); + } + let mut arrow_ui = ui.new_child(egui::UiBuilder::new().max_rect(arrow_rect)); + let arrow = icon_button(&mut arrow_ui, Icon::Chevron, t.text_dim, "Font Size", arrow_rect.size()); + let visuals = ui.style().visuals.clone(); + let looks = if resp.has_focus() || ui.rect_contains_pointer(frame) { &visuals.widgets.hovered } else { &visuals.widgets.inactive }; + let stroke = if resp.has_focus() { visuals.selection.stroke } else { looks.bg_stroke }; + ui.painter().set(bg, egui::epaint::RectShape::new(frame, looks.corner_radius, looks.weak_bg_fill, stroke, egui::StrokeKind::Inside)); + arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Font Size")); + let sizes = [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 36.0, 48.0, 72.0]; + let labels: Vec = sizes.iter().map(|s| format!("{}", *s as i32)).collect(); + let items: Vec<(&str, String)> = labels.iter().map(|s| (s.as_str(), s.clone())).collect(); + if let ComboResult::Picked(value) = list_combo(ui, &arrow, frame, "size", ¤t, &items) + && let Ok(v) = value.parse::() + { + act(app, "home.fontSize", json!({"size": v})); + } +} + +fn number_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) { + let code = st.num_fmt.as_str().to_string(); + let kind = gridcraft_engine::display::number_format(&code).kind(); + let current = format!("{kind:?}"); + let lang = app.ui.language; + let sample = app.session.active().and_then(|d| d.wb.active().map(|sh| sh.value(d.selection.active))).unwrap_or_default(); + let resp = ui.add_sized([150.0, 24.0], egui::Button::new(egui::RichText::new(lang.tr(¤t)).font(theme::ui_font(12.5)))); + control_keytip(app, ui, &resp, "home.numberFormat"); + let forced = invoke(app, "home.numberFormat"); + let names = ["General", "Number", "Currency", "Accounting", "Short Date", "Long Date", "Time", "Percentage", "Fraction", "Scientific", "Text"]; + let items: Vec<(&str, String)> = names + .iter() + .map(|n| { + let code = gridcraft_engine::cmd::format::format_code_for(n); + let preview = app.session.active().map(|d| gridcraft_engine::display::format(&sample, code, &d.wb).text).unwrap_or_default(); + (*n, format!("{:<12} {preview}", lang.tr(n))) + }) + .chain(std::iter::once(("__more", format!("{:>12}", lang.tr("More Number Formats…"))))) + .collect(); + if let ComboResult::Picked(value) = editable_combo(ui, &resp, "number", ¤t, &items, forced) { + match value.as_str() { + "__more" => app.open_dialog("formatCells", json!({"tab": "Number"})), + n => act(app, "home.numberFormat", json!({"format": n})), + } + } +} + +fn cf_menu(app: &mut SheetApp, ui: &mut Ui) { + ui.menu_button("Highlight Cells Rules", |ui| { + for (label, op) in [("Greater Than…", "greater"), ("Less Than…", "less"), ("Between…", "between"), ("Equal To…", "equal")] { + if ui.button(label).clicked() { + app.open_dialog("cfQuick", json!({"type": "cellIs", "operator": op, "title": label})); + } + } + if ui.button("Text that Contains…").clicked() { + app.open_dialog("cfQuick", json!({"type": "containsText", "title": "Text that Contains"})); + } + if ui.button("A Date Occurring…").clicked() { + app.open_dialog("cfQuick", json!({"type": "timePeriod", "title": "A Date Occurring"})); + } + if ui.button("Duplicate Values…").clicked() { + act(app, "home.conditionalFormat", json!({"rule": {"type": "duplicate"}})); + } + }); + ui.menu_button("Top/Bottom Rules", |ui| { + menu_items( + app, + ui, + &[ + ("Top 10 Items", "home.conditionalFormat", json!({"rule": {"type": "top10", "rank": 10}})), + ("Top 10%", "home.conditionalFormat", json!({"rule": {"type": "top10", "rank": 10, "percent": true}})), + ("Bottom 10 Items", "home.conditionalFormat", json!({"rule": {"type": "top10", "rank": 10, "bottom": true}})), + ("Bottom 10%", "home.conditionalFormat", json!({"rule": {"type": "top10", "rank": 10, "bottom": true, "percent": true}})), + ("Above Average", "home.conditionalFormat", json!({"rule": {"type": "aboveAverage"}})), + ("Below Average", "home.conditionalFormat", json!({"rule": {"type": "aboveAverage", "below": true}})), + ], + ); + }); + ui.menu_button("Data Bars", |ui| { + for (label, c) in + [("Blue", "#638EC6"), ("Green", "#63BE7B"), ("Red", "#F8696B"), ("Orange", "#FFB628"), ("Light Blue", "#008AEF"), ("Purple", "#D6007B")] + { + if ui.button(label).clicked() { + act(app, "home.conditionalFormat", json!({"rule": {"type": "dataBar", "color": c}})); + } + } + }); + ui.menu_button("Color Scales", |ui| { + for (label, cols) in [ + ("Green - Yellow - Red", vec!["#63BE7B", "#FFEB84", "#F8696B"]), + ("Red - Yellow - Green", vec!["#F8696B", "#FFEB84", "#63BE7B"]), + ("Green - White - Red", vec!["#63BE7B", "#FCFCFF", "#F8696B"]), + ("Blue - White - Red", vec!["#5A8AC6", "#FCFCFF", "#F8696B"]), + ("White - Green", vec!["#FCFCFF", "#63BE7B"]), + ("White - Red", vec!["#FCFCFF", "#F8696B"]), + ] { + if ui.button(label).clicked() { + act(app, "home.conditionalFormat", json!({"rule": {"type": "colorScale", "colors": cols}})); + } + } + }); + ui.menu_button("Icon Sets", |ui| { + for (label, set) in [ + ("3 Arrows", "3Arrows"), + ("3 Traffic Lights", "3TrafficLights1"), + ("3 Symbols", "3Symbols"), + ("4 Ratings", "4Rating"), + ("5 Arrows", "5Arrows"), + ("5 Quarters", "5Quarters"), + ] { + if ui.button(label).clicked() { + act(app, "home.conditionalFormat", json!({"rule": {"type": "iconSet", "set": set}})); + } + } + }); + ui.separator(); + if ui.button("New Rule…").clicked() { + app.open_dialog("cfQuick", json!({"type": "expression", "title": "New Formatting Rule"})); + } + ui.menu_button("Clear Rules", |ui| { + menu_items( + app, + ui, + &[ + ("Clear Rules from Selected Cells", "home.clearRules", json!({})), + ("Clear Rules from Entire Sheet", "home.clearRules", json!({"sheet": true})), + ], + ); + }); + if ui.button("Manage Rules…").clicked() { + app.open_dialog("manageRules", json!({})); + } +} + +#[cfg(test)] +mod accounting_symbol_tests { + use super::*; + use gridcraft_engine::core::{DateSystem, Value}; + use gridcraft_engine::numfmt::{NumberFormat, format_value}; + + #[test] + fn accounting_dropdown_offers_the_issue_currencies_with_excel_codes() { + // #1063: label, Excel's [$symbol-LCID] tag, and 1234.5 as Excel shows it (fill and the + // `_-` padding aside). The yen and won have no minor unit, so their codes show no decimals. + for (label, tag, shown) in [ + ("Rp Indonesian", "[$Rp-421]", "Rp1,234.50"), + ("₹ English (India)", "[$₹-4009]", "₹1,234.50"), + ("₩ Korean", "[$₩-412]", "₩1,235"), + ("¥ Chinese (PRC)", "[$¥-804]", "¥1,234.50"), + ("¥ Japanese", "[$¥-411]", "¥1,235"), + ] { + let code = ACCOUNTING_SYMBOLS.iter().find(|(l, _)| *l == label).map(|(_, c)| *c); + let Some(code) = code else { panic!("{label} is not in the Accounting dropdown") }; + assert!(code.split(';').take(3).all(|section| section.contains(tag)), "{label}: {code}"); + let out = format_value(&Value::Number(1234.5), &NumberFormat::parse(code), DateSystem::D1900); + assert_eq!(out.text.trim(), shown, "{label}"); + let symbol = tag.trim_start_matches("[$").split('-').next().unwrap_or_default(); + assert_eq!(out.fill.map(|(c, at)| (c, out.text[..at].trim_start())), Some((' ', symbol)), "{label}"); + } + } +} diff --git a/crates/ui-egui/src/ribbon/insert.rs b/crates/ui-egui/src/ribbon/insert.rs new file mode 100644 index 0000000000..9319b23c5d --- /dev/null +++ b/crates/ui-egui/src/ribbon/insert.rs @@ -0,0 +1,245 @@ +use super::controls::{act, anchor_keytip, control_keytip, invoke, popup_command, sep}; +use super::*; + +/// Draws the Insert tab: PivotTable, Table, Pictures, Shapes, Icons, charts, slicer, sparklines, checkbox, links, comments, text box, header/footer and symbols. +pub(super) fn insert(app: &mut SheetApp, ui: &mut Ui) { + let __k = big_button(ui, Icon::PivotTable, "PivotTable", "PivotTable", false); + control_keytip(app, ui, &__k, "insert.pivotTable"); + if __k.clicked() { + act(app, "insert.pivotTable", json!({})); + } + let __k = big_button(ui, Icon::Table, "Table", "Table", false); + control_keytip(app, ui, &__k, "insert.table"); + if __k.clicked() { + act(app, "insert.table", json!({})); + } + sep(ui); + let pic = big_button(ui, Icon::Picture, "Pictures", "Insert a picture", true); + anchor_keytip(app, ui, &pic, &["insert.picture"]); + pic.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Pictures")); + egui::Popup::menu(&pic).show(|ui| { + for (label, placement) in [("Place in Cell", "cell"), ("Place over Cells", "overCells")] { + if ui.button(label).clicked() { + app.open_dialog("insertPicture", json!({"placement": placement})); + ui.close(); + } + } + }); + let shapes = big_button(ui, Icon::Shapes, "Shapes", "Shapes", true); + anchor_keytip(app, ui, &shapes, &["insert.shape"]); + egui::Popup::menu(&shapes).show(|ui| { + for (label, kind) in [ + ("Rectangle", "rectangle"), + ("Rounded Rectangle", "roundedRectangle"), + ("Oval", "ellipse"), + ("Triangle", "triangle"), + ("Line", "line"), + ("Arrow", "arrow"), + ("Text Box", "textBox"), + ] { + if ui.button(label).clicked() { + act(app, "insert.shape", json!({"kind": kind})); + } + } + }); + let icn = big_button(ui, Icon::Theme, "Icons", "Insert an icon", true); + anchor_keytip(app, ui, &icn, &["insert.icons"]); + egui::Popup::menu(&icn).show(|ui| { + ui.set_width(260.0); + egui::Grid::new("icon_lib").show(ui, |ui| { + for (i, (name, icon)) in icons::LIBRARY.iter().enumerate() { + let (r, resp) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, 4.0, Tokens::get(ui.ctx()).hover); + } + icons::paint(ui.painter(), r.shrink(6.0), *icon, Tokens::get(ui.ctx()).text); + if resp.on_hover_text(*name).clicked() { + act(app, "insert.icons", json!({"name": name})); + ui.close(); + } + if (i + 1) % 6 == 0 { + ui.end_row(); + } + } + }); + }); + sep(ui); + let __k = big_button(ui, Icon::Chart, "Recommended\nCharts", "Recommended Charts", false); + control_keytip(app, ui, &__k, "insert.recommendedCharts"); + if __k.clicked() { + // Menu-style: with no chart type, run_or_alert opens the Insert Chart dialog. + act(app, "insert.recommendedCharts", json!({})); + } + ui.vertical(|ui| { + ui.horizontal(|ui| { + for (icon, tip, cid, subs) in [ + ( + Icon::ChartBar, + "Column or Bar", + "insert.chart.column", + vec![ + ("Clustered Column", "column", ""), + ("Stacked Column", "column", "stacked"), + ("100% Stacked Column", "column", "stacked100"), + ("Clustered Bar", "bar", ""), + ("Stacked Bar", "bar", "stacked"), + ("100% Stacked Bar", "bar", "stacked100"), + ], + ), + ( + Icon::ChartLine, + "Line or Area", + "insert.chart.line", + vec![ + ("Line", "line", ""), + ("Line with Markers", "line", "markers"), + ("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", "")]), + ] { + let r = small_button(ui, icon, "", tip, true); + control_keytip(app, ui, &r, cid); + let forced_c = invoke(app, cid); + egui::Popup::menu(&r).open_memory(popup_command(forced_c, r.clicked())).show(|ui| { + for (label, k, s) in &subs { + if ui.button(*label).clicked() { + act(app, "insert.chart", json!({"type": k, "subtype": s})); + } + } + }); + } + }); + ui.horizontal(|ui| { + for (icon, tip, cid, subs) in [ + ( + Icon::ChartScatter, + "Scatter or Bubble", + "insert.chart.scatter", + vec![("Scatter", "scatter", ""), ("Scatter with Lines", "scatter", "lines"), ("Bubble", "bubble", "")], + ), + (Icon::Chart, "Statistic", "insert.chart.statistic", vec![("Histogram", "histogram", ""), ("Box & Whisker", "boxWhisker", "")]), + ( + Icon::Theme, + "Hierarchy & more", + "insert.chart.hierarchy", + vec![ + ("Treemap", "treemap", ""), + ("Sunburst", "sunburst", ""), + ("Waterfall", "waterfall", ""), + ("Funnel", "funnel", ""), + ("Radar", "radar", ""), + ("Stock", "stock", ""), + ("Combo", "combo", ""), + ], + ), + ] { + let r = small_button(ui, icon, "", tip, true); + control_keytip(app, ui, &r, cid); + let forced_c = invoke(app, cid); + egui::Popup::menu(&r).open_memory(popup_command(forced_c, r.clicked())).show(|ui| { + for (label, k, s) in &subs { + if ui.button(*label).clicked() { + act(app, "insert.chart", json!({"type": k, "subtype": s})); + } + } + }); + } + }); + }); + sep(ui); + if big_button(ui, Icon::Filter, "Slicer", "Insert a slicer to filter a table", false).clicked() { + act(app, "insert.slicer", json!({})); + } + sep(ui); + let sp = big_button(ui, Icon::Sparkline, "Sparklines", "Sparklines", true); + anchor_keytip(app, ui, &sp, &["dialog:sparkline"]); + egui::Popup::menu(&sp).show(|ui| { + for (label, kind) in [("Line", "line"), ("Column", "column"), ("Win/Loss", "winLoss")] { + if ui.button(label).clicked() { + app.open_dialog("sparkline", json!({"type": kind})); + } + } + }); + sep(ui); + // Cell Controls. + if big_button(ui, Icon::Checkbox, "Checkbox", "Insert checkboxes in the selected cells", false).clicked() { + act(app, "insert.checkbox", json!({})); + } + sep(ui); + let __k = big_button(ui, Icon::Link, "Link", "Insert Link", false); + control_keytip(app, ui, &__k, "dialog:insertLink"); + if __k.clicked() { + app.open_dialog("insertLink", json!({})); + } + let __k = big_button(ui, Icon::Comment, "Comment", "New Comment", false); + control_keytip(app, ui, &__k, "dialog:comment"); + if __k.clicked() { + app.open_dialog("comment", json!({"threaded": true})); + } + sep(ui); + let __k = big_button(ui, Icon::TextBox, "Text\nBox", "Text Box", false); + control_keytip(app, ui, &__k, "insert.textBox"); + if __k.clicked() { + act(app, "insert.textBox", json!({})); + } + let __k = big_button(ui, Icon::PageLayout, "Header &\nFooter", "Header & Footer", false); + control_keytip(app, ui, &__k, "dialog:headerFooter"); + if __k.clicked() { + // As in Excel: Page Layout view, editing the header in place (the dialog is under Page Setup). + crate::paper_view::start_editing(app); + } + let sym = big_button(ui, Icon::Symbol, "Symbol", "Symbol", true); + control_keytip(app, ui, &sym, "insert.symbol"); + egui::Popup::menu(&sym).show(|ui| { + ui.set_width(260.0); + ui.horizontal_wrapped(|ui| { + for ch in [ + "€", "£", "¥", "©", "®", "™", "±", "≠", "≤", "≥", "÷", "×", "∞", "µ", "α", "β", "π", "Σ", "Ω", "°", "•", "…", "→", "←", "↑", "↓", + "✓", "★", "½", "¼", + ] { + // No bundled face has ✓: the button paints a code-drawn check instead of a tofu box. + if crate::widgets::glyph_button(ui, ch.chars().next().unwrap_or('?'), &format!("Insert {ch}")).clicked() { + act(app, "insert.symbol", json!({"char": ch})); + } + } + }); + }); +} + +pub(super) fn draw(app: &mut SheetApp, ui: &mut Ui) { + let tool = app.session.draw_tool.clone(); + let __k = big_button(ui, Icon::Shapes, "Select\nObjects", "Select and move ink and shapes", false); + control_keytip(app, ui, &__k, "draw.lasso"); + if __k.clicked() { + act(app, "draw.lasso", json!({})); + } + sep(ui); + for (label, color) in [("Pen\nBlue", "#1F5FC9"), ("Pen\nBlack", "#000000"), ("Pen\nRed", "#D13B2F"), ("Highlighter", "#F2E33A")] { + let on = tool == "pen" && app.session.draw_color.eq_ignore_ascii_case(color); + let r = big_button(ui, Icon::Pen, label, "Draw with ink", false); + if on { + ui.painter().rect_stroke(r.rect, 5.0, Stroke::new(2.0, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); + } + if r.clicked() { + let width = if label == "Highlighter" { 12.0 } else { 2.0 }; + act(app, "draw.pen", json!({"on": !on, "color": color, "width": width})); + } + } + let er = big_button(ui, Icon::Eraser, "Eraser", "Erase ink strokes", false); + if tool == "eraser" { + ui.painter().rect_stroke(er.rect, 5.0, Stroke::new(2.0, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); + } + if er.clicked() { + act(app, "draw.eraser", json!({})); + } + sep(ui); + let __k = big_button(ui, Icon::Shapes, "Ink to\nShape", "Convert the last ink stroke to a shape", false); + control_keytip(app, ui, &__k, "draw.inkToShape"); + if __k.clicked() { + act(app, "draw.inkToShape", json!({})); + } +} diff --git a/crates/ui-egui/src/ribbon/page_layout.rs b/crates/ui-egui/src/ribbon/page_layout.rs new file mode 100644 index 0000000000..b8651b0f49 --- /dev/null +++ b/crates/ui-egui/src/ribbon/page_layout.rs @@ -0,0 +1,158 @@ +use super::controls::{act, anchor_keytip, checkbox_on, control_keytip, invoke, keytip, menu_items, sep}; +use super::*; + +pub(super) fn page_layout(app: &mut SheetApp, ui: &mut Ui) { + let th = big_button(ui, Icon::Theme, "Themes", "Themes", true); + anchor_keytip(app, ui, &th, &["pageLayout.theme"]); + egui::Popup::menu(&th).show(|ui| { + for (name, colors) in gridcraft_engine::cmd::view::themes() { + ui.horizontal(|ui| { + for c in colors.iter().skip(4).take(6) { + let (r, _) = ui.allocate_exact_size(vec2(12.0, 12.0), Sense::hover()); + ui.painter().rect_filled(r, 1.0, Color32::from_rgb((c >> 16) as u8, (c >> 8) as u8, *c as u8)); + } + if ui.button(name).clicked() { + act(app, "pageLayout.theme", json!({"name": name})); + } + }); + } + }); + sep(ui); + let m = big_button(ui, Icon::Margins, "Margins", "Margins", true); + for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["pageLayout.margins", "dialog:pageSetup"]) { + keytip(app, ui, sequence, m.rect.center_bottom()); + } + egui::Popup::menu(&m).show(|ui| { + menu_items( + app, + ui, + &[ + ("Normal", "pageLayout.margins", json!({"preset": "normal"})), + ("Wide", "pageLayout.margins", json!({"preset": "wide"})), + ("Narrow", "pageLayout.margins", json!({"preset": "narrow"})), + ("Custom Margins…", "dialog:pageSetup", json!({})), + ], + ); + }); + let o = big_button(ui, Icon::Orient, "Page Layout|Orientation", "Page Layout|Orientation", true); + control_keytip(app, ui, &o, "pageLayout.orientation"); + egui::Popup::menu(&o).show(|ui| { + menu_items( + app, + ui, + &[ + ("Portrait", "pageLayout.orientation", json!({"orientation": "portrait"})), + ("Landscape", "pageLayout.orientation", json!({"orientation": "landscape"})), + ], + ); + }); + let s = big_button(ui, Icon::Paper, "Size", "Paper Size", true); + control_keytip(app, ui, &s, "pageLayout.size"); + egui::Popup::menu(&s).show(|ui| { + // B4/B5/B6 are the JIS sizes Japanese Excel uses; the ISO ones stay selectable by name. + for p in [ + "Letter", + "Legal", + "Tabloid", + "Executive", + "A3", + "A4", + "A5", + "A6", + "B4", + "B5", + "B6", + "Japanese Postcard", + "Japanese Double Postcard", + "Japanese Envelope #3", + "Japanese Envelope #4", + "Japanese Envelope Kaku #2", + "Japanese Envelope Kaku #3", + "B4 (ISO)", + "B5 (ISO)", + "B6 (ISO)", + ] { + if ui.button(p).clicked() { + act(app, "pageLayout.size", json!({"paper": p})); + } + } + }); + let pa = big_button(ui, Icon::PrintArea, "Print\nArea", "Print Area", true); + anchor_keytip(app, ui, &pa, &["pageLayout.printArea"]); + egui::Popup::menu(&pa).show(|ui| { + menu_items( + app, + ui, + &[("Set Print Area", "pageLayout.printArea", json!({})), ("Clear Print Area", "pageLayout.printArea", json!({"clear": true}))], + ); + }); + let br = big_button(ui, Icon::PageBreak, "Breaks", "Page Breaks", true); + for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["pageLayout.breaks"]) { + keytip(app, ui, sequence, br.rect.center_bottom()); + } + egui::Popup::menu(&br).show(|ui| { + menu_items( + app, + ui, + &[ + ("Insert Page Break", "pageLayout.breaks", json!({"insert": true})), + ("Remove Page Break", "pageLayout.breaks", json!({"remove": true})), + ("Reset All Page Breaks", "pageLayout.breaks", json!({"reset": true})), + ], + ); + }); + let __k = big_button(ui, Icon::Sheet, "Print\nTitles", "Print Titles", false); + control_keytip(app, ui, &__k, "pageLayout.printTitles"); + if __k.clicked() { + app.open_dialog("pageSetup", json!({"tab": "Sheet"})); + } + sep(ui); + let sh = app + .session + .active() + .and_then(|d| d.wb.active().map(|s| (s.show_gridlines, s.show_headings, s.print.gridlines, s.print.headings))) + .unwrap_or((true, true, false, false)); + // These two checkboxes mean the screen view vs. the printed page. English shows "View" (as in + // Excel), but the key is distinct from the "View" tab so languages can word them apart. + let screen = app.ui.language.tr("Sheet Options|View"); + let print = app.ui.language.tr("Print"); + ui.vertical(|ui| { + ui.label(egui::RichText::new(app.ui.language.tr("Gridlines")).strong().small()); + let mut v = sh.0; + let resp = ui.checkbox(&mut v, screen); + control_keytip(app, ui, &resp, "view.gridlines"); + if resp.changed() || invoke(app, "view.gridlines") { + act(app, "view.gridlines", json!({"on": checkbox_on(resp.changed(), v)})); + } + let mut p = sh.2; + let resp = ui.checkbox(&mut p, print); + control_keytip(app, ui, &resp, "pageLayout.printGridlines"); + if resp.changed() || invoke(app, "pageLayout.printGridlines") { + act(app, "pageLayout.printGridlines", json!({"on": checkbox_on(resp.changed(), p)})); + } + }); + ui.vertical(|ui| { + ui.label(egui::RichText::new(app.ui.language.tr("Headings")).strong().small()); + let mut v = sh.1; + let resp = ui.checkbox(&mut v, screen); + control_keytip(app, ui, &resp, "view.headings"); + if resp.changed() || invoke(app, "view.headings") { + act(app, "view.headings", json!({"on": checkbox_on(resp.changed(), v)})); + } + let mut p = sh.3; + let resp = ui.checkbox(&mut p, print); + control_keytip(app, ui, &resp, "pageLayout.printHeadings"); + if resp.changed() || invoke(app, "pageLayout.printHeadings") { + act(app, "pageLayout.printHeadings", json!({"on": checkbox_on(resp.changed(), p)})); + } + }); + let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left); + let r = big_button(ui, Icon::SheetRtl, "Sheet\nRight-to-Left", "Sheet Right-to-Left", false); + if rtl { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke(r.rect, 5.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); + } + if r.clicked() { + act(app, "view.rightToLeft", json!({"on": !rtl})); + } +} diff --git a/crates/ui-egui/src/ribbon/review.rs b/crates/ui-egui/src/ribbon/review.rs new file mode 100644 index 0000000000..c8998531ef --- /dev/null +++ b/crates/ui-egui/src/ribbon/review.rs @@ -0,0 +1,82 @@ +use super::controls::{act, anchor_keytip, control_keytip, menu_items, sep}; +use super::*; + +pub(super) fn review(app: &mut SheetApp, ui: &mut Ui) { + let __k = big_button(ui, Icon::Spell, "Spelling", "Spelling (F7)", false); + control_keytip(app, ui, &__k, "dialog:spelling"); + if __k.clicked() { + app.open_dialog("spelling", json!({})); + } + let __k = big_button(ui, Icon::Calc, "Workbook\nStatistics", "Workbook Statistics", false); + control_keytip(app, ui, &__k, "statistics"); + if __k.clicked() { + app.open_dialog("statistics", json!({})); + } + let __k = big_button(ui, Icon::Validation, "Check\nAccessibility", "Check Accessibility", false); + control_keytip(app, ui, &__k, "dialog:accessibility"); + if __k.clicked() { + app.open_dialog("accessibility", json!({})); + } + sep(ui); + let __k = big_button(ui, Icon::Comment, "New\nComment", "New Comment", false); + control_keytip(app, ui, &__k, "dialog:comment"); + if __k.clicked() { + app.open_dialog("comment", json!({"threaded": true})); + } + let __k = big_button(ui, Icon::Delete, "Delete", "Delete Comment", false); + control_keytip(app, ui, &__k, "review.deleteComment"); + if __k.clicked() { + act(app, "review.deleteComment", json!({})); + } + ui.vertical(|ui| { + for (icon, label, tip, id) in + [(Icon::Left, "Previous", "Previous Comment", "review.previousComment"), (Icon::Right, "Next", "Next Comment", "review.nextComment")] + { + if small_button(ui, icon, label, tip, false).clicked() { + act(app, id, json!({})); + app.grid.ensure_visible = true; + } + } + }); + let shown = app.session.comments_pane; + let r = big_button(ui, Icon::Comment, "Show\nComments", "Comments pane", false); + if shown { + ui.painter().rect_stroke(r.rect, 5.0, Stroke::new(2.0, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); + } + if r.clicked() { + act(app, "review.showComments", json!({"on": !shown})); + if app.session.comments_pane { + app.grid.pane = None; + } + } + let notes = big_button(ui, Icon::Note, "Notes", "Notes", true); + anchor_keytip(app, ui, ¬es, &["review.nextComment"]); + egui::Popup::menu(¬es).show(|ui| { + menu_items( + app, + ui, + &[ + ("New Note", "dialog:comment", json!({"threaded": false})), + ("Show/Hide Note", "review.showNote", json!({})), + ("Show All Notes", "review.showAllNotes", json!({})), + ("-", "", json!({})), + ("Convert to Comments", "review.convertToComments", json!({})), + ], + ); + }); + sep(ui); + let protected = app.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false); + let __k = big_button(ui, Icon::Lock, if protected { "Unprotect\nSheet" } else { "Protect\nSheet" }, "Protect Sheet", false); + control_keytip(app, ui, &__k, "dialog:protectSheet"); + if __k.clicked() { + app.open_dialog("protectSheet", json!({})); + } + let __k = big_button(ui, Icon::Book, "Protect\nWorkbook", "Protect Workbook structure", false); + control_keytip(app, ui, &__k, "review.protectWorkbook"); + if __k.clicked() { + crate::backstage::protect_workbook(app); + } + if big_button(ui, Icon::Validation, "Allow Edit\nRanges", "Allow Edit Ranges", false).clicked() { + app.open_dialog("allowEditRanges", json!({})); + } +} diff --git a/crates/ui-egui/src/ribbon/shortcuts.rs b/crates/ui-egui/src/ribbon/shortcuts.rs new file mode 100644 index 0000000000..7edf4c58ca --- /dev/null +++ b/crates/ui-egui/src/ribbon/shortcuts.rs @@ -0,0 +1,112 @@ +use super::*; + +/// Command-key shortcuts when the grid has focus. +pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) { + let shift = m.shift; + let id: Option<(&str, serde_json::Value)> = match key { + Key::B => Some(("home.bold", json!({}))), + Key::I => Some(("home.italic", json!({}))), + Key::U => Some(("home.underline", json!({}))), + Key::Z if shift => Some(("edit.redo", json!({}))), + Key::Z => Some(("edit.undo", json!({}))), + Key::Y => Some(("edit.redo", json!({}))), + Key::A => Some(("edit.selectAll", json!({}))), + Key::D => Some(("edit.fillDown", json!({}))), + Key::R => Some(("edit.fillRight", json!({}))), + Key::S if shift => Some(("file.saveAs", json!({}))), + Key::S => Some(("file.save", json!({}))), + Key::O => Some(("file.open", json!({}))), + Key::N => Some(("file.new", json!({}))), + Key::W => Some(("file.close", json!({}))), + Key::P => { + app.print_now(json!({})); + None + } + Key::K => { + app.open_dialog("insertLink", json!({})); + None + } + Key::F if shift => Some(("data.filter", json!({}))), + Key::F => { + app.open_dialog("find", json!({})); + None + } + Key::H => { + app.open_dialog("find", json!({"replace": true})); + None + } + Key::G => { + app.open_dialog("goTo", json!({})); + None + } + Key::T if shift => Some(("formulas.autoSum", json!({}))), + Key::T => Some(("insert.table", json!({}))), + Key::E => Some(("edit.flashFill", json!({}))), + Key::Num1 => { + app.open_dialog("formatCells", json!({})); + None + } + Key::Num9 if shift => Some(("home.unhideRows", json!({}))), + Key::Num9 => Some(("home.hideRows", json!({}))), + Key::Num0 if shift => Some(("home.unhideColumns", json!({}))), + Key::Num0 => Some(("home.hideColumns", json!({}))), + // As in Excel: with whole rows or whole columns selected, Ctrl+Shift+= / Ctrl+- insert or + // delete them at once; any other selection asks which way to shift. + Key::Minus => match whole_lines(app) { + Some(true) => Some(("home.deleteRows", json!({}))), + Some(false) => Some(("home.deleteColumns", json!({}))), + None => { + app.open_dialog("deleteCells", json!({})); + None + } + }, + // Insert works on one area at a time, so several areas still go through the dialog. + Key::Plus | Key::Equals if shift => match whole_lines(app).filter(|_| single_area(app)) { + Some(true) => Some(("home.insertRows", json!({}))), + Some(false) => Some(("home.insertColumns", json!({}))), + None => { + app.open_dialog("insertCells", json!({})); + None + } + }, + Key::Semicolon => { + // Insert today's date. + let text = app + .session + .active() + .map(|d| { + let today = gridcraft_engine::core::date::serial_in_system(d.wb.date_system, gridcraft_engine::calc::now_serial()).floor(); + gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(today), "m/d/yyyy", &d.wb).text + }) + .unwrap_or_default(); + app.begin_edit(Some(text), false); + None + } + Key::Backtick => Some(("formulas.showFormulas", json!({}))), + _ => None, + }; + if let Some((id, p)) = id { + app.run_or_alert(id, p); + app.grid.ensure_visible = true; + } +} + +/// Whether the selection is a single area. +fn single_area(app: &SheetApp) -> bool { + app.session.active().is_some_and(|d| d.selection.ranges.len() == 1) +} + +/// `Some(true)` when every selected area is whole rows, `Some(false)` when every one is whole +/// columns, `None` otherwise (including the whole sheet). +fn whole_lines(app: &SheetApp) -> Option { + let sel = &app.session.active()?.selection; + let rows = sel.ranges.iter().all(|r| r.is_full_rows() && !r.is_full_cols()); + let cols = sel.ranges.iter().all(|r| r.is_full_cols() && !r.is_full_rows()); + if rows { + Some(true) + } else if cols { + Some(false) + } else { + None + } +} diff --git a/crates/ui-egui/src/ribbon/title_bar.rs b/crates/ui-egui/src/ribbon/title_bar.rs new file mode 100644 index 0000000000..eb243149fc --- /dev/null +++ b/crates/ui-egui/src/ribbon/title_bar.rs @@ -0,0 +1,274 @@ +use super::controls::{act, control_keytip}; +use super::*; + +/// macOS draws the window into the title bar (`with_fullsize_content_view`) and AppKit paints the +/// traffic lights itself, centred in its standard 28 pt title-bar band. Moving them would take +/// AppKit calls (`unsafe`, forbidden here), so on macOS the title bar is that band: the lights sit +/// vertically centred in it, with the controls beside them (#170). +const MAC: bool = cfg!(target_os = "macos"); + +pub const MAC_TITLE_BAR: f32 = 28.0; + +/// The traffic lights as AppKit lays them out: button diameter, centre-to-centre pitch and the +/// first button's left edge (8–10 pt depending on the macOS version; the larger value keeps the +/// gap after them from shrinking). +const MAC_LIGHT: f32 = 12.0; + +const MAC_LIGHT_PITCH: f32 = 20.0; + +const MAC_LIGHTS_LEFT: f32 = 10.0; + +/// Where title-bar content starts on macOS: the end of the three lights plus the gap the lights +/// keep from the top of the window. +pub const MAC_CONTENT_LEFT: f32 = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT + (MAC_TITLE_BAR - MAC_LIGHT) / 2.0; + +/// Leave room for the macOS traffic lights when the content extends into the title bar. +pub const TITLE_LEFT_PAD: f32 = if MAC { MAC_CONTENT_LEFT } else { 8.0 }; + +pub(super) const TITLE_BAR_H: f32 = if MAC { MAC_TITLE_BAR } else { 38.0 }; + +/// The tallest title-bar controls, smaller on macOS to keep a margin in the shorter bar. +pub(super) const TITLE_CONTROL_H: f32 = if MAC { 22.0 } else { 26.0 }; + +/// Draws the title bar: drag/maximize, AutoSave toggle, Home/Save/Undo/Redo/More, search, share, comments and theme controls, with a centered truncated title. +pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let ch = TITLE_CONTROL_H; + egui::Panel::top("title_bar").exact_size(TITLE_BAR_H).frame(egui::Frame::NONE.fill(t.window)).show(ui, |ui| { + let rect = ui.max_rect(); + // Dragging the empty title area moves the window. + let bg = ui.interact(rect, ui.scope_id().with("drag_title"), Sense::click_and_drag()); + if bg.drag_started() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + if bg.double_clicked() { + let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)); + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); + } + ui.horizontal_centered(|ui| { + ui.add_space(TITLE_LEFT_PAD); + // AutoSave toggle (saves after each change when the workbook has a file). + ui.label(egui::RichText::new(app.ui.language.tr("AutoSave")).font(theme::ui_font(12.5)).color(t.text_dim)); + let on = app.ui.autosave; + let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click()); + 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 { 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(); + } + ui.add_space(6.0); + let __k = icon_button(ui, Icon::Home, t.text_dim, "Home", vec2(26.0, ch)); + control_keytip(app, ui, &__k, "start"); + if __k.clicked() { + app.open_dialog("start", json!({})); + } + if icon_button(ui, Icon::Save, t.text_dim, "Save", vec2(26.0, ch)).clicked() + // File operations must include the cell the user is still editing (as the File > Save menu item already does). + && app.commit_edit(0, 0, false, false) + { + app.run_or_alert("file.save", json!({})); + } + // The commands' own availability: there must be a step, and no undo group may be open on + // the document (an action that spans several calls is still running). + let can_undo = gridcraft_engine::cmd::can_undo(&app.session).is_ok(); + let can_redo = gridcraft_engine::cmd::can_redo(&app.session).is_ok(); + // Named and marked enabled or not for assistive technology (and the UI tests). + let undo = icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo", vec2(26.0, ch)); + undo.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, can_undo, "Undo")); + if undo.clicked() && can_undo { + app.run_or_alert("edit.undo", json!({})); + } + let undo_list = icon_button(ui, Icon::Chevron, t.text_dim, "Undo list", vec2(14.0, ch)); + egui::Popup::menu(&undo_list).show(|ui| { + let labels: Vec = if can_undo { + app.session.active().map(|d| d.undo.iter().rev().take(20).map(|e| e.label.clone()).collect()).unwrap_or_default() + } else { + Vec::new() + }; + if labels.is_empty() { + ui.label("Can't Undo"); + } + for (i, l) in labels.iter().enumerate() { + if ui.button(format!("Undo {l}")).clicked() { + app.run_or_alert("edit.undo", json!({"steps": i + 1})); + } + } + }); + let redo = icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo", vec2(26.0, ch)); + redo.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, can_redo, "Redo")); + if redo.clicked() && can_redo { + app.run_or_alert("edit.redo", json!({})); + } + let more = icon_button(ui, Icon::More, t.text_dim, "More commands", vec2(26.0, ch)); + egui::Popup::menu(&more).show(|ui| { + for (label, id) in [ + ("New Workbook", "file.new"), + ("Open…", "file.open"), + ("Save As…", "file.saveAs"), + ("Sort A to Z", "data.sortAscending"), + ("Calculate Now", "formulas.calculateNow"), + ] { + if ui.button(label).clicked() { + app.run_or_alert(id, json!({})); + } + } + if ui.button("Print…").clicked() { + app.print_now(json!({})); + } + }); + let left_end = ui.min_rect().right(); + let right = ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(8.0); + let __k = icon_button(ui, Icon::Search, t.text_dim, "Search commands", vec2(28.0, ch)); + control_keytip(app, ui, &__k, "commandSearch"); + if __k.clicked() { + app.open_dialog("commandSearch", json!({})); + } + let share = small_button(ui, Icon::Share, "Share", "Share: export or save a copy", true); + egui::Popup::menu(&share).show(|ui| { + if ui.button("Export as PDF…").clicked() { + app.run_or_alert("file.exportPdf", json!({})); + } + for (label, fmt) in + [("Save a Copy as Excel Workbook (.xlsx)…", "xlsx"), ("Export as CSV…", "csv"), ("Export as Web Page (.html)…", "html")] + { + if ui.button(label).clicked() { + app.open_dialog("saveCopy", json!({"format": fmt})); + } + } + }); + let __k = icon_button(ui, Icon::Comment, t.text_dim, "Comments", vec2(28.0, ch)); + control_keytip(app, ui, &__k, "comments"); + if __k.clicked() { + app.open_dialog("comments", json!({})); + } + // Quick light/dark switch (#194); right-click offers System as well. The same + // choice lives in File › Display theme and the View tab. + let lang = app.ui.language; + let theme = icon_button(ui, Icon::Appearance, t.text_dim, "Dark Mode", vec2(28.0, 26.0)); + let dark = ui.ctx().theme() == egui::Theme::Dark; + theme.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, dark, lang.tr("Dark Mode"))); + if theme.clicked() { + act(app, "view.darkMode", json!({})); + } + theme.context_menu(|ui| theme_choices(app, ui)); + ui.min_rect().left() + }); + // Centred title, cut with "…" so it never runs under the controls on either side. + let title = app.session.active().map(|d| d.display_title()).unwrap_or_default(); + let dirty = app.session.active().is_some_and(|d| d.is_dirty()); + let max_w = title_max_width(rect.center().x, left_end, right.inner); + paint_title(ui, rect.center(), &title, dirty, max_w, t.text); + }); + }); +} + +/// Lay out the title truncated to fit `max_w` together with the unsaved marker (when `dirty`). +fn title_galleys( + painter: &egui::Painter, + title: &str, + dirty: bool, + max_w: f32, + color: Color32, +) -> (std::sync::Arc, Option>) { + let font = theme::ui_bold(13.0); + let dot = dirty.then(|| painter.layout_no_wrap(" •".to_string(), font.clone(), color)); + let dot_w = dot.as_ref().map_or(0.0, |g| g.size().x); + let mut job = egui::text::LayoutJob::simple_singleline(title.to_string(), font, color); + job.wrap = egui::text::TextWrapping::truncate_at_width((max_w - dot_w).max(0.0)); + (painter.layout_job(job), dot) +} + +/// Gap kept between the title and the controls beside it. +const TITLE_GAP: f32 = 12.0; + +/// The widest the centred title may be without reaching the controls that end at `left_end` or +/// start at `right_start`. +fn title_max_width(center_x: f32, left_end: f32, right_start: f32) -> f32 { + let half = (center_x - left_end).min(right_start - center_x) - TITLE_GAP; + (2.0 * half).max(0.0) +} + +/// Paint the workbook title centred on `center`, truncated with "…" to `max_w`. The unsaved +/// marker stays visible however short the title gets. +fn paint_title(ui: &Ui, center: egui::Pos2, title: &str, dirty: bool, max_w: f32, color: Color32) { + let (galley, dot) = title_galleys(ui.painter(), title, dirty, max_w, color); + let dot_w = dot.as_ref().map_or(0.0, |g| g.size().x); + let w = galley.size().x + dot_w; + let left = center.x - w / 2.0; + let top = center.y - galley.size().y / 2.0; + let galley_w = galley.size().x; + ui.painter().galley(pos2(left, top), galley, color); + if let Some(dot) = dot { + ui.painter().galley(pos2(left + galley_w, top), dot, color); + } +} + +#[cfg(test)] +mod title_bar_tests { + use super::*; + + #[test] + fn mac_title_bar_centres_and_clears_the_traffic_lights() { + // The lights are centred in the bar, and content keeps the same gap after them. + let margin = (MAC_TITLE_BAR - MAC_LIGHT) / 2.0; + let lights_end = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT; + assert_eq!(MAC_CONTENT_LEFT - lights_end, margin); + } + + #[test] + fn title_fits_between_the_controls() { + // Room limited by the nearer side, symmetric about the centre. + assert_eq!(title_max_width(500.0, 300.0, 900.0), 2.0 * (200.0 - TITLE_GAP)); + assert_eq!(title_max_width(500.0, 100.0, 650.0), 2.0 * (150.0 - TITLE_GAP)); + // Controls overlapping the centre leave no room rather than a negative width. + assert_eq!(title_max_width(500.0, 520.0, 900.0), 0.0); + } + + /// Lay out a title in a real egui frame, returning (title text, title width, dot width). + fn layout_title(title: &str, dirty: bool, max_w: f32) -> (String, f32, f32) { + let ctx = egui::Context::default(); + ctx.set_fonts(theme::font_definitions()); + let mut out = (String::new(), 0.0, 0.0); + let mut frame = ctx.run_ui(egui::RawInput::default(), |ui| { + let (galley, dot) = title_galleys(ui.painter(), title, dirty, max_w, Color32::WHITE); + // The glyphs actually drawn: `galley.text()` is the untruncated source. + let shown = galley.rows.iter().flat_map(|r| r.glyphs.iter().map(|g| g.chr)).collect(); + out = (shown, galley.size().x, dot.map_or(0.0, |d| d.size().x)); + }); + frame.textures_delta.clear(); // nothing consumes the atlas in a headless test + out + } + + #[test] + fn long_title_is_truncated_with_an_ellipsis() { + let long = format!("upload-{}-cleaned (1).xlsx", "3FMyNTYg2gDaANiAAACq20AAAAQMBqug".repeat(4)); + let (text, w, _) = layout_title(&long, false, 300.0); + assert!(text.ends_with('…'), "truncated title ends with an ellipsis: {text:?}"); + assert!(text.chars().count() < long.chars().count()); + assert!(w <= 300.0, "title stays within its room: {w}"); + } + + #[test] + fn short_title_is_left_whole() { + let (text, _, _) = layout_title("Book1.xlsx", false, 300.0); + assert_eq!(text, "Book1.xlsx"); + } + + #[test] + fn unsaved_dot_survives_truncation() { + let long = "a very long workbook name ".repeat(20); + let (text, w, dot_w) = layout_title(&long, true, 200.0); + assert!(dot_w > 0.0, "the unsaved marker is laid out"); + assert!(text.ends_with('…')); + assert!(w + dot_w <= 200.0, "title and marker fit together: {}", w + dot_w); + } + + #[test] + fn no_room_draws_nothing_but_does_not_crash() { + let (_, w, _) = layout_title("Book1.xlsx", true, 0.0); + assert!(w <= 20.0, "a zero-width slot leaves at most the ellipsis: {w}"); + } +} diff --git a/crates/ui-egui/src/ribbon/view.rs b/crates/ui-egui/src/ribbon/view.rs new file mode 100644 index 0000000000..da278ad31b --- /dev/null +++ b/crates/ui-egui/src/ribbon/view.rs @@ -0,0 +1,182 @@ +use super::controls::{act, checkbox_on, control_keytip, invoke, keytip, menu_items, popup_command, sep}; +use super::*; + +/// Draws the View tab: view modes, ruler/gridlines/formula-bar/headings toggles, zoom, freeze panes, theme, animations, interface size and language. +pub(super) fn view(app: &mut SheetApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let _ = t; + for (icon, label, cmd) in [ + (Icon::Normal, "Normal", "view.normal"), + (Icon::PageBreak, "Page Break\nPreview", "view.pageBreakPreview"), + (Icon::PageLayout, "Page\nLayout", "view.pageLayout"), + ] { + let __k = big_button(ui, icon, label, label, false); + control_keytip(app, ui, &__k, cmd); + if __k.clicked() { + act(app, cmd, json!({})); + } + } + sep(ui); + let s = app.session.active().and_then(|d| d.wb.active().map(|s| (s.show_gridlines, s.show_headings))).unwrap_or((true, true)); + let lang = app.ui.language; + let page_layout = + app.session.active().and_then(|d| d.wb.active().map(|s| s.view == gridcraft_engine::model::SheetViewMode::PageLayout)).unwrap_or(false); + // Two columns of two, as in Excel's Show group: four stacked rows overflow the ribbon body. + ui.vertical(|ui| { + // Rulers exist only in Page Layout view. + let mut ruler = !app.session.hide_ruler; + let ruler_resp = ui.add_enabled(page_layout, egui::Checkbox::new(&mut ruler, lang.tr("Ruler"))); + control_keytip(app, ui, &ruler_resp, "view.ruler"); + if ruler_resp.changed() { + act(app, "view.ruler", json!({"on": ruler})); + } + let mut g = s.0; + let resp = ui.checkbox(&mut g, lang.tr("Gridlines")); + control_keytip(app, ui, &resp, "view.gridlines"); + if resp.changed() { + act(app, "view.gridlines", json!({"on": g})); + } + }); + ui.add_space(6.0); + ui.vertical(|ui| { + let mut fb = app.session.prefs.formula_bar; + let resp = ui.checkbox(&mut fb, lang.tr("Formula Bar")); + control_keytip(app, ui, &resp, "view.formulaBar"); + if resp.changed() || invoke(app, "view.formulaBar") { + act(app, "view.formulaBar", json!({"on": checkbox_on(resp.changed(), fb)})); + } + let mut h = s.1; + let resp = ui.checkbox(&mut h, lang.tr("Headings")); + control_keytip(app, ui, &resp, "view.headings"); + if resp.changed() { + act(app, "view.headings", json!({"on": h})); + } + }); + sep(ui); + let __k = big_button(ui, Icon::Zoom, "Zoom", "Zoom…", false); + control_keytip(app, ui, &__k, "dialog:zoom"); + if __k.clicked() { + app.open_dialog("zoom", json!({})); + } + let __k = big_button(ui, Icon::Search, "100%", "Zoom to 100%", false); + control_keytip(app, ui, &__k, "view.zoom"); + if __k.clicked() || invoke(app, "view.zoom") { + act(app, "view.zoom", json!({"percent": 100})); + } + let __k = big_button(ui, Icon::Zoom, "Zoom to\nSelection", "Zoom to Selection", false); + control_keytip(app, ui, &__k, "view.zoomToSelection"); + if __k.clicked() { + let r = app.grid.cells_rect.unwrap_or(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 700.0))); + act(app, "view.zoomToSelection", json!({"viewWidth": r.width(), "viewHeight": r.height()})); + } + sep(ui); + let __k = big_button(ui, Icon::NewFile, "New\nWindow", "New Window", false); + control_keytip(app, ui, &__k, "view.newWindow"); + if __k.clicked() { + act(app, "view.newWindow", json!({})); + } + let __k = big_button(ui, Icon::Table, "Arrange\nAll", "Arrange All", false); + control_keytip(app, ui, &__k, "view.arrangeAll"); + if __k.clicked() { + app.open_dialog("arrangeAll", json!({})); + } + let fp = big_button(ui, Icon::Freeze, "Freeze\nPanes", "Freeze Panes", true); + for sequence in + keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["view.freezePanes", "view.freezeTopRow", "view.freezeFirstColumn"]) + { + keytip(app, ui, sequence, fp.rect.center_bottom()); + } + egui::Popup::menu(&fp).show(|ui| { + menu_items( + app, + ui, + &[ + ("Freeze Panes", "view.freezePanes", json!({})), + ("Freeze Top Row", "view.freezeTopRow", json!({})), + ("Freeze First Column", "view.freezeFirstColumn", json!({})), + ("Unfreeze Panes", "view.unfreezePanes", json!({})), + ], + ); + }); + switch_windows(app, ui); + ui.vertical(|ui| { + let label_response = ui.label(lang.tr("Display theme")); + let mode = app.ui.theme_mode(); + let label = match mode { + "system" => "System", + "dark" => "Dark", + _ => "Light", + }; + egui::ComboBox::from_id_salt("display_theme") + .selected_text(lang.tr(label)) + .width(88.0) + .show_ui(ui, |ui| { + for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] { + if ui.selectable_label(mode == value, lang.tr(label)).clicked() { + act(app, "view.theme", json!({"mode": value})); + } + } + }) + .response + .labelled_by(label_response.id); + let mut animations = app.ui.animations; + let hint = lang.tr("Glide the selection outline to the cell you move to instead of jumping there"); + if ui.checkbox(&mut animations, lang.tr("Animations")).on_hover_text(hint).changed() { + act(app, "view.animations", json!({"on": animations})); + } + }); + sep(ui); + // Interface size (issue #230): scales the whole window on top of the desktop's scale. + ui.vertical(|ui| { + let label = ui.label(egui::RichText::new(lang.tr("Interface size")).font(theme::ui_font(11.5)).color(t.text_dim)); + let current = app.ui.interface_scale; + let text = |v: u16| if v == 100 { lang.tr("Auto").to_string() } else { format!("{v}%") }; + egui::ComboBox::from_id_salt("ui_interface_size") + .width(120.0) + .selected_text(egui::RichText::new(text(current)).font(theme::ui_font(12.5))) + .show_ui(ui, |ui| { + for v in [100, 75, 90, 110, 125, 150, 175, 200] { + if ui.selectable_label(v == current, text(v)).clicked() { + act(app, "view.interfaceScale", json!({"percent": v})); + } + } + }) + .response + .labelled_by(label.id); + }); + // Interface language: settings live in the View tab, like Excel's Options. The label follows + // the current language so it stays readable; the choices are always shown in their own script. + ui.vertical(|ui| { + let label = ui.label(egui::RichText::new(lang.tr("Interface language")).font(theme::ui_font(11.5)).color(t.text_dim)); + egui::ComboBox::from_id_salt("ui_language") + .width(120.0) + .selected_text(egui::RichText::new(lang.name()).font(theme::ui_font(12.5))) + .show_ui(ui, |ui| { + for l in crate::i18n::Language::ALL { + if ui.selectable_label(l == lang, l.name()).clicked() { + act(app, "app.language.set", json!({"language": l.code()})); + } + } + }) + .response + .labelled_by(label.id); + }); +} + +/// View › Switch Windows: the open workbooks, the active one checked and unsaved ones marked; +/// choosing one brings it forward. +fn switch_windows(app: &mut SheetApp, ui: &mut Ui) { + let anchor = big_button(ui, Icon::Book, "Switch\nWindows", "Switch Windows", true); + anchor.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Switch Windows")); + control_keytip(app, ui, &anchor, "view.switchWindows"); + let forced = std::mem::take(&mut app.open_switch_windows) | invoke(app, "view.switchWindows"); + egui::Popup::menu(&anchor).open_memory(popup_command(forced, anchor.clicked())).show(|ui| { + for w in app.open_windows() { + let label = theme::available_text(ui, &format!("{}{}", if w.active { "✓ " } else { " " }, w.label())); + if ui.add(egui::Button::selectable(w.active, label).min_size(vec2(220.0, 22.0))).clicked() { + act(app, "view.switchWindows", json!({"window": w.window})); + ui.close(); + } + } + }); +} diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs new file mode 100644 index 0000000000..1e64558601 --- /dev/null +++ b/crates/ui-egui/src/services.rs @@ -0,0 +1,61 @@ +use super::*; + +/// Host services the platform shell provides (file pickers, opening URLs). +#[derive(Default)] +pub struct Services { + /// Desktop: discover configured printers on a worker. + pub printers: Option, + /// Desktop: submit the PDF with the chosen settings on a worker. + pub print_native: Option, + pub pick_open: Option Option>>, + pub pick_save: Option Option>>, + /// Native picture picker (PNG/JPEG), separate from workbook opening. + pub pick_picture: Option Option>>, + /// Browser picture picker. Each dialog owns its inbox, so canceled picks cannot + /// insert into a subsequent dialog or open the image as a workbook. + pub pick_picture_async: Option>, + pub open_url: Option>, + /// Publish HTML and its plain-text alternative together. `Ok` means the host owns the + /// write (including any asynchronous fallback); an immediate error uses egui's text path. + pub copy_html: Option Result<(), String>>>, + /// Read the HTML flavour of the system clipboard when the user pastes text, so cells the + /// source spreadsheet recorded as numbers stay numbers ("Rp10,000.00" → 10000). `None` from + /// the host pastes the plain text alone. + pub paste_html: Option Option>>, + /// Read the system clipboard's plain text for the ribbon and menu Paste commands (Cmd/Ctrl+V + /// gets it from egui's `Event::Paste`). `None` from the host, or no service (web), pastes + /// GridCraft's own clipboard. + pub paste_text: Option Option>>, + /// Web: deliver a file to the user (name, bytes). + pub download: Option>, + /// Web: start an asynchronous file pick; the bytes arrive later in `inbox`. + pub open_async: Option>, + /// Files delivered asynchronously (name, bytes), opened on the next frame. + pub inbox: Option, + /// Web: hand PDF bytes to the browser's print flow directly (no download, + /// no intermediate file) — opens the system print dialog on that PDF. + pub print: Option>, +} + +/// Shared queue of files read asynchronously (browser file picker, drag and drop). +pub type Inbox = std::sync::Arc)>>>; + +/// Which parts of the app's own window chrome are drawn. Everything is on by default; a host +/// that embeds the sheet in its own window (and owns files itself) can turn off the title bar +/// and the File tab, whose Backstage view then never opens. Not persisted: it describes the +/// host, not a user preference. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Chrome { + /// The title bar: AutoSave, Home, Save, Undo/Redo, the file name, Comments, Share, Search and + /// the theme switch. + pub title_bar: bool, + /// The ribbon's File tab and the Backstage view it opens (also reached by keyboard and + /// commands such as `ui.backstage`). + pub file_tab: bool, +} + +impl Default for Chrome { + fn default() -> Self { + Chrome { title_bar: true, file_tab: true } + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs new file mode 100644 index 0000000000..d9e6ceaaac --- /dev/null +++ b/crates/ui-egui/src/state.rs @@ -0,0 +1,40 @@ +/// Per-sheet view state (scroll position in sheet points). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SheetView { + pub scroll: egui::Vec2, +} + +/// Severity of a notice shown in the info bar under the ribbon. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NoticeKind { + Info, + Warning, +} + +/// An answer to the Save Changes question. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SaveAnswer { + Save, + DontSave, + Cancel, +} + +/// One open window, as View › Switch Windows and the macOS Window menu list it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OpenWindow { + /// The window's id, the `window` param of `view.switchWindows`. + pub window: u64, + /// Its workbook's `DocState::uid`. + pub doc: u64, + pub title: String, + pub dirty: bool, + pub active: bool, +} + +impl OpenWindow { + /// The title ("Book1", or "Book1:2" for a workbook's second window), with a bullet after it + /// while the workbook has unsaved changes. + pub fn label(&self) -> String { + if self.dirty { format!("{} •", self.title) } else { self.title.clone() } + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 478b8b86b5..0adb1563c0 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -20,27 +20,22 @@ use std::sync::Arc; use egui::epaint::text::Blob; use egui::{Color32, FontData, FontDefinitions, FontFamily, FontId, Stroke, Visuals}; -/// A font from the optional craft-fonts build input (`CRAFT_FONTS_DIR` at build time; see -/// and its `docs/integration.md`). The slice is empty -/// when the app is built without the checkout; nothing is downloaded and no font is in this repo. -#[derive(Debug)] -pub struct CraftFont { - pub family: &'static str, - pub style: &'static str, - /// ISO 15924 scripts the family is intended for, from the craft-fonts manifest. - pub scripts: &'static [&'static str], - pub bytes: &'static [u8], -} - -mod craft_fonts { - use super::CraftFont; - include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs")); -} +mod font_fallback; +mod font_setup; -/// The light or dark value of a colour, by mode. -fn pick(dark: bool, light: T, dark_value: T) -> T { - if dark { dark_value } else { light } -} +pub use font_fallback::symbol_face_installed; +#[cfg(test)] +use font_fallback::{cjk_han_japanese, cjk_han_simplified, cjk_hangul, fallback_faces, hebrew, lao, symbols_more, thai}; +use font_setup::pick; +pub use font_setup::{ + CELL, CELL_BOLD, CELL_BOLD_ITALIC, CELL_ITALIC, CraftFont, HanOrder, MONO, SERIF, UI, UI_BOLD, available_text, cell_family, cell_text_width, + chinese_font_loaded, craft_font_scripts, craft_fonts_cover, craft_fonts_loaded, craft_fonts_required, font_definitions, + font_definitions_for_language, font_definitions_with, pinned_font_definitions, symbol_fallback, ui_bold, ui_font, +}; +#[cfg(test)] +use font_setup::{candidates, craft_font_faces, try_load}; +#[cfg(test)] +pub(crate) use font_setup::{drop_unparseable_fonts, font_parses}; #[derive(Clone, Copy, Debug, PartialEq)] pub struct Tokens { @@ -261,816 +256,6 @@ pub fn contrast(a: Color32, b: Color32) -> f32 { (la.max(lb) + 0.05) / (la.min(lb) + 0.05) } -pub const UI: &str = "ui"; -pub const UI_BOLD: &str = "ui-bold"; -pub const CELL: &str = "cell"; -pub const CELL_BOLD: &str = "cell-bold"; -pub const CELL_ITALIC: &str = "cell-italic"; -pub const CELL_BOLD_ITALIC: &str = "cell-bold-italic"; -pub const SERIF: &str = "serif"; -pub const MONO: &str = "mono"; - -pub fn ui_font(size: f32) -> FontId { - FontId::new(size, FontFamily::Name(UI.into())) -} -pub fn ui_bold(size: f32) -> FontId { - FontId::new(size, FontFamily::Name(UI_BOLD.into())) -} - -/// The font family to use for a cell font name and weight. -/// -/// When the name is an installed font whose faces were bound by [`font_definitions`], that family -/// is used; otherwise the name is bucketed to one of the built-in roles (mono/serif/cell). egui -/// panics on a family that was never bound, so the installed branch is gated on -/// [`crate::fonts::is_bound`] and never names a family egui hasn't been given. -pub fn cell_family(name: &str, bold: bool, italic: bool) -> FontFamily { - let role = crate::fonts::role_key(name, bold, italic); - if crate::fonts::is_bound(&role) { - return FontFamily::Name(role.into()); - } - let lower = name.to_ascii_lowercase(); - if lower.contains("courier") || lower.contains("mono") || lower.contains("consolas") { - return FontFamily::Name(MONO.into()); - } - if lower.contains("times") - || lower.contains("georgia") - || lower.contains("cambria") - || lower.contains("serif") && !lower.contains("sans") - || lower.contains("garamond") - { - return FontFamily::Name(SERIF.into()); - } - FontFamily::Name( - match (bold, italic) { - (true, true) => CELL_BOLD_ITALIC, - (true, false) => CELL_BOLD, - (false, true) => CELL_ITALIC, - (false, false) => CELL, - } - .into(), - ) -} - -/// Width in points, at 100% zoom, of one line of cell text drawn as the grid draws it: in the -/// face [`cell_family`] picks, which on a machine without the named font is a wider or narrower -/// substitute (#1316). A family the active font set doesn't have yet (bound fonts apply from the -/// next frame) falls back to the engine's estimate rather than letting egui panic on it. -/// -/// `exact` lays the text out (a galley kept until the end of the frame); otherwise the glyph -/// advances are summed without kerning, which is cheap and never narrower. This takes the -/// Context lock (`fonts_mut`): never call it, or run a command, while an egui lock is held. -pub fn cell_text_width(ctx: &egui::Context, text: &str, font: &gridcraft_engine::model::Font, exact: bool) -> f32 { - let family = cell_family(&font.name, font.bold, font.italic); - let id = FontId::new(font.size * 96.0 / 72.0, family); - ctx.fonts_mut(|f| { - f.families().contains(&id.family).then(|| { - if exact { - f.layout_no_wrap(text.to_owned(), id, Color32::PLACEHOLDER).size().x - } else { - // Half a point covers the galley's rounding of its width up to the pixel grid. - text.chars().map(|c| f.glyph_width(&id, c)).sum::() + 0.5 - } - }) - }) - .unwrap_or_else(|| gridcraft_engine::cmd::format::approx_text_width(text, font.size, font.bold)) -} - -/// Whether epaint can open face `index` of this font file and it maps characters to glyphs. -/// epaint panics (in `Context::set_fonts`, i.e. on startup) on a face it can't open, so every font -/// file we did not ship is checked first with the same parser and the same call epaint makes -/// (`skrifa::FontRef::from_index`): a corrupt file, something that is not an sfnt at all, or a -/// collection index past the end is rejected instead of crashing the app (#226). A face whose -/// character map is unreadable (a truncated file) would open but draw nothing, so it is refused -/// too and the next candidate gets its turn. -pub(crate) fn font_parses(bytes: &[u8], index: u32) -> bool { - use skrifa::MetadataProvider; - skrifa::FontRef::from_index(bytes, index).is_ok_and(|font| font.charmap().has_map()) -} - -/// Loads the first candidate that exists and parses. A file that exists but can't be parsed is -/// skipped with a warning and the next candidate is tried. -#[cfg(not(target_arch = "wasm32"))] -fn try_load(paths: &[(&str, u32)]) -> Option { - for (p, index) in paths { - if let Some(blob) = font_file(p, *index) { - return Some(FontData::from_blob(blob, *index)); - } - } - None -} - -/// The interface and script fallback font files, each read once per run and kept as a shared -/// `Blob` for the rest of it. egui shares the blob instead of copying the file into its text -/// engine, because every one of these files is loaded by every font rebuild for as long as the -/// app runs. Only a file that parses is kept: a missing or broken one is looked for again next -/// time. -#[cfg(not(target_arch = "wasm32"))] -fn font_file(path: &str, index: u32) -> Option { - static FILES: std::sync::Mutex> = std::sync::Mutex::new(std::collections::BTreeMap::new()); - let mut files = FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(blob) = files.get(path) { - let bytes: &[u8] = (**blob).as_ref(); - return font_parses(bytes, index).then(|| std::sync::Arc::clone(blob)); - } - let bytes = std::fs::read(path).ok()?; - if !font_parses(&bytes, index) { - log::warn!("skipping font {path} (face {index}): not a font file egui can read"); - return None; - } - let blob: Blob = std::sync::Arc::new(bytes); - files.insert(path.to_string(), blob.clone()); - Some(blob) -} - -/// Last line of defence before [`FontDefinitions`] reach egui: drops every face epaint could not -/// parse (and its key from every family), so a bad font costs a fallback, never a crash. -pub(crate) fn drop_unparseable_fonts(fonts: &mut FontDefinitions) { - let bad: Vec = fonts.font_data.iter().filter(|(_, fd)| !font_parses(fd.bytes(), fd.index)).map(|(k, _)| k.clone()).collect(); - for key in &bad { - log::warn!("dropping font {key:?}: egui can't parse it"); - fonts.font_data.remove(key); - } - if !bad.is_empty() { - for chain in fonts.families.values_mut() { - chain.retain(|k| !bad.contains(k)); - } - } -} - -/// egui's bundled monospace face, used as the last-resort symbol fallback (see `Hack-Regular.ttf` -/// in `epaint_default_fonts`). It is present whenever egui is built with `default_fonts`. -const HACK: &str = "Hack"; - -/// The permitted craft-fonts faces with their `FontDefinitions` keys, in manifest order. Empty -/// without a `CRAFT_FONTS_DIR` checkout at build time. The filter is repeated here so a stale -/// generated table can never smuggle in a barred face or a web-build face outside the allowlist. -fn craft_font_faces() -> Vec<(String, &'static CraftFont)> { - let wasm = cfg!(target_arch = "wasm32"); - craft_fonts::CRAFT_FONTS - .iter() - .enumerate() - .filter(|(_, font)| !crate::craft_fonts_policy::barred(font.family)) - .filter(|(_, font)| !wasm || crate::craft_fonts_policy::web_allowed(font.family, font.style)) - .map(|(i, font)| (format!("craft-{i}-{}-{}", font.family, font.style), font)) - .collect() -} - -/// Adds the optional craft-fonts faces to `fonts` and returns their keys. -fn add_craft_fonts(fonts: &mut FontDefinitions) -> Vec { - let mut keys = Vec::new(); - for (key, font) in craft_font_faces() { - fonts.font_data.insert(key.clone(), Arc::new(FontData::from_static(font.bytes))); - keys.push(key); - } - keys -} - -/// Whether the optional craft-fonts input was embedded in this build. -pub fn craft_fonts_loaded() -> bool { - !craft_fonts::CRAFT_FONTS.is_empty() -} - -/// Whether the native-only Simplified Chinese craft face (LXGW WenKai Lite) was embedded. A web -/// build or an older craft-fonts checkout without `fonts/simplified-chinese-manifest.txt` has none. -pub fn chinese_font_loaded() -> bool { - craft_font_faces().iter().any(|(_, f)| f.family == "LXGW WenKai Lite" && f.style == "Regular") -} - -/// Whether this build was made with `CRAFT_FONTS_REQUIRED=1`. Acceptance tests must fail rather -/// than skip when this is true and the faces are absent. -pub fn craft_fonts_required() -> bool { - craft_fonts::CRAFT_FONTS_REQUIRED -} - -/// A bundled-face character with the same meaning as `c`, for interface chrome whose exact symbol -/// no bundled face has (`✕`, `✓`, `⌃`). Document text still needs a real glyph; this is only a -/// display fallback and never changes the characters a command inserts or stores. -pub fn symbol_fallback(c: char) -> Option { - match c { - '✕' => Some('×'), - '✓' => Some('✔'), - '⌃' => Some('^'), - _ => None, - } -} - -/// `text` with every character the current font set cannot draw replaced by its semantic fallback -/// (see [`symbol_fallback`]). Used by chrome tooltips so a host without symbol faces never paints -/// a tofu box; it is not applied to workbook text. -pub fn available_text(ui: &egui::Ui, text: &str) -> String { - let font = ui_font(13.0); - text.chars().map(|c| if ui.ctx().fonts_mut(|f| f.has_glyph(&font, c)) { c } else { symbol_fallback(c).unwrap_or(c) }).collect() -} - -/// The family, style and scripts of every embedded craft-fonts face (empty without the input). -pub fn craft_font_scripts() -> impl Iterator { - craft_font_faces().into_iter().map(|(_, f)| (f.family, f.style, f.scripts)) -} - -/// Whether one embedded craft-fonts face maps every non-whitespace character in `text`. Tests use -/// this to tell "the pinned fixture covers this script" from "this host has no face for it", -/// without trusting a manifest script tag alone. -pub fn craft_fonts_cover(text: &str) -> bool { - craft_font_faces().iter().any(|(_, f)| { - use skrifa::MetadataProvider; - let Ok(font) = skrifa::FontRef::from_index(f.bytes, 0) else { - return false; - }; - let map = font.charmap(); - text.chars().filter(|c| !c.is_whitespace()).all(|c| map.map(c).is_some()) - }) -} - -#[cfg(target_arch = "wasm32")] -fn try_load(_paths: &[(&str, u32)]) -> Option { - None -} - -fn home_fonts(file: &str) -> String { - let home = std::env::var("HOME").unwrap_or_default(); - format!("{home}/Library/Fonts/{file}") -} - -/// Every file in `files` under every directory in `dirs`, directory first, in the same order a -/// nested loop over the directories would add them. -fn in_dirs(dirs: &[&str], files: &[&str]) -> Vec { - dirs.iter().flat_map(|d| files.iter().map(move |f| format!("{d}/{f}"))).collect() -} - -/// Candidate font files per role, in preference order. -fn candidates(role: &str) -> Vec<(String, u32)> { - let mac_sys = "/System/Library/Fonts"; - let mac_sup = "/System/Library/Fonts/Supplemental"; - let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); - // ChromeOS's Linux container (Crostini) mounts the host's fonts under `chromeos/` (#1762). - let lin = ["/usr/share/fonts/truetype", "/usr/share/fonts", "/usr/local/share/fonts", "/usr/share/fonts/TTF", "/usr/share/fonts/chromeos"]; - let mut v: Vec<(String, u32)> = Vec::new(); - let mut add = |s: String, i: u32| v.push((s, i)); - match role { - "ui" => { - add(format!("{mac_sys}/SFNS.ttf"), 0); - add(format!("{mac_sys}/HelveticaNeue.ttc"), 0); - add(format!("{win}\\segoeui.ttf"), 0); - for p in in_dirs( - &lin, - &["cantarell/Cantarell-Regular.otf", "noto/NotoSans-Regular.ttf", "dejavu/DejaVuSans.ttf", "liberation/LiberationSans-Regular.ttf"], - ) { - add(p, 0); - } - // Fedora keeps each family in its own package folder (#54). - add("/usr/share/fonts/abattis-cantarell-fonts/Cantarell-Regular.otf".into(), 0); - add("/usr/share/fonts/google-noto/NotoSans-Regular.ttf".into(), 0); - add("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf".into(), 0); - add("/usr/share/fonts/liberation-sans-fonts/LiberationSans-Regular.ttf".into(), 0); - } - "ui-bold" => { - add(format!("{mac_sys}/HelveticaNeue.ttc"), 1); - add(format!("{win}\\segoeuib.ttf"), 0); - for p in in_dirs(&lin, &["noto/NotoSans-Bold.ttf", "dejavu/DejaVuSans-Bold.ttf", "liberation/LiberationSans-Bold.ttf"]) { - add(p, 0); - } - add("/usr/share/fonts/google-noto/NotoSans-Bold.ttf".into(), 0); - add("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf".into(), 0); - add("/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf".into(), 0); - } - "cell" | "cell-bold" | "cell-italic" | "cell-bold-italic" => { - let (carlito, calibri_win, calibri_mac, arial_mac, arial_win, lib, dejavu) = match role { - "cell" => { - ("Carlito-Regular.ttf", "calibri.ttf", "Calibri.ttf", "Arial.ttf", "arial.ttf", "LiberationSans-Regular.ttf", "DejaVuSans.ttf") - } - "cell-bold" => ( - "Carlito-Bold.ttf", - "calibrib.ttf", - "Calibri Bold.ttf", - "Arial Bold.ttf", - "arialbd.ttf", - "LiberationSans-Bold.ttf", - "DejaVuSans-Bold.ttf", - ), - "cell-italic" => ( - "Carlito-Italic.ttf", - "calibrii.ttf", - "Calibri Italic.ttf", - "Arial Italic.ttf", - "ariali.ttf", - "LiberationSans-Italic.ttf", - "DejaVuSans-Oblique.ttf", - ), - _ => ( - "Carlito-BoldItalic.ttf", - "calibriz.ttf", - "Calibri Bold Italic.ttf", - "Arial Bold Italic.ttf", - "arialbi.ttf", - "LiberationSans-BoldItalic.ttf", - "DejaVuSans-BoldOblique.ttf", - ), - }; - // Calibri if the user has it installed system-wide (never from an Office bundle). - add(format!("/Library/Fonts/{calibri_mac}"), 0); - add(home_fonts(calibri_mac), 0); - add(format!("{win}\\{calibri_win}"), 0); - for p in in_dirs(&lin, &[&format!("crosextra/{carlito}"), &format!("carlito/{carlito}")]) { - add(p, 0); - } - // Fedora keeps each family in its own package folder, as for the UI fonts (#54). - add(format!("/usr/share/fonts/google-carlito-fonts/{carlito}"), 0); - add(home_fonts(carlito), 0); - add(format!("{mac_sup}/{arial_mac}"), 0); - add(format!("{win}\\{arial_win}"), 0); - for p in in_dirs(&lin, &[&format!("liberation/{lib}"), &format!("dejavu/{dejavu}")]) { - add(p, 0); - } - add(format!("/usr/share/fonts/liberation-sans-fonts/{lib}"), 0); - add(format!("/usr/share/fonts/dejavu-sans-fonts/{dejavu}"), 0); - } - "serif" => { - add(format!("{mac_sup}/Times New Roman.ttf"), 0); - add(format!("{win}\\times.ttf"), 0); - for p in in_dirs(&lin, &["liberation/LiberationSerif-Regular.ttf", "dejavu/DejaVuSerif.ttf"]) { - add(p, 0); - } - add("/usr/share/fonts/liberation-serif-fonts/LiberationSerif-Regular.ttf".into(), 0); - add("/usr/share/fonts/dejavu-serif-fonts/DejaVuSerif.ttf".into(), 0); - } - "mono" => { - add(format!("{mac_sys}/SFNSMono.ttf"), 0); - add(format!("{mac_sys}/Menlo.ttc"), 0); - add(format!("{win}\\consola.ttf"), 0); - for p in in_dirs(&lin, &["dejavu/DejaVuSansMono.ttf"]) { - add(p, 0); - } - add("/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf".into(), 0); - // Fedora installs DejaVu Sans Mono only on request. Liberation Mono comes with LibreOffice - // (in Workstation and KDE by default), Noto Sans Mono (variable, Regular by default) always. - for p in in_dirs(&lin, &["liberation/LiberationMono-Regular.ttf"]) { - add(p, 0); - } - add("/usr/share/fonts/liberation-mono-fonts/LiberationMono-Regular.ttf".into(), 0); - add("/usr/share/fonts/google-noto-vf/NotoSansMono[wght].ttf".into(), 0); - } - _ => {} - } - v -} - -/// Builds the font set from system fonts (with egui's defaults as fallback), with the default -/// (non-Japanese) Han order. Never reads the host locale, so tests and snapshots are stable. -pub fn font_definitions() -> FontDefinitions { - font_definitions_for_language(crate::i18n::Language::En) -} - -/// Which Han face leads the fallback chain. Both Han faces cover kana and most kanji, so this only -/// decides glyph shapes (and which face catches a glyph the other lacks); switching between two -/// languages with the same order needs no font rebuild. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum HanOrder { - /// Japanese glyph shapes first (the interface is Japanese). - Japanese, - /// Simplified Chinese first: the widest Han coverage, for every other interface language. - Chinese, -} - -impl HanOrder { - pub fn of(language: crate::i18n::Language) -> Self { - if language == crate::i18n::Language::Ja { Self::Japanese } else { Self::Chinese } - } -} - -/// Builds the font set with the Han fallback faces ordered for the interface language. -pub fn font_definitions_for_language(language: crate::i18n::Language) -> FontDefinitions { - font_definitions_with(language, &[]) -} - -/// The bundled faces plus the pinned craft-fonts faces, with no system-font probing or installed -/// workbook-font binding. -/// -/// Integration tests use this through their shared harness, so a minimal CI image and a developer -/// machine see the same font set. Craft-fonts remains a pinned build input rather than a repository -/// asset; without it only egui's bundled faces are available, which is the documented no-font mode. -pub fn pinned_font_definitions() -> FontDefinitions { - font_definitions_impl(crate::i18n::Language::En, &[], false) -} - -/// [`font_definitions_for_language`] that also binds the workbook fonts in `used` that are -/// installed, so cells set in them render with them; the rest fall back to the roles. The bound -/// families are recorded in [`crate::fonts`] so [`cell_family`] only names a family egui has. -pub fn font_definitions_with(language: crate::i18n::Language, used: &[String]) -> FontDefinitions { - font_definitions_impl(language, used, true) -} - -/// Builds the role font chains, script fallbacks, installed workbook fonts and optional craft-font faces, drops unparseable data and records which families were bound. -fn font_definitions_impl(language: crate::i18n::Language, used: &[String], system: bool) -> FontDefinitions { - let mut fonts = FontDefinitions::default(); - let base_prop: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); - let base_mono: Vec = fonts.families.get(&FontFamily::Monospace).cloned().unwrap_or_default(); - let mut bound: Vec = Vec::new(); - // The non-Latin fallback faces have to sit at the end of each chain: a Latin font owns Latin, - // and epaint picks the first face that has the glyph, so they only catch their own scripts. - let fallback = if system { load_fallback(&mut fonts, HanOrder::of(language)) } else { Vec::new() }; - let hack = fonts.font_data.contains_key(HACK).then_some(HACK); - for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { - let chain = build_role_chain(&mut fonts, role, &base_prop, &base_mono, &fallback, hack, system); - fonts.families.insert(FontFamily::Name(role.into()), chain); - bound.push(role.to_string()); - } - // Installed workbook fonts, so a cell's chosen font actually renders in it. - if system { - for family in crate::fonts::installed_among(used) { - bound.extend(crate::fonts::bind_family(&mut fonts, &family)); - } - } - // Interface text (formula bar, name box, dialogs) falls back to the cell font before the - // script faces: whatever a cell can draw, the formula bar can draw too, even where no script - // fallback face was found (#349: macOS UI font without Hebrew, cells in Arial with it). - bind_cell_fallback(&mut fonts, &fallback); - // Default proportional text in widgets uses the UI font. - if let Some(ui) = fonts.families.get(&FontFamily::Name(UI.into())).cloned() { - fonts.families.insert(FontFamily::Proportional, ui); - } - // The optional craft-fonts faces go at the very end of every family: a system or bundled face - // that has the script keeps its glyph shapes, while the permitted OFL faces cover scripts and - // symbols the host image lacks (Japanese, Korean, Thai, Arabic, Georgian and the UI symbols). - // Chinese comes from the native-only OFL LXGW face when embedded; a system Han face still - // leads, and the barred Noto CJK collection never enters. - append_craft_faces(&mut fonts); - drop_unparseable_fonts(&mut fonts); - // Only families egui still has data for count as bound (a dropped face can empty a chain). - bound.retain(|f| fonts.families.get(&FontFamily::Name(f.as_str().into())).is_some_and(|c| !c.is_empty())); - crate::fonts::set_bound(bound); - fonts -} - -/// Builds one role family's chain: the loaded system face (or its role fallback), egui's bundled -/// proportional/monospace base, Hack's symbols, then the script fallbacks. Keys without font data -/// are dropped, as before. -fn build_role_chain( - fonts: &mut FontDefinitions, - role: &str, - base_prop: &[String], - base_mono: &[String], - fallback: &[String], - hack: Option<&str>, - system: bool, -) -> Vec { - let paths = candidates(role); - let refs: Vec<(&str, u32)> = paths.iter().map(|(p, i)| (p.as_str(), *i)).collect(); - let mut chain: Vec = Vec::new(); - let loaded = if system { try_load(&refs) } else { None }; - if let Some(mut fd) = loaded { - // Helvetica Neue Bold's own TrueType hints close Cyrillic е into ө at 13 pt on a Retina - // screen (#1184). Core Text never runs them, so this draws it the way macOS does. - if cfg!(target_os = "macos") && role == UI_BOLD { - fd.tweak.hinting = Some(false); - } - let key = format!("sys-{role}"); - fonts.font_data.insert(key.clone(), Arc::new(fd)); - chain.push(key); - } else if role == UI_BOLD { - chain.push(format!("sys-{UI}")); - } else if role.starts_with("cell-") { - chain.push(format!("sys-{CELL}")); - } - chain.extend(if role == MONO { base_mono.to_vec() } else { base_prop.to_vec() }); - // egui bundles Hack, a monospace face rich in arrows, triangles and keyboard symbols, but - // 0.37 only puts it in the Monospace family. The interface draws ↑, ▲, ▼, ▸, ▾ and ↳ as - // text (filter menus, ribbon, keytips), so add it behind the UI/cell and emoji faces; it - // only catches characters those do not have. - if let Some(hack) = hack - && !chain.iter().any(|k| k.as_str() == hack) - { - chain.push(hack.to_string()); - } - chain.extend(fallback.iter().cloned()); - chain.retain(|k| fonts.font_data.contains_key(k)); - chain -} - -/// Inserts the loaded cell face before the script fallbacks of the interface families, so the -/// formula bar and dialogs can draw whatever a cell can (#349). -fn bind_cell_fallback(fonts: &mut FontDefinitions, fallback: &[String]) { - let cell_key = format!("sys-{CELL}"); - if !fonts.font_data.contains_key(&cell_key) { - return; - } - for role in [UI, UI_BOLD, SERIF, MONO] { - if let Some(chain) = fonts.families.get_mut(&FontFamily::Name(role.into())) - && !chain.contains(&cell_key) - { - let at = chain.iter().position(|k| fallback.contains(k)).unwrap_or(chain.len()); - chain.insert(at, cell_key.clone()); - } - } -} - -/// Appends the permitted craft-fonts faces to every family, preserving the leading system or -/// bundled face's glyph shapes. -fn append_craft_faces(fonts: &mut FontDefinitions) { - let craft = add_craft_fonts(fonts); - if craft.is_empty() { - return; - } - for chain in fonts.families.values_mut() { - for key in &craft { - if !chain.contains(key) { - chain.push(key.clone()); - } - } - } -} - -/// Loads the system non-Latin fallback faces (one file per script family) and returns their keys so -/// every family can carry them as fallback. egui's bundled fonts have no coverage beyond Latin, so -/// without this a Thai, Hebrew, Japanese or Chinese workbook shows tofu boxes even though the text -/// was read correctly. With a face installed those scripts get real glyphs instead of tofu; nothing -/// is bundled, and a machine without the face keeps the old behaviour. -#[cfg(not(target_arch = "wasm32"))] -fn load_fallback(fonts: &mut FontDefinitions, han: HanOrder) -> Vec { - let mut keys = Vec::new(); - // Files already loaded as a fallback face. A file serves at most one key: the region-merged - // Noto Sans CJK .ttc found for Han also carries Hangul (and Tahoma carries both Thai and - // Hebrew), so loading it again for a later script would only duplicate tens of MB in memory. - let mut loaded: Vec = Vec::new(); - for (key, cands) in fallback_faces(han) { - for (path, index) in &cands { - if loaded.contains(path) { - break; // a face loaded for an earlier script already covers this one - } - if let Some(fd) = try_load(&[(path.as_str(), *index)]) { - fonts.font_data.insert(key.to_string(), Arc::new(fd)); - keys.push(key.to_string()); - loaded.push(path.clone()); - break; - } - } - } - keys -} - -#[cfg(target_arch = "wasm32")] -fn load_fallback(_fonts: &mut FontDefinitions, _han: HanOrder) -> Vec { - Vec::new() -} - -/// Every fallback face we look for, in chain order: `(key, candidate files)`. One file per script -/// family is enough — epaint picks the first face with the glyph, and a script's glyphs live in one -/// file. Order only matters between faces that both cover a glyph (Japanese vs Chinese kanji), so -/// the interface language reorders the two Han entries and nothing else. -#[cfg(not(target_arch = "wasm32"))] -fn fallback_faces(han: HanOrder) -> Vec<(&'static str, Vec<(String, u32)>)> { - let ja = ("sys-cjk-han-ja", cjk_han_japanese()); - let zh = ("sys-cjk-han-zh", cjk_han_simplified()); - let (first, second) = match han { - HanOrder::Japanese => (ja, zh), - HanOrder::Chinese => (zh, ja), - }; - vec![ - first, - second, - ("sys-cjk-hangul", cjk_hangul()), - ("sys-thai", thai()), - ("sys-lao", lao()), - ("sys-hebrew", hebrew()), - ("sys-symbols", symbols()), - ("sys-symbols-2", symbols_more()), - ] -} - -/// Linux directories that hold CJK fonts across distributions: Debian/Ubuntu (`opentype/noto`, -/// `truetype/wqy`), Fedora (`google-noto-cjk`), Arch (`noto-cjk`, `wenquanyi`), ChromeOS's Linux -/// container (`chromeos/notocjk`, #1762), plus local and -/// flat layouts. Flatpak exposes the same host layouts through read-only font mounts. -#[cfg(not(target_arch = "wasm32"))] -const LINUX_CJK_DIRS: [&str; 22] = [ - // Flatpak mounts host fonts here without a broad filesystem permission. Keep the same - // distribution layouts as below; /usr/share/fonts inside the sandbox belongs to the runtime. - "/run/host/fonts/opentype/noto", - "/run/host/fonts/google-noto-cjk", - "/run/host/fonts/noto-cjk", - "/run/host/fonts/truetype/noto", - "/run/host/fonts/noto", - "/run/host/fonts/truetype", - "/run/host/fonts/TTF", - "/run/host/fonts", - "/run/host/local-fonts", - "/run/host/local-fonts/noto-cjk", - "/run/host/user-fonts", - "/usr/share/fonts/opentype/noto", - "/usr/share/fonts/google-noto-cjk", - "/usr/share/fonts/noto-cjk", - "/usr/share/fonts/truetype/noto", - "/usr/share/fonts/noto", - "/usr/share/fonts/truetype", - "/usr/share/fonts/TTF", - "/usr/share/fonts", - "/usr/local/share/fonts", - "/usr/local/share/fonts/noto-cjk", - "/usr/share/fonts/chromeos/notocjk", -]; - -/// The Windows fonts folder (`%WINDIR%\Fonts`, or the default install location). -#[cfg(not(target_arch = "wasm32"))] -fn windows_fonts() -> String { - std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()) -} - -/// The Noto Sans CJK faces for one locale (`jp`, `sc`, `kr`): the per-locale OTF, then the -/// region-merged TTC at `face`, in every Linux font directory. -#[cfg(not(target_arch = "wasm32"))] -fn noto_cjk(locale: &str, face: u32) -> Vec<(String, u32)> { - let mut v = Vec::new(); - for d in LINUX_CJK_DIRS { - v.push((format!("{d}/NotoSansCJK{locale}-Regular.otf"), 0)); - v.push((format!("{d}/NotoSansCJK-Regular.ttc"), face)); - } - v -} - -/// Japanese Han + Kana faces (Japanese kanji glyph shapes). -/// -/// The region-merged `NotoSansCJK-Regular.ttc` holds one face per locale (JP 0, KR 1, SC 2, TC 3, -/// HK 4) with the same glyph coverage, so the index only picks glyph shapes. Whichever Han list -/// comes first loads it; [`load_fallback`] does not load the same file again for the other. -#[cfg(not(target_arch = "wasm32"))] -fn cjk_han_japanese() -> Vec<(String, u32)> { - let mac = "/System/Library/Fonts"; - let win = windows_fonts(); - let mut v: Vec<(String, u32)> = Vec::new(); - v.push((format!("{win}\\YuGothR.ttc"), 0)); // Yu Gothic - v.push((format!("{win}\\msgothic.ttc"), 0)); // MS Gothic - v.push((format!("{mac}/ヒラギノ角ゴシック W3.ttc"), 0)); // Hiragino Sans - v.extend(noto_cjk("jp", 0)); - v -} - -/// Simplified Chinese Han faces. Loaded apart from the Japanese ones so a glyph one family lacks -/// (simplified-only hanzi in a Japanese font) still falls through to a real glyph. -#[cfg(not(target_arch = "wasm32"))] -fn cjk_han_simplified() -> Vec<(String, u32)> { - let mac = "/System/Library/Fonts"; - let win = windows_fonts(); - let mut v: Vec<(String, u32)> = vec![ - (format!("{win}\\msyh.ttc"), 0), // Microsoft YaHei - (format!("{win}\\simsun.ttc"), 0), // SimSun - (format!("{win}\\simhei.ttf"), 0), // SimHei - (format!("{mac}/Hiragino Sans GB.ttc"), 0), // ships on every recent macOS - (format!("{mac}/PingFang.ttc"), 0), // older macOS only; now a downloadable asset - ]; - v.extend(noto_cjk("sc", 2)); - for d in LINUX_CJK_DIRS { - v.push((format!("{d}/wqy/wqy-microhei.ttc"), 0)); - v.push((format!("{d}/wenquanyi/wqy-microhei.ttc"), 0)); - v.push((format!("{d}/wqy-microhei.ttc"), 0)); - } - v -} - -/// Hangul faces (Korean). The Han faces above that come from the region-merged Noto Sans CJK file -/// already carry Hangul; [`load_fallback`] then skips this list rather than load the file twice. -#[cfg(not(target_arch = "wasm32"))] -fn cjk_hangul() -> Vec<(String, u32)> { - let mac = "/System/Library/Fonts"; - let win = windows_fonts(); - let mut v: Vec<(String, u32)> = Vec::new(); - v.push((format!("{win}\\malgun.ttf"), 0)); // Malgun Gothic - v.push((format!("{mac}/AppleSDGothicNeo.ttc"), 0)); - v.extend(noto_cjk("kr", 1)); - v -} - -/// Thai faces. Windows ships Leelawadee UI and Tahoma/Microsoft Sans Serif (both carry Thai); -/// Linux font directories searched by the Thai and Hebrew faces, in priority order. -#[cfg(not(target_arch = "wasm32"))] -fn linux_noto_dirs() -> [&'static str; 5] { - [ - "/usr/share/fonts/truetype/noto", - "/usr/share/fonts/opentype/noto", - "/usr/share/fonts/truetype", - "/usr/share/fonts", - "/usr/share/fonts/chromeos/noto", // ChromeOS's Linux container (#1762) - ] -} - -/// Faces for one script: Windows files under `WINDIR\Fonts` first, then macOS paths, then the -/// Noto/Linux files in every [`linux_noto_dirs`] directory, then the Fedora variable font. -#[cfg(not(target_arch = "wasm32"))] -fn script_fonts(win: &[&str], mac: &[&str], lin_files: &[&str], variable: &str) -> Vec<(String, u32)> { - let w = windows_fonts(); - let mut v: Vec<(String, u32)> = win.iter().map(|f| (format!("{w}\\{f}"), 0)).collect(); - v.extend(mac.iter().map(|p| (p.to_string(), 0))); - v.extend(in_dirs(&linux_noto_dirs(), lin_files).into_iter().map(|p| (p, 0))); - v.push((variable.into(), 0)); - v -} - -/// macOS has Thonburi; Linux usually has Noto Sans Thai or Garuda. -#[cfg(not(target_arch = "wasm32"))] -fn thai() -> Vec<(String, u32)> { - script_fonts( - // Leelawadee UI, Tahoma, Microsoft Sans Serif - &["LeelawUI.ttf", "tahoma.ttf", "micross.ttf"], - &["/System/Library/Fonts/Supplemental/Thonburi.ttc", "/System/Library/Fonts/SukhumvitSet.ttc"], - &["NotoSansThai-Regular.ttf", "tlwg/Garuda.ttf", "garuda/Garuda.ttf"], - // Fedora ships the variable Noto Sans Thai; its default instance is Regular. - "/usr/share/fonts/google-noto-vf/NotoSansThai[wght].ttf", - ) -} - -/// Lao faces. Windows ships Leelawadee UI (and DokChampa on older versions); macOS has Lao Sangam -/// MN and Lao MN; Linux usually has Noto Sans Lao or Phetsarath OT. -#[cfg(not(target_arch = "wasm32"))] -fn lao() -> Vec<(String, u32)> { - let mac_sup = "/System/Library/Fonts/Supplemental"; - let win = windows_fonts(); - let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"]; - let mut v: Vec<(String, u32)> = vec![ - (format!("{win}\\LeelawUI.ttf"), 0), // Leelawadee UI - (format!("{win}\\dokchamp.ttf"), 0), // DokChampa - (format!("{mac_sup}/Lao Sangam MN.ttf"), 0), - (format!("{mac_sup}/Lao MN.ttc"), 0), - ]; - for d in lin { - v.push((format!("{d}/NotoSansLao-Regular.ttf"), 0)); - v.push((format!("{d}/lao/Phetsarath_OT.ttf"), 0)); - v.push((format!("{d}/Phetsarath_OT.ttf"), 0)); - } - v -} - -/// Hebrew faces. Windows has David and FrankRuehl (and Tahoma/Courier New carry Hebrew); macOS has -/// Arial Hebrew and David; Linux usually has Noto Sans Hebrew or DejaVu Sans. -#[cfg(not(target_arch = "wasm32"))] -fn hebrew() -> Vec<(String, u32)> { - script_fonts( - // David, FrankRuehl, Tahoma - &["david.ttf", "frank.ttf", "tahoma.ttf"], - &[ - "/System/Library/Fonts/ArialHB.ttc", - "/System/Library/Fonts/SFHebrew.ttf", - "/System/Library/Fonts/Supplemental/Arial Hebrew.ttf", - "/System/Library/Fonts/Supplemental/David.ttf", - "/System/Library/Fonts/LucidaGrande.ttc", - "/System/Library/Fonts/Lucida Grande.ttc", - ], - &["NotoSansHebrew-Regular.ttf", "dejavu/DejaVuSans.ttf", "freefont/FreeSans.ttf"], - "/usr/share/fonts/google-noto-vf/NotoSansHebrew[wght].ttf", - ) -} - -/// Symbol faces behind the UI font. Interface fonts such as Noto Sans and Cantarell (and egui's -/// built-in Ubuntu) lack arrows, check marks, geometric shapes and keyboard symbols (▾ ✓ ✕ ▲ ↳ ⇧), -/// which then render as empty boxes (#197, #54). DejaVu Sans covers most of them; the paths cover -/// Debian/Ubuntu, Fedora and Arch layouts. [`load_fallback`] skips the file if Hebrew already -/// loaded it. -#[cfg(not(target_arch = "wasm32"))] -fn symbols() -> Vec<(String, u32)> { - let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); - let mut v: Vec<(String, u32)> = vec![(format!("{win}\\seguisym.ttf"), 0), ("/System/Library/Fonts/Apple Symbols.ttf".into(), 0)]; - for d in [ - "/usr/share/fonts/truetype/dejavu", - "/usr/share/fonts/dejavu-sans-fonts", - "/usr/share/fonts/TTF", - "/usr/share/fonts/dejavu", - "/usr/share/fonts/truetype/ttf-dejavu", - "/usr/local/share/fonts/dejavu", - "/usr/local/share/fonts", - ] { - v.push((format!("{d}/DejaVuSans.ttf"), 0)); - } - v -} - -/// A second symbol face for what the first lacks (⌘ and other technical symbols): Noto Sans -/// Symbols 2 where it is installed, or Segoe UI Symbol behind Segoe UI. -#[cfg(not(target_arch = "wasm32"))] -fn symbols_more() -> Vec<(String, u32)> { - let mut v: Vec<(String, u32)> = Vec::new(); - for d in [ - "/usr/share/fonts/noto", - "/usr/share/fonts/truetype/noto", - "/usr/share/fonts/opentype/noto", - "/usr/share/fonts/google-noto-sans-symbols-2-fonts", - "/usr/share/fonts/google-noto", - "/usr/local/share/fonts/noto", - "/usr/share/fonts/chromeos/noto", - ] { - v.push((format!("{d}/NotoSansSymbols2-Regular.ttf"), 0)); - } - v -} - -/// Whether any symbol fallback face is installed (tests skip their glyph checks without one). -pub fn symbol_face_installed() -> bool { - #[cfg(not(target_arch = "wasm32"))] - { - symbols().iter().chain(symbols_more().iter()).any(|(p, _)| std::path::Path::new(p).exists()) - } - #[cfg(target_arch = "wasm32")] - { - false - } -} - /// Installs light and dark token visuals in both theme slots, keeps the 2-3 px widget and 8-10 px window/menu corner radii from 0.36, and sets spacing, text styles and the active theme. pub fn apply(ctx: &egui::Context, dark: bool) { // Install both palettes before System can switch between the style slots. @@ -1120,54 +305,6 @@ pub fn color32(rgb: [u8; 3]) -> Color32 { Color32::from_rgb(rgb[0], rgb[1], rgb[2]) } -#[cfg(test)] -mod font_binding_tests { - use super::*; - - /// The families [`font_definitions`] always binds, whatever is installed. - fn always_bound() -> Vec { - [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO].iter().map(|s| s.to_string()).collect() - } - - #[test] - fn a_name_that_is_not_an_installed_font_gets_a_built_in_role() { - let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - // Nothing installed: every name buckets to a role that font_definitions bound. - crate::fonts::set_bound(always_bound()); - for name in ["", "Calibri", "Courier New", "Times New Roman", "Grossly Missing Font", "\u{1F600}"] { - for (b, i) in [(false, false), (true, false), (false, true), (true, true)] { - let FontFamily::Name(fam) = cell_family(name, b, i) else { panic!("{name:?} should name a family") }; - assert!(crate::fonts::is_bound(&fam), "{name:?} {b} {i} → unbound {fam:?}"); - } - } - } - - #[test] - fn a_bound_installed_font_is_used_by_name() { - let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let role = crate::fonts::role_key("Ubuntu", true, false); - crate::fonts::set_bound(always_bound().into_iter().chain([role.clone()])); - assert!(matches!(cell_family("Ubuntu", true, false), FontFamily::Name(f) if *f == *role)); - // The same family in a style that wasn't bound falls back to a built-in role that is. - let FontFamily::Name(fallback) = cell_family("Ubuntu", false, true) else { panic!("should name a family") }; - assert!(crate::fonts::is_bound(&fallback), "the fallback is a bound role"); - crate::fonts::set_bound(always_bound()); - } - - #[test] - fn arbitrary_names_never_yield_an_unbound_family() { - let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - // A workbook can hold any font name; none may name a family egui has no data for. - crate::fonts::set_bound(always_bound()); - for seed in 0..500u32 { - let name: String = - (0..(seed % 40)).map(|k| char::from_u32(0x20 + (seed.wrapping_mul(31).wrapping_add(k) % 0x5F)).unwrap_or('x')).collect(); - let FontFamily::Name(fam) = cell_family(&name, seed % 2 == 0, seed % 3 == 0) else { continue }; - assert!(crate::fonts::is_bound(&fam), "{name:?} → unbound {fam:?}"); - } - } -} - #[cfg(test)] mod contrast_tests { use super::*; @@ -1267,363 +404,4 @@ mod contrast_tests { } #[cfg(all(test, not(target_arch = "wasm32")))] -mod tests { - use super::*; - use crate::i18n::Language; - - #[test] - /// Loaded script-fallback faces end each role chain just before the craft fonts, in the language's order. - fn fallback_faces_are_appended_to_every_family() { - // Structural: whatever loaded, the non-Latin fallback keys must sit at the end of each chain - // (after the Latin/default fonts) so Latin still wins for Latin text, in the language's order. - for han in [HanOrder::Chinese, HanOrder::Japanese] { - let language = if han == HanOrder::Japanese { Language::Ja } else { Language::Zh }; - let fonts = font_definitions_for_language(language); - let fb_keys: Vec = - fallback_faces(han).into_iter().map(|(k, _)| k.to_string()).filter(|k| fonts.font_data.contains_key(k)).collect(); - if fb_keys.is_empty() { - return; // no fallback face on this machine (minimal Linux) - } - for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { - let chain = fonts.families.get(&FontFamily::Name(role.into())).map(Vec::as_slice).unwrap_or_default(); - // The optional craft-fonts faces follow the system fallbacks at the very end. - let end = chain.iter().rposition(|k| !k.starts_with("craft-")).map_or(0, |i| i + 1); - assert!(end >= fb_keys.len(), "{role} chain too short for its fallback"); - assert_eq!(&chain[end - fb_keys.len()..end], fb_keys.as_slice(), "{role} must end its non-craft chain with the fallback faces"); - } - } - } - - #[test] - /// Every font family ends with exactly the permitted craft faces in manifest order. - fn craft_fonts_are_the_last_fallback_in_every_family() { - // Ordering: app/bundled faces, Hack/emoji, system script fallbacks, permitted craft faces. - // A craft face must never displace a system face that already covers a glyph. - let fonts = font_definitions_for_language(Language::En); - let expected: Vec = craft_font_faces().into_iter().map(|(key, _)| key).collect(); - if expected.is_empty() { - return; // optional input absent - } - for (family, chain) in &fonts.families { - let first = chain.iter().position(|k| k.starts_with("craft-")).unwrap_or(chain.len()); - assert_eq!(&chain[first..], expected.as_slice(), "{family:?} must end with exactly the permitted craft faces in manifest order"); - } - } - - #[test] - /// Skips on wasm or when required mode is off; a required native build must embed LXGW with `Hani`/`Hans`. - fn a_required_native_build_embeds_the_chinese_face() { - if cfg!(target_arch = "wasm32") || !craft_fonts_required() { - return; - } - assert!( - chinese_font_loaded(), - "CRAFT_FONTS_REQUIRED=1 native builds must embed the LXGW Chinese face from fonts/simplified-chinese-manifest.txt" - ); - let face = craft_font_scripts().find(|(f, s, _)| *f == "LXGW WenKai Lite" && *s == "Regular"); - assert_eq!(face.map(|(_, _, scripts)| scripts), Some(["Hani", "Hans"].as_slice()), "the Chinese manifest's script tags are asserted"); - } - - #[test] - /// No embedded craft face belongs to the barred Noto CJK, Adobe or Source families. - fn barred_faces_never_enter_the_font_set() { - for (family, _, _) in craft_font_scripts() { - assert!(!family.contains("CJK"), "the barred Noto CJK collection must never be embedded: {family}"); - assert!(!family.starts_with("Source ") && !family.starts_with("Adobe "), "a barred Adobe/Source face was embedded: {family}"); - } - } - - #[test] - /// Skips when craft fonts are absent unless required mode is set; otherwise the required faces and their exact script tags are embedded. - fn the_required_craft_faces_keep_their_exact_scripts() { - if !craft_fonts_loaded() { - assert!(!craft_fonts_required(), "CRAFT_FONTS_REQUIRED=1 but no craft-fonts faces were embedded"); - return; - } - let faces: Vec<_> = craft_font_scripts().collect(); - for (family, style, scripts) in [ - ("BIZ UDPGothic", "Regular", ["Jpan", "Latn"].as_slice()), - ("BIZ UDPGothic", "Bold", ["Jpan", "Latn"].as_slice()), - ("NanumGothic", "Regular", ["Hang", "Kore"].as_slice()), - ("Noto Sans Symbols", "Regular", ["Zsym"].as_slice()), - ("Noto Sans Symbols 2", "Regular", ["Zsym"].as_slice()), - ] { - assert!( - faces.iter().any(|(f, s, declared)| *f == family && *s == style && *declared == scripts), - "{family} {style} with {scripts:?} must be embedded; got {faces:?}" - ); - } - } - - #[test] - /// Interface text maps unbundled `⌃`, `✓` and `✕` to readable chrome fallbacks; document text is not rewritten. - fn available_text_maps_unbundled_chrome_symbols() { - // No bundled face has ⌃ ✓ ✕; chrome text must show ^ ✔ × instead of tofu. Document text is - // never rewritten: this helper is only used for interface tooltips. - let ctx = egui::Context::default(); - ctx.set_fonts(FontDefinitions::default()); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - assert_eq!(available_text(ui, "Show Formulas (⌃`) ✓ ✕"), "Show Formulas (^`) ✔ ×"); - }); - out.textures_delta.clear(); - } - - #[test] - fn interface_families_fall_back_to_the_cell_font() { - // Structural (#349): the formula bar, name box and dialogs draw every script the cells can, - // so each interface chain carries the cell face, ahead of the script fallback faces. - let fonts = font_definitions_for_language(Language::En); - let cell = format!("sys-{CELL}"); - if !fonts.font_data.contains_key(&cell) { - return; // no system cell font on this machine - } - let fb: Vec = fallback_faces(HanOrder::Chinese).into_iter().map(|(k, _)| k.to_string()).collect(); - for family in [FontFamily::Proportional, FontFamily::Name(UI.into()), FontFamily::Name(UI_BOLD.into()), FontFamily::Name(MONO.into())] { - let chain = fonts.families.get(&family).cloned().unwrap_or_default(); - let at = chain.iter().position(|k| *k == cell); - assert!(at.is_some(), "{family:?} lacks the cell face: {chain:?}"); - let first_fb = chain.iter().position(|k| fb.contains(k)).unwrap_or(chain.len()); - assert!(at.unwrap_or(usize::MAX) < first_fb, "{family:?}: cell face must precede the script faces"); - } - } - - #[test] - fn language_reorders_only_the_han_faces() { - let keys = |han| fallback_faces(han).into_iter().map(|(k, _)| k).collect::>(); - assert_eq!( - keys(HanOrder::Japanese), - ["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"] - ); - assert_eq!( - keys(HanOrder::Chinese), - ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"] - ); - assert_eq!(HanOrder::of(Language::Ja), HanOrder::Japanese); - for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr, Language::De, Language::Cs, Language::Fr, Language::Tr] { - assert_eq!(HanOrder::of(l), HanOrder::Chinese, "{l:?} shares the default order (no font rebuild)"); - } - } - - #[test] - fn cjk_fallback_probes_flatpak_fonts_with_the_correct_locale_faces() { - // Exercise the candidates the fallback loader actually consumes, not just the directory - // constant. This must work on CI without an installed CJK font or a Flatpak runtime. - for (candidates, face) in [(cjk_han_japanese(), 0), (cjk_han_simplified(), 2), (cjk_hangul(), 1)] { - for directory in [ - "/run/host/fonts/opentype/noto", - "/run/host/fonts/google-noto-cjk", - "/run/host/fonts/noto-cjk", - "/run/host/fonts/truetype/noto", - "/run/host/fonts/noto", - "/run/host/fonts/truetype", - "/run/host/fonts/TTF", - "/run/host/fonts", - "/run/host/local-fonts", - "/run/host/local-fonts/noto-cjk", - "/run/host/user-fonts", - ] { - let path = format!("{directory}/NotoSansCJK-Regular.ttc"); - assert!(candidates.contains(&(path.clone(), face)), "missing Flatpak CJK candidate {path}, face {face}"); - } - } - let chinese = cjk_han_simplified(); - assert!(chinese.contains(&("/run/host/fonts/truetype/wqy/wqy-microhei.ttc".into(), 0))); - assert!(chinese.contains(&("/run/host/fonts/wenquanyi/wqy-microhei.ttc".into(), 0))); - } - - #[test] - /// A font file shared by several fallback scripts is loaded once, with one shared font blob rather than a copy per script. - fn fallback_faces_load_each_file_once() { - // The region-merged Noto Sans CJK .ttc serves Japanese, Chinese and Hangul; it must be loaded - // once, not once per script (tens of MB each). - for language in [Language::En, Language::Ja] { - let fonts = font_definitions_for_language(language); - let present: Vec<&Arc> = - fallback_faces(HanOrder::of(language)).into_iter().filter_map(|(k, _)| fonts.font_data.get(k)).collect(); - for (i, a) in present.iter().enumerate() { - for b in present.iter().skip(i + 1) { - assert!(!std::sync::Arc::ptr_eq(&a.font, &b.font), "the same font file is loaded for two fallback scripts"); - } - } - } - } - - #[test] - /// Checks that the proportional and cell families have glyphs for every present script sample; samples with no system or craft face are printed as skipped. - fn fallback_covers_its_script() { - // Real glyph check, per script: the proportional family (what cells and the UI use) must - // have a glyph for every non-space character in the sample. A script runs when a system - // face or the optional craft-fonts input provides one; the printed skip is a recorded - // gate, and a present face that is not wired in fails. - let cases: [(&str, Vec<(String, u32)>, &str); 10] = [ - ("sys-cjk-han-ja", cjk_han_japanese(), "カテゴリ"), - // Chinese uses a system Han face or the native-only permitted LXGW craft face; the - // barred Noto CJK collection never supplies it. - ("sys-cjk-han-zh", cjk_han_simplified(), "页面布局 客户端配置"), - ("sys-cjk-hangul", cjk_hangul(), "한글"), - ("sys-thai", thai(), "ทดสอบ"), - ("sys-lao", lao(), "ພາສາລາວ"), - ("sys-hebrew", hebrew(), "עברית"), - ("craft-noto-sans-arabic", Vec::new(), "مرحبا"), - ("craft-noto-sans-georgian", Vec::new(), "ქართული"), - ("ui", candidates(UI), "Русский"), - // Czech interface labels: every Czech accented letter, in both cases. - ("ui", candidates(UI), "Příliš žluťoučký kůň úpěl ďábelské ódy ŘŠČŽÝÁÍÉÚŮĚŤĎŇÓ"), - ]; - for language in [Language::En, Language::Ja] { - let ctx = egui::Context::default(); - ctx.set_fonts(font_definitions_for_language(language)); - let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {}); - out.textures_delta.clear(); // nothing consumes the atlas in a headless test - for family in [FontFamily::Proportional, FontFamily::Name(CELL.into())] { - let font = FontId::new(14.0, family.clone()); - for (key, cands, sample) in &cases { - // A script runs when a system face or the optional craft-fonts input provides - // one. The skip is printed: a missing fixture is a recorded gate, not a pass. - let system = cands.iter().any(|(p, _)| std::path::Path::new(p).exists()); - if !system && !craft_fonts_cover(sample) { - eprintln!("skipped {key}: no system face and no craft-fonts face for {sample:?}"); - continue; - } - let missing: String = - sample.chars().filter(|c| !c.is_whitespace()).filter(|&c| !ctx.fonts_mut(|f| f.has_glyph(&font, c))).collect(); - assert!(missing.is_empty(), "{key} is present but {sample:?} has no glyph for {missing:?} ({language:?}, {family:?})"); - } - } - } - } - - #[test] - fn chromeos_font_layout_is_probed() { - // #1762: Crostini mounts the host's fonts under /usr/share/fonts/chromeos, one folder per - // family set; a script whose list misses it renders as tofu with the font installed. - let probes = |cands: Vec<(String, u32)>, path: &str| cands.iter().any(|(p, _)| p == path); - let cros = "/usr/share/fonts/chromeos"; - assert!(probes(thai(), &format!("{cros}/noto/NotoSansThai-Regular.ttf"))); - assert!(probes(hebrew(), &format!("{cros}/noto/NotoSansHebrew-Regular.ttf"))); - assert!(probes(cjk_han_japanese(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); - assert!(probes(cjk_han_simplified(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); - assert!(probes(cjk_hangul(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); - assert!(probes(symbols_more(), &format!("{cros}/noto/NotoSansSymbols2-Regular.ttf"))); - assert!(probes(candidates(UI), &format!("{cros}/noto/NotoSans-Regular.ttf"))); - assert!(probes(candidates(CELL), &format!("{cros}/crosextra/Carlito-Regular.ttf"))); - } - - #[test] - /// Empty, garbage, truncated and bad-index fonts are refused; a bad face is dropped from the definitions and `set_fonts` does not panic. - fn unparseable_font_files_are_rejected_not_fatal() { - // #226: epaint panics in `set_fonts` on a face it can't parse ("An offset was out of - // bounds"). Garbage, a truncated real font and a bad collection index must all be refused. - let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.bytes().to_vec()).unwrap_or_default(); - assert!(font_parses(&real, 0), "egui's own font must parse"); - assert!(!font_parses(b"", 0)); - assert!(!font_parses(&[0xAB; 64], 0)); - assert!(!font_parses(real.get(..20).unwrap_or_default(), 0), "a truncated font must be refused"); - assert!(!font_parses(&real, 3), "a single-face file has no face 3"); - - // A bad face that slipped into the definitions is dropped (key and family entries), and the - // context builds its fonts without panicking. - let mut fonts = font_definitions(); - fonts.font_data.insert("sys-hebrew".into(), Arc::new(FontData::from_owned(vec![0, 1, 0, 0, 0, 0xFF, 0, 0]))); - for chain in fonts.families.values_mut() { - chain.push("sys-hebrew".into()); - } - drop_unparseable_fonts(&mut fonts); - assert!(!fonts.font_data.contains_key("sys-hebrew")); - assert!(fonts.families.values().all(|c| !c.iter().any(|k| k == "sys-hebrew"))); - let ctx = egui::Context::default(); - ctx.set_fonts(fonts); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - ui.label("Hebrew עברית"); - }); - out.textures_delta.clear(); // nothing consumes the atlas in a headless test - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - /// A broken existing font candidate is skipped in favor of a later readable candidate. - fn try_load_skips_a_broken_candidate() { - // A candidate that exists but is not a readable font must not win over a good one behind it. - let dir = std::env::temp_dir().join(format!("gridcraft-font-test-{}", std::process::id())); - let _ = std::fs::create_dir_all(&dir); - let bad = dir.join("broken.ttf"); - let good = dir.join("good.ttf"); - let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.bytes().to_vec()).unwrap_or_default(); - let _ = std::fs::write(&bad, real.get(..40).unwrap_or_default()); - let _ = std::fs::write(&good, &real); - let (bad_s, good_s) = (bad.to_string_lossy().into_owned(), good.to_string_lossy().into_owned()); - assert!(try_load(&[(bad_s.as_str(), 0)]).is_none()); - let loaded = try_load(&[(bad_s.as_str(), 0), (good_s.as_str(), 0)]); - assert_eq!(loaded.map(|fd| fd.bytes().len()), Some(real.len())); - let _ = std::fs::remove_dir_all(&dir); - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - /// Repeated loads reuse one shared font blob, while a face the kept file does not contain is still refused. - fn interface_fonts_are_read_once_and_shared_with_egui() { - // egui copies owned font bytes into its text engine and keeps the original too; borrowed - // bytes it shares. A rebuild of the font set reuses the file read the first time. - let path = std::env::temp_dir().join(format!("gridcraft-font-shared-{}.ttf", std::process::id())); - std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).unwrap(); - let p = path.to_string_lossy().into_owned(); - let first = try_load(&[(p.as_str(), 0)]).unwrap(); - std::fs::remove_file(&path).unwrap(); - let second = try_load(&[(p.as_str(), 0)]).expect("kept from the first read"); - assert!( - std::sync::Arc::ptr_eq(&first.font, &second.font), - "interface font bytes must be one shared blob, so egui shares them instead of copying" - ); - assert_eq!(first.bytes(), epaint_default_fonts::UBUNTU_LIGHT); - assert!(try_load(&[(p.as_str(), 3)]).is_none(), "a face the kept file doesn't have is still refused"); - } - - #[test] - fn macos_bold_interface_face_is_drawn_unhinted() { - // #1184: Helvetica Neue Bold's own TrueType hints close Cyrillic е into ө at 13 pt on a - // Retina screen. Core Text never runs them, so on macOS the face goes unhinted like every - // other app draws it; the regular face and the other platforms keep egui's default. - let fonts = font_definitions_for_language(Language::En); - let hinting = |key: &str| fonts.font_data.get(key).map(|fd| fd.tweak.hinting); - if let Some(bold) = hinting(&format!("sys-{UI_BOLD}")) { - assert_eq!(bold, if cfg!(target_os = "macos") { Some(false) } else { None }); - } - if let Some(regular) = hinting(&format!("sys-{UI}")) { - assert_eq!(regular, None, "only the bold face is affected"); - } - } - - #[test] - fn fedora_package_folders_are_searched_after_the_other_linux_layouts() { - // #1694: Fedora keeps each font family in its own package folder, so without these entries - // no cell face is found there and bold and italic cells draw as regular text. Each comes - // after the existing Linux folders for the same font, so wherever one of those matched, - // nothing changes. - let paths = |cands: Vec<(String, u32)>| cands.into_iter().map(|(p, _)| p).collect::>(); - let cases: [(&str, Vec, &str, &str); 9] = [ - (CELL, paths(candidates(CELL)), "google-carlito-fonts/Carlito-Regular.ttf", "/carlito/Carlito-Regular.ttf"), - (CELL_BOLD, paths(candidates(CELL_BOLD)), "google-carlito-fonts/Carlito-Bold.ttf", "/carlito/Carlito-Bold.ttf"), - (CELL_ITALIC, paths(candidates(CELL_ITALIC)), "google-carlito-fonts/Carlito-Italic.ttf", "/carlito/Carlito-Italic.ttf"), - (CELL_BOLD_ITALIC, paths(candidates(CELL_BOLD_ITALIC)), "google-carlito-fonts/Carlito-BoldItalic.ttf", "/carlito/Carlito-BoldItalic.ttf"), - (CELL_BOLD, paths(candidates(CELL_BOLD)), "liberation-sans-fonts/LiberationSans-Bold.ttf", "/liberation/LiberationSans-Bold.ttf"), - (SERIF, paths(candidates(SERIF)), "liberation-serif-fonts/LiberationSerif-Regular.ttf", "/liberation/LiberationSerif-Regular.ttf"), - (MONO, paths(candidates(MONO)), "liberation-mono-fonts/LiberationMono-Regular.ttf", "/dejavu/DejaVuSansMono.ttf"), - ("thai", paths(thai()), "google-noto-vf/NotoSansThai[wght].ttf", "/NotoSansThai-Regular.ttf"), - ("hebrew", paths(hebrew()), "google-noto-vf/NotoSansHebrew[wght].ttf", "/NotoSansHebrew-Regular.ttf"), - ]; - for (role, list, fedora_file, earlier) in &cases { - let at = list.iter().position(|p| *p == format!("/usr/share/fonts/{fedora_file}")); - let last_earlier = list.iter().rposition(|p| p.ends_with(earlier)); - assert!(at.is_some(), "{role}: Fedora's {fedora_file} is not a candidate"); - assert!(last_earlier.is_some(), "{role}: no existing {earlier} candidate to compare with"); - assert!(at > last_earlier, "{role}: {fedora_file} must come after every existing {earlier}"); - } - // Liberation Mono only stands in for DejaVu Sans Mono, wherever that is installed. - let mono = paths(candidates(MONO)); - let first_liberation = mono.iter().position(|p| p.ends_with("LiberationMono-Regular.ttf")); - let last_dejavu = mono.iter().rposition(|p| p.ends_with("DejaVuSansMono.ttf")); - assert!(first_liberation.is_some() && first_liberation > last_dejavu, "Liberation Mono must follow DejaVu Sans Mono: {mono:?}"); - let noto = mono.iter().position(|p| p == "/usr/share/fonts/google-noto-vf/NotoSansMono[wght].ttf"); - assert!(noto.is_some() && noto.unwrap_or(0) + 1 == mono.len(), "Fedora's default Noto Sans Mono must be the last resort: {mono:?}"); - } -} +mod tests; diff --git a/crates/ui-egui/src/theme/font_fallback.rs b/crates/ui-egui/src/theme/font_fallback.rs new file mode 100644 index 0000000000..940a871d5d --- /dev/null +++ b/crates/ui-egui/src/theme/font_fallback.rs @@ -0,0 +1,296 @@ +use super::font_setup::HanOrder; +#[cfg(not(target_arch = "wasm32"))] +use super::font_setup::{in_dirs, try_load}; +use super::*; + +/// Loads the system non-Latin fallback faces (one file per script family) and returns their keys so +/// every family can carry them as fallback. egui's bundled fonts have no coverage beyond Latin, so +/// without this a Thai, Hebrew, Japanese or Chinese workbook shows tofu boxes even though the text +/// was read correctly. With a face installed those scripts get real glyphs instead of tofu; nothing +/// is bundled, and a machine without the face keeps the old behaviour. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn load_fallback(fonts: &mut FontDefinitions, han: HanOrder) -> Vec { + let mut keys = Vec::new(); + // Files already loaded as a fallback face. A file serves at most one key: the region-merged + // Noto Sans CJK .ttc found for Han also carries Hangul (and Tahoma carries both Thai and + // Hebrew), so loading it again for a later script would only duplicate tens of MB in memory. + let mut loaded: Vec = Vec::new(); + for (key, cands) in fallback_faces(han) { + for (path, index) in &cands { + if loaded.contains(path) { + break; // a face loaded for an earlier script already covers this one + } + if let Some(fd) = try_load(&[(path.as_str(), *index)]) { + fonts.font_data.insert(key.to_string(), Arc::new(fd)); + keys.push(key.to_string()); + loaded.push(path.clone()); + break; + } + } + } + keys +} + +#[cfg(target_arch = "wasm32")] +pub(super) fn load_fallback(_fonts: &mut FontDefinitions, _han: HanOrder) -> Vec { + Vec::new() +} + +/// Every fallback face we look for, in chain order: `(key, candidate files)`. One file per script +/// family is enough — epaint picks the first face with the glyph, and a script's glyphs live in one +/// file. Order only matters between faces that both cover a glyph (Japanese vs Chinese kanji), so +/// the interface language reorders the two Han entries and nothing else. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn fallback_faces(han: HanOrder) -> Vec<(&'static str, Vec<(String, u32)>)> { + let ja = ("sys-cjk-han-ja", cjk_han_japanese()); + let zh = ("sys-cjk-han-zh", cjk_han_simplified()); + let (first, second) = match han { + HanOrder::Japanese => (ja, zh), + HanOrder::Chinese => (zh, ja), + }; + vec![ + first, + second, + ("sys-cjk-hangul", cjk_hangul()), + ("sys-thai", thai()), + ("sys-lao", lao()), + ("sys-hebrew", hebrew()), + ("sys-symbols", symbols()), + ("sys-symbols-2", symbols_more()), + ] +} + +/// Linux directories that hold CJK fonts across distributions: Debian/Ubuntu (`opentype/noto`, +/// `truetype/wqy`), Fedora (`google-noto-cjk`), Arch (`noto-cjk`, `wenquanyi`), ChromeOS's Linux +/// container (`chromeos/notocjk`, #1762), plus local and +/// flat layouts. Flatpak exposes the same host layouts through read-only font mounts. +#[cfg(not(target_arch = "wasm32"))] +const LINUX_CJK_DIRS: [&str; 22] = [ + // Flatpak mounts host fonts here without a broad filesystem permission. Keep the same + // distribution layouts as below; /usr/share/fonts inside the sandbox belongs to the runtime. + "/run/host/fonts/opentype/noto", + "/run/host/fonts/google-noto-cjk", + "/run/host/fonts/noto-cjk", + "/run/host/fonts/truetype/noto", + "/run/host/fonts/noto", + "/run/host/fonts/truetype", + "/run/host/fonts/TTF", + "/run/host/fonts", + "/run/host/local-fonts", + "/run/host/local-fonts/noto-cjk", + "/run/host/user-fonts", + "/usr/share/fonts/opentype/noto", + "/usr/share/fonts/google-noto-cjk", + "/usr/share/fonts/noto-cjk", + "/usr/share/fonts/truetype/noto", + "/usr/share/fonts/noto", + "/usr/share/fonts/truetype", + "/usr/share/fonts/TTF", + "/usr/share/fonts", + "/usr/local/share/fonts", + "/usr/local/share/fonts/noto-cjk", + "/usr/share/fonts/chromeos/notocjk", +]; + +/// The Windows fonts folder (`%WINDIR%\Fonts`, or the default install location). +#[cfg(not(target_arch = "wasm32"))] +fn windows_fonts() -> String { + std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()) +} + +/// The Noto Sans CJK faces for one locale (`jp`, `sc`, `kr`): the per-locale OTF, then the +/// region-merged TTC at `face`, in every Linux font directory. +#[cfg(not(target_arch = "wasm32"))] +fn noto_cjk(locale: &str, face: u32) -> Vec<(String, u32)> { + let mut v = Vec::new(); + for d in LINUX_CJK_DIRS { + v.push((format!("{d}/NotoSansCJK{locale}-Regular.otf"), 0)); + v.push((format!("{d}/NotoSansCJK-Regular.ttc"), face)); + } + v +} + +/// Japanese Han + Kana faces (Japanese kanji glyph shapes). +/// +/// The region-merged `NotoSansCJK-Regular.ttc` holds one face per locale (JP 0, KR 1, SC 2, TC 3, +/// HK 4) with the same glyph coverage, so the index only picks glyph shapes. Whichever Han list +/// comes first loads it; [`load_fallback`] does not load the same file again for the other. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn cjk_han_japanese() -> Vec<(String, u32)> { + let mac = "/System/Library/Fonts"; + let win = windows_fonts(); + let mut v: Vec<(String, u32)> = Vec::new(); + v.push((format!("{win}\\YuGothR.ttc"), 0)); // Yu Gothic + v.push((format!("{win}\\msgothic.ttc"), 0)); // MS Gothic + v.push((format!("{mac}/ヒラギノ角ゴシック W3.ttc"), 0)); // Hiragino Sans + v.extend(noto_cjk("jp", 0)); + v +} + +/// Simplified Chinese Han faces. Loaded apart from the Japanese ones so a glyph one family lacks +/// (simplified-only hanzi in a Japanese font) still falls through to a real glyph. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn cjk_han_simplified() -> Vec<(String, u32)> { + let mac = "/System/Library/Fonts"; + let win = windows_fonts(); + let mut v: Vec<(String, u32)> = vec![ + (format!("{win}\\msyh.ttc"), 0), // Microsoft YaHei + (format!("{win}\\simsun.ttc"), 0), // SimSun + (format!("{win}\\simhei.ttf"), 0), // SimHei + (format!("{mac}/Hiragino Sans GB.ttc"), 0), // ships on every recent macOS + (format!("{mac}/PingFang.ttc"), 0), // older macOS only; now a downloadable asset + ]; + v.extend(noto_cjk("sc", 2)); + for d in LINUX_CJK_DIRS { + v.push((format!("{d}/wqy/wqy-microhei.ttc"), 0)); + v.push((format!("{d}/wenquanyi/wqy-microhei.ttc"), 0)); + v.push((format!("{d}/wqy-microhei.ttc"), 0)); + } + v +} + +/// Hangul faces (Korean). The Han faces above that come from the region-merged Noto Sans CJK file +/// already carry Hangul; [`load_fallback`] then skips this list rather than load the file twice. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn cjk_hangul() -> Vec<(String, u32)> { + let mac = "/System/Library/Fonts"; + let win = windows_fonts(); + let mut v: Vec<(String, u32)> = Vec::new(); + v.push((format!("{win}\\malgun.ttf"), 0)); // Malgun Gothic + v.push((format!("{mac}/AppleSDGothicNeo.ttc"), 0)); + v.extend(noto_cjk("kr", 1)); + v +} + +/// Thai faces. Windows ships Leelawadee UI and Tahoma/Microsoft Sans Serif (both carry Thai); +/// Linux font directories searched by the Thai and Hebrew faces, in priority order. +#[cfg(not(target_arch = "wasm32"))] +fn linux_noto_dirs() -> [&'static str; 5] { + [ + "/usr/share/fonts/truetype/noto", + "/usr/share/fonts/opentype/noto", + "/usr/share/fonts/truetype", + "/usr/share/fonts", + "/usr/share/fonts/chromeos/noto", // ChromeOS's Linux container (#1762) + ] +} + +/// Faces for one script: Windows files under `WINDIR\Fonts` first, then macOS paths, then the +/// Noto/Linux files in every [`linux_noto_dirs`] directory, then the Fedora variable font. +#[cfg(not(target_arch = "wasm32"))] +fn script_fonts(win: &[&str], mac: &[&str], lin_files: &[&str], variable: &str) -> Vec<(String, u32)> { + let w = windows_fonts(); + let mut v: Vec<(String, u32)> = win.iter().map(|f| (format!("{w}\\{f}"), 0)).collect(); + v.extend(mac.iter().map(|p| (p.to_string(), 0))); + v.extend(in_dirs(&linux_noto_dirs(), lin_files).into_iter().map(|p| (p, 0))); + v.push((variable.into(), 0)); + v +} + +/// macOS has Thonburi; Linux usually has Noto Sans Thai or Garuda. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn thai() -> Vec<(String, u32)> { + script_fonts( + // Leelawadee UI, Tahoma, Microsoft Sans Serif + &["LeelawUI.ttf", "tahoma.ttf", "micross.ttf"], + &["/System/Library/Fonts/Supplemental/Thonburi.ttc", "/System/Library/Fonts/SukhumvitSet.ttc"], + &["NotoSansThai-Regular.ttf", "tlwg/Garuda.ttf", "garuda/Garuda.ttf"], + // Fedora ships the variable Noto Sans Thai; its default instance is Regular. + "/usr/share/fonts/google-noto-vf/NotoSansThai[wght].ttf", + ) +} + +/// Lao faces. Windows ships Leelawadee UI (and DokChampa on older versions); macOS has Lao Sangam +/// MN and Lao MN; Linux usually has Noto Sans Lao or Phetsarath OT. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn lao() -> Vec<(String, u32)> { + let mac_sup = "/System/Library/Fonts/Supplemental"; + let win = windows_fonts(); + let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"]; + let mut v: Vec<(String, u32)> = vec![ + (format!("{win}\\LeelawUI.ttf"), 0), // Leelawadee UI + (format!("{win}\\dokchamp.ttf"), 0), // DokChampa + (format!("{mac_sup}/Lao Sangam MN.ttf"), 0), + (format!("{mac_sup}/Lao MN.ttc"), 0), + ]; + for d in lin { + v.push((format!("{d}/NotoSansLao-Regular.ttf"), 0)); + v.push((format!("{d}/lao/Phetsarath_OT.ttf"), 0)); + v.push((format!("{d}/Phetsarath_OT.ttf"), 0)); + } + v +} + +/// Hebrew faces. Windows has David and FrankRuehl (and Tahoma/Courier New carry Hebrew); macOS has +/// Arial Hebrew and David; Linux usually has Noto Sans Hebrew or DejaVu Sans. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn hebrew() -> Vec<(String, u32)> { + script_fonts( + // David, FrankRuehl, Tahoma + &["david.ttf", "frank.ttf", "tahoma.ttf"], + &[ + "/System/Library/Fonts/ArialHB.ttc", + "/System/Library/Fonts/SFHebrew.ttf", + "/System/Library/Fonts/Supplemental/Arial Hebrew.ttf", + "/System/Library/Fonts/Supplemental/David.ttf", + "/System/Library/Fonts/LucidaGrande.ttc", + "/System/Library/Fonts/Lucida Grande.ttc", + ], + &["NotoSansHebrew-Regular.ttf", "dejavu/DejaVuSans.ttf", "freefont/FreeSans.ttf"], + "/usr/share/fonts/google-noto-vf/NotoSansHebrew[wght].ttf", + ) +} + +/// Symbol faces behind the UI font. Interface fonts such as Noto Sans and Cantarell (and egui's +/// built-in Ubuntu) lack arrows, check marks, geometric shapes and keyboard symbols (▾ ✓ ✕ ▲ ↳ ⇧), +/// which then render as empty boxes (#197, #54). DejaVu Sans covers most of them; the paths cover +/// Debian/Ubuntu, Fedora and Arch layouts. [`load_fallback`] skips the file if Hebrew already +/// loaded it. +#[cfg(not(target_arch = "wasm32"))] +fn symbols() -> Vec<(String, u32)> { + let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); + let mut v: Vec<(String, u32)> = vec![(format!("{win}\\seguisym.ttf"), 0), ("/System/Library/Fonts/Apple Symbols.ttf".into(), 0)]; + for d in [ + "/usr/share/fonts/truetype/dejavu", + "/usr/share/fonts/dejavu-sans-fonts", + "/usr/share/fonts/TTF", + "/usr/share/fonts/dejavu", + "/usr/share/fonts/truetype/ttf-dejavu", + "/usr/local/share/fonts/dejavu", + "/usr/local/share/fonts", + ] { + v.push((format!("{d}/DejaVuSans.ttf"), 0)); + } + v +} + +/// A second symbol face for what the first lacks (⌘ and other technical symbols): Noto Sans +/// Symbols 2 where it is installed, or Segoe UI Symbol behind Segoe UI. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn symbols_more() -> Vec<(String, u32)> { + let mut v: Vec<(String, u32)> = Vec::new(); + for d in [ + "/usr/share/fonts/noto", + "/usr/share/fonts/truetype/noto", + "/usr/share/fonts/opentype/noto", + "/usr/share/fonts/google-noto-sans-symbols-2-fonts", + "/usr/share/fonts/google-noto", + "/usr/local/share/fonts/noto", + "/usr/share/fonts/chromeos/noto", + ] { + v.push((format!("{d}/NotoSansSymbols2-Regular.ttf"), 0)); + } + v +} + +/// Whether any symbol fallback face is installed (tests skip their glyph checks without one). +pub fn symbol_face_installed() -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + symbols().iter().chain(symbols_more().iter()).any(|(p, _)| std::path::Path::new(p).exists()) + } + #[cfg(target_arch = "wasm32")] + { + false + } +} diff --git a/crates/ui-egui/src/theme/font_setup.rs b/crates/ui-egui/src/theme/font_setup.rs new file mode 100644 index 0000000000..749079e71a --- /dev/null +++ b/crates/ui-egui/src/theme/font_setup.rs @@ -0,0 +1,598 @@ +use super::font_fallback::load_fallback; +use super::*; + +/// A font from the optional craft-fonts build input (`CRAFT_FONTS_DIR` at build time; see +/// and its `docs/integration.md`). The slice is empty +/// when the app is built without the checkout; nothing is downloaded and no font is in this repo. +#[derive(Debug)] +pub struct CraftFont { + pub family: &'static str, + pub style: &'static str, + /// ISO 15924 scripts the family is intended for, from the craft-fonts manifest. + pub scripts: &'static [&'static str], + pub bytes: &'static [u8], +} + +mod craft_fonts { + use super::CraftFont; + include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs")); +} + +/// The light or dark value of a colour, by mode. +pub(super) fn pick(dark: bool, light: T, dark_value: T) -> T { + if dark { dark_value } else { light } +} + +pub const UI: &str = "ui"; + +pub const UI_BOLD: &str = "ui-bold"; + +pub const CELL: &str = "cell"; + +pub const CELL_BOLD: &str = "cell-bold"; + +pub const CELL_ITALIC: &str = "cell-italic"; + +pub const CELL_BOLD_ITALIC: &str = "cell-bold-italic"; + +pub const SERIF: &str = "serif"; + +pub const MONO: &str = "mono"; + +pub fn ui_font(size: f32) -> FontId { + FontId::new(size, FontFamily::Name(UI.into())) +} + +pub fn ui_bold(size: f32) -> FontId { + FontId::new(size, FontFamily::Name(UI_BOLD.into())) +} + +/// The font family to use for a cell font name and weight. +/// +/// When the name is an installed font whose faces were bound by [`font_definitions`], that family +/// is used; otherwise the name is bucketed to one of the built-in roles (mono/serif/cell). egui +/// panics on a family that was never bound, so the installed branch is gated on +/// [`crate::fonts::is_bound`] and never names a family egui hasn't been given. +pub fn cell_family(name: &str, bold: bool, italic: bool) -> FontFamily { + let role = crate::fonts::role_key(name, bold, italic); + if crate::fonts::is_bound(&role) { + return FontFamily::Name(role.into()); + } + let lower = name.to_ascii_lowercase(); + if lower.contains("courier") || lower.contains("mono") || lower.contains("consolas") { + return FontFamily::Name(MONO.into()); + } + if lower.contains("times") + || lower.contains("georgia") + || lower.contains("cambria") + || lower.contains("serif") && !lower.contains("sans") + || lower.contains("garamond") + { + return FontFamily::Name(SERIF.into()); + } + FontFamily::Name( + match (bold, italic) { + (true, true) => CELL_BOLD_ITALIC, + (true, false) => CELL_BOLD, + (false, true) => CELL_ITALIC, + (false, false) => CELL, + } + .into(), + ) +} + +/// Width in points, at 100% zoom, of one line of cell text drawn as the grid draws it: in the +/// face [`cell_family`] picks, which on a machine without the named font is a wider or narrower +/// substitute (#1316). A family the active font set doesn't have yet (bound fonts apply from the +/// next frame) falls back to the engine's estimate rather than letting egui panic on it. +/// +/// `exact` lays the text out (a galley kept until the end of the frame); otherwise the glyph +/// advances are summed without kerning, which is cheap and never narrower. This takes the +/// Context lock (`fonts_mut`): never call it, or run a command, while an egui lock is held. +pub fn cell_text_width(ctx: &egui::Context, text: &str, font: &gridcraft_engine::model::Font, exact: bool) -> f32 { + let family = cell_family(&font.name, font.bold, font.italic); + let id = FontId::new(font.size * 96.0 / 72.0, family); + ctx.fonts_mut(|f| { + f.families().contains(&id.family).then(|| { + if exact { + f.layout_no_wrap(text.to_owned(), id, Color32::PLACEHOLDER).size().x + } else { + // Half a point covers the galley's rounding of its width up to the pixel grid. + text.chars().map(|c| f.glyph_width(&id, c)).sum::() + 0.5 + } + }) + }) + .unwrap_or_else(|| gridcraft_engine::cmd::format::approx_text_width(text, font.size, font.bold)) +} + +/// Whether epaint can open face `index` of this font file and it maps characters to glyphs. +/// epaint panics (in `Context::set_fonts`, i.e. on startup) on a face it can't open, so every font +/// file we did not ship is checked first with the same parser and the same call epaint makes +/// (`skrifa::FontRef::from_index`): a corrupt file, something that is not an sfnt at all, or a +/// collection index past the end is rejected instead of crashing the app (#226). A face whose +/// character map is unreadable (a truncated file) would open but draw nothing, so it is refused +/// too and the next candidate gets its turn. +pub(crate) fn font_parses(bytes: &[u8], index: u32) -> bool { + use skrifa::MetadataProvider; + skrifa::FontRef::from_index(bytes, index).is_ok_and(|font| font.charmap().has_map()) +} + +/// Loads the first candidate that exists and parses. A file that exists but can't be parsed is +/// skipped with a warning and the next candidate is tried. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn try_load(paths: &[(&str, u32)]) -> Option { + for (p, index) in paths { + if let Some(blob) = font_file(p, *index) { + return Some(FontData::from_blob(blob, *index)); + } + } + None +} + +/// The interface and script fallback font files, each read once per run and kept as a shared +/// `Blob` for the rest of it. egui shares the blob instead of copying the file into its text +/// engine, because every one of these files is loaded by every font rebuild for as long as the +/// app runs. Only a file that parses is kept: a missing or broken one is looked for again next +/// time. +#[cfg(not(target_arch = "wasm32"))] +fn font_file(path: &str, index: u32) -> Option { + static FILES: std::sync::Mutex> = std::sync::Mutex::new(std::collections::BTreeMap::new()); + let mut files = FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(blob) = files.get(path) { + let bytes: &[u8] = (**blob).as_ref(); + return font_parses(bytes, index).then(|| std::sync::Arc::clone(blob)); + } + let bytes = std::fs::read(path).ok()?; + if !font_parses(&bytes, index) { + log::warn!("skipping font {path} (face {index}): not a font file egui can read"); + return None; + } + let blob: Blob = std::sync::Arc::new(bytes); + files.insert(path.to_string(), blob.clone()); + Some(blob) +} + +/// Last line of defence before [`FontDefinitions`] reach egui: drops every face epaint could not +/// parse (and its key from every family), so a bad font costs a fallback, never a crash. +pub(crate) fn drop_unparseable_fonts(fonts: &mut FontDefinitions) { + let bad: Vec = fonts.font_data.iter().filter(|(_, fd)| !font_parses(fd.bytes(), fd.index)).map(|(k, _)| k.clone()).collect(); + for key in &bad { + log::warn!("dropping font {key:?}: egui can't parse it"); + fonts.font_data.remove(key); + } + if !bad.is_empty() { + for chain in fonts.families.values_mut() { + chain.retain(|k| !bad.contains(k)); + } + } +} + +/// egui's bundled monospace face, used as the last-resort symbol fallback (see `Hack-Regular.ttf` +/// in `epaint_default_fonts`). It is present whenever egui is built with `default_fonts`. +const HACK: &str = "Hack"; + +/// The permitted craft-fonts faces with their `FontDefinitions` keys, in manifest order. Empty +/// without a `CRAFT_FONTS_DIR` checkout at build time. The filter is repeated here so a stale +/// generated table can never smuggle in a barred face or a web-build face outside the allowlist. +pub(super) fn craft_font_faces() -> Vec<(String, &'static CraftFont)> { + let wasm = cfg!(target_arch = "wasm32"); + craft_fonts::CRAFT_FONTS + .iter() + .enumerate() + .filter(|(_, font)| !crate::craft_fonts_policy::barred(font.family)) + .filter(|(_, font)| !wasm || crate::craft_fonts_policy::web_allowed(font.family, font.style)) + .map(|(i, font)| (format!("craft-{i}-{}-{}", font.family, font.style), font)) + .collect() +} + +/// Adds the optional craft-fonts faces to `fonts` and returns their keys. +fn add_craft_fonts(fonts: &mut FontDefinitions) -> Vec { + let mut keys = Vec::new(); + for (key, font) in craft_font_faces() { + fonts.font_data.insert(key.clone(), Arc::new(FontData::from_static(font.bytes))); + keys.push(key); + } + keys +} + +/// Whether the optional craft-fonts input was embedded in this build. +pub fn craft_fonts_loaded() -> bool { + !craft_fonts::CRAFT_FONTS.is_empty() +} + +/// Whether the native-only Simplified Chinese craft face (LXGW WenKai Lite) was embedded. A web +/// build or an older craft-fonts checkout without `fonts/simplified-chinese-manifest.txt` has none. +pub fn chinese_font_loaded() -> bool { + craft_font_faces().iter().any(|(_, f)| f.family == "LXGW WenKai Lite" && f.style == "Regular") +} + +/// Whether this build was made with `CRAFT_FONTS_REQUIRED=1`. Acceptance tests must fail rather +/// than skip when this is true and the faces are absent. +pub fn craft_fonts_required() -> bool { + craft_fonts::CRAFT_FONTS_REQUIRED +} + +/// A bundled-face character with the same meaning as `c`, for interface chrome whose exact symbol +/// no bundled face has (`✕`, `✓`, `⌃`). Document text still needs a real glyph; this is only a +/// display fallback and never changes the characters a command inserts or stores. +pub fn symbol_fallback(c: char) -> Option { + match c { + '✕' => Some('×'), + '✓' => Some('✔'), + '⌃' => Some('^'), + _ => None, + } +} + +/// `text` with every character the current font set cannot draw replaced by its semantic fallback +/// (see [`symbol_fallback`]). Used by chrome tooltips so a host without symbol faces never paints +/// a tofu box; it is not applied to workbook text. +pub fn available_text(ui: &egui::Ui, text: &str) -> String { + let font = ui_font(13.0); + text.chars().map(|c| if ui.ctx().fonts_mut(|f| f.has_glyph(&font, c)) { c } else { symbol_fallback(c).unwrap_or(c) }).collect() +} + +/// The family, style and scripts of every embedded craft-fonts face (empty without the input). +pub fn craft_font_scripts() -> impl Iterator { + craft_font_faces().into_iter().map(|(_, f)| (f.family, f.style, f.scripts)) +} + +/// Whether one embedded craft-fonts face maps every non-whitespace character in `text`. Tests use +/// this to tell "the pinned fixture covers this script" from "this host has no face for it", +/// without trusting a manifest script tag alone. +pub fn craft_fonts_cover(text: &str) -> bool { + craft_font_faces().iter().any(|(_, f)| { + use skrifa::MetadataProvider; + let Ok(font) = skrifa::FontRef::from_index(f.bytes, 0) else { + return false; + }; + let map = font.charmap(); + text.chars().filter(|c| !c.is_whitespace()).all(|c| map.map(c).is_some()) + }) +} + +#[cfg(target_arch = "wasm32")] +pub(super) fn try_load(_paths: &[(&str, u32)]) -> Option { + None +} + +fn home_fonts(file: &str) -> String { + let home = std::env::var("HOME").unwrap_or_default(); + format!("{home}/Library/Fonts/{file}") +} + +/// Every file in `files` under every directory in `dirs`, directory first, in the same order a +/// nested loop over the directories would add them. +pub(super) fn in_dirs(dirs: &[&str], files: &[&str]) -> Vec { + dirs.iter().flat_map(|d| files.iter().map(move |f| format!("{d}/{f}"))).collect() +} + +/// Candidate font files per role, in preference order. +pub(super) fn candidates(role: &str) -> Vec<(String, u32)> { + let mac_sys = "/System/Library/Fonts"; + let mac_sup = "/System/Library/Fonts/Supplemental"; + let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); + // ChromeOS's Linux container (Crostini) mounts the host's fonts under `chromeos/` (#1762). + let lin = ["/usr/share/fonts/truetype", "/usr/share/fonts", "/usr/local/share/fonts", "/usr/share/fonts/TTF", "/usr/share/fonts/chromeos"]; + let mut v: Vec<(String, u32)> = Vec::new(); + let mut add = |s: String, i: u32| v.push((s, i)); + match role { + "ui" => { + add(format!("{mac_sys}/SFNS.ttf"), 0); + add(format!("{mac_sys}/HelveticaNeue.ttc"), 0); + add(format!("{win}\\segoeui.ttf"), 0); + for p in in_dirs( + &lin, + &["cantarell/Cantarell-Regular.otf", "noto/NotoSans-Regular.ttf", "dejavu/DejaVuSans.ttf", "liberation/LiberationSans-Regular.ttf"], + ) { + add(p, 0); + } + // Fedora keeps each family in its own package folder (#54). + add("/usr/share/fonts/abattis-cantarell-fonts/Cantarell-Regular.otf".into(), 0); + add("/usr/share/fonts/google-noto/NotoSans-Regular.ttf".into(), 0); + add("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf".into(), 0); + add("/usr/share/fonts/liberation-sans-fonts/LiberationSans-Regular.ttf".into(), 0); + } + "ui-bold" => { + add(format!("{mac_sys}/HelveticaNeue.ttc"), 1); + add(format!("{win}\\segoeuib.ttf"), 0); + for p in in_dirs(&lin, &["noto/NotoSans-Bold.ttf", "dejavu/DejaVuSans-Bold.ttf", "liberation/LiberationSans-Bold.ttf"]) { + add(p, 0); + } + add("/usr/share/fonts/google-noto/NotoSans-Bold.ttf".into(), 0); + add("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf".into(), 0); + add("/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf".into(), 0); + } + "cell" | "cell-bold" | "cell-italic" | "cell-bold-italic" => { + let (carlito, calibri_win, calibri_mac, arial_mac, arial_win, lib, dejavu) = match role { + "cell" => { + ("Carlito-Regular.ttf", "calibri.ttf", "Calibri.ttf", "Arial.ttf", "arial.ttf", "LiberationSans-Regular.ttf", "DejaVuSans.ttf") + } + "cell-bold" => ( + "Carlito-Bold.ttf", + "calibrib.ttf", + "Calibri Bold.ttf", + "Arial Bold.ttf", + "arialbd.ttf", + "LiberationSans-Bold.ttf", + "DejaVuSans-Bold.ttf", + ), + "cell-italic" => ( + "Carlito-Italic.ttf", + "calibrii.ttf", + "Calibri Italic.ttf", + "Arial Italic.ttf", + "ariali.ttf", + "LiberationSans-Italic.ttf", + "DejaVuSans-Oblique.ttf", + ), + _ => ( + "Carlito-BoldItalic.ttf", + "calibriz.ttf", + "Calibri Bold Italic.ttf", + "Arial Bold Italic.ttf", + "arialbi.ttf", + "LiberationSans-BoldItalic.ttf", + "DejaVuSans-BoldOblique.ttf", + ), + }; + // Calibri if the user has it installed system-wide (never from an Office bundle). + add(format!("/Library/Fonts/{calibri_mac}"), 0); + add(home_fonts(calibri_mac), 0); + add(format!("{win}\\{calibri_win}"), 0); + for p in in_dirs(&lin, &[&format!("crosextra/{carlito}"), &format!("carlito/{carlito}")]) { + add(p, 0); + } + // Fedora keeps each family in its own package folder, as for the UI fonts (#54). + add(format!("/usr/share/fonts/google-carlito-fonts/{carlito}"), 0); + add(home_fonts(carlito), 0); + add(format!("{mac_sup}/{arial_mac}"), 0); + add(format!("{win}\\{arial_win}"), 0); + for p in in_dirs(&lin, &[&format!("liberation/{lib}"), &format!("dejavu/{dejavu}")]) { + add(p, 0); + } + add(format!("/usr/share/fonts/liberation-sans-fonts/{lib}"), 0); + add(format!("/usr/share/fonts/dejavu-sans-fonts/{dejavu}"), 0); + } + "serif" => { + add(format!("{mac_sup}/Times New Roman.ttf"), 0); + add(format!("{win}\\times.ttf"), 0); + for p in in_dirs(&lin, &["liberation/LiberationSerif-Regular.ttf", "dejavu/DejaVuSerif.ttf"]) { + add(p, 0); + } + add("/usr/share/fonts/liberation-serif-fonts/LiberationSerif-Regular.ttf".into(), 0); + add("/usr/share/fonts/dejavu-serif-fonts/DejaVuSerif.ttf".into(), 0); + } + "mono" => { + add(format!("{mac_sys}/SFNSMono.ttf"), 0); + add(format!("{mac_sys}/Menlo.ttc"), 0); + add(format!("{win}\\consola.ttf"), 0); + for p in in_dirs(&lin, &["dejavu/DejaVuSansMono.ttf"]) { + add(p, 0); + } + add("/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf".into(), 0); + // Fedora installs DejaVu Sans Mono only on request. Liberation Mono comes with LibreOffice + // (in Workstation and KDE by default), Noto Sans Mono (variable, Regular by default) always. + for p in in_dirs(&lin, &["liberation/LiberationMono-Regular.ttf"]) { + add(p, 0); + } + add("/usr/share/fonts/liberation-mono-fonts/LiberationMono-Regular.ttf".into(), 0); + add("/usr/share/fonts/google-noto-vf/NotoSansMono[wght].ttf".into(), 0); + } + _ => {} + } + v +} + +/// Builds the font set from system fonts (with egui's defaults as fallback), with the default +/// (non-Japanese) Han order. Never reads the host locale, so tests and snapshots are stable. +pub fn font_definitions() -> FontDefinitions { + font_definitions_for_language(crate::i18n::Language::En) +} + +/// Which Han face leads the fallback chain. Both Han faces cover kana and most kanji, so this only +/// decides glyph shapes (and which face catches a glyph the other lacks); switching between two +/// languages with the same order needs no font rebuild. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HanOrder { + /// Japanese glyph shapes first (the interface is Japanese). + Japanese, + /// Simplified Chinese first: the widest Han coverage, for every other interface language. + Chinese, +} + +impl HanOrder { + pub fn of(language: crate::i18n::Language) -> Self { + if language == crate::i18n::Language::Ja { Self::Japanese } else { Self::Chinese } + } +} + +/// Builds the font set with the Han fallback faces ordered for the interface language. +pub fn font_definitions_for_language(language: crate::i18n::Language) -> FontDefinitions { + font_definitions_with(language, &[]) +} + +/// The bundled faces plus the pinned craft-fonts faces, with no system-font probing or installed +/// workbook-font binding. +/// +/// Integration tests use this through their shared harness, so a minimal CI image and a developer +/// machine see the same font set. Craft-fonts remains a pinned build input rather than a repository +/// asset; without it only egui's bundled faces are available, which is the documented no-font mode. +pub fn pinned_font_definitions() -> FontDefinitions { + font_definitions_impl(crate::i18n::Language::En, &[], false) +} + +/// [`font_definitions_for_language`] that also binds the workbook fonts in `used` that are +/// installed, so cells set in them render with them; the rest fall back to the roles. The bound +/// families are recorded in [`crate::fonts`] so [`cell_family`] only names a family egui has. +pub fn font_definitions_with(language: crate::i18n::Language, used: &[String]) -> FontDefinitions { + font_definitions_impl(language, used, true) +} + +/// Builds the role font chains, script fallbacks, installed workbook fonts and optional craft-font faces, drops unparseable data and records which families were bound. +fn font_definitions_impl(language: crate::i18n::Language, used: &[String], system: bool) -> FontDefinitions { + let mut fonts = FontDefinitions::default(); + let base_prop: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); + let base_mono: Vec = fonts.families.get(&FontFamily::Monospace).cloned().unwrap_or_default(); + let mut bound: Vec = Vec::new(); + // The non-Latin fallback faces have to sit at the end of each chain: a Latin font owns Latin, + // and epaint picks the first face that has the glyph, so they only catch their own scripts. + let fallback = if system { load_fallback(&mut fonts, HanOrder::of(language)) } else { Vec::new() }; + let hack = fonts.font_data.contains_key(HACK).then_some(HACK); + for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { + let chain = build_role_chain(&mut fonts, role, &base_prop, &base_mono, &fallback, hack, system); + fonts.families.insert(FontFamily::Name(role.into()), chain); + bound.push(role.to_string()); + } + // Installed workbook fonts, so a cell's chosen font actually renders in it. + if system { + for family in crate::fonts::installed_among(used) { + bound.extend(crate::fonts::bind_family(&mut fonts, &family)); + } + } + // Interface text (formula bar, name box, dialogs) falls back to the cell font before the + // script faces: whatever a cell can draw, the formula bar can draw too, even where no script + // fallback face was found (#349: macOS UI font without Hebrew, cells in Arial with it). + bind_cell_fallback(&mut fonts, &fallback); + // Default proportional text in widgets uses the UI font. + if let Some(ui) = fonts.families.get(&FontFamily::Name(UI.into())).cloned() { + fonts.families.insert(FontFamily::Proportional, ui); + } + // The optional craft-fonts faces go at the very end of every family: a system or bundled face + // that has the script keeps its glyph shapes, while the permitted OFL faces cover scripts and + // symbols the host image lacks (Japanese, Korean, Thai, Arabic, Georgian and the UI symbols). + // Chinese comes from the native-only OFL LXGW face when embedded; a system Han face still + // leads, and the barred Noto CJK collection never enters. + append_craft_faces(&mut fonts); + drop_unparseable_fonts(&mut fonts); + // Only families egui still has data for count as bound (a dropped face can empty a chain). + bound.retain(|f| fonts.families.get(&FontFamily::Name(f.as_str().into())).is_some_and(|c| !c.is_empty())); + crate::fonts::set_bound(bound); + fonts +} + +/// Builds one role family's chain: the loaded system face (or its role fallback), egui's bundled +/// proportional/monospace base, Hack's symbols, then the script fallbacks. Keys without font data +/// are dropped, as before. +fn build_role_chain( + fonts: &mut FontDefinitions, + role: &str, + base_prop: &[String], + base_mono: &[String], + fallback: &[String], + hack: Option<&str>, + system: bool, +) -> Vec { + let paths = candidates(role); + let refs: Vec<(&str, u32)> = paths.iter().map(|(p, i)| (p.as_str(), *i)).collect(); + let mut chain: Vec = Vec::new(); + let loaded = if system { try_load(&refs) } else { None }; + if let Some(mut fd) = loaded { + // Helvetica Neue Bold's own TrueType hints close Cyrillic е into ө at 13 pt on a Retina + // screen (#1184). Core Text never runs them, so this draws it the way macOS does. + if cfg!(target_os = "macos") && role == UI_BOLD { + fd.tweak.hinting = Some(false); + } + let key = format!("sys-{role}"); + fonts.font_data.insert(key.clone(), Arc::new(fd)); + chain.push(key); + } else if role == UI_BOLD { + chain.push(format!("sys-{UI}")); + } else if role.starts_with("cell-") { + chain.push(format!("sys-{CELL}")); + } + chain.extend(if role == MONO { base_mono.to_vec() } else { base_prop.to_vec() }); + // egui bundles Hack, a monospace face rich in arrows, triangles and keyboard symbols, but + // 0.37 only puts it in the Monospace family. The interface draws ↑, ▲, ▼, ▸, ▾ and ↳ as + // text (filter menus, ribbon, keytips), so add it behind the UI/cell and emoji faces; it + // only catches characters those do not have. + if let Some(hack) = hack + && !chain.iter().any(|k| k.as_str() == hack) + { + chain.push(hack.to_string()); + } + chain.extend(fallback.iter().cloned()); + chain.retain(|k| fonts.font_data.contains_key(k)); + chain +} + +/// Inserts the loaded cell face before the script fallbacks of the interface families, so the +/// formula bar and dialogs can draw whatever a cell can (#349). +fn bind_cell_fallback(fonts: &mut FontDefinitions, fallback: &[String]) { + let cell_key = format!("sys-{CELL}"); + if !fonts.font_data.contains_key(&cell_key) { + return; + } + for role in [UI, UI_BOLD, SERIF, MONO] { + if let Some(chain) = fonts.families.get_mut(&FontFamily::Name(role.into())) + && !chain.contains(&cell_key) + { + let at = chain.iter().position(|k| fallback.contains(k)).unwrap_or(chain.len()); + chain.insert(at, cell_key.clone()); + } + } +} + +/// Appends the permitted craft-fonts faces to every family, preserving the leading system or +/// bundled face's glyph shapes. +fn append_craft_faces(fonts: &mut FontDefinitions) { + let craft = add_craft_fonts(fonts); + if craft.is_empty() { + return; + } + for chain in fonts.families.values_mut() { + for key in &craft { + if !chain.contains(key) { + chain.push(key.clone()); + } + } + } +} + +#[cfg(test)] +mod font_binding_tests { + use super::*; + + /// The families [`font_definitions`] always binds, whatever is installed. + fn always_bound() -> Vec { + [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO].iter().map(|s| s.to_string()).collect() + } + + #[test] + fn a_name_that_is_not_an_installed_font_gets_a_built_in_role() { + let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // Nothing installed: every name buckets to a role that font_definitions bound. + crate::fonts::set_bound(always_bound()); + for name in ["", "Calibri", "Courier New", "Times New Roman", "Grossly Missing Font", "\u{1F600}"] { + for (b, i) in [(false, false), (true, false), (false, true), (true, true)] { + let FontFamily::Name(fam) = cell_family(name, b, i) else { panic!("{name:?} should name a family") }; + assert!(crate::fonts::is_bound(&fam), "{name:?} {b} {i} → unbound {fam:?}"); + } + } + } + + #[test] + fn a_bound_installed_font_is_used_by_name() { + let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let role = crate::fonts::role_key("Ubuntu", true, false); + crate::fonts::set_bound(always_bound().into_iter().chain([role.clone()])); + assert!(matches!(cell_family("Ubuntu", true, false), FontFamily::Name(f) if *f == *role)); + // The same family in a style that wasn't bound falls back to a built-in role that is. + let FontFamily::Name(fallback) = cell_family("Ubuntu", false, true) else { panic!("should name a family") }; + assert!(crate::fonts::is_bound(&fallback), "the fallback is a bound role"); + crate::fonts::set_bound(always_bound()); + } + + #[test] + fn arbitrary_names_never_yield_an_unbound_family() { + let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // A workbook can hold any font name; none may name a family egui has no data for. + crate::fonts::set_bound(always_bound()); + for seed in 0..500u32 { + let name: String = + (0..(seed % 40)).map(|k| char::from_u32(0x20 + (seed.wrapping_mul(31).wrapping_add(k) % 0x5F)).unwrap_or('x')).collect(); + let FontFamily::Name(fam) = cell_family(&name, seed % 2 == 0, seed % 3 == 0) else { continue }; + assert!(crate::fonts::is_bound(&fam), "{name:?} → unbound {fam:?}"); + } + } +} diff --git a/crates/ui-egui/src/theme/tests.rs b/crates/ui-egui/src/theme/tests.rs new file mode 100644 index 0000000000..b8ad4416e3 --- /dev/null +++ b/crates/ui-egui/src/theme/tests.rs @@ -0,0 +1,355 @@ +use super::*; +use crate::i18n::Language; + +#[test] +/// Loaded script-fallback faces end each role chain just before the craft fonts, in the language's order. +fn fallback_faces_are_appended_to_every_family() { + // Structural: whatever loaded, the non-Latin fallback keys must sit at the end of each chain + // (after the Latin/default fonts) so Latin still wins for Latin text, in the language's order. + for han in [HanOrder::Chinese, HanOrder::Japanese] { + let language = if han == HanOrder::Japanese { Language::Ja } else { Language::Zh }; + let fonts = font_definitions_for_language(language); + let fb_keys: Vec = fallback_faces(han).into_iter().map(|(k, _)| k.to_string()).filter(|k| fonts.font_data.contains_key(k)).collect(); + if fb_keys.is_empty() { + return; // no fallback face on this machine (minimal Linux) + } + for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { + let chain = fonts.families.get(&FontFamily::Name(role.into())).map(Vec::as_slice).unwrap_or_default(); + // The optional craft-fonts faces follow the system fallbacks at the very end. + let end = chain.iter().rposition(|k| !k.starts_with("craft-")).map_or(0, |i| i + 1); + assert!(end >= fb_keys.len(), "{role} chain too short for its fallback"); + assert_eq!(&chain[end - fb_keys.len()..end], fb_keys.as_slice(), "{role} must end its non-craft chain with the fallback faces"); + } + } +} + +#[test] +/// Every font family ends with exactly the permitted craft faces in manifest order. +fn craft_fonts_are_the_last_fallback_in_every_family() { + // Ordering: app/bundled faces, Hack/emoji, system script fallbacks, permitted craft faces. + // A craft face must never displace a system face that already covers a glyph. + let fonts = font_definitions_for_language(Language::En); + let expected: Vec = craft_font_faces().into_iter().map(|(key, _)| key).collect(); + if expected.is_empty() { + return; // optional input absent + } + for (family, chain) in &fonts.families { + let first = chain.iter().position(|k| k.starts_with("craft-")).unwrap_or(chain.len()); + assert_eq!(&chain[first..], expected.as_slice(), "{family:?} must end with exactly the permitted craft faces in manifest order"); + } +} + +#[test] +/// Skips on wasm or when required mode is off; a required native build must embed LXGW with `Hani`/`Hans`. +fn a_required_native_build_embeds_the_chinese_face() { + if cfg!(target_arch = "wasm32") || !craft_fonts_required() { + return; + } + assert!( + chinese_font_loaded(), + "CRAFT_FONTS_REQUIRED=1 native builds must embed the LXGW Chinese face from fonts/simplified-chinese-manifest.txt" + ); + let face = craft_font_scripts().find(|(f, s, _)| *f == "LXGW WenKai Lite" && *s == "Regular"); + assert_eq!(face.map(|(_, _, scripts)| scripts), Some(["Hani", "Hans"].as_slice()), "the Chinese manifest's script tags are asserted"); +} + +#[test] +/// No embedded craft face belongs to the barred Noto CJK, Adobe or Source families. +fn barred_faces_never_enter_the_font_set() { + for (family, _, _) in craft_font_scripts() { + assert!(!family.contains("CJK"), "the barred Noto CJK collection must never be embedded: {family}"); + assert!(!family.starts_with("Source ") && !family.starts_with("Adobe "), "a barred Adobe/Source face was embedded: {family}"); + } +} + +#[test] +/// Skips when craft fonts are absent unless required mode is set; otherwise the required faces and their exact script tags are embedded. +fn the_required_craft_faces_keep_their_exact_scripts() { + if !craft_fonts_loaded() { + assert!(!craft_fonts_required(), "CRAFT_FONTS_REQUIRED=1 but no craft-fonts faces were embedded"); + return; + } + let faces: Vec<_> = craft_font_scripts().collect(); + for (family, style, scripts) in [ + ("BIZ UDPGothic", "Regular", ["Jpan", "Latn"].as_slice()), + ("BIZ UDPGothic", "Bold", ["Jpan", "Latn"].as_slice()), + ("NanumGothic", "Regular", ["Hang", "Kore"].as_slice()), + ("Noto Sans Symbols", "Regular", ["Zsym"].as_slice()), + ("Noto Sans Symbols 2", "Regular", ["Zsym"].as_slice()), + ] { + assert!( + faces.iter().any(|(f, s, declared)| *f == family && *s == style && *declared == scripts), + "{family} {style} with {scripts:?} must be embedded; got {faces:?}" + ); + } +} + +#[test] +/// Interface text maps unbundled `⌃`, `✓` and `✕` to readable chrome fallbacks; document text is not rewritten. +fn available_text_maps_unbundled_chrome_symbols() { + // No bundled face has ⌃ ✓ ✕; chrome text must show ^ ✔ × instead of tofu. Document text is + // never rewritten: this helper is only used for interface tooltips. + let ctx = egui::Context::default(); + ctx.set_fonts(FontDefinitions::default()); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + assert_eq!(available_text(ui, "Show Formulas (⌃`) ✓ ✕"), "Show Formulas (^`) ✔ ×"); + }); + out.textures_delta.clear(); +} + +#[test] +fn interface_families_fall_back_to_the_cell_font() { + // Structural (#349): the formula bar, name box and dialogs draw every script the cells can, + // so each interface chain carries the cell face, ahead of the script fallback faces. + let fonts = font_definitions_for_language(Language::En); + let cell = format!("sys-{CELL}"); + if !fonts.font_data.contains_key(&cell) { + return; // no system cell font on this machine + } + let fb: Vec = fallback_faces(HanOrder::Chinese).into_iter().map(|(k, _)| k.to_string()).collect(); + for family in [FontFamily::Proportional, FontFamily::Name(UI.into()), FontFamily::Name(UI_BOLD.into()), FontFamily::Name(MONO.into())] { + let chain = fonts.families.get(&family).cloned().unwrap_or_default(); + let at = chain.iter().position(|k| *k == cell); + assert!(at.is_some(), "{family:?} lacks the cell face: {chain:?}"); + let first_fb = chain.iter().position(|k| fb.contains(k)).unwrap_or(chain.len()); + assert!(at.unwrap_or(usize::MAX) < first_fb, "{family:?}: cell face must precede the script faces"); + } +} + +#[test] +fn language_reorders_only_the_han_faces() { + let keys = |han| fallback_faces(han).into_iter().map(|(k, _)| k).collect::>(); + assert_eq!( + keys(HanOrder::Japanese), + ["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"] + ); + assert_eq!( + keys(HanOrder::Chinese), + ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"] + ); + assert_eq!(HanOrder::of(Language::Ja), HanOrder::Japanese); + for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr, Language::De, Language::Cs, Language::Fr, Language::Tr] { + assert_eq!(HanOrder::of(l), HanOrder::Chinese, "{l:?} shares the default order (no font rebuild)"); + } +} + +#[test] +fn cjk_fallback_probes_flatpak_fonts_with_the_correct_locale_faces() { + // Exercise the candidates the fallback loader actually consumes, not just the directory + // constant. This must work on CI without an installed CJK font or a Flatpak runtime. + for (candidates, face) in [(cjk_han_japanese(), 0), (cjk_han_simplified(), 2), (cjk_hangul(), 1)] { + for directory in [ + "/run/host/fonts/opentype/noto", + "/run/host/fonts/google-noto-cjk", + "/run/host/fonts/noto-cjk", + "/run/host/fonts/truetype/noto", + "/run/host/fonts/noto", + "/run/host/fonts/truetype", + "/run/host/fonts/TTF", + "/run/host/fonts", + "/run/host/local-fonts", + "/run/host/local-fonts/noto-cjk", + "/run/host/user-fonts", + ] { + let path = format!("{directory}/NotoSansCJK-Regular.ttc"); + assert!(candidates.contains(&(path.clone(), face)), "missing Flatpak CJK candidate {path}, face {face}"); + } + } + let chinese = cjk_han_simplified(); + assert!(chinese.contains(&("/run/host/fonts/truetype/wqy/wqy-microhei.ttc".into(), 0))); + assert!(chinese.contains(&("/run/host/fonts/wenquanyi/wqy-microhei.ttc".into(), 0))); +} + +#[test] +/// A font file shared by several fallback scripts is loaded once, with one shared font blob rather than a copy per script. +fn fallback_faces_load_each_file_once() { + // The region-merged Noto Sans CJK .ttc serves Japanese, Chinese and Hangul; it must be loaded + // once, not once per script (tens of MB each). + for language in [Language::En, Language::Ja] { + let fonts = font_definitions_for_language(language); + let present: Vec<&Arc> = fallback_faces(HanOrder::of(language)).into_iter().filter_map(|(k, _)| fonts.font_data.get(k)).collect(); + for (i, a) in present.iter().enumerate() { + for b in present.iter().skip(i + 1) { + assert!(!std::sync::Arc::ptr_eq(&a.font, &b.font), "the same font file is loaded for two fallback scripts"); + } + } + } +} + +#[test] +/// Checks that the proportional and cell families have glyphs for every present script sample; samples with no system or craft face are printed as skipped. +fn fallback_covers_its_script() { + // Real glyph check, per script: the proportional family (what cells and the UI use) must + // have a glyph for every non-space character in the sample. A script runs when a system + // face or the optional craft-fonts input provides one; the printed skip is a recorded + // gate, and a present face that is not wired in fails. + let cases: [(&str, Vec<(String, u32)>, &str); 10] = [ + ("sys-cjk-han-ja", cjk_han_japanese(), "カテゴリ"), + // Chinese uses a system Han face or the native-only permitted LXGW craft face; the + // barred Noto CJK collection never supplies it. + ("sys-cjk-han-zh", cjk_han_simplified(), "页面布局 客户端配置"), + ("sys-cjk-hangul", cjk_hangul(), "한글"), + ("sys-thai", thai(), "ทดสอบ"), + ("sys-lao", lao(), "ພາສາລາວ"), + ("sys-hebrew", hebrew(), "עברית"), + ("craft-noto-sans-arabic", Vec::new(), "مرحبا"), + ("craft-noto-sans-georgian", Vec::new(), "ქართული"), + ("ui", candidates(UI), "Русский"), + // Czech interface labels: every Czech accented letter, in both cases. + ("ui", candidates(UI), "Příliš žluťoučký kůň úpěl ďábelské ódy ŘŠČŽÝÁÍÉÚŮĚŤĎŇÓ"), + ]; + for language in [Language::En, Language::Ja] { + let ctx = egui::Context::default(); + ctx.set_fonts(font_definitions_for_language(language)); + let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {}); + out.textures_delta.clear(); // nothing consumes the atlas in a headless test + for family in [FontFamily::Proportional, FontFamily::Name(CELL.into())] { + let font = FontId::new(14.0, family.clone()); + for (key, cands, sample) in &cases { + // A script runs when a system face or the optional craft-fonts input provides + // one. The skip is printed: a missing fixture is a recorded gate, not a pass. + let system = cands.iter().any(|(p, _)| std::path::Path::new(p).exists()); + if !system && !craft_fonts_cover(sample) { + eprintln!("skipped {key}: no system face and no craft-fonts face for {sample:?}"); + continue; + } + let missing: String = sample.chars().filter(|c| !c.is_whitespace()).filter(|&c| !ctx.fonts_mut(|f| f.has_glyph(&font, c))).collect(); + assert!(missing.is_empty(), "{key} is present but {sample:?} has no glyph for {missing:?} ({language:?}, {family:?})"); + } + } + } +} + +#[test] +fn chromeos_font_layout_is_probed() { + // #1762: Crostini mounts the host's fonts under /usr/share/fonts/chromeos, one folder per + // family set; a script whose list misses it renders as tofu with the font installed. + let probes = |cands: Vec<(String, u32)>, path: &str| cands.iter().any(|(p, _)| p == path); + let cros = "/usr/share/fonts/chromeos"; + assert!(probes(thai(), &format!("{cros}/noto/NotoSansThai-Regular.ttf"))); + assert!(probes(hebrew(), &format!("{cros}/noto/NotoSansHebrew-Regular.ttf"))); + assert!(probes(cjk_han_japanese(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); + assert!(probes(cjk_han_simplified(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); + assert!(probes(cjk_hangul(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); + assert!(probes(symbols_more(), &format!("{cros}/noto/NotoSansSymbols2-Regular.ttf"))); + assert!(probes(candidates(UI), &format!("{cros}/noto/NotoSans-Regular.ttf"))); + assert!(probes(candidates(CELL), &format!("{cros}/crosextra/Carlito-Regular.ttf"))); +} + +#[test] +/// Empty, garbage, truncated and bad-index fonts are refused; a bad face is dropped from the definitions and `set_fonts` does not panic. +fn unparseable_font_files_are_rejected_not_fatal() { + // #226: epaint panics in `set_fonts` on a face it can't parse ("An offset was out of + // bounds"). Garbage, a truncated real font and a bad collection index must all be refused. + let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.bytes().to_vec()).unwrap_or_default(); + assert!(font_parses(&real, 0), "egui's own font must parse"); + assert!(!font_parses(b"", 0)); + assert!(!font_parses(&[0xAB; 64], 0)); + assert!(!font_parses(real.get(..20).unwrap_or_default(), 0), "a truncated font must be refused"); + assert!(!font_parses(&real, 3), "a single-face file has no face 3"); + + // A bad face that slipped into the definitions is dropped (key and family entries), and the + // context builds its fonts without panicking. + let mut fonts = font_definitions(); + fonts.font_data.insert("sys-hebrew".into(), Arc::new(FontData::from_owned(vec![0, 1, 0, 0, 0, 0xFF, 0, 0]))); + for chain in fonts.families.values_mut() { + chain.push("sys-hebrew".into()); + } + drop_unparseable_fonts(&mut fonts); + assert!(!fonts.font_data.contains_key("sys-hebrew")); + assert!(fonts.families.values().all(|c| !c.iter().any(|k| k == "sys-hebrew"))); + let ctx = egui::Context::default(); + ctx.set_fonts(fonts); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + ui.label("Hebrew עברית"); + }); + out.textures_delta.clear(); // nothing consumes the atlas in a headless test +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +/// A broken existing font candidate is skipped in favor of a later readable candidate. +fn try_load_skips_a_broken_candidate() { + // A candidate that exists but is not a readable font must not win over a good one behind it. + let dir = std::env::temp_dir().join(format!("gridcraft-font-test-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let bad = dir.join("broken.ttf"); + let good = dir.join("good.ttf"); + let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.bytes().to_vec()).unwrap_or_default(); + let _ = std::fs::write(&bad, real.get(..40).unwrap_or_default()); + let _ = std::fs::write(&good, &real); + let (bad_s, good_s) = (bad.to_string_lossy().into_owned(), good.to_string_lossy().into_owned()); + assert!(try_load(&[(bad_s.as_str(), 0)]).is_none()); + let loaded = try_load(&[(bad_s.as_str(), 0), (good_s.as_str(), 0)]); + assert_eq!(loaded.map(|fd| fd.bytes().len()), Some(real.len())); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +/// Repeated loads reuse one shared font blob, while a face the kept file does not contain is still refused. +fn interface_fonts_are_read_once_and_shared_with_egui() { + // egui copies owned font bytes into its text engine and keeps the original too; borrowed + // bytes it shares. A rebuild of the font set reuses the file read the first time. + let path = std::env::temp_dir().join(format!("gridcraft-font-shared-{}.ttf", std::process::id())); + std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).unwrap(); + let p = path.to_string_lossy().into_owned(); + let first = try_load(&[(p.as_str(), 0)]).unwrap(); + std::fs::remove_file(&path).unwrap(); + let second = try_load(&[(p.as_str(), 0)]).expect("kept from the first read"); + assert!( + std::sync::Arc::ptr_eq(&first.font, &second.font), + "interface font bytes must be one shared blob, so egui shares them instead of copying" + ); + assert_eq!(first.bytes(), epaint_default_fonts::UBUNTU_LIGHT); + assert!(try_load(&[(p.as_str(), 3)]).is_none(), "a face the kept file doesn't have is still refused"); +} + +#[test] +fn macos_bold_interface_face_is_drawn_unhinted() { + // #1184: Helvetica Neue Bold's own TrueType hints close Cyrillic е into ө at 13 pt on a + // Retina screen. Core Text never runs them, so on macOS the face goes unhinted like every + // other app draws it; the regular face and the other platforms keep egui's default. + let fonts = font_definitions_for_language(Language::En); + let hinting = |key: &str| fonts.font_data.get(key).map(|fd| fd.tweak.hinting); + if let Some(bold) = hinting(&format!("sys-{UI_BOLD}")) { + assert_eq!(bold, if cfg!(target_os = "macos") { Some(false) } else { None }); + } + if let Some(regular) = hinting(&format!("sys-{UI}")) { + assert_eq!(regular, None, "only the bold face is affected"); + } +} + +#[test] +fn fedora_package_folders_are_searched_after_the_other_linux_layouts() { + // #1694: Fedora keeps each font family in its own package folder, so without these entries + // no cell face is found there and bold and italic cells draw as regular text. Each comes + // after the existing Linux folders for the same font, so wherever one of those matched, + // nothing changes. + let paths = |cands: Vec<(String, u32)>| cands.into_iter().map(|(p, _)| p).collect::>(); + let cases: [(&str, Vec, &str, &str); 9] = [ + (CELL, paths(candidates(CELL)), "google-carlito-fonts/Carlito-Regular.ttf", "/carlito/Carlito-Regular.ttf"), + (CELL_BOLD, paths(candidates(CELL_BOLD)), "google-carlito-fonts/Carlito-Bold.ttf", "/carlito/Carlito-Bold.ttf"), + (CELL_ITALIC, paths(candidates(CELL_ITALIC)), "google-carlito-fonts/Carlito-Italic.ttf", "/carlito/Carlito-Italic.ttf"), + (CELL_BOLD_ITALIC, paths(candidates(CELL_BOLD_ITALIC)), "google-carlito-fonts/Carlito-BoldItalic.ttf", "/carlito/Carlito-BoldItalic.ttf"), + (CELL_BOLD, paths(candidates(CELL_BOLD)), "liberation-sans-fonts/LiberationSans-Bold.ttf", "/liberation/LiberationSans-Bold.ttf"), + (SERIF, paths(candidates(SERIF)), "liberation-serif-fonts/LiberationSerif-Regular.ttf", "/liberation/LiberationSerif-Regular.ttf"), + (MONO, paths(candidates(MONO)), "liberation-mono-fonts/LiberationMono-Regular.ttf", "/dejavu/DejaVuSansMono.ttf"), + ("thai", paths(thai()), "google-noto-vf/NotoSansThai[wght].ttf", "/NotoSansThai-Regular.ttf"), + ("hebrew", paths(hebrew()), "google-noto-vf/NotoSansHebrew[wght].ttf", "/NotoSansHebrew-Regular.ttf"), + ]; + for (role, list, fedora_file, earlier) in &cases { + let at = list.iter().position(|p| *p == format!("/usr/share/fonts/{fedora_file}")); + let last_earlier = list.iter().rposition(|p| p.ends_with(earlier)); + assert!(at.is_some(), "{role}: Fedora's {fedora_file} is not a candidate"); + assert!(last_earlier.is_some(), "{role}: no existing {earlier} candidate to compare with"); + assert!(at > last_earlier, "{role}: {fedora_file} must come after every existing {earlier}"); + } + // Liberation Mono only stands in for DejaVu Sans Mono, wherever that is installed. + let mono = paths(candidates(MONO)); + let first_liberation = mono.iter().position(|p| p.ends_with("LiberationMono-Regular.ttf")); + let last_dejavu = mono.iter().rposition(|p| p.ends_with("DejaVuSansMono.ttf")); + assert!(first_liberation.is_some() && first_liberation > last_dejavu, "Liberation Mono must follow DejaVu Sans Mono: {mono:?}"); + let noto = mono.iter().position(|p| p == "/usr/share/fonts/google-noto-vf/NotoSansMono[wght].ttf"); + assert!(noto.is_some() && noto.unwrap_or(0) + 1 == mono.len(), "Fedora's default Noto Sans Mono must be the last resort: {mono:?}"); +} diff --git a/crates/xlsx/src/passthrough.rs b/crates/xlsx/src/passthrough.rs index 056fc6114b..04f757d3aa 100644 --- a/crates/xlsx/src/passthrough.rs +++ b/crates/xlsx/src/passthrough.rs @@ -42,6 +42,16 @@ use crate::read::Ctx; use crate::write::{NS_REL, Out, Rels, XML_DECL}; use crate::xml::{self, esc_attr}; +mod read; +mod rebase; +mod write; + +pub use read::KeptReader; +pub use rebase::{SheetKept, rebase_links}; +#[cfg(test)] +pub(crate) use write::sheet_names_in; +pub use write::{KeptOut, SheetIds, workbook_elements, write_package_rels, write_workbook}; + /// Most bytes kept for one workbook. pub const MAX_KEPT_TOTAL: usize = 256 * 1024 * 1024; /// Largest single kept part. @@ -178,1209 +188,3 @@ fn rels_part_name(part: &str) -> String { let (dir, file) = package::split_dir(part); if dir.is_empty() { format!("_rels/{file}.rels") } else { format!("{dir}/_rels/{file}.rels") } } - -/// A part's relationships with full type URIs. `Ok(vec![])` when it has none; an error when the -/// rels part is damaged or too large (its part then can't be kept faithfully). -fn raw_rels(cx: &mut Ctx<'_>, part: &str) -> Result, String> { - let name = rels_part_name(part); - let bytes = match cx.pkg.read_limited(&name, MAX_META) { - Ok(Some(b)) => b, - Ok(None) => return Ok(vec![]), - Err(e) => return Err(e.to_string()), - }; - let root = xml::parse(&bytes).map_err(|e| format!("{name}: {e}"))?; - let (dir, _) = package::split_dir(part); - let mut out = vec![]; - for r in root.kids("Relationship") { - if out.len() >= MAX_RELS { - return Err(format!("{name}: too many relationships")); - } - let (Some(id), Some(target)) = (r.attr("Id"), r.attr("Target")) else { continue }; - let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External")); - out.push(PreservedRel { - id: id.to_string(), - rel_type: r.attr("Type").unwrap_or("").to_string(), - target: if external { target.to_string() } else { package::resolve(dir, target) }, - external, - }); - } - Ok(out) -} - -// ---------------------------------------------------------------- reading - -/// The `` entries of the workbook that are chart sheets, each with the number of worksheets -/// (model sheets) before it. -fn chart_sheet_entries<'a>(wb_xml: &'a xml::El, wb_rels: &[PreservedRel], file_to_model: &[Option]) -> Vec<(usize, &'a xml::El)> { - let mut out = vec![]; - let mut worksheets = 0usize; - for (i, se) in wb_xml.child("sheets").into_iter().flat_map(|s| s.kids("sheet")).enumerate() { - if file_to_model.get(i).copied().flatten().is_some() { - worksheets += 1; - } else if se.attr("id").and_then(|id| wb_rels.iter().find(|r| r.id == id)).is_some_and(|r| is_kind(r, &["chartsheet"])) { - out.push((worksheets, se)); - } - } - out -} - -/// Collects kept content while a package is read. -pub struct KeptReader { - on: bool, - strict: bool, - defaults: HashMap, - overrides: HashMap, - budget: usize, - parts_left: usize, -} - -impl KeptReader { - pub fn new(cx: &mut Ctx<'_>) -> KeptReader { - let mut k = KeptReader { - on: true, - strict: false, - defaults: HashMap::new(), - overrides: HashMap::new(), - budget: kept_total(), - parts_left: MAX_KEPT_PARTS, - }; - if let Ok(root) = raw_rels(cx, "") - && root.iter().any(|r| r.rel_type.starts_with("http://purl.oclc.org/")) - { - k.on = false; - k.strict = true; - } - match cx.pkg.read_limited("[Content_Types].xml", MAX_META).ok().flatten().and_then(|b| xml::parse(&b).ok()) { - Some(types) => { - for d in types.kids("Default") { - if let (Some(e), Some(t)) = (d.attr("Extension"), d.attr("ContentType")) { - k.defaults.insert(e.to_ascii_lowercase(), t.to_string()); - } - } - for o in types.kids("Override") { - if let (Some(p), Some(t)) = (o.attr("PartName"), o.attr("ContentType")) { - k.overrides.insert(normalize(p), t.to_string()); - } - } - } - None => k.on = false, - } - k - } - - fn content_type(&self, part: &str) -> Option { - let key = normalize(part); - if let Some(t) = self.overrides.get(&key) { - return Some(t.clone()); - } - let (_, file) = package::split_dir(&key); - let ext = file.rsplit_once('.').map(|(_, e)| e)?; - self.defaults.get(ext).cloned() - } - - /// The parts reachable from `root` that aren't in `seen` yet. Fails (and keeps nothing) when the - /// graph reaches rewritten content, a missing or damaged part, or a size limit. - fn closure(&mut self, cx: &mut Ctx<'_>, root: &PreservedRel, seen: &HashSet, through: &[&str]) -> Result, String> { - if root.external { - return Ok(vec![]); - } - let blocked = |r: &PreservedRel| rewritten(r) && !through.iter().any(|k| k.eq_ignore_ascii_case(kind(&r.rel_type))); - if blocked(root) { - return Err(format!("it refers to {}", kind(&root.rel_type))); - } - let mut parts = vec![]; - let mut bytes = 0usize; - let mut local: HashSet = HashSet::new(); - let mut queue = VecDeque::from([(root.target.clone(), 0usize)]); - while let Some((name, depth)) = queue.pop_front() { - let key = normalize(&name); - if seen.contains(&key) || !local.insert(key) { - continue; - } - if depth > MAX_CHAIN { - return Err("its relationships are nested too deeply".into()); - } - if parts.len() >= self.parts_left { - return Err("it has too many parts".into()); - } - let content_type = self.content_type(&name).ok_or_else(|| format!("{name} has no content type"))?; - let data = match cx.pkg.read_limited(&name, MAX_KEPT_PART) { - Ok(Some(d)) => d, - Ok(None) => return Err(format!("{name} is missing")), - Err(e) => return Err(e.to_string()), - }; - bytes = bytes.saturating_add(data.len()); - if bytes > self.budget { - return Err("it is too large".into()); - } - let rels = raw_rels(cx, &name)?; - for r in rels.iter().filter(|r| !r.external) { - if blocked(r) { - return Err(format!("it refers to {}", kind(&r.rel_type))); - } - queue.push_back((r.target.clone(), depth + 1)); - } - parts.push(PreservedPart { name, content_type, data: Arc::from(data), rels }); - } - self.budget = self.budget.saturating_sub(bytes); - self.parts_left = self.parts_left.saturating_sub(parts.len()); - Ok(parts) - } - - /// Keeps a set of relationships (all or nothing) and the parts behind them. - fn keep(&mut self, cx: &mut Ctx<'_>, p: &mut Preserved, roots: &[PreservedRel], seen: &mut HashSet) -> Result<(), String> { - self.keep_through(cx, p, roots, seen, &[]) - } - - /// [`Self::keep`] for roots whose graph may reach the rewritten kinds in `through`. - fn keep_through( - &mut self, - cx: &mut Ctx<'_>, - p: &mut Preserved, - roots: &[PreservedRel], - seen: &mut HashSet, - through: &[&str], - ) -> Result<(), String> { - let mut new_parts: Vec = vec![]; - let mut scratch = seen.clone(); - for r in roots { - let parts = self.closure(cx, r, &scratch, through)?; - scratch.extend(parts.iter().map(|q| normalize(&q.name))); - new_parts.extend(parts); - } - *seen = scratch; - p.parts.extend(new_parts); - for r in roots { - if !p.rels.iter().any(|x| x.id == r.id) { - p.rels.push(r.clone()); - } - } - Ok(()) - } - - /// Package-root and workbook-level content. - pub fn workbook(&mut self, cx: &mut Ctx<'_>, wb_part: &str, wb_xml: &xml::El, file_to_model: &[Option]) -> Option> { - if !self.on { - let wb_rels = raw_rels(cx, wb_part).unwrap_or_default(); - for (_, se) in chart_sheet_entries(wb_xml, &wb_rels, file_to_model) { - cx.warn(format!("chart sheet \"{}\" was skipped and is not kept on save", se.attr("name").unwrap_or(""))); - } - if self.strict { - cx.warn("unmodelled content of a Strict Open XML workbook (custom XML, links, controls…) is not kept on save"); - } - return None; - } - let mut p = Preserved::default(); - let mut seen = HashSet::new(); - // Package root: custom document properties, ribbon customisation, task panes. - if let Ok(root) = raw_rels(cx, "") { - for r in root.iter().filter(|r| is_kind(r, PACKAGE_ROOTS)) { - let mut q = Preserved::default(); - match self.keep(cx, &mut q, std::slice::from_ref(r), &mut seen) { - Ok(()) => { - p.parts.extend(q.parts); - p.package_rels.extend(q.rels); - } - Err(why) => cx.warn(format!("{} was dropped: {why}", kind(&r.rel_type))), - } - } - } - let wb_rels = raw_rels(cx, wb_part).unwrap_or_default(); - for r in wb_rels.iter().filter(|r| is_kind(r, WORKBOOK_ROOTS)) { - if let Err(why) = self.keep(cx, &mut p, std::slice::from_ref(r), &mut seen) { - cx.warn(format!("{} part {} was dropped: {why}", kind(&r.rel_type), r.target)); - } - } - // Chart sheets: the sheet entry (name, state, position) plus the chart sheet part and the - // drawing, chart, style and colour parts behind it. - for (worksheets, se) in chart_sheet_entries(wb_xml, &wb_rels, file_to_model) { - let name = se.attr("name").unwrap_or("").to_string(); - let Some(r) = se.attr("id").and_then(|id| wb_rels.iter().find(|r| r.id == id)) else { continue }; - match self.keep_through(cx, &mut p, std::slice::from_ref(r), &mut seen, CHART_SHEET_GRAPH) { - Ok(()) => p.sheets.push(PreservedSheet { - name, - visibility: match se.attr("state") { - Some("hidden") => Visibility::Hidden, - Some("veryHidden") => Visibility::VeryHidden, - _ => Visibility::Visible, - }, - rel_id: r.id.clone(), - before: worksheets, - sheet_id: se.attr_u32("sheetId").unwrap_or(0), - }), - Err(why) => cx.warn(format!("chart sheet \"{name}\" was dropped: {why}")), - } - } - // Links to external workbooks: `[1]` in formulas is the position in , - // so they are kept all together or not at all. - let ids: Vec<&str> = - wb_xml.child("externalReferences").map(|e| e.kids("externalReference").filter_map(|x| x.attr("id")).collect()).unwrap_or_default(); - if !ids.is_empty() { - let roots: Option> = - ids.iter().map(|id| wb_rels.iter().find(|r| r.id == *id && is_kind(r, &["externalLink"])).cloned()).collect(); - let kept = match roots { - Some(roots) => self.keep(cx, &mut p, &roots, &mut seen), - None => Err("a link has no part".into()), - }; - match kept { - Ok(()) => { - let mut x = format!(""); - for id in &ids { - let _ = write!(x, "", esc_attr(id)); - } - x.push_str(""); - p.elements.push(PreservedXml { slot: "externalReferences".into(), xml: x }); - cx.warn("links to external workbooks were kept; they are not followed or refreshed"); - } - Err(why) => cx.warn(format!("links to external workbooks were dropped: {why}")), - } - } - // Excel trusts the saved cell values only when both are present; without them it - // recalculates everything on open, and links into closed workbooks come back as errors. - if let Some(v) = wb_xml.child("fileVersion") { - let mut x = String::from(""); - p.elements.push(PreservedXml { slot: "fileVersion".into(), xml: x }); - } - let calc_features = wb_xml - .child("extLst") - .and_then(|l| l.kids("ext").find(|e| e.attr("uri").is_some_and(|u| u.eq_ignore_ascii_case(CALC_FEATURES_URI)))) - .and_then(|e| e.child("calcFeatures")); - if let Some(c) = calc_features { - let mut x = format!(""); - for name in c.kids("feature").filter_map(|f| f.attr("name")) { - let _ = write!(x, "", esc_attr(name)); - } - x.push_str(""); - p.elements.push(PreservedXml { slot: "ext".into(), xml: x }); - } - (!p.is_empty()).then(|| Arc::new(p)) - } - - /// Sheet-level content of the sheet part `part`. - pub fn sheet(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str) -> Option> { - if !self.on { - return None; - } - let rels = match raw_rels(cx, part) { - Ok(r) => r, - Err(e) => { - cx.warn(format!("sheet \"{name}\": relationships could not be read ({e}); unmodelled content was dropped")); - return None; - } - }; - let mut p = Preserved::default(); - let mut seen = HashSet::new(); - for r in rels.iter().filter(|r| is_kind(r, SHEET_ROOTS)) { - if let Err(why) = self.keep(cx, &mut p, std::slice::from_ref(r), &mut seen) { - cx.warn(format!("sheet \"{name}\": {} was dropped: {why}", kind(&r.rel_type))); - } - } - // Notes have one VML drawing per sheet; another one holds the header and footer pictures. - let vml_drawings = rels.iter().filter(|r| is_kind(r, &["vmlDrawing"])).count(); - let notes = rels.iter().filter(|r| is_kind(r, &["comments"])).count(); - if rels.iter().any(|r| is_kind(r, SHEET_ELEMENT_KINDS)) || vml_drawings > notes { - self.sheet_elements(cx, part, name, &rels, &mut p, &mut seen); - } - (!p.is_empty()).then(|| Arc::new(p)) - } - - fn sheet_elements(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str, rels: &[PreservedRel], p: &mut Preserved, seen: &mut HashSet) { - let scan = match cx.pkg.read(part) { - Ok(Some(bytes)) => scan_sheet(&bytes), - _ => None, - }; - let Some(scan) = scan else { - cx.warn(format!("sheet \"{name}\": controls and embedded objects could not be read and were dropped")); - return; - }; - // Printer settings (a binary part per sheet) hang off . - if let Some(id) = scan.page_setup.as_deref() - && let Some(r) = rels.iter().find(|r| r.id == id && is_kind(r, &["printerSettings"])) - { - match self.keep(cx, p, std::slice::from_ref(r), seen) { - Ok(()) => p.elements.push(PreservedXml { slot: "pageSetup".into(), xml: r.id.clone() }), - Err(why) => cx.warn(format!("sheet \"{name}\": printer settings were dropped: {why}")), - } - } - // Header and footer pictures (`&G`): a VML drawing of their own and the images it shows, - // kept byte for byte (GridCraft writes the header text, not the pictures). - if let Some(id) = scan.legacy_drawing_hf.as_deref() - && scan.legacy_drawing.as_deref() != Some(id) - && let Some(r) = rels.iter().find(|r| r.id == id && is_kind(r, &["vmlDrawing"])) - { - match self.keep_through(cx, p, std::slice::from_ref(r), seen, &["vmlDrawing"]) { - Ok(()) => { - let xml = format!("", esc_attr(&r.id)); - p.elements.push(PreservedXml { slot: "legacyDrawingHF".into(), xml }); - cx.warn(format!("sheet \"{name}\": pictures in the header and footer are not shown; they are kept for saving")); - } - Err(why) => cx.warn(format!("sheet \"{name}\": header and footer pictures were dropped: {why}")), - } - } - let mut frags = scan.fragments; - // Controls and OLE objects are drawn from shapes in the legacy VML drawing. - if frags.iter().any(|(slot, _)| slot != "picture") { - match self.vml(cx, rels, scan.legacy_drawing.as_deref(), seen) { - Ok((vml, parts)) => { - p.parts.extend(parts); - p.vml = Some(vml); - } - Err(why) => { - cx.warn(format!("sheet \"{name}\": controls and embedded objects were dropped: {why}")); - frags.retain(|(slot, _)| slot == "picture"); - } - } - } - for (slot, x) in frags { - let mut ids = vec![]; - let ok = map_rel_ids(&x, &mut |id| { - ids.push(id.to_string()); - Some(id.to_string()) - }) - .is_some(); - let roots: Option> = ids.iter().map(|id| rels.iter().find(|r| r.id == *id).cloned()).collect(); - let kept = match (ok, roots) { - (true, Some(roots)) => self.keep(cx, p, &roots, seen), - (false, _) => Err("its XML is damaged".into()), - (_, None) => Err("it refers to a missing relationship".into()), - }; - match kept { - Ok(()) => p.elements.push(PreservedXml { slot, xml: x }), - Err(why) => cx.warn(format!("sheet \"{name}\": {slot} were dropped: {why}")), - } - } - if p.elements.iter().any(|e| e.slot == "oleObjects" || e.slot == "controls") { - cx.warn(format!("sheet \"{name}\": embedded objects and form controls are not shown; they are kept for saving")); - } else { - p.vml = None; - } - } - - /// The sheet's VML drawing without its notes (GridCraft writes those), and the parts its - /// shapes refer to (preview images). - fn vml( - &mut self, - cx: &mut Ctx<'_>, - rels: &[PreservedRel], - rid: Option<&str>, - seen: &mut HashSet, - ) -> Result<(PreservedPart, Vec), String> { - let rel = rid.and_then(|id| rels.iter().find(|r| r.id == id && !r.external)).ok_or("the sheet has no VML drawing")?; - // Charged to the kept total like any other part: many sheets can point at one highly - // compressed VML drawing, and each keeps its own copy. - if self.budget == 0 { - return Err("it is too large".into()); - } - let limit = MAX_META.min(u64::try_from(self.budget).unwrap_or(u64::MAX)); - let data = match cx.pkg.read_limited(&rel.target, limit) { - Ok(Some(d)) => d, - Ok(None) => return Err(format!("{} is missing", rel.target)), - Err(IoError::TooLarge(_)) => return Err("it is too large".into()), - Err(e) => return Err(e.to_string()), - }; - self.budget = self.budget.saturating_sub(data.len()); - let text = String::from_utf8(data).map_err(|_| "the VML drawing is not UTF-8".to_string())?; - let text = strip_notes(&text); - let vrels = raw_rels(cx, &rel.target)?; - let mut q = Preserved::default(); - let roots: Vec = vrels.iter().filter(|r| !r.external).cloned().collect(); - self.keep(cx, &mut q, &roots, seen)?; - let vml = PreservedPart { name: rel.target.clone(), content_type: CT_VML.into(), data: Arc::from(text.into_bytes()), rels: vrels }; - Ok((vml, q.parts)) - } -} - -/// What a sheet part holds for pass-through. -struct SheetScan { - /// (slot, XML with the namespace declarations it needs). - fragments: Vec<(String, String)>, - legacy_drawing: Option, - /// The relationship id on `` (header and footer pictures). - legacy_drawing_hf: Option, - /// The relationship id on `` (printer settings). - page_setup: Option, -} - -/// Finds the sheet's ``, ``, `` (also inside -/// ``) and the `` and `` ids. `None` when the XML is damaged. -fn scan_sheet(bytes: &[u8]) -> Option { - let data = xml::to_utf8(bytes); - let text = std::str::from_utf8(&data).ok()?; - let mut reader = Reader::from_str(text); - let mut decls: Vec<(String, String)> = vec![]; - let mut out = SheetScan { fragments: vec![], legacy_drawing: None, legacy_drawing_hf: None, page_setup: None }; - let mut in_root = false; - loop { - let start = usize::try_from(reader.buffer_position()).ok()?; - match reader.read_event().ok()? { - Event::Eof => break, - Event::Start(e) if !in_root => { - in_root = true; - for a in e.attributes().with_checks(false).flatten() { - if let Some(p) = a.key.as_ref().strip_prefix(b"xmlns:") - && let (Ok(p), Ok(v)) = (std::str::from_utf8(p), std::str::from_utf8(&a.value)) - && !v.contains('"') - { - decls.push((p.to_string(), v.to_string())); - } - } - } - Event::Start(e) => { - let local = e.local_name().as_ref().to_vec(); - match local.as_slice() { - b"pageSetup" => out.page_setup = rel_id_attr(&e), - b"legacyDrawing" => out.legacy_drawing = rel_id_attr(&e), - b"legacyDrawingHF" => out.legacy_drawing_hf = rel_id_attr(&e), - _ => {} - } - reader.read_to_end(e.name()).ok()?; - let end = usize::try_from(reader.buffer_position()).ok()?; - let wanted = matches!(local.as_slice(), b"picture" | b"oleObjects" | b"controls" | b"AlternateContent"); - if wanted && end.saturating_sub(start) <= MAX_FRAGMENT { - let frag = text.get(start..end)?; - push_fragment(&mut out, frag, &local, &decls); - } - } - Event::Empty(e) if in_root => { - let local = e.local_name().as_ref().to_vec(); - if local == b"legacyDrawing" || local == b"legacyDrawingHF" || local == b"pageSetup" { - let id = rel_id_attr(&e); - match local.as_slice() { - b"pageSetup" => out.page_setup = id, - b"legacyDrawing" => out.legacy_drawing = id, - _ => out.legacy_drawing_hf = id, - } - } else if local == b"picture" - && let Some(frag) = text.get(start..usize::try_from(reader.buffer_position()).ok()?) - { - push_fragment(&mut out, frag, &local, &decls); - } - } - Event::End(_) => break, - _ => {} - } - } - Some(out) -} - -/// The `r:id` of an element (an `id` attribute with a namespace prefix). -fn rel_id_attr(e: &BytesStart<'_>) -> Option { - e.attributes() - .with_checks(false) - .flatten() - .find(|a| a.key.local_name().as_ref() == b"id" && a.key.prefix().is_some()) - .and_then(|a| String::from_utf8(a.value.to_vec()).ok()) -} - -fn push_fragment(out: &mut SheetScan, frag: &str, local: &[u8], decls: &[(String, String)]) { - let frag = with_decls(frag, decls); - let slot = if local == b"AlternateContent" { - // The kept element is the one the first choice holds. - let Ok(el) = xml::parse(frag.as_bytes()) else { return }; - match el.child("Choice").and_then(|c| c.children.first()) { - Some(c) => c.name.clone(), - None => return, - } - } else { - String::from_utf8_lossy(local).into_owned() - }; - if SHEET_SLOTS.contains(&slot.as_str()) && !out.fragments.iter().any(|(s, _)| *s == slot) { - out.fragments.push((slot, frag)); - } -} - -/// Adds the root's namespace declarations a fragment doesn't declare itself. -fn with_decls(frag: &str, decls: &[(String, String)]) -> String { - let Some(name_end) = frag.char_indices().skip(1).find(|(_, c)| c.is_whitespace() || *c == '/' || *c == '>').map(|(i, _)| i) else { - return frag.to_string(); - }; - let head = frag.get(..frag.find('>').unwrap_or(frag.len())).unwrap_or(""); - let mut add = String::new(); - for (p, v) in decls { - if !head.contains(&format!("xmlns:{p}=")) { - let _ = write!(add, " xmlns:{p}=\"{v}\""); - } - } - format!("{}{add}{}", frag.get(..name_end).unwrap_or(""), frag.get(name_end..).unwrap_or("")) -} - -/// Byte ranges of the `…` (or self-closed) elements in VML text. -fn shape_spans(vml: &str) -> Vec<(usize, usize)> { - let mut spans = vec![]; - let mut from = 0; - while let Some(i) = vml.get(from..).and_then(|s| s.find("' || c == '/'); - if !is_shape { - from = after; - continue; - } - let Some(tag_end) = vml.get(i..).and_then(|s| s.find('>')).map(|j| i + j + 1) else { break }; - let end = if vml.get(..tag_end).is_some_and(|t| t.ends_with("/>")) { - tag_end - } else { - match vml.get(tag_end..).and_then(|s| s.find("")) { - Some(j) => tag_end + j + "".len(), - None => break, - } - }; - spans.push((i, end)); - from = end; - } - spans -} - -/// VML without its note shapes (`ObjectType="Note"`). -fn strip_notes(vml: &str) -> String { - let mut out = String::with_capacity(vml.len()); - let mut last = 0; - for (a, b) in shape_spans(vml) { - let shape = vml.get(a..b).unwrap_or(""); - if shape.contains("ObjectType=\"Note\"") { - out.push_str(vml.get(last..a).unwrap_or("")); - last = b; - } - } - out.push_str(vml.get(last..).unwrap_or("")); - out -} - -/// Shape numbers (`_x0000_s1025` → 1025) used in VML text. -fn shape_numbers(vml: &str) -> Vec { - vml.match_indices("_x0000_s") - .filter_map(|(i, m)| { - let digits: String = vml.get(i + m.len()..)?.chars().take_while(char::is_ascii_digit).take(18).collect(); - digits.parse().ok() - }) - .collect() -} - -/// The highest shape block VML text claims in `` or uses in a shape id. -fn vml_block_max(vml: &str) -> u64 { - let used = shape_numbers(vml).into_iter().max().unwrap_or(0) / 1024; - let claimed = vml - .find("().ok()).max().unwrap_or(0)) - .unwrap_or(0); - used.max(claimed) -} - -/// Kept VML (controls, OLE objects) plus GridCraft's note shapes: the notes get ids in a fresh -/// shape block so they can't collide with the kept shapes the sheet XML refers to. -fn merge_vml(kept: &str, ours: &str) -> String { - let Some(close) = kept.rfind("") else { return ours.to_string() }; - let block = shape_numbers(kept).into_iter().max().unwrap_or(0) / 1024 + 1; - let mut add = String::new(); - if !kept.contains("id=\"_x0000_t202\"") - && let Some(i) = ours.find("")) - { - add.push_str(ours.get(i..i + j + "".len()).unwrap_or("")); - } - for (k, (a, b)) in shape_spans(ours).into_iter().enumerate() { - let shape = ours.get(a..b).unwrap_or(""); - let id = block.saturating_mul(1024).saturating_add(1 + k as u64); - match (shape.find("id=\"_x0000_s"), shape.find("id=\"_x0000_s").and_then(|i| shape.get(i + 4..)?.find('"').map(|j| i + 4 + j))) { - (Some(i), Some(j)) => { - let _ = write!(add, "{}id=\"_x0000_s{id}\"{}", shape.get(..i).unwrap_or(""), shape.get(j + 1..).unwrap_or("")); - } - _ => add.push_str(shape), - } - } - let mut head = kept.get(..close).unwrap_or("").to_string(); - // Claim the block in the drawing's id map. - if let Some(i) = head.find(" Option) -> Option { - let mut reader = Reader::from_str(x); - let mut writer = Writer::new(Vec::with_capacity(x.len())); - let mut prefixes: HashSet> = HashSet::new(); - let mut depth = 0usize; - loop { - match reader.read_event().ok()? { - Event::Eof => break, - Event::Start(e) => { - depth += 1; - if depth > MAX_FRAGMENT_DEPTH { - return None; - } - let e = map_start(&e, &mut prefixes, map)?; - writer.write_event(Event::Start(e)).ok()?; - } - Event::Empty(e) => { - let e = map_start(&e, &mut prefixes, map)?; - writer.write_event(Event::Empty(e)).ok()?; - } - Event::End(e) => { - depth = depth.saturating_sub(1); - writer.write_event(Event::End(e)).ok()?; - } - ev => writer.write_event(ev).ok()?, - } - } - String::from_utf8(writer.into_inner()).ok() -} - -fn map_start(e: &BytesStart<'_>, prefixes: &mut HashSet>, map: &mut dyn FnMut(&str) -> Option) -> Option> { - for a in e.attributes().with_checks(false) { - let a = a.ok()?; - if let Some(p) = a.key.as_ref().strip_prefix(b"xmlns:") - && (a.value.as_ref() == NS_REL.as_bytes() || a.value.as_ref() == NS_REL_STRICT.as_bytes()) - { - prefixes.insert(p.to_vec()); - } - } - let name = String::from_utf8(e.name().as_ref().to_vec()).ok()?; - let mut out = BytesStart::new(name); - for a in e.attributes().with_checks(false) { - let a = a.ok()?; - let key = a.key.as_ref(); - let is_rel = !key.starts_with(b"xmlns") && a.key.prefix().is_some_and(|p| prefixes.contains(p.as_ref())); - if is_rel { - let old = std::str::from_utf8(&a.value).ok()?; - let new = map(old)?; - out.push_attribute(Attribute { key: QName(key), value: Cow::Owned(esc_attr(&new).into_bytes()) }); - } else { - out.push_attribute(Attribute { key: QName(key), value: a.value.clone() }); - } - } - Some(out) -} - -// ---------------------------------------------------------------- writing - -/// Names given to kept parts while a package is written. -#[derive(Default)] -pub struct KeptOut { - used: HashSet, - n: u32, - /// The highest VML shape block a kept header and footer drawing uses: notes go after it. - vml_blocks: u32, - /// Package-root relationships (type, target, external), added when `_rels/.rels` is written. - root: Vec<(String, String, bool)>, -} - -impl KeptOut { - /// Reserves the VML shape blocks the kept header and footer drawings of `wb` use, so that the - /// notes drawings GridCraft writes don't reuse their shape ids. - pub fn reserve_vml_blocks(&mut self, wb: &Workbook) { - let vmls = - wb.sheets.iter().filter_map(|s| s.preserved.as_deref()).flat_map(|p| &p.parts).filter(|q| q.content_type.eq_ignore_ascii_case(CT_VML)); - let max = vmls.map(|q| vml_block_max(&String::from_utf8_lossy(&q.data))).max().unwrap_or(0); - self.vml_blocks = u32::try_from(max).unwrap_or(u32::MAX); - } - - /// The shape block of the `n`th notes drawing. - pub fn notes_block(&self, n: u32) -> u32 { - n.saturating_add(self.vml_blocks) - } - - /// A part name for a kept part: its original name when that can't collide with anything - /// written, otherwise a fresh one in the same folder. - fn claim(&mut self, original: &str) -> String { - let lower = original.to_ascii_lowercase(); - let own = OWN_DIRS.iter().any(|d| lower.starts_with(d)); - if own && self.used.insert(lower) { - return original.to_string(); - } - let (dir, file) = package::split_dir(original); - let (stem, ext) = match file.rsplit_once('.') { - Some((s, e)) => (s, format!(".{e}")), - None => (file, String::new()), - }; - let stem = if own { format!("{stem}_") } else { "kept".to_string() }; - loop { - self.n = self.n.saturating_add(1); - let file = format!("{stem}{}{ext}", self.n); - let cand = if dir.is_empty() { file } else { format!("{dir}/{file}") }; - if self.used.insert(cand.to_ascii_lowercase()) || self.n == u32::MAX { - return cand; - } - } - } -} - -/// Original part name (normalized) → written part name. -type NameMap = HashMap; - -fn rel_target(r: &PreservedRel, names: &NameMap) -> Option { - if r.external { Some(r.target.clone()) } else { names.get(&normalize(&r.target)).map(|n| format!("/{n}")) } -} - -/// A kept part's relationships, ids unchanged, targets renamed. -fn part_rels_xml(rels: &[PreservedRel], names: &NameMap) -> String { - let mut s = format!("{XML_DECL}"); - for r in rels { - let Some(target) = rel_target(r, names) else { continue }; - let _ = write!( - s, - "", - esc_attr(&r.id), - esc_attr(&r.rel_type), - esc_attr(&target), - if r.external { " TargetMode=\"External\"" } else { "" } - ); - } - s.push_str(""); - s -} - -fn write_parts(out: &mut Out, parts: &[PreservedPart]) -> NameMap { - let mut names = NameMap::new(); - for p in parts { - names.entry(normalize(&p.name)).or_insert_with(|| out.kept.claim(&p.name)); - } - let mut written = HashSet::new(); - for p in parts { - let Some(new) = names.get(&normalize(&p.name)).cloned() else { continue }; - if !written.insert(new.clone()) { - continue; - } - out.part(&new, Some(&p.content_type), p.data.to_vec()); - if !p.rels.is_empty() { - out.part(&rels_part_name(&new), None, part_rels_xml(&p.rels, &names).into_bytes()); - } - } - names -} - -/// Adds the owner's relationships; returns old id → new id. -fn attach(rels: &mut Rels, list: &[PreservedRel], names: &NameMap) -> HashMap { - let mut ids = HashMap::new(); - for r in list { - if let Some(target) = rel_target(r, names) { - let id = rels.add_raw(&r.rel_type, &target, r.external); - ids.insert(r.id.clone(), id); - } - } - ids -} - -fn remap(x: &str, ids: &HashMap) -> Option { - map_rel_ids(x, &mut |id| ids.get(id).cloned()) -} - -/// A kept chart sheet being written: its `` entry's fields and its new relationship id. -pub struct KeptSheet { - before: usize, - name: String, - visibility: Visibility, - rid: String, - sheet_id: u32, -} - -/// The `sheetId`s of the written sheets: each keeps the one the file gave it, unless another -/// sheet took it first (a copied sheet) or it has none (a new sheet), and those get the next id -/// that no sheet asks for. -pub struct SheetIds { - wanted: HashSet, - given: HashSet, - next: u32, -} - -impl SheetIds { - pub fn new(wb: &Workbook, kept: &WorkbookKept) -> SheetIds { - let wanted: HashSet = wb.sheets.iter().map(|s| s.file_id).chain(kept.sheets.iter().map(|s| s.sheet_id)).filter(|&id| id != 0).collect(); - SheetIds { wanted, given: HashSet::new(), next: 1 } - } - - pub fn take(&mut self, wanted: u32) -> u32 { - if wanted != 0 && self.given.insert(wanted) { - return wanted; - } - while self.wanted.contains(&self.next) || self.given.contains(&self.next) { - self.next += 1; - } - self.given.insert(self.next); - self.next - } -} - -/// The workbook's kept content as written. -#[derive(Default)] -pub struct WorkbookKept { - /// Workbook XML elements that go right after `` (``). - pub elements: String, - sheets: Vec, -} - -impl WorkbookKept { - /// Where the model's sheet `i` is in the file's sheet list: chart sheets come between worksheets. - pub fn file_index(&self, i: usize) -> usize { - i + self.sheets.iter().filter(|s| s.before <= i).count() - } - - /// The `` entries of the chart sheets that go right before the model's sheet `i` - /// (`i` = the number of sheets for those that come last), with their ids from `ids`. - pub fn sheet_entries(&self, i: usize, last: bool, ids: &mut SheetIds) -> String { - let mut x = String::new(); - for s in self.sheets.iter().filter(|s| if last { s.before >= i } else { s.before == i }) { - let state = match s.visibility { - Visibility::Visible => "", - Visibility::Hidden => " state=\"hidden\"", - Visibility::VeryHidden => " state=\"veryHidden\"", - }; - let name: String = s.name.chars().take(31).collect(); - let id = ids.take(s.sheet_id); - let _ = write!(x, "", esc_attr(&name), esc_attr(&s.rid)); - } - x - } -} - -/// The part names reachable from `root` through kept parts' relationships (normalized). -fn reachable(p: &Preserved, root: &PreservedRel) -> HashSet { - let by_name: HashMap = p.parts.iter().map(|q| (normalize(&q.name), q)).collect(); - let mut seen = HashSet::new(); - let mut queue = VecDeque::from([normalize(&root.target)]); - while let Some(n) = queue.pop_front() { - if !seen.insert(n.clone()) { - continue; - } - if let Some(q) = by_name.get(&n) { - queue.extend(q.rels.iter().filter(|r| !r.external).map(|r| normalize(&r.target))); - } - } - seen -} - -/// Every formula (``) of a chart part, depth first. -fn chart_formulas(el: &xml::El, out: &mut Vec) { - if el.name == "f" && !el.text.trim().is_empty() { - out.push(el.text.trim().to_string()); - } - for c in &el.children { - chart_formulas(c, out); - } -} - -/// The sheet names a formula such as `'My Sheet'!$A$1:$A$9,Data!$B$1` refers to. References to -/// other workbooks (`[1]Sheet!A1`) are not sheets of this one and are left out. -pub(crate) fn sheet_names_in(formula: &str) -> Vec { - let mut names = vec![]; - let chars: Vec = formula.chars().collect(); - let at = |i: usize| chars.get(i).copied(); - let mut i = 0; - while let Some(c) = at(i) { - let mut name = String::new(); - let start = i; - if c == '\'' { - i += 1; - while let Some(c) = at(i) { - if c == '\'' { - if at(i + 1) == Some('\'') { - name.push('\''); - i += 2; - continue; - } - break; - } - name.push(c); - i += 1; - } - i += 1; - } else { - while let Some(c) = at(i).filter(|c| !matches!(c, '!' | ',' | '(' | ')' | '\'' | ' ')) { - name.push(c); - i += 1; - } - } - if at(i) == Some('!') { - if !name.is_empty() && !name.starts_with('[') { - names.push(name); - } - i += 1; - } else if i == start { - i += 1; - } - } - names -} - -/// Whether a kept chart sheet can be written back: its chart's series must still refer to sheets -/// that exist under the same names (the chart is kept as it was, so a series on a deleted or -/// renamed sheet would be left pointing at nothing), and no worksheet may have taken its name. -fn chart_sheet_usable(wb: &Workbook, p: &Preserved, s: &PreservedSheet) -> bool { - let same = |a: &str, b: &str| a.chars().take(31).collect::().eq_ignore_ascii_case(&b.chars().take(31).collect::()); - if wb.sheets.iter().any(|w| same(&w.name, &s.name)) { - return false; - } - let Some(root) = p.rels.iter().find(|r| r.id == s.rel_id) else { return false }; - let parts = reachable(p, root); - let mut formulas = vec![]; - for q in p.parts.iter().filter(|q| parts.contains(&normalize(&q.name)) && q.content_type.contains("chart")) { - if let Ok(x) = xml::parse(&q.data) { - chart_formulas(&x, &mut formulas); - } - } - formulas.iter().flat_map(|f| sheet_names_in(f)).all(|n| wb.sheets.iter().any(|w| same(&w.name, &n))) -} - -/// Writes the workbook's kept parts and relationships. Chart sheets that can't be written back -/// (see [`chart_sheet_usable`]) are left out with the parts only they reach. -pub fn write_workbook(wb: &Workbook, wb_rels: &mut Rels, out: &mut Out) -> WorkbookKept { - let Some(p) = wb.preserved.as_deref() else { return WorkbookKept::default() }; - let (live, dropped): (Vec<&PreservedSheet>, Vec<&PreservedSheet>) = p.sheets.iter().partition(|s| chart_sheet_usable(wb, p, s)); - let root_of = |s: &PreservedSheet| p.rels.iter().find(|r| r.id == s.rel_id); - let dropped_ids: HashSet<&str> = dropped.iter().map(|s| s.rel_id.as_str()).collect(); - // Parts reached only by dropped chart sheets go with them. - let mut gone: HashSet = dropped.iter().filter_map(|s| root_of(s)).flat_map(|r| reachable(p, r)).collect(); - if !gone.is_empty() { - let others = p.rels.iter().filter(|r| !dropped_ids.contains(r.id.as_str())).flat_map(|r| reachable(p, r)); - for n in others { - gone.remove(&n); - } - } - let parts: Vec = p.parts.iter().filter(|q| !gone.contains(&normalize(&q.name))).cloned().collect(); - let rels: Vec = p.rels.iter().filter(|r| !dropped_ids.contains(r.id.as_str())).cloned().collect(); - let names = write_parts(out, &parts); - let ids = attach(wb_rels, &rels, &names); - for r in &p.package_rels { - if let Some(target) = rel_target(r, &names) { - out.kept.root.push((r.rel_type.clone(), target, r.external)); - } - } - WorkbookKept { - elements: p.elements.iter().filter(|e| e.slot == "externalReferences").filter_map(|e| remap(&e.xml, &ids)).collect(), - sheets: live - .iter() - .filter_map(|s| { - Some(KeptSheet { - before: s.before, - name: s.name.clone(), - visibility: s.visibility, - rid: ids.get(&s.rel_id)?.clone(), - sheet_id: s.sheet_id, - }) - }) - .collect(), - } -} - -/// The workbook's kept elements of `slot` that refer to no part (`fileVersion`, `ext`). -pub fn workbook_elements(wb: &Workbook, slot: &str) -> String { - let Some(p) = wb.preserved.as_deref() else { return String::new() }; - p.elements.iter().filter(|e| e.slot == slot).map(|e| e.xml.as_str()).collect() -} - -/// Adds the kept package-root relationships (custom properties…) to `_rels/.rels`. -pub fn write_package_rels(root: &mut Rels, out: &mut Out) { - for (ty, target, external) in std::mem::take(&mut out.kept.root) { - root.add_raw(&ty, &target, external); - } -} - -/// A sheet's kept content being written. -pub struct SheetKept<'a> { - p: Option<&'a Preserved>, - names: NameMap, - ids: HashMap, -} - -impl<'a> SheetKept<'a> { - /// Writes the sheet's kept parts and adds their relationships to the sheet's. - pub fn write(sheet: &'a Sheet, rels: &mut Rels, out: &mut Out) -> SheetKept<'a> { - let Some(p) = sheet.preserved.as_deref() else { return SheetKept { p: None, names: NameMap::new(), ids: HashMap::new() } }; - let names = write_parts(out, &p.parts); - let ids = attach(rels, &p.rels, &names); - SheetKept { p: Some(p), names, ids } - } - - fn vml(&self) -> Option<&'a PreservedPart> { - self.p?.vml.as_ref() - } - - fn write_vml_rels(&self, vml: &PreservedPart, name: &str, out: &mut Out) { - if !vml.rels.is_empty() { - out.part(&rels_part_name(name), None, part_rels_xml(&vml.rels, &self.names).into_bytes()); - } - } - - /// GridCraft's notes VML for part `name`, merged with the kept control and object shapes. - pub fn notes_vml(&self, ours: String, name: &str, out: &mut Out) -> String { - let Some(vml) = self.vml() else { return ours }; - self.write_vml_rels(vml, name, out); - merge_vml(&String::from_utf8_lossy(&vml.data), &ours) - } - - /// The kept VML drawing of a sheet without notes; its new relationship id. - pub fn lone_vml(&self, rels: &mut Rels, out: &mut Out) -> Option { - let vml = self.vml()?; - let name = out.kept.claim(&vml.name); - out.part(&name, Some(CT_VML), vml.data.to_vec()); - self.write_vml_rels(vml, &name, out); - Some(rels.add("vmlDrawing", &format!("/{name}"))) - } - - /// The new relationship id for `` (printer settings). - pub fn page_setup_id(&self) -> Option<&str> { - let old = &self.p?.elements.iter().find(|e| e.slot == "pageSetup")?.xml; - self.ids.get(old).map(String::as_str) - } - - /// The kept sheet elements, in schema order. - pub fn elements(&self) -> String { - let Some(p) = self.p else { return String::new() }; - let mut s = String::new(); - for slot in SHEET_SLOTS { - for e in p.elements.iter().filter(|e| e.slot == *slot) { - if let Some(x) = remap(&e.xml, &self.ids) { - s.push_str(&x); - } - } - } - s - } -} - -// ---------------------------------------------------------------- linked workbooks - -/// `wb` as it is saved into the folder `to` (absolute): the relative paths of its linked -/// workbooks, relative to the folder it was read from, are rewritten relative to `to`, as Excel -/// does on Save As, or made absolute when no relative path exists (another drive). `None` when -/// nothing changes. -pub fn rebase_links(wb: &Workbook, to: &Path) -> Option { - let p = wb.preserved.as_deref()?; - let from = Folder::of(wb.source_dir.as_deref()?)?; - let to = Folder::of(to)?; - if from == to { - return None; - } - let mut parts = p.parts.clone(); - let mut changed = false; - for r in parts.iter_mut().flat_map(|part| part.rels.iter_mut()) { - if r.external - && is_kind(r, &["externalLinkPath"]) - && let Some(target) = rebase(&r.target, &from, &to) - { - r.target = target; - changed = true; - } - } - if !changed { - return None; - } - let mut out = wb.clone(); - out.preserved = Some(Arc::new(Preserved { parts, ..p.clone() })); - Some(out) -} - -/// An absolute folder, lexically normalized. -#[derive(PartialEq)] -struct Folder { - /// Drive or share and root separator (`/`, `C:\`, `\\server\share\`). - root: String, - names: Vec, -} - -impl Folder { - fn of(dir: &Path) -> Option { - if !dir.is_absolute() { - return None; - } - let mut f = Folder { root: String::new(), names: vec![] }; - for c in dir.components() { - match c { - Component::Prefix(_) | Component::RootDir => f.root.push_str(&c.as_os_str().to_string_lossy()), - Component::CurDir => {} - Component::ParentDir => { - f.names.pop(); - } - Component::Normal(n) => f.names.push(n.to_string_lossy().into_owned()), - } - } - Some(f) - } -} - -/// A relative link target read from `from`, rewritten for `to`. `None` keeps it as it is: an -/// absolute path or URL, or a path that climbs above the root. -fn rebase(target: &str, from: &Folder, to: &Folder) -> Option { - if !is_relative_path(target) { - return None; - } - // Each name as the file system knows it, and as it is written in a target. Names taken from - // the original target keep their spelling. - let mut path: Vec<(String, String)> = from.names.iter().map(|n| (n.clone(), encode_name(n))).collect(); - for seg in target.split(['/', '\\']) { - match seg { - "" | "." => {} - ".." => { - path.pop()?; - } - _ => path.push((decode_name(seg), seg.to_string())), - } - } - let folders = path.len().checked_sub(1)?; - if !from.root.eq_ignore_ascii_case(&to.root) { - let sep = if from.root.contains('\\') { "\\" } else { "/" }; - let names: Vec<&str> = path.iter().map(|(name, _)| name.as_str()).collect(); - return Some(format!("file:///{}{}", from.root, names.join(sep))); - } - let common = to.names.iter().zip(&path).take(folders).take_while(|(a, (b, _))| *a == b).count(); - let up = "../".repeat(to.names.len().saturating_sub(common)); - let down: Vec<&str> = path.iter().skip(common).map(|(_, written)| written.as_str()).collect(); - Some(format!("{up}{}", down.join("/"))) -} - -/// Not empty, no root, no scheme or drive (`file:`, `http:`, `C:`). -fn is_relative_path(target: &str) -> bool { - let scheme = target.split_once(':').is_some_and(|(s, _)| { - s.starts_with(|c: char| c.is_ascii_alphabetic()) && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) - }); - !target.is_empty() && !target.starts_with(['/', '\\']) && !scheme -} - -/// A folder name as a segment of a relationship target (a URI): ASCII characters a URI path -/// can't hold are percent-encoded, other characters are written as they are. -fn encode_name(name: &str) -> String { - let mut s = String::with_capacity(name.len()); - for c in name.chars() { - if !c.is_ascii() || c.is_ascii_alphanumeric() || "-._~!$&'()+,;=@".contains(c) { - s.push(c); - } else { - let _ = write!(s, "%{:02X}", c as u32); - } - } - s -} - -/// A target segment with its percent-escapes decoded; as written when they don't decode to UTF-8. -fn decode_name(seg: &str) -> String { - let b = seg.as_bytes(); - let mut out = Vec::with_capacity(b.len()); - let mut i = 0; - while let Some(&c) = b.get(i) { - let hex = b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()); - match hex { - Some(byte) if c == b'%' => { - out.push(byte); - i += 3; - } - _ => { - out.push(c); - i += 1; - } - } - } - String::from_utf8(out).unwrap_or_else(|_| seg.to_string()) -} diff --git a/crates/xlsx/src/passthrough/read.rs b/crates/xlsx/src/passthrough/read.rs new file mode 100644 index 0000000000..6be20d76d0 --- /dev/null +++ b/crates/xlsx/src/passthrough/read.rs @@ -0,0 +1,688 @@ +use super::*; + +/// A part's relationships with full type URIs. `Ok(vec![])` when it has none; an error when the +/// rels part is damaged or too large (its part then can't be kept faithfully). +fn raw_rels(cx: &mut Ctx<'_>, part: &str) -> Result, String> { + let name = rels_part_name(part); + let bytes = match cx.pkg.read_limited(&name, MAX_META) { + Ok(Some(b)) => b, + Ok(None) => return Ok(vec![]), + Err(e) => return Err(e.to_string()), + }; + let root = xml::parse(&bytes).map_err(|e| format!("{name}: {e}"))?; + let (dir, _) = package::split_dir(part); + let mut out = vec![]; + for r in root.kids("Relationship") { + if out.len() >= MAX_RELS { + return Err(format!("{name}: too many relationships")); + } + let (Some(id), Some(target)) = (r.attr("Id"), r.attr("Target")) else { continue }; + let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External")); + out.push(PreservedRel { + id: id.to_string(), + rel_type: r.attr("Type").unwrap_or("").to_string(), + target: if external { target.to_string() } else { package::resolve(dir, target) }, + external, + }); + } + Ok(out) +} + +// ---------------------------------------------------------------- reading + +/// The `` entries of the workbook that are chart sheets, each with the number of worksheets +/// (model sheets) before it. +fn chart_sheet_entries<'a>(wb_xml: &'a xml::El, wb_rels: &[PreservedRel], file_to_model: &[Option]) -> Vec<(usize, &'a xml::El)> { + let mut out = vec![]; + let mut worksheets = 0usize; + for (i, se) in wb_xml.child("sheets").into_iter().flat_map(|s| s.kids("sheet")).enumerate() { + if file_to_model.get(i).copied().flatten().is_some() { + worksheets += 1; + } else if se.attr("id").and_then(|id| wb_rels.iter().find(|r| r.id == id)).is_some_and(|r| is_kind(r, &["chartsheet"])) { + out.push((worksheets, se)); + } + } + out +} + +/// Collects kept content while a package is read. +pub struct KeptReader { + on: bool, + strict: bool, + defaults: HashMap, + overrides: HashMap, + budget: usize, + parts_left: usize, +} + +impl KeptReader { + pub fn new(cx: &mut Ctx<'_>) -> KeptReader { + let mut k = KeptReader { + on: true, + strict: false, + defaults: HashMap::new(), + overrides: HashMap::new(), + budget: kept_total(), + parts_left: MAX_KEPT_PARTS, + }; + if let Ok(root) = raw_rels(cx, "") + && root.iter().any(|r| r.rel_type.starts_with("http://purl.oclc.org/")) + { + k.on = false; + k.strict = true; + } + match cx.pkg.read_limited("[Content_Types].xml", MAX_META).ok().flatten().and_then(|b| xml::parse(&b).ok()) { + Some(types) => { + for d in types.kids("Default") { + if let (Some(e), Some(t)) = (d.attr("Extension"), d.attr("ContentType")) { + k.defaults.insert(e.to_ascii_lowercase(), t.to_string()); + } + } + for o in types.kids("Override") { + if let (Some(p), Some(t)) = (o.attr("PartName"), o.attr("ContentType")) { + k.overrides.insert(normalize(p), t.to_string()); + } + } + } + None => k.on = false, + } + k + } + + fn content_type(&self, part: &str) -> Option { + let key = normalize(part); + if let Some(t) = self.overrides.get(&key) { + return Some(t.clone()); + } + let (_, file) = package::split_dir(&key); + let ext = file.rsplit_once('.').map(|(_, e)| e)?; + self.defaults.get(ext).cloned() + } + + /// The parts reachable from `root` that aren't in `seen` yet. Fails (and keeps nothing) when the + /// graph reaches rewritten content, a missing or damaged part, or a size limit. + fn closure(&mut self, cx: &mut Ctx<'_>, root: &PreservedRel, seen: &HashSet, through: &[&str]) -> Result, String> { + if root.external { + return Ok(vec![]); + } + let blocked = |r: &PreservedRel| rewritten(r) && !through.iter().any(|k| k.eq_ignore_ascii_case(kind(&r.rel_type))); + if blocked(root) { + return Err(format!("it refers to {}", kind(&root.rel_type))); + } + let mut parts = vec![]; + let mut bytes = 0usize; + let mut local: HashSet = HashSet::new(); + let mut queue = VecDeque::from([(root.target.clone(), 0usize)]); + while let Some((name, depth)) = queue.pop_front() { + let key = normalize(&name); + if seen.contains(&key) || !local.insert(key) { + continue; + } + if depth > MAX_CHAIN { + return Err("its relationships are nested too deeply".into()); + } + if parts.len() >= self.parts_left { + return Err("it has too many parts".into()); + } + let content_type = self.content_type(&name).ok_or_else(|| format!("{name} has no content type"))?; + let data = match cx.pkg.read_limited(&name, MAX_KEPT_PART) { + Ok(Some(d)) => d, + Ok(None) => return Err(format!("{name} is missing")), + Err(e) => return Err(e.to_string()), + }; + bytes = bytes.saturating_add(data.len()); + if bytes > self.budget { + return Err("it is too large".into()); + } + let rels = raw_rels(cx, &name)?; + for r in rels.iter().filter(|r| !r.external) { + if blocked(r) { + return Err(format!("it refers to {}", kind(&r.rel_type))); + } + queue.push_back((r.target.clone(), depth + 1)); + } + parts.push(PreservedPart { name, content_type, data: Arc::from(data), rels }); + } + self.budget = self.budget.saturating_sub(bytes); + self.parts_left = self.parts_left.saturating_sub(parts.len()); + Ok(parts) + } + + /// Keeps a set of relationships (all or nothing) and the parts behind them. + fn keep(&mut self, cx: &mut Ctx<'_>, p: &mut Preserved, roots: &[PreservedRel], seen: &mut HashSet) -> Result<(), String> { + self.keep_through(cx, p, roots, seen, &[]) + } + + /// [`Self::keep`] for roots whose graph may reach the rewritten kinds in `through`. + fn keep_through( + &mut self, + cx: &mut Ctx<'_>, + p: &mut Preserved, + roots: &[PreservedRel], + seen: &mut HashSet, + through: &[&str], + ) -> Result<(), String> { + let mut new_parts: Vec = vec![]; + let mut scratch = seen.clone(); + for r in roots { + let parts = self.closure(cx, r, &scratch, through)?; + scratch.extend(parts.iter().map(|q| normalize(&q.name))); + new_parts.extend(parts); + } + *seen = scratch; + p.parts.extend(new_parts); + for r in roots { + if !p.rels.iter().any(|x| x.id == r.id) { + p.rels.push(r.clone()); + } + } + Ok(()) + } + + /// Package-root and workbook-level content. + pub fn workbook(&mut self, cx: &mut Ctx<'_>, wb_part: &str, wb_xml: &xml::El, file_to_model: &[Option]) -> Option> { + if !self.on { + let wb_rels = raw_rels(cx, wb_part).unwrap_or_default(); + for (_, se) in chart_sheet_entries(wb_xml, &wb_rels, file_to_model) { + cx.warn(format!("chart sheet \"{}\" was skipped and is not kept on save", se.attr("name").unwrap_or(""))); + } + if self.strict { + cx.warn("unmodelled content of a Strict Open XML workbook (custom XML, links, controls…) is not kept on save"); + } + return None; + } + let mut p = Preserved::default(); + let mut seen = HashSet::new(); + // Package root: custom document properties, ribbon customisation, task panes. + if let Ok(root) = raw_rels(cx, "") { + for r in root.iter().filter(|r| is_kind(r, PACKAGE_ROOTS)) { + let mut q = Preserved::default(); + match self.keep(cx, &mut q, std::slice::from_ref(r), &mut seen) { + Ok(()) => { + p.parts.extend(q.parts); + p.package_rels.extend(q.rels); + } + Err(why) => cx.warn(format!("{} was dropped: {why}", kind(&r.rel_type))), + } + } + } + let wb_rels = raw_rels(cx, wb_part).unwrap_or_default(); + for r in wb_rels.iter().filter(|r| is_kind(r, WORKBOOK_ROOTS)) { + if let Err(why) = self.keep(cx, &mut p, std::slice::from_ref(r), &mut seen) { + cx.warn(format!("{} part {} was dropped: {why}", kind(&r.rel_type), r.target)); + } + } + // Chart sheets: the sheet entry (name, state, position) plus the chart sheet part and the + // drawing, chart, style and colour parts behind it. + for (worksheets, se) in chart_sheet_entries(wb_xml, &wb_rels, file_to_model) { + let name = se.attr("name").unwrap_or("").to_string(); + let Some(r) = se.attr("id").and_then(|id| wb_rels.iter().find(|r| r.id == id)) else { continue }; + match self.keep_through(cx, &mut p, std::slice::from_ref(r), &mut seen, CHART_SHEET_GRAPH) { + Ok(()) => p.sheets.push(PreservedSheet { + name, + visibility: match se.attr("state") { + Some("hidden") => Visibility::Hidden, + Some("veryHidden") => Visibility::VeryHidden, + _ => Visibility::Visible, + }, + rel_id: r.id.clone(), + before: worksheets, + sheet_id: se.attr_u32("sheetId").unwrap_or(0), + }), + Err(why) => cx.warn(format!("chart sheet \"{name}\" was dropped: {why}")), + } + } + // Links to external workbooks: `[1]` in formulas is the position in , + // so they are kept all together or not at all. + let ids: Vec<&str> = + wb_xml.child("externalReferences").map(|e| e.kids("externalReference").filter_map(|x| x.attr("id")).collect()).unwrap_or_default(); + if !ids.is_empty() { + let roots: Option> = + ids.iter().map(|id| wb_rels.iter().find(|r| r.id == *id && is_kind(r, &["externalLink"])).cloned()).collect(); + let kept = match roots { + Some(roots) => self.keep(cx, &mut p, &roots, &mut seen), + None => Err("a link has no part".into()), + }; + match kept { + Ok(()) => { + let mut x = format!(""); + for id in &ids { + let _ = write!(x, "", esc_attr(id)); + } + x.push_str(""); + p.elements.push(PreservedXml { slot: "externalReferences".into(), xml: x }); + cx.warn("links to external workbooks were kept; they are not followed or refreshed"); + } + Err(why) => cx.warn(format!("links to external workbooks were dropped: {why}")), + } + } + // Excel trusts the saved cell values only when both are present; without them it + // recalculates everything on open, and links into closed workbooks come back as errors. + if let Some(v) = wb_xml.child("fileVersion") { + let mut x = String::from(""); + p.elements.push(PreservedXml { slot: "fileVersion".into(), xml: x }); + } + let calc_features = wb_xml + .child("extLst") + .and_then(|l| l.kids("ext").find(|e| e.attr("uri").is_some_and(|u| u.eq_ignore_ascii_case(CALC_FEATURES_URI)))) + .and_then(|e| e.child("calcFeatures")); + if let Some(c) = calc_features { + let mut x = format!(""); + for name in c.kids("feature").filter_map(|f| f.attr("name")) { + let _ = write!(x, "", esc_attr(name)); + } + x.push_str(""); + p.elements.push(PreservedXml { slot: "ext".into(), xml: x }); + } + (!p.is_empty()).then(|| Arc::new(p)) + } + + /// Sheet-level content of the sheet part `part`. + pub fn sheet(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str) -> Option> { + if !self.on { + return None; + } + let rels = match raw_rels(cx, part) { + Ok(r) => r, + Err(e) => { + cx.warn(format!("sheet \"{name}\": relationships could not be read ({e}); unmodelled content was dropped")); + return None; + } + }; + let mut p = Preserved::default(); + let mut seen = HashSet::new(); + for r in rels.iter().filter(|r| is_kind(r, SHEET_ROOTS)) { + if let Err(why) = self.keep(cx, &mut p, std::slice::from_ref(r), &mut seen) { + cx.warn(format!("sheet \"{name}\": {} was dropped: {why}", kind(&r.rel_type))); + } + } + // Notes have one VML drawing per sheet; another one holds the header and footer pictures. + let vml_drawings = rels.iter().filter(|r| is_kind(r, &["vmlDrawing"])).count(); + let notes = rels.iter().filter(|r| is_kind(r, &["comments"])).count(); + if rels.iter().any(|r| is_kind(r, SHEET_ELEMENT_KINDS)) || vml_drawings > notes { + self.sheet_elements(cx, part, name, &rels, &mut p, &mut seen); + } + (!p.is_empty()).then(|| Arc::new(p)) + } + + fn sheet_elements(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str, rels: &[PreservedRel], p: &mut Preserved, seen: &mut HashSet) { + let scan = match cx.pkg.read(part) { + Ok(Some(bytes)) => scan_sheet(&bytes), + _ => None, + }; + let Some(scan) = scan else { + cx.warn(format!("sheet \"{name}\": controls and embedded objects could not be read and were dropped")); + return; + }; + // Printer settings (a binary part per sheet) hang off . + if let Some(id) = scan.page_setup.as_deref() + && let Some(r) = rels.iter().find(|r| r.id == id && is_kind(r, &["printerSettings"])) + { + match self.keep(cx, p, std::slice::from_ref(r), seen) { + Ok(()) => p.elements.push(PreservedXml { slot: "pageSetup".into(), xml: r.id.clone() }), + Err(why) => cx.warn(format!("sheet \"{name}\": printer settings were dropped: {why}")), + } + } + // Header and footer pictures (`&G`): a VML drawing of their own and the images it shows, + // kept byte for byte (GridCraft writes the header text, not the pictures). + if let Some(id) = scan.legacy_drawing_hf.as_deref() + && scan.legacy_drawing.as_deref() != Some(id) + && let Some(r) = rels.iter().find(|r| r.id == id && is_kind(r, &["vmlDrawing"])) + { + match self.keep_through(cx, p, std::slice::from_ref(r), seen, &["vmlDrawing"]) { + Ok(()) => { + let xml = format!("", esc_attr(&r.id)); + p.elements.push(PreservedXml { slot: "legacyDrawingHF".into(), xml }); + cx.warn(format!("sheet \"{name}\": pictures in the header and footer are not shown; they are kept for saving")); + } + Err(why) => cx.warn(format!("sheet \"{name}\": header and footer pictures were dropped: {why}")), + } + } + let mut frags = scan.fragments; + // Controls and OLE objects are drawn from shapes in the legacy VML drawing. + if frags.iter().any(|(slot, _)| slot != "picture") { + match self.vml(cx, rels, scan.legacy_drawing.as_deref(), seen) { + Ok((vml, parts)) => { + p.parts.extend(parts); + p.vml = Some(vml); + } + Err(why) => { + cx.warn(format!("sheet \"{name}\": controls and embedded objects were dropped: {why}")); + frags.retain(|(slot, _)| slot == "picture"); + } + } + } + for (slot, x) in frags { + let mut ids = vec![]; + let ok = map_rel_ids(&x, &mut |id| { + ids.push(id.to_string()); + Some(id.to_string()) + }) + .is_some(); + let roots: Option> = ids.iter().map(|id| rels.iter().find(|r| r.id == *id).cloned()).collect(); + let kept = match (ok, roots) { + (true, Some(roots)) => self.keep(cx, p, &roots, seen), + (false, _) => Err("its XML is damaged".into()), + (_, None) => Err("it refers to a missing relationship".into()), + }; + match kept { + Ok(()) => p.elements.push(PreservedXml { slot, xml: x }), + Err(why) => cx.warn(format!("sheet \"{name}\": {slot} were dropped: {why}")), + } + } + if p.elements.iter().any(|e| e.slot == "oleObjects" || e.slot == "controls") { + cx.warn(format!("sheet \"{name}\": embedded objects and form controls are not shown; they are kept for saving")); + } else { + p.vml = None; + } + } + + /// The sheet's VML drawing without its notes (GridCraft writes those), and the parts its + /// shapes refer to (preview images). + fn vml( + &mut self, + cx: &mut Ctx<'_>, + rels: &[PreservedRel], + rid: Option<&str>, + seen: &mut HashSet, + ) -> Result<(PreservedPart, Vec), String> { + let rel = rid.and_then(|id| rels.iter().find(|r| r.id == id && !r.external)).ok_or("the sheet has no VML drawing")?; + // Charged to the kept total like any other part: many sheets can point at one highly + // compressed VML drawing, and each keeps its own copy. + if self.budget == 0 { + return Err("it is too large".into()); + } + let limit = MAX_META.min(u64::try_from(self.budget).unwrap_or(u64::MAX)); + let data = match cx.pkg.read_limited(&rel.target, limit) { + Ok(Some(d)) => d, + Ok(None) => return Err(format!("{} is missing", rel.target)), + Err(IoError::TooLarge(_)) => return Err("it is too large".into()), + Err(e) => return Err(e.to_string()), + }; + self.budget = self.budget.saturating_sub(data.len()); + let text = String::from_utf8(data).map_err(|_| "the VML drawing is not UTF-8".to_string())?; + let text = strip_notes(&text); + let vrels = raw_rels(cx, &rel.target)?; + let mut q = Preserved::default(); + let roots: Vec = vrels.iter().filter(|r| !r.external).cloned().collect(); + self.keep(cx, &mut q, &roots, seen)?; + let vml = PreservedPart { name: rel.target.clone(), content_type: CT_VML.into(), data: Arc::from(text.into_bytes()), rels: vrels }; + Ok((vml, q.parts)) + } +} + +/// What a sheet part holds for pass-through. +struct SheetScan { + /// (slot, XML with the namespace declarations it needs). + fragments: Vec<(String, String)>, + legacy_drawing: Option, + /// The relationship id on `` (header and footer pictures). + legacy_drawing_hf: Option, + /// The relationship id on `` (printer settings). + page_setup: Option, +} + +/// Finds the sheet's ``, ``, `` (also inside +/// ``) and the `` and `` ids. `None` when the XML is damaged. +fn scan_sheet(bytes: &[u8]) -> Option { + let data = xml::to_utf8(bytes); + let text = std::str::from_utf8(&data).ok()?; + let mut reader = Reader::from_str(text); + let mut decls: Vec<(String, String)> = vec![]; + let mut out = SheetScan { fragments: vec![], legacy_drawing: None, legacy_drawing_hf: None, page_setup: None }; + let mut in_root = false; + loop { + let start = usize::try_from(reader.buffer_position()).ok()?; + match reader.read_event().ok()? { + Event::Eof => break, + Event::Start(e) if !in_root => { + in_root = true; + for a in e.attributes().with_checks(false).flatten() { + if let Some(p) = a.key.as_ref().strip_prefix(b"xmlns:") + && let (Ok(p), Ok(v)) = (std::str::from_utf8(p), std::str::from_utf8(&a.value)) + && !v.contains('"') + { + decls.push((p.to_string(), v.to_string())); + } + } + } + Event::Start(e) => { + let local = e.local_name().as_ref().to_vec(); + match local.as_slice() { + b"pageSetup" => out.page_setup = rel_id_attr(&e), + b"legacyDrawing" => out.legacy_drawing = rel_id_attr(&e), + b"legacyDrawingHF" => out.legacy_drawing_hf = rel_id_attr(&e), + _ => {} + } + reader.read_to_end(e.name()).ok()?; + let end = usize::try_from(reader.buffer_position()).ok()?; + let wanted = matches!(local.as_slice(), b"picture" | b"oleObjects" | b"controls" | b"AlternateContent"); + if wanted && end.saturating_sub(start) <= MAX_FRAGMENT { + let frag = text.get(start..end)?; + push_fragment(&mut out, frag, &local, &decls); + } + } + Event::Empty(e) if in_root => { + let local = e.local_name().as_ref().to_vec(); + if local == b"legacyDrawing" || local == b"legacyDrawingHF" || local == b"pageSetup" { + let id = rel_id_attr(&e); + match local.as_slice() { + b"pageSetup" => out.page_setup = id, + b"legacyDrawing" => out.legacy_drawing = id, + _ => out.legacy_drawing_hf = id, + } + } else if local == b"picture" + && let Some(frag) = text.get(start..usize::try_from(reader.buffer_position()).ok()?) + { + push_fragment(&mut out, frag, &local, &decls); + } + } + Event::End(_) => break, + _ => {} + } + } + Some(out) +} + +/// The `r:id` of an element (an `id` attribute with a namespace prefix). +fn rel_id_attr(e: &BytesStart<'_>) -> Option { + e.attributes() + .with_checks(false) + .flatten() + .find(|a| a.key.local_name().as_ref() == b"id" && a.key.prefix().is_some()) + .and_then(|a| String::from_utf8(a.value.to_vec()).ok()) +} + +fn push_fragment(out: &mut SheetScan, frag: &str, local: &[u8], decls: &[(String, String)]) { + let frag = with_decls(frag, decls); + let slot = if local == b"AlternateContent" { + // The kept element is the one the first choice holds. + let Ok(el) = xml::parse(frag.as_bytes()) else { return }; + match el.child("Choice").and_then(|c| c.children.first()) { + Some(c) => c.name.clone(), + None => return, + } + } else { + String::from_utf8_lossy(local).into_owned() + }; + if SHEET_SLOTS.contains(&slot.as_str()) && !out.fragments.iter().any(|(s, _)| *s == slot) { + out.fragments.push((slot, frag)); + } +} + +/// Adds the root's namespace declarations a fragment doesn't declare itself. +fn with_decls(frag: &str, decls: &[(String, String)]) -> String { + let Some(name_end) = frag.char_indices().skip(1).find(|(_, c)| c.is_whitespace() || *c == '/' || *c == '>').map(|(i, _)| i) else { + return frag.to_string(); + }; + let head = frag.get(..frag.find('>').unwrap_or(frag.len())).unwrap_or(""); + let mut add = String::new(); + for (p, v) in decls { + if !head.contains(&format!("xmlns:{p}=")) { + let _ = write!(add, " xmlns:{p}=\"{v}\""); + } + } + format!("{}{add}{}", frag.get(..name_end).unwrap_or(""), frag.get(name_end..).unwrap_or("")) +} + +/// Byte ranges of the `…` (or self-closed) elements in VML text. +fn shape_spans(vml: &str) -> Vec<(usize, usize)> { + let mut spans = vec![]; + let mut from = 0; + while let Some(i) = vml.get(from..).and_then(|s| s.find("' || c == '/'); + if !is_shape { + from = after; + continue; + } + let Some(tag_end) = vml.get(i..).and_then(|s| s.find('>')).map(|j| i + j + 1) else { break }; + let end = if vml.get(..tag_end).is_some_and(|t| t.ends_with("/>")) { + tag_end + } else { + match vml.get(tag_end..).and_then(|s| s.find("")) { + Some(j) => tag_end + j + "".len(), + None => break, + } + }; + spans.push((i, end)); + from = end; + } + spans +} + +/// VML without its note shapes (`ObjectType="Note"`). +fn strip_notes(vml: &str) -> String { + let mut out = String::with_capacity(vml.len()); + let mut last = 0; + for (a, b) in shape_spans(vml) { + let shape = vml.get(a..b).unwrap_or(""); + if shape.contains("ObjectType=\"Note\"") { + out.push_str(vml.get(last..a).unwrap_or("")); + last = b; + } + } + out.push_str(vml.get(last..).unwrap_or("")); + out +} + +/// Shape numbers (`_x0000_s1025` → 1025) used in VML text. +fn shape_numbers(vml: &str) -> Vec { + vml.match_indices("_x0000_s") + .filter_map(|(i, m)| { + let digits: String = vml.get(i + m.len()..)?.chars().take_while(char::is_ascii_digit).take(18).collect(); + digits.parse().ok() + }) + .collect() +} + +/// The highest shape block VML text claims in `` or uses in a shape id. +pub(super) fn vml_block_max(vml: &str) -> u64 { + let used = shape_numbers(vml).into_iter().max().unwrap_or(0) / 1024; + let claimed = vml + .find("().ok()).max().unwrap_or(0)) + .unwrap_or(0); + used.max(claimed) +} + +/// Kept VML (controls, OLE objects) plus GridCraft's note shapes: the notes get ids in a fresh +/// shape block so they can't collide with the kept shapes the sheet XML refers to. +pub(super) fn merge_vml(kept: &str, ours: &str) -> String { + let Some(close) = kept.rfind("") else { return ours.to_string() }; + let block = shape_numbers(kept).into_iter().max().unwrap_or(0) / 1024 + 1; + let mut add = String::new(); + if !kept.contains("id=\"_x0000_t202\"") + && let Some(i) = ours.find("")) + { + add.push_str(ours.get(i..i + j + "".len()).unwrap_or("")); + } + for (k, (a, b)) in shape_spans(ours).into_iter().enumerate() { + let shape = ours.get(a..b).unwrap_or(""); + let id = block.saturating_mul(1024).saturating_add(1 + k as u64); + match (shape.find("id=\"_x0000_s"), shape.find("id=\"_x0000_s").and_then(|i| shape.get(i + 4..)?.find('"').map(|j| i + 4 + j))) { + (Some(i), Some(j)) => { + let _ = write!(add, "{}id=\"_x0000_s{id}\"{}", shape.get(..i).unwrap_or(""), shape.get(j + 1..).unwrap_or("")); + } + _ => add.push_str(shape), + } + } + let mut head = kept.get(..close).unwrap_or("").to_string(); + // Claim the block in the drawing's id map. + if let Some(i) = head.find(" Option) -> Option { + let mut reader = Reader::from_str(x); + let mut writer = Writer::new(Vec::with_capacity(x.len())); + let mut prefixes: HashSet> = HashSet::new(); + let mut depth = 0usize; + loop { + match reader.read_event().ok()? { + Event::Eof => break, + Event::Start(e) => { + depth += 1; + if depth > MAX_FRAGMENT_DEPTH { + return None; + } + let e = map_start(&e, &mut prefixes, map)?; + writer.write_event(Event::Start(e)).ok()?; + } + Event::Empty(e) => { + let e = map_start(&e, &mut prefixes, map)?; + writer.write_event(Event::Empty(e)).ok()?; + } + Event::End(e) => { + depth = depth.saturating_sub(1); + writer.write_event(Event::End(e)).ok()?; + } + ev => writer.write_event(ev).ok()?, + } + } + String::from_utf8(writer.into_inner()).ok() +} + +fn map_start(e: &BytesStart<'_>, prefixes: &mut HashSet>, map: &mut dyn FnMut(&str) -> Option) -> Option> { + for a in e.attributes().with_checks(false) { + let a = a.ok()?; + if let Some(p) = a.key.as_ref().strip_prefix(b"xmlns:") + && (a.value.as_ref() == NS_REL.as_bytes() || a.value.as_ref() == NS_REL_STRICT.as_bytes()) + { + prefixes.insert(p.to_vec()); + } + } + let name = String::from_utf8(e.name().as_ref().to_vec()).ok()?; + let mut out = BytesStart::new(name); + for a in e.attributes().with_checks(false) { + let a = a.ok()?; + let key = a.key.as_ref(); + let is_rel = !key.starts_with(b"xmlns") && a.key.prefix().is_some_and(|p| prefixes.contains(p.as_ref())); + if is_rel { + let old = std::str::from_utf8(&a.value).ok()?; + let new = map(old)?; + out.push_attribute(Attribute { key: QName(key), value: Cow::Owned(esc_attr(&new).into_bytes()) }); + } else { + out.push_attribute(Attribute { key: QName(key), value: a.value.clone() }); + } + } + Some(out) +} diff --git a/crates/xlsx/src/passthrough/rebase.rs b/crates/xlsx/src/passthrough/rebase.rs new file mode 100644 index 0000000000..d758f86d67 --- /dev/null +++ b/crates/xlsx/src/passthrough/rebase.rs @@ -0,0 +1,199 @@ +use super::read::merge_vml; +use super::write::{NameMap, attach, part_rels_xml, remap, write_parts}; +use super::*; + +/// A sheet's kept content being written. +pub struct SheetKept<'a> { + p: Option<&'a Preserved>, + names: NameMap, + ids: HashMap, +} + +impl<'a> SheetKept<'a> { + /// Writes the sheet's kept parts and adds their relationships to the sheet's. + pub fn write(sheet: &'a Sheet, rels: &mut Rels, out: &mut Out) -> SheetKept<'a> { + let Some(p) = sheet.preserved.as_deref() else { return SheetKept { p: None, names: NameMap::new(), ids: HashMap::new() } }; + let names = write_parts(out, &p.parts); + let ids = attach(rels, &p.rels, &names); + SheetKept { p: Some(p), names, ids } + } + + fn vml(&self) -> Option<&'a PreservedPart> { + self.p?.vml.as_ref() + } + + fn write_vml_rels(&self, vml: &PreservedPart, name: &str, out: &mut Out) { + if !vml.rels.is_empty() { + out.part(&rels_part_name(name), None, part_rels_xml(&vml.rels, &self.names).into_bytes()); + } + } + + /// GridCraft's notes VML for part `name`, merged with the kept control and object shapes. + pub fn notes_vml(&self, ours: String, name: &str, out: &mut Out) -> String { + let Some(vml) = self.vml() else { return ours }; + self.write_vml_rels(vml, name, out); + merge_vml(&String::from_utf8_lossy(&vml.data), &ours) + } + + /// The kept VML drawing of a sheet without notes; its new relationship id. + pub fn lone_vml(&self, rels: &mut Rels, out: &mut Out) -> Option { + let vml = self.vml()?; + let name = out.kept.claim(&vml.name); + out.part(&name, Some(CT_VML), vml.data.to_vec()); + self.write_vml_rels(vml, &name, out); + Some(rels.add("vmlDrawing", &format!("/{name}"))) + } + + /// The new relationship id for `` (printer settings). + pub fn page_setup_id(&self) -> Option<&str> { + let old = &self.p?.elements.iter().find(|e| e.slot == "pageSetup")?.xml; + self.ids.get(old).map(String::as_str) + } + + /// The kept sheet elements, in schema order. + pub fn elements(&self) -> String { + let Some(p) = self.p else { return String::new() }; + let mut s = String::new(); + for slot in SHEET_SLOTS { + for e in p.elements.iter().filter(|e| e.slot == *slot) { + if let Some(x) = remap(&e.xml, &self.ids) { + s.push_str(&x); + } + } + } + s + } +} + +// ---------------------------------------------------------------- linked workbooks + +/// `wb` as it is saved into the folder `to` (absolute): the relative paths of its linked +/// workbooks, relative to the folder it was read from, are rewritten relative to `to`, as Excel +/// does on Save As, or made absolute when no relative path exists (another drive). `None` when +/// nothing changes. +pub fn rebase_links(wb: &Workbook, to: &Path) -> Option { + let p = wb.preserved.as_deref()?; + let from = Folder::of(wb.source_dir.as_deref()?)?; + let to = Folder::of(to)?; + if from == to { + return None; + } + let mut parts = p.parts.clone(); + let mut changed = false; + for r in parts.iter_mut().flat_map(|part| part.rels.iter_mut()) { + if r.external + && is_kind(r, &["externalLinkPath"]) + && let Some(target) = rebase(&r.target, &from, &to) + { + r.target = target; + changed = true; + } + } + if !changed { + return None; + } + let mut out = wb.clone(); + out.preserved = Some(Arc::new(Preserved { parts, ..p.clone() })); + Some(out) +} + +/// An absolute folder, lexically normalized. +#[derive(PartialEq)] +struct Folder { + /// Drive or share and root separator (`/`, `C:\`, `\\server\share\`). + root: String, + names: Vec, +} + +impl Folder { + fn of(dir: &Path) -> Option { + if !dir.is_absolute() { + return None; + } + let mut f = Folder { root: String::new(), names: vec![] }; + for c in dir.components() { + match c { + Component::Prefix(_) | Component::RootDir => f.root.push_str(&c.as_os_str().to_string_lossy()), + Component::CurDir => {} + Component::ParentDir => { + f.names.pop(); + } + Component::Normal(n) => f.names.push(n.to_string_lossy().into_owned()), + } + } + Some(f) + } +} + +/// A relative link target read from `from`, rewritten for `to`. `None` keeps it as it is: an +/// absolute path or URL, or a path that climbs above the root. +fn rebase(target: &str, from: &Folder, to: &Folder) -> Option { + if !is_relative_path(target) { + return None; + } + // Each name as the file system knows it, and as it is written in a target. Names taken from + // the original target keep their spelling. + let mut path: Vec<(String, String)> = from.names.iter().map(|n| (n.clone(), encode_name(n))).collect(); + for seg in target.split(['/', '\\']) { + match seg { + "" | "." => {} + ".." => { + path.pop()?; + } + _ => path.push((decode_name(seg), seg.to_string())), + } + } + let folders = path.len().checked_sub(1)?; + if !from.root.eq_ignore_ascii_case(&to.root) { + let sep = if from.root.contains('\\') { "\\" } else { "/" }; + let names: Vec<&str> = path.iter().map(|(name, _)| name.as_str()).collect(); + return Some(format!("file:///{}{}", from.root, names.join(sep))); + } + let common = to.names.iter().zip(&path).take(folders).take_while(|(a, (b, _))| *a == b).count(); + let up = "../".repeat(to.names.len().saturating_sub(common)); + let down: Vec<&str> = path.iter().skip(common).map(|(_, written)| written.as_str()).collect(); + Some(format!("{up}{}", down.join("/"))) +} + +/// Not empty, no root, no scheme or drive (`file:`, `http:`, `C:`). +fn is_relative_path(target: &str) -> bool { + let scheme = target.split_once(':').is_some_and(|(s, _)| { + s.starts_with(|c: char| c.is_ascii_alphabetic()) && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) + }); + !target.is_empty() && !target.starts_with(['/', '\\']) && !scheme +} + +/// A folder name as a segment of a relationship target (a URI): ASCII characters a URI path +/// can't hold are percent-encoded, other characters are written as they are. +fn encode_name(name: &str) -> String { + let mut s = String::with_capacity(name.len()); + for c in name.chars() { + if !c.is_ascii() || c.is_ascii_alphanumeric() || "-._~!$&'()+,;=@".contains(c) { + s.push(c); + } else { + let _ = write!(s, "%{:02X}", c as u32); + } + } + s +} + +/// A target segment with its percent-escapes decoded; as written when they don't decode to UTF-8. +fn decode_name(seg: &str) -> String { + let b = seg.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while let Some(&c) = b.get(i) { + let hex = b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()); + match hex { + Some(byte) if c == b'%' => { + out.push(byte); + i += 3; + } + _ => { + out.push(c); + i += 1; + } + } + } + String::from_utf8(out).unwrap_or_else(|_| seg.to_string()) +} diff --git a/crates/xlsx/src/passthrough/write.rs b/crates/xlsx/src/passthrough/write.rs new file mode 100644 index 0000000000..8b8d24591d --- /dev/null +++ b/crates/xlsx/src/passthrough/write.rs @@ -0,0 +1,325 @@ +use super::read::{map_rel_ids, vml_block_max}; +use super::*; + +// ---------------------------------------------------------------- writing + +/// Names given to kept parts while a package is written. +#[derive(Default)] +pub struct KeptOut { + used: HashSet, + n: u32, + /// The highest VML shape block a kept header and footer drawing uses: notes go after it. + vml_blocks: u32, + /// Package-root relationships (type, target, external), added when `_rels/.rels` is written. + root: Vec<(String, String, bool)>, +} + +impl KeptOut { + /// Reserves the VML shape blocks the kept header and footer drawings of `wb` use, so that the + /// notes drawings GridCraft writes don't reuse their shape ids. + pub fn reserve_vml_blocks(&mut self, wb: &Workbook) { + let vmls = + wb.sheets.iter().filter_map(|s| s.preserved.as_deref()).flat_map(|p| &p.parts).filter(|q| q.content_type.eq_ignore_ascii_case(CT_VML)); + let max = vmls.map(|q| vml_block_max(&String::from_utf8_lossy(&q.data))).max().unwrap_or(0); + self.vml_blocks = u32::try_from(max).unwrap_or(u32::MAX); + } + + /// The shape block of the `n`th notes drawing. + pub fn notes_block(&self, n: u32) -> u32 { + n.saturating_add(self.vml_blocks) + } + + /// A part name for a kept part: its original name when that can't collide with anything + /// written, otherwise a fresh one in the same folder. + pub(super) fn claim(&mut self, original: &str) -> String { + let lower = original.to_ascii_lowercase(); + let own = OWN_DIRS.iter().any(|d| lower.starts_with(d)); + if own && self.used.insert(lower) { + return original.to_string(); + } + let (dir, file) = package::split_dir(original); + let (stem, ext) = match file.rsplit_once('.') { + Some((s, e)) => (s, format!(".{e}")), + None => (file, String::new()), + }; + let stem = if own { format!("{stem}_") } else { "kept".to_string() }; + loop { + self.n = self.n.saturating_add(1); + let file = format!("{stem}{}{ext}", self.n); + let cand = if dir.is_empty() { file } else { format!("{dir}/{file}") }; + if self.used.insert(cand.to_ascii_lowercase()) || self.n == u32::MAX { + return cand; + } + } + } +} + +/// Original part name (normalized) → written part name. +pub(super) type NameMap = HashMap; + +fn rel_target(r: &PreservedRel, names: &NameMap) -> Option { + if r.external { Some(r.target.clone()) } else { names.get(&normalize(&r.target)).map(|n| format!("/{n}")) } +} + +/// A kept part's relationships, ids unchanged, targets renamed. +pub(super) fn part_rels_xml(rels: &[PreservedRel], names: &NameMap) -> String { + let mut s = format!("{XML_DECL}"); + for r in rels { + let Some(target) = rel_target(r, names) else { continue }; + let _ = write!( + s, + "", + esc_attr(&r.id), + esc_attr(&r.rel_type), + esc_attr(&target), + if r.external { " TargetMode=\"External\"" } else { "" } + ); + } + s.push_str(""); + s +} + +pub(super) fn write_parts(out: &mut Out, parts: &[PreservedPart]) -> NameMap { + let mut names = NameMap::new(); + for p in parts { + names.entry(normalize(&p.name)).or_insert_with(|| out.kept.claim(&p.name)); + } + let mut written = HashSet::new(); + for p in parts { + let Some(new) = names.get(&normalize(&p.name)).cloned() else { continue }; + if !written.insert(new.clone()) { + continue; + } + out.part(&new, Some(&p.content_type), p.data.to_vec()); + if !p.rels.is_empty() { + out.part(&rels_part_name(&new), None, part_rels_xml(&p.rels, &names).into_bytes()); + } + } + names +} + +/// Adds the owner's relationships; returns old id → new id. +pub(super) fn attach(rels: &mut Rels, list: &[PreservedRel], names: &NameMap) -> HashMap { + let mut ids = HashMap::new(); + for r in list { + if let Some(target) = rel_target(r, names) { + let id = rels.add_raw(&r.rel_type, &target, r.external); + ids.insert(r.id.clone(), id); + } + } + ids +} + +pub(super) fn remap(x: &str, ids: &HashMap) -> Option { + map_rel_ids(x, &mut |id| ids.get(id).cloned()) +} + +/// A kept chart sheet being written: its `` entry's fields and its new relationship id. +pub struct KeptSheet { + before: usize, + name: String, + visibility: Visibility, + rid: String, + sheet_id: u32, +} + +/// The `sheetId`s of the written sheets: each keeps the one the file gave it, unless another +/// sheet took it first (a copied sheet) or it has none (a new sheet), and those get the next id +/// that no sheet asks for. +pub struct SheetIds { + wanted: HashSet, + given: HashSet, + next: u32, +} + +impl SheetIds { + pub fn new(wb: &Workbook, kept: &WorkbookKept) -> SheetIds { + let wanted: HashSet = wb.sheets.iter().map(|s| s.file_id).chain(kept.sheets.iter().map(|s| s.sheet_id)).filter(|&id| id != 0).collect(); + SheetIds { wanted, given: HashSet::new(), next: 1 } + } + + pub fn take(&mut self, wanted: u32) -> u32 { + if wanted != 0 && self.given.insert(wanted) { + return wanted; + } + while self.wanted.contains(&self.next) || self.given.contains(&self.next) { + self.next += 1; + } + self.given.insert(self.next); + self.next + } +} + +/// The workbook's kept content as written. +#[derive(Default)] +pub struct WorkbookKept { + /// Workbook XML elements that go right after `` (``). + pub elements: String, + sheets: Vec, +} + +impl WorkbookKept { + /// Where the model's sheet `i` is in the file's sheet list: chart sheets come between worksheets. + pub fn file_index(&self, i: usize) -> usize { + i + self.sheets.iter().filter(|s| s.before <= i).count() + } + + /// The `` entries of the chart sheets that go right before the model's sheet `i` + /// (`i` = the number of sheets for those that come last), with their ids from `ids`. + pub fn sheet_entries(&self, i: usize, last: bool, ids: &mut SheetIds) -> String { + let mut x = String::new(); + for s in self.sheets.iter().filter(|s| if last { s.before >= i } else { s.before == i }) { + let state = match s.visibility { + Visibility::Visible => "", + Visibility::Hidden => " state=\"hidden\"", + Visibility::VeryHidden => " state=\"veryHidden\"", + }; + let name: String = s.name.chars().take(31).collect(); + let id = ids.take(s.sheet_id); + let _ = write!(x, "", esc_attr(&name), esc_attr(&s.rid)); + } + x + } +} + +/// The part names reachable from `root` through kept parts' relationships (normalized). +fn reachable(p: &Preserved, root: &PreservedRel) -> HashSet { + let by_name: HashMap = p.parts.iter().map(|q| (normalize(&q.name), q)).collect(); + let mut seen = HashSet::new(); + let mut queue = VecDeque::from([normalize(&root.target)]); + while let Some(n) = queue.pop_front() { + if !seen.insert(n.clone()) { + continue; + } + if let Some(q) = by_name.get(&n) { + queue.extend(q.rels.iter().filter(|r| !r.external).map(|r| normalize(&r.target))); + } + } + seen +} + +/// Every formula (``) of a chart part, depth first. +fn chart_formulas(el: &xml::El, out: &mut Vec) { + if el.name == "f" && !el.text.trim().is_empty() { + out.push(el.text.trim().to_string()); + } + for c in &el.children { + chart_formulas(c, out); + } +} + +/// The sheet names a formula such as `'My Sheet'!$A$1:$A$9,Data!$B$1` refers to. References to +/// other workbooks (`[1]Sheet!A1`) are not sheets of this one and are left out. +pub(crate) fn sheet_names_in(formula: &str) -> Vec { + let mut names = vec![]; + let chars: Vec = formula.chars().collect(); + let at = |i: usize| chars.get(i).copied(); + let mut i = 0; + while let Some(c) = at(i) { + let mut name = String::new(); + let start = i; + if c == '\'' { + i += 1; + while let Some(c) = at(i) { + if c == '\'' { + if at(i + 1) == Some('\'') { + name.push('\''); + i += 2; + continue; + } + break; + } + name.push(c); + i += 1; + } + i += 1; + } else { + while let Some(c) = at(i).filter(|c| !matches!(c, '!' | ',' | '(' | ')' | '\'' | ' ')) { + name.push(c); + i += 1; + } + } + if at(i) == Some('!') { + if !name.is_empty() && !name.starts_with('[') { + names.push(name); + } + i += 1; + } else if i == start { + i += 1; + } + } + names +} + +/// Whether a kept chart sheet can be written back: its chart's series must still refer to sheets +/// that exist under the same names (the chart is kept as it was, so a series on a deleted or +/// renamed sheet would be left pointing at nothing), and no worksheet may have taken its name. +fn chart_sheet_usable(wb: &Workbook, p: &Preserved, s: &PreservedSheet) -> bool { + let same = |a: &str, b: &str| a.chars().take(31).collect::().eq_ignore_ascii_case(&b.chars().take(31).collect::()); + if wb.sheets.iter().any(|w| same(&w.name, &s.name)) { + return false; + } + let Some(root) = p.rels.iter().find(|r| r.id == s.rel_id) else { return false }; + let parts = reachable(p, root); + let mut formulas = vec![]; + for q in p.parts.iter().filter(|q| parts.contains(&normalize(&q.name)) && q.content_type.contains("chart")) { + if let Ok(x) = xml::parse(&q.data) { + chart_formulas(&x, &mut formulas); + } + } + formulas.iter().flat_map(|f| sheet_names_in(f)).all(|n| wb.sheets.iter().any(|w| same(&w.name, &n))) +} + +/// Writes the workbook's kept parts and relationships. Chart sheets that can't be written back +/// (see [`chart_sheet_usable`]) are left out with the parts only they reach. +pub fn write_workbook(wb: &Workbook, wb_rels: &mut Rels, out: &mut Out) -> WorkbookKept { + let Some(p) = wb.preserved.as_deref() else { return WorkbookKept::default() }; + let (live, dropped): (Vec<&PreservedSheet>, Vec<&PreservedSheet>) = p.sheets.iter().partition(|s| chart_sheet_usable(wb, p, s)); + let root_of = |s: &PreservedSheet| p.rels.iter().find(|r| r.id == s.rel_id); + let dropped_ids: HashSet<&str> = dropped.iter().map(|s| s.rel_id.as_str()).collect(); + // Parts reached only by dropped chart sheets go with them. + let mut gone: HashSet = dropped.iter().filter_map(|s| root_of(s)).flat_map(|r| reachable(p, r)).collect(); + if !gone.is_empty() { + let others = p.rels.iter().filter(|r| !dropped_ids.contains(r.id.as_str())).flat_map(|r| reachable(p, r)); + for n in others { + gone.remove(&n); + } + } + let parts: Vec = p.parts.iter().filter(|q| !gone.contains(&normalize(&q.name))).cloned().collect(); + let rels: Vec = p.rels.iter().filter(|r| !dropped_ids.contains(r.id.as_str())).cloned().collect(); + let names = write_parts(out, &parts); + let ids = attach(wb_rels, &rels, &names); + for r in &p.package_rels { + if let Some(target) = rel_target(r, &names) { + out.kept.root.push((r.rel_type.clone(), target, r.external)); + } + } + WorkbookKept { + elements: p.elements.iter().filter(|e| e.slot == "externalReferences").filter_map(|e| remap(&e.xml, &ids)).collect(), + sheets: live + .iter() + .filter_map(|s| { + Some(KeptSheet { + before: s.before, + name: s.name.clone(), + visibility: s.visibility, + rid: ids.get(&s.rel_id)?.clone(), + sheet_id: s.sheet_id, + }) + }) + .collect(), + } +} + +/// The workbook's kept elements of `slot` that refer to no part (`fileVersion`, `ext`). +pub fn workbook_elements(wb: &Workbook, slot: &str) -> String { + let Some(p) = wb.preserved.as_deref() else { return String::new() }; + p.elements.iter().filter(|e| e.slot == slot).map(|e| e.xml.as_str()).collect() +} + +/// Adds the kept package-root relationships (custom properties…) to `_rels/.rels`. +pub fn write_package_rels(root: &mut Rels, out: &mut Out) { + for (ty, target, external) in std::mem::take(&mut out.kept.root) { + root.add_raw(&ty, &target, external); + } +} diff --git a/crates/xlsx/src/pivot.rs b/crates/xlsx/src/pivot.rs index b3837e2f0f..45a6188933 100644 --- a/crates/xlsx/src/pivot.rs +++ b/crates/xlsx/src/pivot.rs @@ -26,6 +26,12 @@ use crate::read::Ctx; use crate::write::{NS_MAIN, NS_REL, Out, Rels, XML_DECL, abs_range}; use crate::xml::{El, esc_attr}; +mod read; +mod write; + +pub use read::{CachesIn, read_pivots}; +pub use write::PivotWriter; + pub const CT_PIVOT_TABLE: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml"; pub const CT_PIVOT_CACHE_DEF: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml"; pub const CT_PIVOT_CACHE_REC: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml"; @@ -168,1259 +174,3 @@ fn key(v: &Value) -> Key { Value::Array(_) => key(&v.scalar()), } } - -#[derive(Default)] -struct Stats { - blank: bool, - text: bool, - number: bool, - non_int: bool, - boolean: bool, - error: bool, - long: bool, - min: f64, - max: f64, -} - -impl Stats { - fn add(&mut self, v: &Value) { - match v { - Value::Empty => self.blank = true, - Value::Number(n) => { - if !self.number { - self.min = *n; - self.max = *n; - } - self.number = true; - self.min = self.min.min(*n); - self.max = self.max.max(*n); - if n.fract() != 0.0 { - self.non_int = true; - } - } - Value::Text(t) => { - self.text = true; - if t.chars().count() > 255 { - self.long = true; - } - } - Value::Bool(_) => self.boolean = true, - Value::Error(_) => self.error = true, - Value::Array(_) => self.add(&v.scalar()), - } - } - /// Excel counts booleans and errors as strings in the cache field flags. - fn strings(&self) -> bool { - self.text || self.boolean || self.error - } - /// Kinds for `containsMixedTypes`: strings (text, booleans, errors) and numbers. - fn kinds(&self) -> usize { - usize::from(self.strings()) + usize::from(self.number) - } -} - -/// Date grouping of a cache field. -struct Group { - by: PivotDateGroup, - /// The database field the grouping applies to. - base: usize, - /// The next (coarser) grouping field of the same base. - par: Option, - start: f64, - end: f64, - /// Group item captions, including the `end` items. - items: Vec, -} - -struct CField { - name: String, - /// Column offset in the source (database fields only). - col: Option, - stats: Stats, - /// Shared items (written when `itemized`). - items: Vec, - index: HashMap, - itemized: bool, - group: Option, - /// A calculated field's formula (without `=`); such fields aren't database fields. - formula: Option, -} - -impl CField { - /// Item captions the PivotTable's `` indices refer to. - fn item_labels(&self) -> Vec { - match &self.group { - Some(g) => g.items.clone(), - None => self.items.iter().map(label).collect(), - } - } -} - -/// A cache built from a PivotTable's source. -struct Cache { - fields: Vec, - /// Pivot field → cache field, by the pivot field's position in rows, then columns. - axis_map: Vec>, - source_xml: String, - records: Option<(u64, String)>, -} - -/// PivotTables being written; the tables themselves are finished after the styles part (their -/// value number formats need style number-format ids). -#[derive(Default)] -pub struct PivotWriter { - /// (cache id, workbook-relative part name). - caches: Vec<(u32, String)>, - pending: Vec, - tables: u32, -} - -struct Pending { - part: String, - sheet: usize, - idx: usize, - cache_id: u32, - cache: Cache, -} - -impl PivotWriter { - /// Writes the caches of a sheet's PivotTables and adds the sheet's relationships to its - /// PivotTable parts. Pivots whose source can't be resolved are not written. - pub fn add_sheet(&mut self, wb: &Workbook, si: usize, out: &mut Out, rels: &mut Rels) { - let Some(sheet) = wb.sheet(si) else { return }; - for (idx, pt) in sheet.pivots.iter().enumerate() { - let Some(cache) = build_cache(wb, pt, si) else { continue }; - let n = self.caches.len() as u32 + 1; - let cache_part = format!("xl/pivotCache/pivotCacheDefinition{n}.xml"); - let rec_rid = if let Some((count, xml)) = &cache.records { - let mut r = Rels::default(); - let rid = r.add("pivotCacheRecords", &format!("pivotCacheRecords{n}.xml")); - out.part(&format!("xl/pivotCache/_rels/pivotCacheDefinition{n}.xml.rels"), None, r.xml().into_bytes()); - let s = format!("{XML_DECL}{xml}"); - out.part(&format!("xl/pivotCache/pivotCacheRecords{n}.xml"), Some(CT_PIVOT_CACHE_REC), s.into_bytes()); - Some(rid) - } else { - None - }; - out.part(&cache_part, Some(CT_PIVOT_CACHE_DEF), cache_xml(&cache, rec_rid.as_deref(), wb.date_system).into_bytes()); - self.caches.push((n, format!("pivotCache/pivotCacheDefinition{n}.xml"))); - self.tables += 1; - let t = self.tables; - rels.add("pivotTable", &format!("../pivotTables/pivotTable{t}.xml")); - let mut tr = Rels::default(); - tr.add("pivotCacheDefinition", &format!("../pivotCache/pivotCacheDefinition{n}.xml")); - out.part(&format!("xl/pivotTables/_rels/pivotTable{t}.xml.rels"), None, tr.xml().into_bytes()); - self.pending.push(Pending { part: format!("xl/pivotTables/pivotTable{t}.xml"), sheet: si, idx, cache_id: n, cache }); - } - } - - /// Adds the workbook relationships to the caches; returns the `` element (empty - /// when there are none). - pub fn workbook_xml(&self, wb_rels: &mut Rels) -> String { - if self.caches.is_empty() { - return String::new(); - } - let mut s = String::from(""); - for (id, target) in &self.caches { - let rid = wb_rels.add("pivotCacheDefinition", target); - let _ = write!(s, ""); - } - s.push_str(""); - s - } - - /// Number formats used by value fields (to be registered in the styles part). - pub fn number_formats(&self, wb: &Workbook) -> Vec { - let mut v = Vec::new(); - for p in &self.pending { - if let Some(pt) = wb.sheet(p.sheet).and_then(|s| s.pivots.get(p.idx)) { - for val in &pt.values { - if let Some(f) = &val.number_format - && !v.contains(f) - { - v.push(f.clone()); - } - } - } - } - v - } - - /// Writes the PivotTable parts. `fmt_id` maps a number format code to its styles id. - pub fn finish(self, wb: &Workbook, out: &mut Out, fmt_id: &dyn Fn(&str) -> Option) { - for p in self.pending { - let Some(pt) = wb.sheet(p.sheet).and_then(|s| s.pivots.get(p.idx)) else { continue }; - let xml = table_xml(pt, p.idx, p.cache_id, &p.cache, fmt_id); - out.part(&p.part, Some(CT_PIVOT_TABLE), xml.into_bytes()); - } - } -} - -/// Resolves a pivot source to (sheet, range including the header row, worksheetSource XML). -fn resolve_source(wb: &Workbook, text: &str, default_sheet: usize, depth: u32) -> Option<(usize, RangeRef, String)> { - let t = text.trim().trim_start_matches('=').trim(); - if t.is_empty() || depth > 4 { - return None; - } - if let Some((si, ti)) = wb.table(t) - && let Some(sh) = wb.sheet(si) - && let Some(tb) = sh.tables.get(ti) - { - if !tb.header_row { - return None; - } - let end_row = tb.range.end.row.saturating_sub(tb.totals_row as u32).max(tb.range.start.row); - let r = RangeRef::new(tb.range.start, CellRef::new(end_row, tb.range.end.col)); - // Table names are sanitized when written; refer by range when this one would change. - let valid = !tb.name.is_empty() - && tb.name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') - && tb.name.starts_with(|c: char| c.is_alphabetic() || c == '_') - && CellRef::parse(&tb.name).is_none(); - let xml = if valid { format!("", esc_attr(&tb.name)) } else { range_source_xml(&sh.name, r) }; - return Some((si, r, xml)); - } - let (sheet, body) = gridcraft_core::split_sheet(t); - if let Some(r) = RangeRef::parse(body) { - let si = match &sheet { - Some(n) => wb.sheet_index(n)?, - None => default_sheet, - }; - let sh = wb.sheet(si)?; - let r = if r.is_full_cols() || r.is_full_rows() { r.intersection(&sh.used_range()?)? } else { r }; - return Some((si, r, range_source_xml(&sh.name, r))); - } - if sheet.is_none() - && let Some(n) = wb.name(t, default_sheet) - { - let (si, r, _) = resolve_source(wb, &n.formula.clone(), default_sheet, depth + 1)?; - let xml = if n.scope.is_none() { - format!("", esc_attr(&n.name)) - } else { - wb.sheet(si).map(|s| range_source_xml(&s.name, r))? - }; - return Some((si, r, xml)); - } - None -} - -fn range_source_xml(sheet: &str, r: RangeRef) -> String { - let name: String = sheet.chars().take(31).collect(); - format!("", r.a1(), esc_attr(&name)) -} - -fn header_text(v: &Value) -> String { - match v { - Value::Empty => String::new(), - v => label(v).trim().to_string(), - } -} - -fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Option { - let (si, r, source_xml) = resolve_source(wb, &pt.source, pivot_sheet, 0)?; - if r.count() > MAX_SOURCE_CELLS { - return None; - } - let sh = wb.sheet(si)?; - let mut fields: Vec = Vec::new(); - // Lower-case names taken, and the next suffix for each header (linear for repeated headers). - let mut taken = std::collections::HashSet::new(); - let mut next_suffix: HashMap = HashMap::new(); - for (k, c) in (r.start.col..=r.end.col).enumerate() { - let mut h = header_text(&sh.value(CellRef::new(r.start.row, c)).scalar()); - if h.is_empty() { - h = format!("Column{}", k + 1); - } - let mut n = h.clone(); - let mut i = next_suffix.get(&h.to_lowercase()).copied().unwrap_or(2); - while taken.contains(&n.to_lowercase()) { - n = format!("{h}{i}"); - i += 1; - } - next_suffix.insert(h.to_lowercase(), i); - taken.insert(n.to_lowercase()); - fields.push(CField { - name: n, - col: Some(k as u32), - stats: Stats::default(), - items: vec![], - index: HashMap::new(), - itemized: false, - group: None, - formula: None, - }); - } - let find = |fields: &[CField], name: &str| fields.iter().position(|f| f.col.is_some() && same(&f.name, name)); - - // Records (rows that aren't entirely blank), read once. - let mut rows: Vec = Vec::new(); - if r.end.row > r.start.row { - for row in r.start.row + 1..=r.end.row { - let blank = (r.start.col..=r.end.col).all(|c| sh.value(CellRef::new(row, c)).scalar().is_empty()); - if !blank { - rows.push(row); - } - } - } - for &row in &rows { - for f in fields.iter_mut() { - let c = r.start.col + f.col.unwrap_or(0); - f.stats.add(&sh.value(CellRef::new(row, c)).scalar()); - } - } - - // Which fields are on an axis, and how dates are grouped: per base column, the base field - // takes the finest grouping and coarser ones become extra (non-database) fields chained - // through `par`, as Excel lays them out. - let axis: Vec<&PivotField> = pt.rows.iter().chain(pt.columns.iter()).collect(); - let groupable = |f: &CField| { - let s = &f.stats; - s.number - && !s.text - && !s.boolean - && !s.error - && datetime_from_serial(wb.date_system, s.min).is_some() - && datetime_from_serial(wb.date_system, s.max).is_some() - }; - // (base column field, groupings fine → coarse) - let mut groupings: Vec<(usize, Vec)> = Vec::new(); - for pf in &axis { - let Some(b) = find(&fields, &pf.source_col) else { continue }; - if pf.date_group == PivotDateGroup::None || !fields.get(b).is_some_and(groupable) { - continue; - } - match groupings.iter_mut().find(|(x, _)| *x == b) { - Some((_, v)) => { - if !v.contains(&pf.date_group) { - v.push(pf.date_group); - } - } - None => groupings.push((b, vec![pf.date_group])), - } - } - let fineness = |g: &PivotDateGroup| match g { - PivotDateGroup::Days => 0, - PivotDateGroup::Months => 1, - PivotDateGroup::Quarters => 2, - PivotDateGroup::Years => 3, - PivotDateGroup::None => 4, - }; - for (b, list) in groupings.iter_mut() { - list.sort_by_key(fineness); - let (min, max) = fields.get(*b).map(|f| (f.stats.min, f.stats.max)).unwrap_or((0.0, 0.0)); - let (start, end) = (min.floor(), max.floor() + 1.0); - let make = |by: PivotDateGroup| Group { by, base: *b, par: None, start, end, items: group_items(wb.date_system, by, start, end) }; - let mut prev = *b; - for (k, by) in list.iter().enumerate() { - if k == 0 { - if let Some(f) = fields.get_mut(*b) { - f.group = Some(make(*by)); - } - continue; - } - let new = fields.len(); - let caption = group_caption(*by); - let mut n = caption.to_string(); - let mut i = 2; - while fields.iter().any(|f| same(&f.name, &n)) { - n = format!("{caption}{i}"); - i += 1; - } - if let Some(g) = fields.get_mut(prev).and_then(|f| f.group.as_mut()) { - g.par = Some(new); - } - fields.push(CField { - name: n, - col: None, - stats: Stats::default(), - items: vec![], - index: HashMap::new(), - itemized: false, - group: Some(make(*by)), - formula: None, - }); - prev = new; - } - } - let mut axis_map: Vec> = Vec::with_capacity(axis.len()); - for pf in &axis { - let Some(b) = find(&fields, &pf.source_col) else { - axis_map.push(None); - continue; - }; - let grouped = fields.iter().position(|f| f.group.as_ref().is_some_and(|g| g.base == b && g.by == pf.date_group)); - match grouped { - Some(i) if pf.date_group != PivotDateGroup::None => axis_map.push(Some(i)), - _ => { - if let Some(f) = fields.get_mut(b) - && f.group.is_none() - { - f.itemized = true; - } - axis_map.push(Some(b)); - } - } - } - // Page fields (filters not on an axis) need items too. - for f in &pt.filters { - let on_axis = axis.iter().any(|a| same(&a.source_col, &f.source_col)); - if !on_axis - && let Some(i) = find(&fields, &f.source_col) - && let Some(cf) = fields.get_mut(i) - { - cf.itemized = true; - } - } - for f in fields.iter_mut() { - if f.group.is_some() { - f.itemized = false; - } else if f.col.is_some() && (f.stats.text || f.stats.boolean || f.stats.error) { - f.itemized = true; - } - } - // Shared items, in order of first appearance. - for &row in &rows { - for f in fields.iter_mut().filter(|f| f.itemized) { - let c = r.start.col + f.col.unwrap_or(0); - let v = sh.value(CellRef::new(row, c)).scalar(); - let k = key(&v); - if !f.index.contains_key(&k) && f.items.len() < MAX_ITEMS { - f.index.insert(k, f.items.len() as u32); - f.items.push(v); - } - } - } - // Records snapshot. - let db_fields = fields.iter().filter(|f| f.col.is_some()).count() as u64; - let records = if (rows.len() as u64).saturating_mul(db_fields) <= MAX_RECORD_CELLS { - let mut s = String::new(); - for &row in &rows { - s.push_str(""); - for f in fields.iter().filter(|f| f.col.is_some()) { - let c = r.start.col + f.col.unwrap_or(0); - let v = sh.value(CellRef::new(row, c)).scalar(); - if f.itemized { - match f.index.get(&key(&v)) { - Some(i) => { - let _ = write!(s, ""); - } - None => s.push_str(&item_xml(&v, wb.date_system, false)), - } - } else { - s.push_str(&item_xml(&v, wb.date_system, f.group.is_some())); - } - } - s.push_str(""); - } - Some((rows.len() as u64, s)) - } else { - None - }; - // Calculated fields come after the database and grouping fields. - for cf in &pt.calculated_fields { - let f = cf.formula.trim(); - let f = f.strip_prefix('=').unwrap_or(f).trim(); - if cf.name.trim().is_empty() || f.is_empty() || fields.iter().any(|x| same(&x.name, &cf.name)) { - continue; - } - fields.push(CField { - name: cf.name.clone(), - col: None, - stats: Stats::default(), - items: vec![], - index: HashMap::new(), - itemized: false, - group: None, - formula: Some(f.to_string()), - }); - } - Some(Cache { fields, axis_map, source_xml, records }) -} - -fn group_items(sys: DateSystem, by: PivotDateGroup, start: f64, end: f64) -> Vec { - let mut v = vec![format!("<{}", mdy(sys, start))]; - match by { - PivotDateGroup::Years => { - let y0 = datetime_from_serial(sys, start).map(|d| d.year).unwrap_or(1900); - let y1 = datetime_from_serial(sys, end - 1.0).map(|d| d.year).unwrap_or(y0).max(y0); - for y in y0..=y1.min(y0 + 10_000) { - v.push(y.to_string()); - } - } - PivotDateGroup::Quarters => v.extend((1..=4).map(|q| format!("Qtr{q}"))), - PivotDateGroup::Months => v.extend(MONTHS.iter().map(ToString::to_string)), - PivotDateGroup::Days | PivotDateGroup::None => { - const DAYS: [u32; 12] = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; - for (m, n) in MONTHS.iter().zip(DAYS) { - for d in 1..=n { - v.push(format!("{d}-{m}")); - } - } - } - } - v.push(format!(">{}", mdy(sys, end))); - v -} - -/// A cache value element (``, ``, ``…); `date` writes numbers as ``. -fn item_xml(v: &Value, sys: DateSystem, date: bool) -> String { - match v { - Value::Empty => "".into(), - Value::Number(n) => { - if date && let Some(d) = iso(sys, *n) { - return format!(""); - } - format!("", crate::xml::num(*n)) - } - Value::Text(t) => format!("", esc_attr(t)), - Value::Bool(b) => format!("", u8::from(*b)), - Value::Error(e) => format!("", esc_attr(e.as_str())), - Value::Array(_) => item_xml(&v.scalar(), sys, date), - } -} - -fn cache_xml(c: &Cache, records_rid: Option<&str>, sys: DateSystem) -> String { - let mut s = format!("{XML_DECL} { - let _ = write!(s, " r:id=\"{rid}\" recordCount=\"{count}\""); - } - _ => s.push_str(" saveData=\"0\""), - } - s.push_str(" refreshOnLoad=\"1\" refreshedBy=\"GridCraft\" createdVersion=\"6\" refreshedVersion=\"6\" minRefreshableVersion=\"3\">"); - let _ = write!(s, "{}", c.source_xml); - let _ = write!(s, "", c.fields.len()); - for f in &c.fields { - let fmt = if f.group.is_some() { 14 } else { 0 }; - let _ = write!(s, "'); - let st = &f.stats; - if f.col.is_some() { - s.push_str(" 1 { - s.push_str(" containsMixedTypes=\"1\""); - } - if st.number { - s.push_str(" containsNumber=\"1\""); - if !st.non_int { - s.push_str(" containsInteger=\"1\""); - } - let _ = write!(s, " minValue=\"{}\" maxValue=\"{}\"", crate::xml::num(st.min), crate::xml::num(st.max)); - } - if st.long { - s.push_str(" longText=\"1\""); - } - } - if f.itemized { - let _ = write!(s, " count=\"{}\">", f.items.len()); - for v in &f.items { - s.push_str(&item_xml(v, sys, false)); - } - s.push_str(""); - } else { - s.push_str("/>"); - } - } - if let Some(g) = &f.group { - s.push_str("", g.base); - let _ = write!(s, "", g.items.len()); - for it in &g.items { - let _ = write!(s, "", esc_attr(it)); - } - s.push_str(""); - } - s.push_str(""); - } - s.push_str(""); - s -} - -fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn Fn(&str) -> Option) -> String { - let nf = c.fields.len(); - let find_db = |name: &str| c.fields.iter().position(|f| f.col.is_some() && same(&f.name, name)); - let row_idx: Vec = c.axis_map.iter().take(pt.rows.len()).filter_map(|x| *x).collect(); - let col_idx: Vec = c.axis_map.iter().skip(pt.rows.len()).filter_map(|x| *x).collect(); - // Filters: on an axis field → hidden items there; otherwise a page field. - let mut hidden: HashMap = HashMap::new(); - let mut pages: Vec<(usize, &PivotFilter)> = Vec::new(); - for f in &pt.filters { - let axis_pos = pt.rows.iter().chain(pt.columns.iter()).position(|a| same(&a.source_col, &f.source_col)); - match axis_pos { - Some(p) => { - if let Some(Some(i)) = c.axis_map.get(p) { - hidden.insert(*i, f); - } - } - None => { - if let Some(i) = find_db(&f.source_col) - && !pages.iter().any(|(j, _)| *j == i) - { - pages.push((i, f)); - hidden.insert(i, f); - } - } - } - } - let find_calc = |name: &str| c.fields.iter().position(|f| f.formula.is_some() && same(&f.name, name)); - let vals: Vec<(usize, &PivotValue)> = - pt.values.iter().filter_map(|v| find_db(&v.source_col).or_else(|| find_calc(&v.source_col)).map(|i| (i, v))).collect(); - let axis_field = |i: usize| -> Option<&PivotField> { - let p = c.axis_map.iter().position(|x| *x == Some(i))?; - pt.rows.iter().chain(pt.columns.iter()).nth(p) - }; - - let compact = pt.layout == PivotLayout::Compact; - let name = if pt.name.trim().is_empty() { format!("PivotTable{}", idx + 1) } else { pt.name.clone() }; - let mut s = format!( - "{XML_DECL} s.push_str(" outline=\"1\" outlineData=\"1\""), - PivotLayout::Outline => s.push_str(" compact=\"0\" compactData=\"0\" outline=\"1\" outlineData=\"1\""), - PivotLayout::Tabular => s.push_str(" compact=\"0\" compactData=\"0\""), - } - if pt.values_on_rows { - s.push_str(" dataOnRows=\"1\""); - } - 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 { - s.push_str(" rowGrandTotals=\"0\""); - } - if !pt.grand_totals_rows { - s.push_str(" colGrandTotals=\"0\""); - } - s.push_str(" multipleFieldFilters=\"0\">"); - - // Location: the body (report filters sit above it). - let a = pt.anchor; - let end = pt.last_range.map(|r| r.end).unwrap_or(a); - let loc = RangeRef::new(a, CellRef::new(end.row.max(a.row), end.col.max(a.col))); - let k = vals.len(); - // Σ Values (two or more value fields): its index among the written fields of its axis. - let (row_map, _) = c.axis_map.split_at(pt.rows.len().min(c.axis_map.len())); - let values_at = |map: &[Option], n: usize| pt.values_position.map_or(n, |p| map.iter().take(p).filter(|x| x.is_some()).count()); - let row_values = (k > 1 && pt.values_on_rows).then(|| values_at(row_map, row_idx.len())); - // In Columns Σ Values comes after the last field: the column layout doesn't place it elsewhere. - let col_values = (k > 1 && !pt.values_on_rows).then_some(col_idx.len()); - let first_data_row = if col_idx.is_empty() { 1 } else { 1 + col_idx.len() + usize::from(col_values.is_some()) }; - let row_levels = row_idx.len() + usize::from(row_values.is_some()); - let first_data_col = if row_levels == 0 && col_idx.is_empty() { - 0 - } else if compact { - 1 - } else { - row_levels.max(1) - }; - // Two or more value fields in Columns alone: Excel 365 would add a `Values` caption row. - let hide_values_row = col_values.is_some() && col_idx.is_empty(); - let first_header_row = usize::from(!hide_values_row); - let _ = write!( - s, - ""); - - // Fields. - let _ = write!(s, ""); - for (i, f) in c.fields.iter().enumerate() { - s.push_str(" {} - PivotLayout::Outline => s.push_str(" compact=\"0\""), - PivotLayout::Tabular => s.push_str(" compact=\"0\" outline=\"0\""), - } - if f.group.is_some() { - s.push_str(" numFmtId=\"14\""); - } - let filter = hidden.get(&i).copied(); - let labels = f.item_labels(); - let is_page = axis == Some("axisPage"); - // Hidden items; never hide every item (a field needs one visible item). - let mut hide: Vec = match filter.and_then(|f| f.selected.as_ref()) { - Some(sel) if !(is_page && sel.len() == 1) => labels.iter().map(|l| !sel.iter().any(|x| same(x, l))).collect(), - _ => vec![false; labels.len()], - }; - if !hide.is_empty() && hide.iter().all(|h| *h) { - hide.iter_mut().for_each(|h| *h = false); - } - if is_page && hide.iter().any(|h| *h) { - s.push_str(" multipleItemSelectionAllowed=\"1\""); - } - s.push_str(" showAll=\"0\""); - if let Some(af) = axis_field(i) { - match af.sort { - PivotSort::Asc => s.push_str(" sortType=\"ascending\""), - PivotSort::Desc => s.push_str(" sortType=\"descending\""), - PivotSort::None => {} - } - } - if !pt.subtotals { - s.push_str(" defaultSubtotal=\"0\""); - } - if !pt.subtotals_top { - s.push_str(" subtotalTop=\"0\""); - } - if axis.is_some() { - let collapsed: &[String] = axis_field(i).map(|f| f.collapsed_items.as_slice()).unwrap_or(&[]); - let count = labels.len() + usize::from(pt.subtotals); - let _ = write!(s, ">"); - for (x, l) in labels.iter().enumerate() { - let _ = write!(s, ""); - } - if pt.subtotals { - s.push_str(""); - } - s.push_str(""); - } else { - s.push_str("/>"); - } - } - s.push_str(""); - - axis_fields_xml(&mut s, "rowFields", &row_idx, row_values); - axis_fields_xml(&mut s, "colFields", &col_idx, col_values); - if !pages.is_empty() { - let _ = write!(s, "", pages.len()); - for (i, f) in &pages { - let _ = write!(s, ""); - } - s.push_str(""); - } - if !vals.is_empty() { - let _ = write!(s, ""); - let mut used: Vec = Vec::new(); - for (i, v) in &vals { - let base = if v.name.trim().is_empty() { - format!("{} of {}", func_caption(v.func), c.fields.get(*i).map(|f| f.name.as_str()).unwrap_or("")) - } else { - v.name.clone() - }; - let mut n = base.clone(); - let mut j = 2; - while used.iter().any(|u| same(u, &n)) { - n = format!("{base}{j}"); - j += 1; - } - used.push(n.clone()); - let _ = write!(s, " s.push_str(" baseField=\"0\" baseItem=\"0\""), - PivotShowAs::PercentOfGrandTotal => s.push_str(" showDataAs=\"percentOfTotal\" baseField=\"0\" baseItem=\"0\""), - PivotShowAs::PercentOfColumnTotal => s.push_str(" showDataAs=\"percentOfCol\" baseField=\"0\" baseItem=\"0\""), - PivotShowAs::PercentOfRowTotal => s.push_str(" showDataAs=\"percentOfRow\" baseField=\"0\" baseItem=\"0\""), - PivotShowAs::RunningTotal => { - let _ = write!(s, " showDataAs=\"runTotal\" baseField=\"{base_field}\" baseItem=\"0\""); - } - PivotShowAs::Rank => { - let _ = write!(s, " baseField=\"{base_field}\" baseItem=\"0\""); - } - } - if let Some(id) = v.number_format.as_deref().and_then(fmt_id) { - let _ = write!(s, " numFmtId=\"{id}\""); - } - if v.show_as == PivotShowAs::Rank { - let _ = write!( - s, - ">" - ); - } else { - s.push_str("/>"); - } - } - s.push_str(""); - } - s.push_str(""); - if hide_values_row { - let _ = write!( - s, - "" - ); - } - s.push_str(""); - s -} - -/// ``/``: the axis's cache field indices, with Σ Values (`-2`) at `values_at`. -fn axis_fields_xml(s: &mut String, tag: &str, idx: &[usize], values_at: Option) { - let n = idx.len() + usize::from(values_at.is_some()); - if n == 0 { - return; - } - let _ = write!(s, "<{tag} count=\"{n}\">"); - for (j, i) in idx.iter().enumerate() { - if values_at == Some(j) { - s.push_str(""); - } - let _ = write!(s, ""); - } - if values_at.is_some_and(|p| p >= idx.len()) { - s.push_str(""); - } - let _ = write!(s, ""); -} - -// ================================================================ reading - -/// A cache field as read. -#[derive(Clone, Debug, Default)] -struct FieldIn { - name: String, - database: bool, - /// Shared item captions. - items: Vec, - /// (base field, grouping, group item captions); grouping `None` = unsupported grouping. - group: Option<(Option, Option, Vec)>, - /// A calculated field's formula. - formula: Option, -} - -impl FieldIn { - fn labels(&self) -> &[String] { - match &self.group { - Some((_, _, items)) if !items.is_empty() => items, - _ => &self.items, - } - } -} - -#[derive(Clone, Debug, Default)] -struct CacheIn { - source: String, - fields: Vec, -} - -/// Workbook-level pivot cache lookup, filled from ``; caches are parsed once. -#[derive(Default)] -pub struct CachesIn { - by_id: HashMap, - parsed: HashMap>, - next_id: u32, -} - -impl CachesIn { - pub fn new(wb_xml: &El, wb_rels: &[crate::package::Rel]) -> CachesIn { - let mut by_id = HashMap::new(); - if let Some(pc) = wb_xml.child("pivotCaches") { - for c in pc.kids("pivotCache") { - if let (Some(id), Some(rid)) = (c.attr_u32("cacheId"), c.attr("id")) - && let Some(r) = wb_rels.iter().find(|r| r.id == rid) - { - by_id.insert(id, r.target.clone()); - } - } - } - CachesIn { by_id, parsed: HashMap::new(), next_id: 1 } - } -} - -fn item_label_el(e: &El, sys: DateSystem) -> String { - let v = e.attr("v").unwrap_or(""); - match e.name.as_str() { - "m" => "(blank)".into(), - "n" => v.trim().parse::().ok().filter(|n| n.is_finite()).map(number_to_text).unwrap_or_else(|| v.to_string()), - "b" => if matches!(v.trim(), "1" | "true") { "TRUE" } else { "FALSE" }.into(), - "d" => crate::sheet_read::parse_iso_datetime(v, sys).map(number_to_text).unwrap_or_else(|| v.to_string()), - _ => v.to_string(), - } -} - -fn read_cache(cx: &mut Ctx<'_>, part: &str, sheet_name: &str) -> Option { - let x = cx.optional_xml(part).ok().flatten()?; - if x.name != "pivotCacheDefinition" { - cx.warn(format!("{part} is not a pivot cache definition; its PivotTable was kept as plain cells")); - return None; - } - let src = x.child("cacheSource"); - let ty = src.and_then(|s| s.attr("type")).unwrap_or("worksheet"); - if ty != "worksheet" { - cx.warn(format!("PivotTables with a {ty} data source are not supported; kept as plain cells")); - return None; - } - let ws = src.and_then(|s| s.child("worksheetSource")); - let source = match ws { - Some(w) if w.attr("name").is_some_and(|n| !n.trim().is_empty()) => w.attr("name").unwrap_or("").trim().to_string(), - Some(w) => { - if w.attr("id").is_some() { - cx.warn("a PivotTable's source is in another workbook; its source reference may not resolve"); - } - let r = w.attr("ref").map(|r| r.replace('$', "")).and_then(|r| RangeRef::parse(&r)); - let Some(r) = r else { - cx.warn("a PivotTable's source reference is missing or invalid; kept as plain cells"); - return None; - }; - let sheet = w.attr("sheet").filter(|s| !s.is_empty()).unwrap_or(sheet_name); - format!("{}!{}", quote_sheet(sheet), abs_range(&r)) - } - None => { - cx.warn("a PivotTable's cache has no worksheet source; kept as plain cells"); - return None; - } - }; - let sys = cx.date_system; - let mut fields = Vec::new(); - if let Some(cf) = x.child("cacheFields") { - for f in cf.kids("cacheField").take(MAX_FIELDS) { - let items = f.child("sharedItems").map(|si| si.children.iter().map(|e| item_label_el(e, sys)).collect()).unwrap_or_default(); - let group = f.child("fieldGroup").map(|g| { - let base = g.attr_u32("base").map(|b| b as usize); - let by = match g.child("rangePr").map(|r| r.attr("groupBy").unwrap_or("range")) { - Some("years") => Some(PivotDateGroup::Years), - Some("quarters") => Some(PivotDateGroup::Quarters), - Some("months") => Some(PivotDateGroup::Months), - Some("days") => Some(PivotDateGroup::Days), - _ => None, - }; - let gi = g.child("groupItems").map(|gi| gi.children.iter().map(|e| item_label_el(e, sys)).collect()).unwrap_or_default(); - (base, by, gi) - }); - fields.push(FieldIn { - name: f.attr("name").unwrap_or("").to_string(), - database: f.flag("databaseField", true), - items, - group, - formula: f.attr("formula").map(str::to_string), - }); - } - } - if fields.is_empty() { - cx.warn("a PivotTable's cache has no fields; kept as plain cells"); - return None; - } - Some(CacheIn { source, fields }) -} - -/// Reads the PivotTables related to a worksheet part. -pub fn read_pivots(cx: &mut Ctx<'_>, caches: &mut CachesIn, sheet_part: &str, sheet_name: &str) -> Result, IoError> { - let rels = cx.pkg.rels(sheet_part)?; - let mut out = Vec::new(); - for rel in rels.iter().filter(|r| r.kind == "pivotTable" && !r.external) { - let Some(x) = cx.optional_xml(&rel.target)? else { continue }; - if x.name != "pivotTableDefinition" { - cx.warn(format!("{} is not a PivotTable definition; skipped", rel.target)); - continue; - } - // The cache: the table's own relationship, else the workbook's cache id. - let trels = cx.pkg.rels(&rel.target)?; - let cache_part = trels - .iter() - .find(|r| r.kind == "pivotCacheDefinition" && !r.external) - .map(|r| r.target.clone()) - .or_else(|| x.attr_u32("cacheId").and_then(|id| caches.by_id.get(&id).cloned())); - let Some(cache_part) = cache_part else { - cx.warn(format!("PivotTable in {} has no cache; kept as plain cells", rel.target)); - continue; - }; - let cache = match caches.parsed.get(&cache_part) { - Some(c) => c.clone(), - None => { - let c = read_cache(cx, &cache_part, sheet_name); - caches.parsed.insert(cache_part.clone(), c.clone()); - c - } - }; - let Some(cache) = cache else { continue }; - if let Some(pt) = read_table(cx, &x, &cache, caches.next_id) { - caches.next_id = caches.next_id.saturating_add(1); - out.push(pt); - } - } - Ok(out) -} - -/// A pivot field's data items (not subtotal or grand-total items). -fn items_of(e: &El) -> Vec<&El> { - e.child("items").map(|it| it.kids("item").filter(|i| i.attr("t").is_none_or(|t| t == "data")).collect()).unwrap_or_default() -} - -/// (source column name, date grouping) of a cache field used on an axis. -fn field_ref(cx: &mut Ctx<'_>, cache: &CacheIn, i: usize) -> Option<(String, PivotDateGroup)> { - let f = cache.fields.get(i)?; - if f.formula.is_some() { - cx.warn("calculated PivotTable fields are not supported"); - return None; - } - match &f.group { - Some((base, by, _)) => { - let by = match by { - Some(b) => *b, - None => { - cx.warn(format!("PivotTable grouping of \"{}\" is not supported; the field is ungrouped", f.name)); - PivotDateGroup::None - } - }; - let base_name = if f.database { f.name.clone() } else { base.and_then(|b| cache.fields.get(b))?.name.clone() }; - Some((base_name, by)) - } - None if !f.database => { - cx.warn(format!("PivotTable field \"{}\" (grouped items) is not supported", f.name)); - None - } - None => Some((f.name.clone(), PivotDateGroup::None)), - } -} - -fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option { - let mut pt = PivotTable { id, ..PivotTable::default() }; - if let Some(n) = x.attr("name").filter(|n| !n.trim().is_empty()) { - pt.name = n.to_string(); - } else { - pt.name = format!("PivotTable{id}"); - } - pt.source = cache.source.clone(); - let Some(loc) = x.child("location").and_then(|l| l.attr("ref")).and_then(|r| RangeRef::parse(&r.replace('$', ""))) else { - cx.warn(format!("PivotTable \"{}\" has no valid location; kept as plain cells", pt.name)); - return None; - }; - let pfs: Vec<&El> = x.child("pivotFields").map(|p| p.kids("pivotField").collect()).unwrap_or_default(); - let pf = |i: usize| pfs.get(i).copied(); - let idx_list = |name: &str, attr: &str| -> Vec { - x.child(name).map(|e| e.children.iter().filter_map(|f| f.attr_i64(attr)).collect()).unwrap_or_default() - }; - let row_x = idx_list("rowFields", "x"); - let col_x = idx_list("colFields", "x"); - let pages: Vec<&El> = x.child("pageFields").map(|p| p.kids("pageField").collect()).unwrap_or_default(); - - // Layout from the first axis field (pivotField defaults: compact and outline on). - let first_axis = row_x.iter().chain(col_x.iter()).find(|i| **i >= 0).and_then(|i| pf(*i as usize)); - let (compact, outline) = match first_axis { - Some(f) => (f.flag("compact", true), f.flag("outline", true)), - None => (x.flag("compact", true), x.flag("outline", false)), - }; - pt.layout = if compact { - PivotLayout::Compact - } else if outline { - PivotLayout::Outline - } else { - PivotLayout::Tabular - }; - if let Some(f) = first_axis { - pt.subtotals = f.flag("defaultSubtotal", true); - pt.subtotals_top = f.flag("subtotalTop", true); - } - 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)); - } - - let mut filters: Vec = Vec::new(); - - // (fields, position of Σ Values (`-2`) among them) - let axis = |cx: &mut Ctx<'_>, list: &[i64], filters: &mut Vec| -> (Vec, Option) { - let mut v = Vec::new(); - let mut values_at = None; - for &i in list { - if i == -2 { - values_at = Some(v.len()); - continue; - } - if i < 0 { - continue; - } - let i = i as usize; - let Some((name, group)) = field_ref(cx, cache, i) else { continue }; - let labels = cache.fields.get(i).map(|f| f.labels().to_vec()).unwrap_or_default(); - let mut f = PivotField { source_col: name.clone(), date_group: group, sort: PivotSort::None, collapsed_items: vec![] }; - if let Some(e) = pf(i) { - f.sort = match e.attr("sortType") { - Some("ascending") => PivotSort::Asc, - Some("descending") => PivotSort::Desc, - _ => PivotSort::None, - }; - let items = items_of(e); - let lab = |it: &El| it.attr_u32("x").and_then(|k| labels.get(k as usize)).cloned(); - f.collapsed_items = items.iter().filter(|it| !it.flag("sd", true)).filter_map(|it| lab(it)).collect(); - if items.iter().any(|it| it.flag("h", false)) { - let shown: Vec = items.iter().filter(|it| !it.flag("h", false)).filter_map(|it| lab(it)).collect(); - if !filters.iter().any(|ff| same(&ff.source_col, &name)) { - filters.push(PivotFilter { source_col: name.clone(), selected: Some(shown) }); - } - } - } - v.push(f); - } - (v, values_at) - }; - let mut page_filters = Vec::new(); - for p in &pages { - let Some(i) = p.attr_i64("fld").filter(|i| *i >= 0).map(|i| i as usize) else { continue }; - let Some((name, _)) = field_ref(cx, cache, i) else { continue }; - let labels = cache.fields.get(i).map(|f| f.labels().to_vec()).unwrap_or_default(); - let selected = if let Some(k) = p.attr_u32("item") { - labels.get(k as usize).map(|l| vec![l.clone()]) - } else { - pf(i).and_then(|e| { - let items = items_of(e); - items.iter().any(|it| it.flag("h", false)).then(|| { - items - .iter() - .filter(|it| !it.flag("h", false)) - .filter_map(|it| it.attr_u32("x").and_then(|k| labels.get(k as usize)).cloned()) - .collect() - }) - }) - }; - page_filters.push(PivotFilter { source_col: name, selected }); - } - let mut axis_filters = Vec::new(); - let (rows, rows_values) = axis(cx, &row_x, &mut axis_filters); - let (columns, cols_values) = axis(cx, &col_x, &mut axis_filters); - pt.rows = rows; - pt.columns = columns; - // Σ Values: where its `-2` entry is, else `dataOnRows` and `dataPosition`; after the last - // field by default. In Columns it always comes last, as the column layout puts it there. - pt.values_on_rows = rows_values.is_some() || (cols_values.is_none() && x.flag("dataOnRows", false)); - pt.values_position = - if pt.values_on_rows { rows_values.or_else(|| x.attr_u32("dataPosition").map(|p| p as usize)).filter(|p| *p < pt.rows.len()) } else { None }; - filters.extend(page_filters); - filters.extend(axis_filters); - pt.filters = filters; - - // Values. - if let Some(df) = x.child("dataFields") { - for d in df.kids("dataField") { - let Some(i) = d.attr_u32("fld").map(|i| i as usize) else { continue }; - let Some(f) = cache.fields.get(i) else { continue }; - let func = match d.attr("subtotal") { - None => PivotFunc::Sum, - Some(s) => func_from(s).unwrap_or_else(|| { - cx.warn(format!("PivotTable function {s} is not supported; using Sum")); - PivotFunc::Sum - }), - }; - let ext = d - .child("extLst") - .into_iter() - .flat_map(|l| l.kids("ext")) - .filter_map(|e| e.child("dataField")) - .find_map(|e| e.attr("pivotShowAs").map(str::to_string)); - let show_as = match (ext.as_deref(), d.attr("showDataAs")) { - (Some("rankDescending"), _) => PivotShowAs::Rank, - (Some(other), _) => { - cx.warn(format!("PivotTable show-values-as {other} is not supported")); - PivotShowAs::Normal - } - (None, None | Some("normal")) => PivotShowAs::Normal, - (None, Some("percentOfTotal")) => PivotShowAs::PercentOfGrandTotal, - (None, Some("percentOfCol")) => PivotShowAs::PercentOfColumnTotal, - (None, Some("percentOfRow")) => PivotShowAs::PercentOfRowTotal, - (None, Some("runTotal")) => PivotShowAs::RunningTotal, - (None, Some(other)) => { - cx.warn(format!("PivotTable show-values-as {other} is not supported")); - PivotShowAs::Normal - } - }; - let number_format = d - .attr_u32("numFmtId") - .filter(|id| *id != 0) - .and_then(|id| crate::tables::builtin_format(id).map(str::to_string).or_else(|| cx.num_fmts.get(&id).cloned())); - let name = d.attr("name").map(str::to_string).unwrap_or_else(|| format!("{} of {}", func_caption(func), f.name)); - pt.values.push(PivotValue { source_col: f.name.clone(), func, name, show_as, number_format }); - } - } - - // Calculated fields of the cache (each PivotTable on it can use them). - for f in &cache.fields { - if let Some(formula) = &f.formula - && !f.name.trim().is_empty() - && !pt.calculated_fields.iter().any(|c| same(&c.name, &f.name)) - { - let formula = formula.trim(); - let formula = if formula.starts_with('=') { formula.to_string() } else { format!("={formula}") }; - pt.calculated_fields.push(PivotCalcField { name: f.name.clone(), formula }); - } - } - - pt.anchor = loc.start; - let above = if pages.is_empty() { 0 } else { pages.len() as u32 + 1 }; - pt.last_range = Some(RangeRef::new(CellRef::new(loc.start.row.saturating_sub(above), loc.start.col), loc.end)); - Some(pt) -} diff --git a/crates/xlsx/src/pivot/read.rs b/crates/xlsx/src/pivot/read.rs new file mode 100644 index 0000000000..f811533ab3 --- /dev/null +++ b/crates/xlsx/src/pivot/read.rs @@ -0,0 +1,382 @@ +use super::*; + +// ================================================================ reading + +/// A cache field as read. +#[derive(Clone, Debug, Default)] +struct FieldIn { + name: String, + database: bool, + /// Shared item captions. + items: Vec, + /// (base field, grouping, group item captions); grouping `None` = unsupported grouping. + group: Option<(Option, Option, Vec)>, + /// A calculated field's formula. + formula: Option, +} + +impl FieldIn { + fn labels(&self) -> &[String] { + match &self.group { + Some((_, _, items)) if !items.is_empty() => items, + _ => &self.items, + } + } +} + +#[derive(Clone, Debug, Default)] +struct CacheIn { + source: String, + fields: Vec, +} + +/// Workbook-level pivot cache lookup, filled from ``; caches are parsed once. +#[derive(Default)] +pub struct CachesIn { + by_id: HashMap, + parsed: HashMap>, + next_id: u32, +} + +impl CachesIn { + pub fn new(wb_xml: &El, wb_rels: &[crate::package::Rel]) -> CachesIn { + let mut by_id = HashMap::new(); + if let Some(pc) = wb_xml.child("pivotCaches") { + for c in pc.kids("pivotCache") { + if let (Some(id), Some(rid)) = (c.attr_u32("cacheId"), c.attr("id")) + && let Some(r) = wb_rels.iter().find(|r| r.id == rid) + { + by_id.insert(id, r.target.clone()); + } + } + } + CachesIn { by_id, parsed: HashMap::new(), next_id: 1 } + } +} + +fn item_label_el(e: &El, sys: DateSystem) -> String { + let v = e.attr("v").unwrap_or(""); + match e.name.as_str() { + "m" => "(blank)".into(), + "n" => v.trim().parse::().ok().filter(|n| n.is_finite()).map(number_to_text).unwrap_or_else(|| v.to_string()), + "b" => if matches!(v.trim(), "1" | "true") { "TRUE" } else { "FALSE" }.into(), + "d" => crate::sheet_read::parse_iso_datetime(v, sys).map(number_to_text).unwrap_or_else(|| v.to_string()), + _ => v.to_string(), + } +} + +fn read_cache(cx: &mut Ctx<'_>, part: &str, sheet_name: &str) -> Option { + let x = cx.optional_xml(part).ok().flatten()?; + if x.name != "pivotCacheDefinition" { + cx.warn(format!("{part} is not a pivot cache definition; its PivotTable was kept as plain cells")); + return None; + } + let src = x.child("cacheSource"); + let ty = src.and_then(|s| s.attr("type")).unwrap_or("worksheet"); + if ty != "worksheet" { + cx.warn(format!("PivotTables with a {ty} data source are not supported; kept as plain cells")); + return None; + } + let ws = src.and_then(|s| s.child("worksheetSource")); + let source = match ws { + Some(w) if w.attr("name").is_some_and(|n| !n.trim().is_empty()) => w.attr("name").unwrap_or("").trim().to_string(), + Some(w) => { + if w.attr("id").is_some() { + cx.warn("a PivotTable's source is in another workbook; its source reference may not resolve"); + } + let r = w.attr("ref").map(|r| r.replace('$', "")).and_then(|r| RangeRef::parse(&r)); + let Some(r) = r else { + cx.warn("a PivotTable's source reference is missing or invalid; kept as plain cells"); + return None; + }; + let sheet = w.attr("sheet").filter(|s| !s.is_empty()).unwrap_or(sheet_name); + format!("{}!{}", quote_sheet(sheet), abs_range(&r)) + } + None => { + cx.warn("a PivotTable's cache has no worksheet source; kept as plain cells"); + return None; + } + }; + let sys = cx.date_system; + let mut fields = Vec::new(); + if let Some(cf) = x.child("cacheFields") { + for f in cf.kids("cacheField").take(MAX_FIELDS) { + let items = f.child("sharedItems").map(|si| si.children.iter().map(|e| item_label_el(e, sys)).collect()).unwrap_or_default(); + let group = f.child("fieldGroup").map(|g| { + let base = g.attr_u32("base").map(|b| b as usize); + let by = match g.child("rangePr").map(|r| r.attr("groupBy").unwrap_or("range")) { + Some("years") => Some(PivotDateGroup::Years), + Some("quarters") => Some(PivotDateGroup::Quarters), + Some("months") => Some(PivotDateGroup::Months), + Some("days") => Some(PivotDateGroup::Days), + _ => None, + }; + let gi = g.child("groupItems").map(|gi| gi.children.iter().map(|e| item_label_el(e, sys)).collect()).unwrap_or_default(); + (base, by, gi) + }); + fields.push(FieldIn { + name: f.attr("name").unwrap_or("").to_string(), + database: f.flag("databaseField", true), + items, + group, + formula: f.attr("formula").map(str::to_string), + }); + } + } + if fields.is_empty() { + cx.warn("a PivotTable's cache has no fields; kept as plain cells"); + return None; + } + Some(CacheIn { source, fields }) +} + +/// Reads the PivotTables related to a worksheet part. +pub fn read_pivots(cx: &mut Ctx<'_>, caches: &mut CachesIn, sheet_part: &str, sheet_name: &str) -> Result, IoError> { + let rels = cx.pkg.rels(sheet_part)?; + let mut out = Vec::new(); + for rel in rels.iter().filter(|r| r.kind == "pivotTable" && !r.external) { + let Some(x) = cx.optional_xml(&rel.target)? else { continue }; + if x.name != "pivotTableDefinition" { + cx.warn(format!("{} is not a PivotTable definition; skipped", rel.target)); + continue; + } + // The cache: the table's own relationship, else the workbook's cache id. + let trels = cx.pkg.rels(&rel.target)?; + let cache_part = trels + .iter() + .find(|r| r.kind == "pivotCacheDefinition" && !r.external) + .map(|r| r.target.clone()) + .or_else(|| x.attr_u32("cacheId").and_then(|id| caches.by_id.get(&id).cloned())); + let Some(cache_part) = cache_part else { + cx.warn(format!("PivotTable in {} has no cache; kept as plain cells", rel.target)); + continue; + }; + let cache = match caches.parsed.get(&cache_part) { + Some(c) => c.clone(), + None => { + let c = read_cache(cx, &cache_part, sheet_name); + caches.parsed.insert(cache_part.clone(), c.clone()); + c + } + }; + let Some(cache) = cache else { continue }; + if let Some(pt) = read_table(cx, &x, &cache, caches.next_id) { + caches.next_id = caches.next_id.saturating_add(1); + out.push(pt); + } + } + Ok(out) +} + +/// A pivot field's data items (not subtotal or grand-total items). +fn items_of(e: &El) -> Vec<&El> { + e.child("items").map(|it| it.kids("item").filter(|i| i.attr("t").is_none_or(|t| t == "data")).collect()).unwrap_or_default() +} + +/// (source column name, date grouping) of a cache field used on an axis. +fn field_ref(cx: &mut Ctx<'_>, cache: &CacheIn, i: usize) -> Option<(String, PivotDateGroup)> { + let f = cache.fields.get(i)?; + if f.formula.is_some() { + cx.warn("calculated PivotTable fields are not supported"); + return None; + } + match &f.group { + Some((base, by, _)) => { + let by = match by { + Some(b) => *b, + None => { + cx.warn(format!("PivotTable grouping of \"{}\" is not supported; the field is ungrouped", f.name)); + PivotDateGroup::None + } + }; + let base_name = if f.database { f.name.clone() } else { base.and_then(|b| cache.fields.get(b))?.name.clone() }; + Some((base_name, by)) + } + None if !f.database => { + cx.warn(format!("PivotTable field \"{}\" (grouped items) is not supported", f.name)); + None + } + None => Some((f.name.clone(), PivotDateGroup::None)), + } +} + +fn read_table(cx: &mut Ctx<'_>, x: &El, cache: &CacheIn, id: u32) -> Option { + let mut pt = PivotTable { id, ..PivotTable::default() }; + if let Some(n) = x.attr("name").filter(|n| !n.trim().is_empty()) { + pt.name = n.to_string(); + } else { + pt.name = format!("PivotTable{id}"); + } + pt.source = cache.source.clone(); + let Some(loc) = x.child("location").and_then(|l| l.attr("ref")).and_then(|r| RangeRef::parse(&r.replace('$', ""))) else { + cx.warn(format!("PivotTable \"{}\" has no valid location; kept as plain cells", pt.name)); + return None; + }; + let pfs: Vec<&El> = x.child("pivotFields").map(|p| p.kids("pivotField").collect()).unwrap_or_default(); + let pf = |i: usize| pfs.get(i).copied(); + let idx_list = |name: &str, attr: &str| -> Vec { + x.child(name).map(|e| e.children.iter().filter_map(|f| f.attr_i64(attr)).collect()).unwrap_or_default() + }; + let row_x = idx_list("rowFields", "x"); + let col_x = idx_list("colFields", "x"); + let pages: Vec<&El> = x.child("pageFields").map(|p| p.kids("pageField").collect()).unwrap_or_default(); + + // Layout from the first axis field (pivotField defaults: compact and outline on). + let first_axis = row_x.iter().chain(col_x.iter()).find(|i| **i >= 0).and_then(|i| pf(*i as usize)); + let (compact, outline) = match first_axis { + Some(f) => (f.flag("compact", true), f.flag("outline", true)), + None => (x.flag("compact", true), x.flag("outline", false)), + }; + pt.layout = if compact { + PivotLayout::Compact + } else if outline { + PivotLayout::Outline + } else { + PivotLayout::Tabular + }; + if let Some(f) = first_axis { + pt.subtotals = f.flag("defaultSubtotal", true); + pt.subtotals_top = f.flag("subtotalTop", true); + } + 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)); + } + + let mut filters: Vec = Vec::new(); + + // (fields, position of Σ Values (`-2`) among them) + let axis = |cx: &mut Ctx<'_>, list: &[i64], filters: &mut Vec| -> (Vec, Option) { + let mut v = Vec::new(); + let mut values_at = None; + for &i in list { + if i == -2 { + values_at = Some(v.len()); + continue; + } + if i < 0 { + continue; + } + let i = i as usize; + let Some((name, group)) = field_ref(cx, cache, i) else { continue }; + let labels = cache.fields.get(i).map(|f| f.labels().to_vec()).unwrap_or_default(); + let mut f = PivotField { source_col: name.clone(), date_group: group, sort: PivotSort::None, collapsed_items: vec![] }; + if let Some(e) = pf(i) { + f.sort = match e.attr("sortType") { + Some("ascending") => PivotSort::Asc, + Some("descending") => PivotSort::Desc, + _ => PivotSort::None, + }; + let items = items_of(e); + let lab = |it: &El| it.attr_u32("x").and_then(|k| labels.get(k as usize)).cloned(); + f.collapsed_items = items.iter().filter(|it| !it.flag("sd", true)).filter_map(|it| lab(it)).collect(); + if items.iter().any(|it| it.flag("h", false)) { + let shown: Vec = items.iter().filter(|it| !it.flag("h", false)).filter_map(|it| lab(it)).collect(); + if !filters.iter().any(|ff| same(&ff.source_col, &name)) { + filters.push(PivotFilter { source_col: name.clone(), selected: Some(shown) }); + } + } + } + v.push(f); + } + (v, values_at) + }; + let mut page_filters = Vec::new(); + for p in &pages { + let Some(i) = p.attr_i64("fld").filter(|i| *i >= 0).map(|i| i as usize) else { continue }; + let Some((name, _)) = field_ref(cx, cache, i) else { continue }; + let labels = cache.fields.get(i).map(|f| f.labels().to_vec()).unwrap_or_default(); + let selected = if let Some(k) = p.attr_u32("item") { + labels.get(k as usize).map(|l| vec![l.clone()]) + } else { + pf(i).and_then(|e| { + let items = items_of(e); + items.iter().any(|it| it.flag("h", false)).then(|| { + items + .iter() + .filter(|it| !it.flag("h", false)) + .filter_map(|it| it.attr_u32("x").and_then(|k| labels.get(k as usize)).cloned()) + .collect() + }) + }) + }; + page_filters.push(PivotFilter { source_col: name, selected }); + } + let mut axis_filters = Vec::new(); + let (rows, rows_values) = axis(cx, &row_x, &mut axis_filters); + let (columns, cols_values) = axis(cx, &col_x, &mut axis_filters); + pt.rows = rows; + pt.columns = columns; + // Σ Values: where its `-2` entry is, else `dataOnRows` and `dataPosition`; after the last + // field by default. In Columns it always comes last, as the column layout puts it there. + pt.values_on_rows = rows_values.is_some() || (cols_values.is_none() && x.flag("dataOnRows", false)); + pt.values_position = + if pt.values_on_rows { rows_values.or_else(|| x.attr_u32("dataPosition").map(|p| p as usize)).filter(|p| *p < pt.rows.len()) } else { None }; + filters.extend(page_filters); + filters.extend(axis_filters); + pt.filters = filters; + + // Values. + if let Some(df) = x.child("dataFields") { + for d in df.kids("dataField") { + let Some(i) = d.attr_u32("fld").map(|i| i as usize) else { continue }; + let Some(f) = cache.fields.get(i) else { continue }; + let func = match d.attr("subtotal") { + None => PivotFunc::Sum, + Some(s) => func_from(s).unwrap_or_else(|| { + cx.warn(format!("PivotTable function {s} is not supported; using Sum")); + PivotFunc::Sum + }), + }; + let ext = d + .child("extLst") + .into_iter() + .flat_map(|l| l.kids("ext")) + .filter_map(|e| e.child("dataField")) + .find_map(|e| e.attr("pivotShowAs").map(str::to_string)); + let show_as = match (ext.as_deref(), d.attr("showDataAs")) { + (Some("rankDescending"), _) => PivotShowAs::Rank, + (Some(other), _) => { + cx.warn(format!("PivotTable show-values-as {other} is not supported")); + PivotShowAs::Normal + } + (None, None | Some("normal")) => PivotShowAs::Normal, + (None, Some("percentOfTotal")) => PivotShowAs::PercentOfGrandTotal, + (None, Some("percentOfCol")) => PivotShowAs::PercentOfColumnTotal, + (None, Some("percentOfRow")) => PivotShowAs::PercentOfRowTotal, + (None, Some("runTotal")) => PivotShowAs::RunningTotal, + (None, Some(other)) => { + cx.warn(format!("PivotTable show-values-as {other} is not supported")); + PivotShowAs::Normal + } + }; + let number_format = d + .attr_u32("numFmtId") + .filter(|id| *id != 0) + .and_then(|id| crate::tables::builtin_format(id).map(str::to_string).or_else(|| cx.num_fmts.get(&id).cloned())); + let name = d.attr("name").map(str::to_string).unwrap_or_else(|| format!("{} of {}", func_caption(func), f.name)); + pt.values.push(PivotValue { source_col: f.name.clone(), func, name, show_as, number_format }); + } + } + + // Calculated fields of the cache (each PivotTable on it can use them). + for f in &cache.fields { + if let Some(formula) = &f.formula + && !f.name.trim().is_empty() + && !pt.calculated_fields.iter().any(|c| same(&c.name, &f.name)) + { + let formula = formula.trim(); + let formula = if formula.starts_with('=') { formula.to_string() } else { format!("={formula}") }; + pt.calculated_fields.push(PivotCalcField { name: f.name.clone(), formula }); + } + } + + pt.anchor = loc.start; + let above = if pages.is_empty() { 0 } else { pages.len() as u32 + 1 }; + pt.last_range = Some(RangeRef::new(CellRef::new(loc.start.row.saturating_sub(above), loc.start.col), loc.end)); + Some(pt) +} diff --git a/crates/xlsx/src/pivot/write.rs b/crates/xlsx/src/pivot/write.rs new file mode 100644 index 0000000000..44e0c386af --- /dev/null +++ b/crates/xlsx/src/pivot/write.rs @@ -0,0 +1,876 @@ +use super::*; + +#[derive(Default)] +struct Stats { + blank: bool, + text: bool, + number: bool, + non_int: bool, + boolean: bool, + error: bool, + long: bool, + min: f64, + max: f64, +} + +impl Stats { + fn add(&mut self, v: &Value) { + match v { + Value::Empty => self.blank = true, + Value::Number(n) => { + if !self.number { + self.min = *n; + self.max = *n; + } + self.number = true; + self.min = self.min.min(*n); + self.max = self.max.max(*n); + if n.fract() != 0.0 { + self.non_int = true; + } + } + Value::Text(t) => { + self.text = true; + if t.chars().count() > 255 { + self.long = true; + } + } + Value::Bool(_) => self.boolean = true, + Value::Error(_) => self.error = true, + Value::Array(_) => self.add(&v.scalar()), + } + } + /// Excel counts booleans and errors as strings in the cache field flags. + fn strings(&self) -> bool { + self.text || self.boolean || self.error + } + /// Kinds for `containsMixedTypes`: strings (text, booleans, errors) and numbers. + fn kinds(&self) -> usize { + usize::from(self.strings()) + usize::from(self.number) + } +} + +/// Date grouping of a cache field. +struct Group { + by: PivotDateGroup, + /// The database field the grouping applies to. + base: usize, + /// The next (coarser) grouping field of the same base. + par: Option, + start: f64, + end: f64, + /// Group item captions, including the `end` items. + items: Vec, +} + +struct CField { + name: String, + /// Column offset in the source (database fields only). + col: Option, + stats: Stats, + /// Shared items (written when `itemized`). + items: Vec, + index: HashMap, + itemized: bool, + group: Option, + /// A calculated field's formula (without `=`); such fields aren't database fields. + formula: Option, +} + +impl CField { + /// Item captions the PivotTable's `` indices refer to. + fn item_labels(&self) -> Vec { + match &self.group { + Some(g) => g.items.clone(), + None => self.items.iter().map(label).collect(), + } + } +} + +/// A cache built from a PivotTable's source. +struct Cache { + fields: Vec, + /// Pivot field → cache field, by the pivot field's position in rows, then columns. + axis_map: Vec>, + source_xml: String, + records: Option<(u64, String)>, +} + +/// PivotTables being written; the tables themselves are finished after the styles part (their +/// value number formats need style number-format ids). +#[derive(Default)] +pub struct PivotWriter { + /// (cache id, workbook-relative part name). + caches: Vec<(u32, String)>, + pending: Vec, + tables: u32, +} + +struct Pending { + part: String, + sheet: usize, + idx: usize, + cache_id: u32, + cache: Cache, +} + +impl PivotWriter { + /// Writes the caches of a sheet's PivotTables and adds the sheet's relationships to its + /// PivotTable parts. Pivots whose source can't be resolved are not written. + pub fn add_sheet(&mut self, wb: &Workbook, si: usize, out: &mut Out, rels: &mut Rels) { + let Some(sheet) = wb.sheet(si) else { return }; + for (idx, pt) in sheet.pivots.iter().enumerate() { + let Some(cache) = build_cache(wb, pt, si) else { continue }; + let n = self.caches.len() as u32 + 1; + let cache_part = format!("xl/pivotCache/pivotCacheDefinition{n}.xml"); + let rec_rid = if let Some((count, xml)) = &cache.records { + let mut r = Rels::default(); + let rid = r.add("pivotCacheRecords", &format!("pivotCacheRecords{n}.xml")); + out.part(&format!("xl/pivotCache/_rels/pivotCacheDefinition{n}.xml.rels"), None, r.xml().into_bytes()); + let s = format!("{XML_DECL}{xml}"); + out.part(&format!("xl/pivotCache/pivotCacheRecords{n}.xml"), Some(CT_PIVOT_CACHE_REC), s.into_bytes()); + Some(rid) + } else { + None + }; + out.part(&cache_part, Some(CT_PIVOT_CACHE_DEF), cache_xml(&cache, rec_rid.as_deref(), wb.date_system).into_bytes()); + self.caches.push((n, format!("pivotCache/pivotCacheDefinition{n}.xml"))); + self.tables += 1; + let t = self.tables; + rels.add("pivotTable", &format!("../pivotTables/pivotTable{t}.xml")); + let mut tr = Rels::default(); + tr.add("pivotCacheDefinition", &format!("../pivotCache/pivotCacheDefinition{n}.xml")); + out.part(&format!("xl/pivotTables/_rels/pivotTable{t}.xml.rels"), None, tr.xml().into_bytes()); + self.pending.push(Pending { part: format!("xl/pivotTables/pivotTable{t}.xml"), sheet: si, idx, cache_id: n, cache }); + } + } + + /// Adds the workbook relationships to the caches; returns the `` element (empty + /// when there are none). + pub fn workbook_xml(&self, wb_rels: &mut Rels) -> String { + if self.caches.is_empty() { + return String::new(); + } + let mut s = String::from(""); + for (id, target) in &self.caches { + let rid = wb_rels.add("pivotCacheDefinition", target); + let _ = write!(s, ""); + } + s.push_str(""); + s + } + + /// Number formats used by value fields (to be registered in the styles part). + pub fn number_formats(&self, wb: &Workbook) -> Vec { + let mut v = Vec::new(); + for p in &self.pending { + if let Some(pt) = wb.sheet(p.sheet).and_then(|s| s.pivots.get(p.idx)) { + for val in &pt.values { + if let Some(f) = &val.number_format + && !v.contains(f) + { + v.push(f.clone()); + } + } + } + } + v + } + + /// Writes the PivotTable parts. `fmt_id` maps a number format code to its styles id. + pub fn finish(self, wb: &Workbook, out: &mut Out, fmt_id: &dyn Fn(&str) -> Option) { + for p in self.pending { + let Some(pt) = wb.sheet(p.sheet).and_then(|s| s.pivots.get(p.idx)) else { continue }; + let xml = table_xml(pt, p.idx, p.cache_id, &p.cache, fmt_id); + out.part(&p.part, Some(CT_PIVOT_TABLE), xml.into_bytes()); + } + } +} + +/// Resolves a pivot source to (sheet, range including the header row, worksheetSource XML). +fn resolve_source(wb: &Workbook, text: &str, default_sheet: usize, depth: u32) -> Option<(usize, RangeRef, String)> { + let t = text.trim().trim_start_matches('=').trim(); + if t.is_empty() || depth > 4 { + return None; + } + if let Some((si, ti)) = wb.table(t) + && let Some(sh) = wb.sheet(si) + && let Some(tb) = sh.tables.get(ti) + { + if !tb.header_row { + return None; + } + let end_row = tb.range.end.row.saturating_sub(tb.totals_row as u32).max(tb.range.start.row); + let r = RangeRef::new(tb.range.start, CellRef::new(end_row, tb.range.end.col)); + // Table names are sanitized when written; refer by range when this one would change. + let valid = !tb.name.is_empty() + && tb.name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') + && tb.name.starts_with(|c: char| c.is_alphabetic() || c == '_') + && CellRef::parse(&tb.name).is_none(); + let xml = if valid { format!("", esc_attr(&tb.name)) } else { range_source_xml(&sh.name, r) }; + return Some((si, r, xml)); + } + let (sheet, body) = gridcraft_core::split_sheet(t); + if let Some(r) = RangeRef::parse(body) { + let si = match &sheet { + Some(n) => wb.sheet_index(n)?, + None => default_sheet, + }; + let sh = wb.sheet(si)?; + let r = if r.is_full_cols() || r.is_full_rows() { r.intersection(&sh.used_range()?)? } else { r }; + return Some((si, r, range_source_xml(&sh.name, r))); + } + if sheet.is_none() + && let Some(n) = wb.name(t, default_sheet) + { + let (si, r, _) = resolve_source(wb, &n.formula.clone(), default_sheet, depth + 1)?; + let xml = if n.scope.is_none() { + format!("", esc_attr(&n.name)) + } else { + wb.sheet(si).map(|s| range_source_xml(&s.name, r))? + }; + return Some((si, r, xml)); + } + None +} + +fn range_source_xml(sheet: &str, r: RangeRef) -> String { + let name: String = sheet.chars().take(31).collect(); + format!("", r.a1(), esc_attr(&name)) +} + +fn header_text(v: &Value) -> String { + match v { + Value::Empty => String::new(), + v => label(v).trim().to_string(), + } +} + +fn build_cache(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Option { + let (si, r, source_xml) = resolve_source(wb, &pt.source, pivot_sheet, 0)?; + if r.count() > MAX_SOURCE_CELLS { + return None; + } + let sh = wb.sheet(si)?; + let mut fields: Vec = Vec::new(); + // Lower-case names taken, and the next suffix for each header (linear for repeated headers). + let mut taken = std::collections::HashSet::new(); + let mut next_suffix: HashMap = HashMap::new(); + for (k, c) in (r.start.col..=r.end.col).enumerate() { + let mut h = header_text(&sh.value(CellRef::new(r.start.row, c)).scalar()); + if h.is_empty() { + h = format!("Column{}", k + 1); + } + let mut n = h.clone(); + let mut i = next_suffix.get(&h.to_lowercase()).copied().unwrap_or(2); + while taken.contains(&n.to_lowercase()) { + n = format!("{h}{i}"); + i += 1; + } + next_suffix.insert(h.to_lowercase(), i); + taken.insert(n.to_lowercase()); + fields.push(CField { + name: n, + col: Some(k as u32), + stats: Stats::default(), + items: vec![], + index: HashMap::new(), + itemized: false, + group: None, + formula: None, + }); + } + let find = |fields: &[CField], name: &str| fields.iter().position(|f| f.col.is_some() && same(&f.name, name)); + + // Records (rows that aren't entirely blank), read once. + let mut rows: Vec = Vec::new(); + if r.end.row > r.start.row { + for row in r.start.row + 1..=r.end.row { + let blank = (r.start.col..=r.end.col).all(|c| sh.value(CellRef::new(row, c)).scalar().is_empty()); + if !blank { + rows.push(row); + } + } + } + for &row in &rows { + for f in fields.iter_mut() { + let c = r.start.col + f.col.unwrap_or(0); + f.stats.add(&sh.value(CellRef::new(row, c)).scalar()); + } + } + + // Which fields are on an axis, and how dates are grouped: per base column, the base field + // takes the finest grouping and coarser ones become extra (non-database) fields chained + // through `par`, as Excel lays them out. + let axis: Vec<&PivotField> = pt.rows.iter().chain(pt.columns.iter()).collect(); + let groupable = |f: &CField| { + let s = &f.stats; + s.number + && !s.text + && !s.boolean + && !s.error + && datetime_from_serial(wb.date_system, s.min).is_some() + && datetime_from_serial(wb.date_system, s.max).is_some() + }; + // (base column field, groupings fine → coarse) + let mut groupings: Vec<(usize, Vec)> = Vec::new(); + for pf in &axis { + let Some(b) = find(&fields, &pf.source_col) else { continue }; + if pf.date_group == PivotDateGroup::None || !fields.get(b).is_some_and(groupable) { + continue; + } + match groupings.iter_mut().find(|(x, _)| *x == b) { + Some((_, v)) => { + if !v.contains(&pf.date_group) { + v.push(pf.date_group); + } + } + None => groupings.push((b, vec![pf.date_group])), + } + } + let fineness = |g: &PivotDateGroup| match g { + PivotDateGroup::Days => 0, + PivotDateGroup::Months => 1, + PivotDateGroup::Quarters => 2, + PivotDateGroup::Years => 3, + PivotDateGroup::None => 4, + }; + for (b, list) in groupings.iter_mut() { + list.sort_by_key(fineness); + let (min, max) = fields.get(*b).map(|f| (f.stats.min, f.stats.max)).unwrap_or((0.0, 0.0)); + let (start, end) = (min.floor(), max.floor() + 1.0); + let make = |by: PivotDateGroup| Group { by, base: *b, par: None, start, end, items: group_items(wb.date_system, by, start, end) }; + let mut prev = *b; + for (k, by) in list.iter().enumerate() { + if k == 0 { + if let Some(f) = fields.get_mut(*b) { + f.group = Some(make(*by)); + } + continue; + } + let new = fields.len(); + let caption = group_caption(*by); + let mut n = caption.to_string(); + let mut i = 2; + while fields.iter().any(|f| same(&f.name, &n)) { + n = format!("{caption}{i}"); + i += 1; + } + if let Some(g) = fields.get_mut(prev).and_then(|f| f.group.as_mut()) { + g.par = Some(new); + } + fields.push(CField { + name: n, + col: None, + stats: Stats::default(), + items: vec![], + index: HashMap::new(), + itemized: false, + group: Some(make(*by)), + formula: None, + }); + prev = new; + } + } + let mut axis_map: Vec> = Vec::with_capacity(axis.len()); + for pf in &axis { + let Some(b) = find(&fields, &pf.source_col) else { + axis_map.push(None); + continue; + }; + let grouped = fields.iter().position(|f| f.group.as_ref().is_some_and(|g| g.base == b && g.by == pf.date_group)); + match grouped { + Some(i) if pf.date_group != PivotDateGroup::None => axis_map.push(Some(i)), + _ => { + if let Some(f) = fields.get_mut(b) + && f.group.is_none() + { + f.itemized = true; + } + axis_map.push(Some(b)); + } + } + } + // Page fields (filters not on an axis) need items too. + for f in &pt.filters { + let on_axis = axis.iter().any(|a| same(&a.source_col, &f.source_col)); + if !on_axis + && let Some(i) = find(&fields, &f.source_col) + && let Some(cf) = fields.get_mut(i) + { + cf.itemized = true; + } + } + for f in fields.iter_mut() { + if f.group.is_some() { + f.itemized = false; + } else if f.col.is_some() && (f.stats.text || f.stats.boolean || f.stats.error) { + f.itemized = true; + } + } + // Shared items, in order of first appearance. + for &row in &rows { + for f in fields.iter_mut().filter(|f| f.itemized) { + let c = r.start.col + f.col.unwrap_or(0); + let v = sh.value(CellRef::new(row, c)).scalar(); + let k = key(&v); + if !f.index.contains_key(&k) && f.items.len() < MAX_ITEMS { + f.index.insert(k, f.items.len() as u32); + f.items.push(v); + } + } + } + // Records snapshot. + let db_fields = fields.iter().filter(|f| f.col.is_some()).count() as u64; + let records = if (rows.len() as u64).saturating_mul(db_fields) <= MAX_RECORD_CELLS { + let mut s = String::new(); + for &row in &rows { + s.push_str(""); + for f in fields.iter().filter(|f| f.col.is_some()) { + let c = r.start.col + f.col.unwrap_or(0); + let v = sh.value(CellRef::new(row, c)).scalar(); + if f.itemized { + match f.index.get(&key(&v)) { + Some(i) => { + let _ = write!(s, ""); + } + None => s.push_str(&item_xml(&v, wb.date_system, false)), + } + } else { + s.push_str(&item_xml(&v, wb.date_system, f.group.is_some())); + } + } + s.push_str(""); + } + Some((rows.len() as u64, s)) + } else { + None + }; + // Calculated fields come after the database and grouping fields. + for cf in &pt.calculated_fields { + let f = cf.formula.trim(); + let f = f.strip_prefix('=').unwrap_or(f).trim(); + if cf.name.trim().is_empty() || f.is_empty() || fields.iter().any(|x| same(&x.name, &cf.name)) { + continue; + } + fields.push(CField { + name: cf.name.clone(), + col: None, + stats: Stats::default(), + items: vec![], + index: HashMap::new(), + itemized: false, + group: None, + formula: Some(f.to_string()), + }); + } + Some(Cache { fields, axis_map, source_xml, records }) +} + +fn group_items(sys: DateSystem, by: PivotDateGroup, start: f64, end: f64) -> Vec { + let mut v = vec![format!("<{}", mdy(sys, start))]; + match by { + PivotDateGroup::Years => { + let y0 = datetime_from_serial(sys, start).map(|d| d.year).unwrap_or(1900); + let y1 = datetime_from_serial(sys, end - 1.0).map(|d| d.year).unwrap_or(y0).max(y0); + for y in y0..=y1.min(y0 + 10_000) { + v.push(y.to_string()); + } + } + PivotDateGroup::Quarters => v.extend((1..=4).map(|q| format!("Qtr{q}"))), + PivotDateGroup::Months => v.extend(MONTHS.iter().map(ToString::to_string)), + PivotDateGroup::Days | PivotDateGroup::None => { + const DAYS: [u32; 12] = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + for (m, n) in MONTHS.iter().zip(DAYS) { + for d in 1..=n { + v.push(format!("{d}-{m}")); + } + } + } + } + v.push(format!(">{}", mdy(sys, end))); + v +} + +/// A cache value element (``, ``, ``…); `date` writes numbers as ``. +fn item_xml(v: &Value, sys: DateSystem, date: bool) -> String { + match v { + Value::Empty => "".into(), + Value::Number(n) => { + if date && let Some(d) = iso(sys, *n) { + return format!(""); + } + format!("", crate::xml::num(*n)) + } + Value::Text(t) => format!("", esc_attr(t)), + Value::Bool(b) => format!("", u8::from(*b)), + Value::Error(e) => format!("", esc_attr(e.as_str())), + Value::Array(_) => item_xml(&v.scalar(), sys, date), + } +} + +fn cache_xml(c: &Cache, records_rid: Option<&str>, sys: DateSystem) -> String { + let mut s = format!("{XML_DECL} { + let _ = write!(s, " r:id=\"{rid}\" recordCount=\"{count}\""); + } + _ => s.push_str(" saveData=\"0\""), + } + s.push_str(" refreshOnLoad=\"1\" refreshedBy=\"GridCraft\" createdVersion=\"6\" refreshedVersion=\"6\" minRefreshableVersion=\"3\">"); + let _ = write!(s, "{}", c.source_xml); + let _ = write!(s, "", c.fields.len()); + for f in &c.fields { + let fmt = if f.group.is_some() { 14 } else { 0 }; + let _ = write!(s, "'); + let st = &f.stats; + if f.col.is_some() { + s.push_str(" 1 { + s.push_str(" containsMixedTypes=\"1\""); + } + if st.number { + s.push_str(" containsNumber=\"1\""); + if !st.non_int { + s.push_str(" containsInteger=\"1\""); + } + let _ = write!(s, " minValue=\"{}\" maxValue=\"{}\"", crate::xml::num(st.min), crate::xml::num(st.max)); + } + if st.long { + s.push_str(" longText=\"1\""); + } + } + if f.itemized { + let _ = write!(s, " count=\"{}\">", f.items.len()); + for v in &f.items { + s.push_str(&item_xml(v, sys, false)); + } + s.push_str(""); + } else { + s.push_str("/>"); + } + } + if let Some(g) = &f.group { + s.push_str("", g.base); + let _ = write!(s, "", g.items.len()); + for it in &g.items { + let _ = write!(s, "", esc_attr(it)); + } + s.push_str(""); + } + s.push_str(""); + } + s.push_str(""); + s +} + +fn table_xml(pt: &PivotTable, idx: usize, cache_id: u32, c: &Cache, fmt_id: &dyn Fn(&str) -> Option) -> String { + let nf = c.fields.len(); + let find_db = |name: &str| c.fields.iter().position(|f| f.col.is_some() && same(&f.name, name)); + let row_idx: Vec = c.axis_map.iter().take(pt.rows.len()).filter_map(|x| *x).collect(); + let col_idx: Vec = c.axis_map.iter().skip(pt.rows.len()).filter_map(|x| *x).collect(); + // Filters: on an axis field → hidden items there; otherwise a page field. + let mut hidden: HashMap = HashMap::new(); + let mut pages: Vec<(usize, &PivotFilter)> = Vec::new(); + for f in &pt.filters { + let axis_pos = pt.rows.iter().chain(pt.columns.iter()).position(|a| same(&a.source_col, &f.source_col)); + match axis_pos { + Some(p) => { + if let Some(Some(i)) = c.axis_map.get(p) { + hidden.insert(*i, f); + } + } + None => { + if let Some(i) = find_db(&f.source_col) + && !pages.iter().any(|(j, _)| *j == i) + { + pages.push((i, f)); + hidden.insert(i, f); + } + } + } + } + let find_calc = |name: &str| c.fields.iter().position(|f| f.formula.is_some() && same(&f.name, name)); + let vals: Vec<(usize, &PivotValue)> = + pt.values.iter().filter_map(|v| find_db(&v.source_col).or_else(|| find_calc(&v.source_col)).map(|i| (i, v))).collect(); + let axis_field = |i: usize| -> Option<&PivotField> { + let p = c.axis_map.iter().position(|x| *x == Some(i))?; + pt.rows.iter().chain(pt.columns.iter()).nth(p) + }; + + let compact = pt.layout == PivotLayout::Compact; + let name = if pt.name.trim().is_empty() { format!("PivotTable{}", idx + 1) } else { pt.name.clone() }; + let mut s = format!( + "{XML_DECL} s.push_str(" outline=\"1\" outlineData=\"1\""), + PivotLayout::Outline => s.push_str(" compact=\"0\" compactData=\"0\" outline=\"1\" outlineData=\"1\""), + PivotLayout::Tabular => s.push_str(" compact=\"0\" compactData=\"0\""), + } + if pt.values_on_rows { + s.push_str(" dataOnRows=\"1\""); + } + 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 { + s.push_str(" rowGrandTotals=\"0\""); + } + if !pt.grand_totals_rows { + s.push_str(" colGrandTotals=\"0\""); + } + s.push_str(" multipleFieldFilters=\"0\">"); + + // Location: the body (report filters sit above it). + let a = pt.anchor; + let end = pt.last_range.map(|r| r.end).unwrap_or(a); + let loc = RangeRef::new(a, CellRef::new(end.row.max(a.row), end.col.max(a.col))); + let k = vals.len(); + // Σ Values (two or more value fields): its index among the written fields of its axis. + let (row_map, _) = c.axis_map.split_at(pt.rows.len().min(c.axis_map.len())); + let values_at = |map: &[Option], n: usize| pt.values_position.map_or(n, |p| map.iter().take(p).filter(|x| x.is_some()).count()); + let row_values = (k > 1 && pt.values_on_rows).then(|| values_at(row_map, row_idx.len())); + // In Columns Σ Values comes after the last field: the column layout doesn't place it elsewhere. + let col_values = (k > 1 && !pt.values_on_rows).then_some(col_idx.len()); + let first_data_row = if col_idx.is_empty() { 1 } else { 1 + col_idx.len() + usize::from(col_values.is_some()) }; + let row_levels = row_idx.len() + usize::from(row_values.is_some()); + let first_data_col = if row_levels == 0 && col_idx.is_empty() { + 0 + } else if compact { + 1 + } else { + row_levels.max(1) + }; + // Two or more value fields in Columns alone: Excel 365 would add a `Values` caption row. + let hide_values_row = col_values.is_some() && col_idx.is_empty(); + let first_header_row = usize::from(!hide_values_row); + let _ = write!( + s, + ""); + + // Fields. + let _ = write!(s, ""); + for (i, f) in c.fields.iter().enumerate() { + s.push_str(" {} + PivotLayout::Outline => s.push_str(" compact=\"0\""), + PivotLayout::Tabular => s.push_str(" compact=\"0\" outline=\"0\""), + } + if f.group.is_some() { + s.push_str(" numFmtId=\"14\""); + } + let filter = hidden.get(&i).copied(); + let labels = f.item_labels(); + let is_page = axis == Some("axisPage"); + // Hidden items; never hide every item (a field needs one visible item). + let mut hide: Vec = match filter.and_then(|f| f.selected.as_ref()) { + Some(sel) if !(is_page && sel.len() == 1) => labels.iter().map(|l| !sel.iter().any(|x| same(x, l))).collect(), + _ => vec![false; labels.len()], + }; + if !hide.is_empty() && hide.iter().all(|h| *h) { + hide.iter_mut().for_each(|h| *h = false); + } + if is_page && hide.iter().any(|h| *h) { + s.push_str(" multipleItemSelectionAllowed=\"1\""); + } + s.push_str(" showAll=\"0\""); + if let Some(af) = axis_field(i) { + match af.sort { + PivotSort::Asc => s.push_str(" sortType=\"ascending\""), + PivotSort::Desc => s.push_str(" sortType=\"descending\""), + PivotSort::None => {} + } + } + if !pt.subtotals { + s.push_str(" defaultSubtotal=\"0\""); + } + if !pt.subtotals_top { + s.push_str(" subtotalTop=\"0\""); + } + if axis.is_some() { + let collapsed: &[String] = axis_field(i).map(|f| f.collapsed_items.as_slice()).unwrap_or(&[]); + let count = labels.len() + usize::from(pt.subtotals); + let _ = write!(s, ">"); + for (x, l) in labels.iter().enumerate() { + let _ = write!(s, ""); + } + if pt.subtotals { + s.push_str(""); + } + s.push_str(""); + } else { + s.push_str("/>"); + } + } + s.push_str(""); + + axis_fields_xml(&mut s, "rowFields", &row_idx, row_values); + axis_fields_xml(&mut s, "colFields", &col_idx, col_values); + if !pages.is_empty() { + let _ = write!(s, "", pages.len()); + for (i, f) in &pages { + let _ = write!(s, ""); + } + s.push_str(""); + } + if !vals.is_empty() { + let _ = write!(s, ""); + let mut used: Vec = Vec::new(); + for (i, v) in &vals { + let base = if v.name.trim().is_empty() { + format!("{} of {}", func_caption(v.func), c.fields.get(*i).map(|f| f.name.as_str()).unwrap_or("")) + } else { + v.name.clone() + }; + let mut n = base.clone(); + let mut j = 2; + while used.iter().any(|u| same(u, &n)) { + n = format!("{base}{j}"); + j += 1; + } + used.push(n.clone()); + let _ = write!(s, " s.push_str(" baseField=\"0\" baseItem=\"0\""), + PivotShowAs::PercentOfGrandTotal => s.push_str(" showDataAs=\"percentOfTotal\" baseField=\"0\" baseItem=\"0\""), + PivotShowAs::PercentOfColumnTotal => s.push_str(" showDataAs=\"percentOfCol\" baseField=\"0\" baseItem=\"0\""), + PivotShowAs::PercentOfRowTotal => s.push_str(" showDataAs=\"percentOfRow\" baseField=\"0\" baseItem=\"0\""), + PivotShowAs::RunningTotal => { + let _ = write!(s, " showDataAs=\"runTotal\" baseField=\"{base_field}\" baseItem=\"0\""); + } + PivotShowAs::Rank => { + let _ = write!(s, " baseField=\"{base_field}\" baseItem=\"0\""); + } + } + if let Some(id) = v.number_format.as_deref().and_then(fmt_id) { + let _ = write!(s, " numFmtId=\"{id}\""); + } + if v.show_as == PivotShowAs::Rank { + let _ = write!( + s, + ">" + ); + } else { + s.push_str("/>"); + } + } + s.push_str(""); + } + s.push_str(""); + if hide_values_row { + let _ = write!( + s, + "" + ); + } + s.push_str(""); + s +} + +/// ``/``: the axis's cache field indices, with Σ Values (`-2`) at `values_at`. +fn axis_fields_xml(s: &mut String, tag: &str, idx: &[usize], values_at: Option) { + let n = idx.len() + usize::from(values_at.is_some()); + if n == 0 { + return; + } + let _ = write!(s, "<{tag} count=\"{n}\">"); + for (j, i) in idx.iter().enumerate() { + if values_at == Some(j) { + s.push_str(""); + } + let _ = write!(s, ""); + } + if values_at.is_some_and(|p| p >= idx.len()) { + s.push_str(""); + } + let _ = write!(s, ""); +} diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index aadd1141e9..57b5ac7e31 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -20,6 +20,13 @@ use crate::styles::read_color; use crate::tables::{col_width_to_px, paper_name, pt_to_px}; use crate::xml::{self, El}; +mod parts; +mod rules; + +pub use parts::{read_autofilter, read_table}; +use parts::{read_comments, read_hyperlinks, read_note_shapes, read_threaded}; +use rules::{read_cf, read_validation}; + struct Shared { anchor: CellRef, expr: Option, @@ -615,452 +622,6 @@ pub fn parse_sqref(s: Option<&str>) -> Vec { s.unwrap_or("").split_whitespace().filter_map(RangeRef::parse).collect() } -fn cf_op(s: Option<&str>) -> CfOperator { - match s.unwrap_or("between") { - "notBetween" => CfOperator::NotBetween, - "equal" => CfOperator::Equal, - "notEqual" => CfOperator::NotEqual, - "greaterThan" => CfOperator::Greater, - "lessThan" => CfOperator::Less, - "greaterThanOrEqual" => CfOperator::GreaterOrEqual, - "lessThanOrEqual" => CfOperator::LessOrEqual, - _ => CfOperator::Between, - } -} - -fn cfvo(e: &El) -> CfValueKind { - let val = e.attr("val").unwrap_or(""); - let n = val.trim().parse::().ok().filter(|v| v.is_finite()); - match e.attr("type").unwrap_or("num") { - "min" | "autoMin" => CfValueKind::Min, - "max" | "autoMax" => CfValueKind::Max, - "percent" => n.map(CfValueKind::Percent).unwrap_or_else(|| CfValueKind::Formula(from_file(val))), - "percentile" => n.map(CfValueKind::Percentile).unwrap_or_else(|| CfValueKind::Formula(from_file(val))), - "formula" => CfValueKind::Formula(from_file(val)), - _ => n.map(CfValueKind::Number).unwrap_or_else(|| CfValueKind::Formula(from_file(val))), - } -} - -/// `cfvo` elements: plain children, or x14 ones with the value in an `xm:f` child. -fn cfvos(e: &El) -> Vec { - e.kids("cfvo") - .map(|c| { - if c.attr("val").is_none() - && let Some(f) = c.child("f") - { - let mut c2 = c.clone(); - c2.attrs.push(("val".into(), f.text.clone())); - return cfvo(&c2); - } - cfvo(c) - }) - .collect() -} - -fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { - let ranges = parse_sqref(e.attr("sqref")); - if ranges.is_empty() { - return; - } - for r in e.kids("cfRule") { - let style = Box::new(r.attr_u32("dxfId").and_then(|i| cx.dxfs.get(i as usize)).cloned().unwrap_or_default()); - let formulas: Vec = r.kids("formula").map(|f| from_file(f.text.trim())).collect(); - let f0 = formulas.first().cloned().unwrap_or_default(); - let text = r.attr("text").unwrap_or("").to_string(); - // The x14 id that links a data bar to its Excel 2010 extension (solid fill lives there). - let ext_id = - r.child("extLst").and_then(|l| l.kids("ext").find_map(|x| x.child("id"))).map(|i| i.text.trim().to_string()).filter(|i| !i.is_empty()); - let rule = match r.attr("type").unwrap_or("") { - "cellIs" => CfRule::CellIs { op: cf_op(r.attr("operator")), a: f0, b: formulas.get(1).cloned(), style }, - "expression" => CfRule::Expression { formula: f0, style }, - "containsText" => CfRule::ContainsText { text, style }, - "notContainsText" => CfRule::NotContainsText { text, style }, - "beginsWith" => CfRule::BeginsWith { text, style }, - "endsWith" => CfRule::EndsWith { text, style }, - "containsBlanks" => CfRule::Blanks { style }, - "notContainsBlanks" => CfRule::NoBlanks { style }, - "containsErrors" => CfRule::Errors { style }, - "notContainsErrors" => CfRule::NoErrors { style }, - "duplicateValues" => CfRule::Duplicate { style }, - "uniqueValues" => CfRule::Unique { style }, - "top10" => { - CfRule::Top10 { bottom: r.flag("bottom", false), percent: r.flag("percent", false), rank: r.attr_u32("rank").unwrap_or(10), style } - } - "aboveAverage" => CfRule::AboveAverage { - below: !r.flag("aboveAverage", true), - equal: r.flag("equalAverage", false), - std_dev: r.attr_u32("stdDev").unwrap_or(0).min(3) as u8, - style, - }, - "timePeriod" => CfRule::TimePeriod { period: r.attr("timePeriod").unwrap_or("today").to_string(), style }, - "colorScale" => { - let Some(cs) = r.child("colorScale") else { continue }; - let colors = cs.kids("color").map(|c| read_color(c, &cx.palette)); - CfRule::ColorScale { stops: cfvos(cs).into_iter().zip(colors).collect() } - } - "dataBar" => { - let Some(db) = r.child("dataBar") else { continue }; - let v = cfvos(db); - CfRule::DataBar { - min: v.first().cloned().unwrap_or(CfValueKind::Min), - max: v.get(1).cloned().unwrap_or(CfValueKind::Max), - color: db.child("color").map(|c| read_color(c, &cx.palette)).unwrap_or_default(), - gradient: true, - show_value: db.flag("showValue", true), - } - } - "iconSet" => { - let Some(is) = r.child("iconSet") else { continue }; - CfRule::IconSet { - set: is.attr("iconSet").unwrap_or("3TrafficLights1").to_string(), - thresholds: cfvos(is), - reverse: is.flag("reverse", false), - show_value: is.flag("showValue", true), - } - } - other => { - cx.warn(format!("conditional format type \"{other}\" is not supported")); - continue; - } - }; - 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::().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, - priority: r.attr_u32("priority").unwrap_or(0), - stop_if_true: r.flag("stopIfTrue", false), - }); - } -} - -/// A `dataValidation` (main namespace, or x14 with `xm:f` formulas and an `xm:sqref` child). -fn read_validation(v: &El) -> Option { - let sq = v.attr("sqref").map(str::to_string).or_else(|| v.child("sqref").map(|s| s.text.clone())); - let ranges = parse_sqref(sq.as_deref()); - if ranges.is_empty() { - return None; - } - let formula = |n: &str| -> Option { - let f = v.child(n)?; - let t = f.child("f").map(|x| x.text.as_str()).unwrap_or(f.text.as_str()).trim(); - if t.is_empty() { None } else { Some(t.to_string()) } - }; - let kind = match v.attr("type").unwrap_or("none") { - "whole" => ValidationKind::Whole, - "decimal" => ValidationKind::Decimal, - "list" => ValidationKind::List, - "date" => ValidationKind::Date, - "time" => ValidationKind::Time, - "textLength" => ValidationKind::TextLength, - "custom" => ValidationKind::Custom, - _ => ValidationKind::Any, - }; - let mut f1 = formula("formula1").unwrap_or_default(); - if kind == ValidationKind::List && !f1.is_empty() && !f1.starts_with('"') { - f1 = format!("={}", from_file(&f1)); - } else if !f1.starts_with('"') && !f1.is_empty() { - f1 = from_file(&f1); - } - Some(Validation { - ranges, - kind, - op: cf_op(v.attr("operator")), - f1, - f2: formula("formula2").map(|f| from_file(&f)), - allow_blank: v.flag("allowBlank", false), - // `showDropDown="1"` *hides* the in-cell list arrow. - in_cell_dropdown: !v.flag("showDropDown", false), - input_title: v.attr("promptTitle").unwrap_or("").to_string(), - input_message: v.attr("prompt").unwrap_or("").to_string(), - show_input: v.flag("showInputMessage", false), - error_title: v.attr("errorTitle").unwrap_or("").to_string(), - error_message: v.attr("error").unwrap_or("").to_string(), - error_style: match v.attr("errorStyle") { - Some("warning") => ErrorStyle::Warning, - Some("information") => ErrorStyle::Information, - _ => ErrorStyle::Stop, - }, - show_error: v.flag("showErrorMessage", false), - }) -} - -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(); - // A relationship is a file or web page; a bare `location` is a place in this workbook. - let external = !target.is_empty(); - if let Some(loc) = h.attr("location").filter(|l| !l.is_empty()) { - if target.is_empty() { - target = loc.trim_start_matches('#').to_string(); - } else { - target = format!("{target}#{loc}"); - } - } - if target.is_empty() { - continue; - } - 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()); - } - } -} - -fn filter_op(s: Option<&str>) -> &'static str { - match s.unwrap_or("equal") { - "lessThan" => "<", - "lessThanOrEqual" => "<=", - "notEqual" => "<>", - "greaterThanOrEqual" => ">=", - "greaterThan" => ">", - _ => "=", - } -} - -pub fn read_autofilter(cx: &mut Ctx<'_>, e: &El) -> Option { - let range = e.attr("ref").and_then(RangeRef::parse)?; - let mut criteria = vec![]; - for fc in e.kids("filterColumn") { - let Some(col) = fc.attr_u32("colId") else { continue }; - let crit = if let Some(f) = fc.child("filters") { - FilterCriterion::Values { - values: f.kids("filter").filter_map(|x| x.attr("val")).map(str::to_string).collect(), - blanks: f.flag("blank", false), - } - } else if let Some(cf) = fc.child("customFilters") { - let mut it = cf.kids("customFilter").map(|c| (filter_op(c.attr("operator")).to_string(), c.attr("val").unwrap_or("").to_string())); - let Some(a) = it.next() else { continue }; - FilterCriterion::Custom { a, b: it.next(), and: cf.flag("and", false) } - } else if let Some(t) = fc.child("top10") { - FilterCriterion::Top10 { - bottom: !t.flag("top", true), - percent: t.flag("percent", false), - count: t.attr_f64("val").unwrap_or(10.0).clamp(0.0, 1e9) as u32, - } - } else if let Some(d) = fc.child("dynamicFilter") { - match d.attr("type") { - Some("aboveAverage") => FilterCriterion::AboveAverage(true), - Some("belowAverage") => FilterCriterion::AboveAverage(false), - _ => { - cx.warn("date filters in AutoFilters are not supported"); - continue; - } - } - } else if let Some(c) = fc.child("colorFilter") { - let Some(dxf) = c.attr_u32("dxfId").and_then(|i| cx.dxfs.get(i as usize)) else { continue }; - if c.flag("cellColor", true) { FilterCriterion::FillColor(dxf.fill.fg) } else { FilterCriterion::FontColor(dxf.font.color) } - } else if let Some(i) = fc.child("iconFilter") { - let Some(set) = i.attr("iconSet") else { continue }; - FilterCriterion::Icon { set: set.to_string(), id: i.attr_u32("iconId") } - } else { - continue; - }; - criteria.push((col, crit)); - } - Some(AutoFilter { range, criteria }) -} - -pub fn read_table(cx: &mut Ctx<'_>, x: &El) -> Option
"), HAlign::General), + ("B2", Value::Number(3.0), HAlign::General), + ("A3", Value::text("Total"), HAlign::Right), + ], + ); + wb.sheet_mut(0).unwrap().merges.push(RangeRef::parse("A3:B3").unwrap()); + assert_eq!( + to_html(&wb, 0).unwrap(), + "Sheet1\ + \ + \ + \ +
NameQty
Apples & <pears>3
Total
" + ); + let cell = "border:1px solid #d4d4d4;padding:2px 4px;white-space:pre-wrap;"; + assert_eq!( + copied(&wb, "A1:B2"), + format!( + "\ + \ +
NameQty
Apples & <pears>3
" + ) + ); +} + +#[test] +fn html_export_fails_instead_of_dropping_cells_past_the_row_limit() { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::text("near")); + sh.set_value(CellRef::new(149_999, 0), gridcraft_core::Value::text("far")); + assert!(save_bytes(&wb, "book.html").is_err()); + let sh = wb.sheet_mut(0).unwrap(); + sh.remove_cell(CellRef::new(149_999, 0)); + sh.set_value(CellRef::new(100_000, 0), gridcraft_core::Value::text("far")); + let html = String::from_utf8(save_bytes(&wb, "book.html").unwrap()).unwrap(); + assert!(html.contains(">near
{ - let range = x.attr("ref").and_then(RangeRef::parse)?; - let name = x.attr("displayName").or_else(|| x.attr("name")).unwrap_or("Table").to_string(); - let header_row = x.attr_u32("headerRowCount").unwrap_or(1) > 0; - let totals_row = x.attr_u32("totalsRowCount").unwrap_or(0) > 0; - let columns = x - .child("tableColumns") - .map(|tc| { - tc.kids("tableColumn") - .map(|c| { - let totals = match c.attr("totalsRowFunction").unwrap_or("none") { - "average" => TotalsFn::Average, - "count" => TotalsFn::Count, - "countNums" => TotalsFn::CountNums, - "max" => TotalsFn::Max, - "min" => TotalsFn::Min, - "sum" => TotalsFn::Sum, - "stdDev" => TotalsFn::StdDev, - "var" => TotalsFn::Var, - "custom" => TotalsFn::Custom, - _ => TotalsFn::None, - }; - let totals_label = if totals == TotalsFn::Custom { - c.child("totalsRowFormula").map(|f| from_file(f.text.trim())) - } else { - c.attr("totalsRowLabel").map(str::to_string) - }; - TableColumn { - name: xml::decode_xstring(c.attr("name").unwrap_or("")), - totals, - totals_label, - formula: c.child("calculatedColumnFormula").map(|f| from_file(f.text.trim())), - unique_name: c.attr("uniqueName").map(str::to_string), - xml: c.child("xmlColumnPr").and_then(|x| { - Some(XmlColumnPr { - map_id: x.attr_u32("mapId")?, - xpath: x.attr("xpath")?.to_string(), - data_type: x.attr("xmlDataType").unwrap_or("string").to_string(), - denormalized: x.flag("denormalized", false), - }) - }), - } - }) - .collect() - }) - .unwrap_or_default(); - let si = x.child("tableStyleInfo"); - // Only XML tables are kept as such: a query table's definition hangs off the table part, which - // is rewritten, so it would lose it. - let xml_table = x.attr("tableType") == Some("xml"); - Some(Table { - id: x.attr_u32("id").unwrap_or(0), - name, - range, - header_row, - totals_row, - columns, - style: si.and_then(|s| s.attr("name")).unwrap_or("").to_string(), - banded_rows: si.is_some_and(|s| s.flag("showRowStripes", false)), - banded_cols: si.is_some_and(|s| s.flag("showColumnStripes", false)), - first_col: si.is_some_and(|s| s.flag("showFirstColumn", false)), - last_col: si.is_some_and(|s| s.flag("showLastColumn", false)), - filter_button: x.child("autoFilter").is_some(), - filter: x.child("autoFilter").and_then(|e| read_autofilter(cx, e)).map(|af| af.criteria).unwrap_or_default(), - table_type: xml_table.then(|| "xml".to_string()), - connection_id: x.attr_u32("connectionId").filter(|_| xml_table), - }) -} - -fn read_comments(x: &El, sheet: &mut Sheet) { - let authors: Vec = x.child("authors").map(|a| a.kids("author").map(|a| a.text.clone()).collect()).unwrap_or_default(); - let Some(list) = x.child("commentList") else { return }; - for c in list.kids("comment") { - let Some(cell) = c.attr("ref").and_then(CellRef::parse) else { continue }; - let author = c.attr_u32("authorId").and_then(|i| authors.get(i as usize)).cloned().unwrap_or_default(); - let mut text = c.child("text").map(xml::rich_text).unwrap_or_default(); - // Excel starts a note with a bold "Author:" line. - if !author.is_empty() - && let Some(rest) = text.strip_prefix(&format!("{author}:")) - { - text = rest.strip_prefix('\n').unwrap_or(rest).to_string(); - } - sheet - .comments - .insert(cell, Comment { author, text, replies: vec![], threaded: false, resolved: false, visible: false, dates: vec![], note_box: None }); - } -} - -/// Reads the notes' VML shapes: a shape carrying `` marks its note as shown, and its -/// `` and style size become the note's box. Shapes may sit in groups. -fn read_note_shapes(x: &El, sheet: &mut Sheet, depth: usize) { - if depth > xml::MAX_DEPTH { - return; - } - for e in &x.children { - if e.name != "shape" { - read_note_shapes(e, sheet, depth + 1); - continue; - } - let Some(cd) = e.child("ClientData").filter(|cd| cd.attr("ObjectType") == Some("Note")) else { continue }; - let num = |k: &str| cd.child(k).and_then(|v| v.text.trim().parse::().ok()); - let (Some(row), Some(col)) = (num("Row"), num("Column")) else { continue }; - let cell = CellRef::new(row, col); - let Some(c) = sheet.comments.get_mut(&cell) else { continue }; - if cd.child("Visible").is_some() && !c.threaded { - c.visible = true; - } - if let Some(anchor) = cd.child("Anchor") { - c.note_box = note_box(&anchor.text, e.attr("style").unwrap_or(""), cell).filter(|b| *b != crate::sheet_write::DEFAULT_NOTE_BOX); - } - } -} - -/// A note's box from its VML `` (`LeftColumn, LeftOffset, TopRow, TopOffset, -/// RightColumn, RightOffset, BottomRow, BottomOffset`; offsets in pixels) and its shape's style, -/// the corners made relative to the note's `cell`. `None` when the anchor isn't eight in-sheet -/// numbers. -fn note_box(anchor: &str, style: &str, cell: CellRef) -> Option { - let v: Vec = anchor.split(',').map(|p| p.trim().parse::().ok()).collect::>()?; - let [c1, dx1, r1, dy1, c2, dx2, r2, dy2] = <[u32; 8]>::try_from(v).ok()?; - if c1.max(c2) >= MAX_COLS || r1.max(r2) >= MAX_ROWS { - return None; - } - let corner = |c: u32, dx: u32, r: u32, dy: u32| NoteCorner { - col: i64::from(c) - i64::from(cell.col), - col_px: dx, - row: i64::from(r) - i64::from(cell.row), - row_px: dy, - }; - let pt = |key: &str| { - style - .split(';') - .filter_map(|d| d.split_once(':')) - .find(|(k, _)| k.trim().eq_ignore_ascii_case(key)) - .and_then(|(_, v)| v.trim().strip_suffix("pt")?.trim().parse::().ok().filter(|n| n.is_finite() && *n > 0.0 && *n < 1e6)) - }; - Some(NoteBox { from: corner(c1, dx1, r1, dy1), to: corner(c2, dx2, r2, dy2), size_pt: pt("width").zip(pt("height")) }) -} - -fn read_threaded(cx: &Ctx<'_>, x: &El, sheet: &mut Sheet) { - let mut by_id: HashMap = HashMap::new(); - let mut replies: Vec<(String, String, String, String)> = vec![]; - for t in x.kids("threadedComment") { - let Some(cell) = t.attr("ref").and_then(CellRef::parse) else { continue }; - let author = t.attr("personId").and_then(|p| cx.persons.get(p)).cloned().unwrap_or_default(); - let text = t.child("text").map(|e| e.text.clone()).unwrap_or_default(); - let date = t.attr("dT").unwrap_or("").to_string(); - match t.attr("parentId") { - Some(p) => replies.push((p.to_string(), author, text, date)), - None => { - if let Some(id) = t.attr("id") { - by_id.insert(id.to_string(), cell); - } - let dates = if date.is_empty() { vec![] } else { vec![date] }; - sheet.comments.insert( - cell, - Comment { author, text, replies: vec![], threaded: true, resolved: t.flag("done", false), visible: false, dates, note_box: None }, - ); - } - } - } - // Replies in file order; ones whose thread is missing are dropped. - for (parent, author, text, date) in replies { - if let Some(c) = by_id.get(&parent) - && let Some(cm) = sheet.comments.get_mut(c) - { - if !date.is_empty() { - // `dates[0]` is the thread, `dates[i + 1]` reply i; unknown dates stay empty. - let at = cm.replies.len() + 1; - if cm.dates.len() < at { - cm.dates.resize(at, String::new()); - } - cm.dates.push(date); - } - cm.replies.push((author, text)); - } - } -} - /// Worksheet extensions: x14 data validations and sparklines. fn read_ext(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { for ext in e.kids("ext") { diff --git a/crates/xlsx/src/sheet_read/parts.rs b/crates/xlsx/src/sheet_read/parts.rs new file mode 100644 index 0000000000..c7626e6e9e --- /dev/null +++ b/crates/xlsx/src/sheet_read/parts.rs @@ -0,0 +1,270 @@ +use super::*; + +pub(super) 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(); + // A relationship is a file or web page; a bare `location` is a place in this workbook. + let external = !target.is_empty(); + if let Some(loc) = h.attr("location").filter(|l| !l.is_empty()) { + if target.is_empty() { + target = loc.trim_start_matches('#').to_string(); + } else { + target = format!("{target}#{loc}"); + } + } + if target.is_empty() { + continue; + } + 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()); + } + } +} + +fn filter_op(s: Option<&str>) -> &'static str { + match s.unwrap_or("equal") { + "lessThan" => "<", + "lessThanOrEqual" => "<=", + "notEqual" => "<>", + "greaterThanOrEqual" => ">=", + "greaterThan" => ">", + _ => "=", + } +} + +pub fn read_autofilter(cx: &mut Ctx<'_>, e: &El) -> Option { + let range = e.attr("ref").and_then(RangeRef::parse)?; + let mut criteria = vec![]; + for fc in e.kids("filterColumn") { + let Some(col) = fc.attr_u32("colId") else { continue }; + let crit = if let Some(f) = fc.child("filters") { + FilterCriterion::Values { + values: f.kids("filter").filter_map(|x| x.attr("val")).map(str::to_string).collect(), + blanks: f.flag("blank", false), + } + } else if let Some(cf) = fc.child("customFilters") { + let mut it = cf.kids("customFilter").map(|c| (filter_op(c.attr("operator")).to_string(), c.attr("val").unwrap_or("").to_string())); + let Some(a) = it.next() else { continue }; + FilterCriterion::Custom { a, b: it.next(), and: cf.flag("and", false) } + } else if let Some(t) = fc.child("top10") { + FilterCriterion::Top10 { + bottom: !t.flag("top", true), + percent: t.flag("percent", false), + count: t.attr_f64("val").unwrap_or(10.0).clamp(0.0, 1e9) as u32, + } + } else if let Some(d) = fc.child("dynamicFilter") { + match d.attr("type") { + Some("aboveAverage") => FilterCriterion::AboveAverage(true), + Some("belowAverage") => FilterCriterion::AboveAverage(false), + _ => { + cx.warn("date filters in AutoFilters are not supported"); + continue; + } + } + } else if let Some(c) = fc.child("colorFilter") { + let Some(dxf) = c.attr_u32("dxfId").and_then(|i| cx.dxfs.get(i as usize)) else { continue }; + if c.flag("cellColor", true) { FilterCriterion::FillColor(dxf.fill.fg) } else { FilterCriterion::FontColor(dxf.font.color) } + } else if let Some(i) = fc.child("iconFilter") { + let Some(set) = i.attr("iconSet") else { continue }; + FilterCriterion::Icon { set: set.to_string(), id: i.attr_u32("iconId") } + } else { + continue; + }; + criteria.push((col, crit)); + } + Some(AutoFilter { range, criteria }) +} + +pub fn read_table(cx: &mut Ctx<'_>, x: &El) -> Option
{ + let range = x.attr("ref").and_then(RangeRef::parse)?; + let name = x.attr("displayName").or_else(|| x.attr("name")).unwrap_or("Table").to_string(); + let header_row = x.attr_u32("headerRowCount").unwrap_or(1) > 0; + let totals_row = x.attr_u32("totalsRowCount").unwrap_or(0) > 0; + let columns = x + .child("tableColumns") + .map(|tc| { + tc.kids("tableColumn") + .map(|c| { + let totals = match c.attr("totalsRowFunction").unwrap_or("none") { + "average" => TotalsFn::Average, + "count" => TotalsFn::Count, + "countNums" => TotalsFn::CountNums, + "max" => TotalsFn::Max, + "min" => TotalsFn::Min, + "sum" => TotalsFn::Sum, + "stdDev" => TotalsFn::StdDev, + "var" => TotalsFn::Var, + "custom" => TotalsFn::Custom, + _ => TotalsFn::None, + }; + let totals_label = if totals == TotalsFn::Custom { + c.child("totalsRowFormula").map(|f| from_file(f.text.trim())) + } else { + c.attr("totalsRowLabel").map(str::to_string) + }; + TableColumn { + name: xml::decode_xstring(c.attr("name").unwrap_or("")), + totals, + totals_label, + formula: c.child("calculatedColumnFormula").map(|f| from_file(f.text.trim())), + unique_name: c.attr("uniqueName").map(str::to_string), + xml: c.child("xmlColumnPr").and_then(|x| { + Some(XmlColumnPr { + map_id: x.attr_u32("mapId")?, + xpath: x.attr("xpath")?.to_string(), + data_type: x.attr("xmlDataType").unwrap_or("string").to_string(), + denormalized: x.flag("denormalized", false), + }) + }), + } + }) + .collect() + }) + .unwrap_or_default(); + let si = x.child("tableStyleInfo"); + // Only XML tables are kept as such: a query table's definition hangs off the table part, which + // is rewritten, so it would lose it. + let xml_table = x.attr("tableType") == Some("xml"); + Some(Table { + id: x.attr_u32("id").unwrap_or(0), + name, + range, + header_row, + totals_row, + columns, + style: si.and_then(|s| s.attr("name")).unwrap_or("").to_string(), + banded_rows: si.is_some_and(|s| s.flag("showRowStripes", false)), + banded_cols: si.is_some_and(|s| s.flag("showColumnStripes", false)), + first_col: si.is_some_and(|s| s.flag("showFirstColumn", false)), + last_col: si.is_some_and(|s| s.flag("showLastColumn", false)), + filter_button: x.child("autoFilter").is_some(), + filter: x.child("autoFilter").and_then(|e| read_autofilter(cx, e)).map(|af| af.criteria).unwrap_or_default(), + table_type: xml_table.then(|| "xml".to_string()), + connection_id: x.attr_u32("connectionId").filter(|_| xml_table), + }) +} + +pub(super) fn read_comments(x: &El, sheet: &mut Sheet) { + let authors: Vec = x.child("authors").map(|a| a.kids("author").map(|a| a.text.clone()).collect()).unwrap_or_default(); + let Some(list) = x.child("commentList") else { return }; + for c in list.kids("comment") { + let Some(cell) = c.attr("ref").and_then(CellRef::parse) else { continue }; + let author = c.attr_u32("authorId").and_then(|i| authors.get(i as usize)).cloned().unwrap_or_default(); + let mut text = c.child("text").map(xml::rich_text).unwrap_or_default(); + // Excel starts a note with a bold "Author:" line. + if !author.is_empty() + && let Some(rest) = text.strip_prefix(&format!("{author}:")) + { + text = rest.strip_prefix('\n').unwrap_or(rest).to_string(); + } + sheet + .comments + .insert(cell, Comment { author, text, replies: vec![], threaded: false, resolved: false, visible: false, dates: vec![], note_box: None }); + } +} + +/// Reads the notes' VML shapes: a shape carrying `` marks its note as shown, and its +/// `` and style size become the note's box. Shapes may sit in groups. +pub(super) fn read_note_shapes(x: &El, sheet: &mut Sheet, depth: usize) { + if depth > xml::MAX_DEPTH { + return; + } + for e in &x.children { + if e.name != "shape" { + read_note_shapes(e, sheet, depth + 1); + continue; + } + let Some(cd) = e.child("ClientData").filter(|cd| cd.attr("ObjectType") == Some("Note")) else { continue }; + let num = |k: &str| cd.child(k).and_then(|v| v.text.trim().parse::().ok()); + let (Some(row), Some(col)) = (num("Row"), num("Column")) else { continue }; + let cell = CellRef::new(row, col); + let Some(c) = sheet.comments.get_mut(&cell) else { continue }; + if cd.child("Visible").is_some() && !c.threaded { + c.visible = true; + } + if let Some(anchor) = cd.child("Anchor") { + c.note_box = note_box(&anchor.text, e.attr("style").unwrap_or(""), cell).filter(|b| *b != crate::sheet_write::DEFAULT_NOTE_BOX); + } + } +} + +/// A note's box from its VML `` (`LeftColumn, LeftOffset, TopRow, TopOffset, +/// RightColumn, RightOffset, BottomRow, BottomOffset`; offsets in pixels) and its shape's style, +/// the corners made relative to the note's `cell`. `None` when the anchor isn't eight in-sheet +/// numbers. +fn note_box(anchor: &str, style: &str, cell: CellRef) -> Option { + let v: Vec = anchor.split(',').map(|p| p.trim().parse::().ok()).collect::>()?; + let [c1, dx1, r1, dy1, c2, dx2, r2, dy2] = <[u32; 8]>::try_from(v).ok()?; + if c1.max(c2) >= MAX_COLS || r1.max(r2) >= MAX_ROWS { + return None; + } + let corner = |c: u32, dx: u32, r: u32, dy: u32| NoteCorner { + col: i64::from(c) - i64::from(cell.col), + col_px: dx, + row: i64::from(r) - i64::from(cell.row), + row_px: dy, + }; + let pt = |key: &str| { + style + .split(';') + .filter_map(|d| d.split_once(':')) + .find(|(k, _)| k.trim().eq_ignore_ascii_case(key)) + .and_then(|(_, v)| v.trim().strip_suffix("pt")?.trim().parse::().ok().filter(|n| n.is_finite() && *n > 0.0 && *n < 1e6)) + }; + Some(NoteBox { from: corner(c1, dx1, r1, dy1), to: corner(c2, dx2, r2, dy2), size_pt: pt("width").zip(pt("height")) }) +} + +pub(super) fn read_threaded(cx: &Ctx<'_>, x: &El, sheet: &mut Sheet) { + let mut by_id: HashMap = HashMap::new(); + let mut replies: Vec<(String, String, String, String)> = vec![]; + for t in x.kids("threadedComment") { + let Some(cell) = t.attr("ref").and_then(CellRef::parse) else { continue }; + let author = t.attr("personId").and_then(|p| cx.persons.get(p)).cloned().unwrap_or_default(); + let text = t.child("text").map(|e| e.text.clone()).unwrap_or_default(); + let date = t.attr("dT").unwrap_or("").to_string(); + match t.attr("parentId") { + Some(p) => replies.push((p.to_string(), author, text, date)), + None => { + if let Some(id) = t.attr("id") { + by_id.insert(id.to_string(), cell); + } + let dates = if date.is_empty() { vec![] } else { vec![date] }; + sheet.comments.insert( + cell, + Comment { author, text, replies: vec![], threaded: true, resolved: t.flag("done", false), visible: false, dates, note_box: None }, + ); + } + } + } + // Replies in file order; ones whose thread is missing are dropped. + for (parent, author, text, date) in replies { + if let Some(c) = by_id.get(&parent) + && let Some(cm) = sheet.comments.get_mut(c) + { + if !date.is_empty() { + // `dates[0]` is the thread, `dates[i + 1]` reply i; unknown dates stay empty. + let at = cm.replies.len() + 1; + if cm.dates.len() < at { + cm.dates.resize(at, String::new()); + } + cm.dates.push(date); + } + cm.replies.push((author, text)); + } + } +} diff --git a/crates/xlsx/src/sheet_read/rules.rs b/crates/xlsx/src/sheet_read/rules.rs new file mode 100644 index 0000000000..e0ad3129fe --- /dev/null +++ b/crates/xlsx/src/sheet_read/rules.rs @@ -0,0 +1,178 @@ +use super::*; + +fn cf_op(s: Option<&str>) -> CfOperator { + match s.unwrap_or("between") { + "notBetween" => CfOperator::NotBetween, + "equal" => CfOperator::Equal, + "notEqual" => CfOperator::NotEqual, + "greaterThan" => CfOperator::Greater, + "lessThan" => CfOperator::Less, + "greaterThanOrEqual" => CfOperator::GreaterOrEqual, + "lessThanOrEqual" => CfOperator::LessOrEqual, + _ => CfOperator::Between, + } +} + +fn cfvo(e: &El) -> CfValueKind { + let val = e.attr("val").unwrap_or(""); + let n = val.trim().parse::().ok().filter(|v| v.is_finite()); + match e.attr("type").unwrap_or("num") { + "min" | "autoMin" => CfValueKind::Min, + "max" | "autoMax" => CfValueKind::Max, + "percent" => n.map(CfValueKind::Percent).unwrap_or_else(|| CfValueKind::Formula(from_file(val))), + "percentile" => n.map(CfValueKind::Percentile).unwrap_or_else(|| CfValueKind::Formula(from_file(val))), + "formula" => CfValueKind::Formula(from_file(val)), + _ => n.map(CfValueKind::Number).unwrap_or_else(|| CfValueKind::Formula(from_file(val))), + } +} + +/// `cfvo` elements: plain children, or x14 ones with the value in an `xm:f` child. +fn cfvos(e: &El) -> Vec { + e.kids("cfvo") + .map(|c| { + if c.attr("val").is_none() + && let Some(f) = c.child("f") + { + let mut c2 = c.clone(); + c2.attrs.push(("val".into(), f.text.clone())); + return cfvo(&c2); + } + cfvo(c) + }) + .collect() +} + +pub(super) fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) { + let ranges = parse_sqref(e.attr("sqref")); + if ranges.is_empty() { + return; + } + for r in e.kids("cfRule") { + let style = Box::new(r.attr_u32("dxfId").and_then(|i| cx.dxfs.get(i as usize)).cloned().unwrap_or_default()); + let formulas: Vec = r.kids("formula").map(|f| from_file(f.text.trim())).collect(); + let f0 = formulas.first().cloned().unwrap_or_default(); + let text = r.attr("text").unwrap_or("").to_string(); + // The x14 id that links a data bar to its Excel 2010 extension (solid fill lives there). + let ext_id = + r.child("extLst").and_then(|l| l.kids("ext").find_map(|x| x.child("id"))).map(|i| i.text.trim().to_string()).filter(|i| !i.is_empty()); + let rule = match r.attr("type").unwrap_or("") { + "cellIs" => CfRule::CellIs { op: cf_op(r.attr("operator")), a: f0, b: formulas.get(1).cloned(), style }, + "expression" => CfRule::Expression { formula: f0, style }, + "containsText" => CfRule::ContainsText { text, style }, + "notContainsText" => CfRule::NotContainsText { text, style }, + "beginsWith" => CfRule::BeginsWith { text, style }, + "endsWith" => CfRule::EndsWith { text, style }, + "containsBlanks" => CfRule::Blanks { style }, + "notContainsBlanks" => CfRule::NoBlanks { style }, + "containsErrors" => CfRule::Errors { style }, + "notContainsErrors" => CfRule::NoErrors { style }, + "duplicateValues" => CfRule::Duplicate { style }, + "uniqueValues" => CfRule::Unique { style }, + "top10" => { + CfRule::Top10 { bottom: r.flag("bottom", false), percent: r.flag("percent", false), rank: r.attr_u32("rank").unwrap_or(10), style } + } + "aboveAverage" => CfRule::AboveAverage { + below: !r.flag("aboveAverage", true), + equal: r.flag("equalAverage", false), + std_dev: r.attr_u32("stdDev").unwrap_or(0).min(3) as u8, + style, + }, + "timePeriod" => CfRule::TimePeriod { period: r.attr("timePeriod").unwrap_or("today").to_string(), style }, + "colorScale" => { + let Some(cs) = r.child("colorScale") else { continue }; + let colors = cs.kids("color").map(|c| read_color(c, &cx.palette)); + CfRule::ColorScale { stops: cfvos(cs).into_iter().zip(colors).collect() } + } + "dataBar" => { + let Some(db) = r.child("dataBar") else { continue }; + let v = cfvos(db); + CfRule::DataBar { + min: v.first().cloned().unwrap_or(CfValueKind::Min), + max: v.get(1).cloned().unwrap_or(CfValueKind::Max), + color: db.child("color").map(|c| read_color(c, &cx.palette)).unwrap_or_default(), + gradient: true, + show_value: db.flag("showValue", true), + } + } + "iconSet" => { + let Some(is) = r.child("iconSet") else { continue }; + CfRule::IconSet { + set: is.attr("iconSet").unwrap_or("3TrafficLights1").to_string(), + thresholds: cfvos(is), + reverse: is.flag("reverse", false), + show_value: is.flag("showValue", true), + } + } + other => { + cx.warn(format!("conditional format type \"{other}\" is not supported")); + continue; + } + }; + 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::().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, + priority: r.attr_u32("priority").unwrap_or(0), + stop_if_true: r.flag("stopIfTrue", false), + }); + } +} + +/// A `dataValidation` (main namespace, or x14 with `xm:f` formulas and an `xm:sqref` child). +pub(super) fn read_validation(v: &El) -> Option { + let sq = v.attr("sqref").map(str::to_string).or_else(|| v.child("sqref").map(|s| s.text.clone())); + let ranges = parse_sqref(sq.as_deref()); + if ranges.is_empty() { + return None; + } + let formula = |n: &str| -> Option { + let f = v.child(n)?; + let t = f.child("f").map(|x| x.text.as_str()).unwrap_or(f.text.as_str()).trim(); + if t.is_empty() { None } else { Some(t.to_string()) } + }; + let kind = match v.attr("type").unwrap_or("none") { + "whole" => ValidationKind::Whole, + "decimal" => ValidationKind::Decimal, + "list" => ValidationKind::List, + "date" => ValidationKind::Date, + "time" => ValidationKind::Time, + "textLength" => ValidationKind::TextLength, + "custom" => ValidationKind::Custom, + _ => ValidationKind::Any, + }; + let mut f1 = formula("formula1").unwrap_or_default(); + if kind == ValidationKind::List && !f1.is_empty() && !f1.starts_with('"') { + f1 = format!("={}", from_file(&f1)); + } else if !f1.starts_with('"') && !f1.is_empty() { + f1 = from_file(&f1); + } + Some(Validation { + ranges, + kind, + op: cf_op(v.attr("operator")), + f1, + f2: formula("formula2").map(|f| from_file(&f)), + allow_blank: v.flag("allowBlank", false), + // `showDropDown="1"` *hides* the in-cell list arrow. + in_cell_dropdown: !v.flag("showDropDown", false), + input_title: v.attr("promptTitle").unwrap_or("").to_string(), + input_message: v.attr("prompt").unwrap_or("").to_string(), + show_input: v.flag("showInputMessage", false), + error_title: v.attr("errorTitle").unwrap_or("").to_string(), + error_message: v.attr("error").unwrap_or("").to_string(), + error_style: match v.attr("errorStyle") { + Some("warning") => ErrorStyle::Warning, + Some("information") => ErrorStyle::Information, + _ => ErrorStyle::Stop, + }, + show_error: v.flag("showErrorMessage", false), + }) +} diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 52dc979e66..aeca781239 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -18,56 +18,15 @@ use crate::write::{ }; use crate::xml::{encode_xstring, esc, esc_attr, num}; -fn sqref(ranges: &[RangeRef]) -> String { - ranges.iter().map(|r| r.a1()).collect::>().join(" ") -} +mod parts; +mod rules; -fn cf_op_name(op: CfOperator) -> &'static str { - match op { - CfOperator::Between => "between", - CfOperator::NotBetween => "notBetween", - CfOperator::Equal => "equal", - CfOperator::NotEqual => "notEqual", - CfOperator::Greater => "greaterThan", - CfOperator::Less => "lessThan", - CfOperator::GreaterOrEqual => "greaterThanOrEqual", - CfOperator::LessOrEqual => "lessThanOrEqual", - } -} - -/// Valid table name: letters, digits, `_` and `.`, starting with a letter, `_` or `\` -/// (Microsoft "Names in formulas" rules). -fn table_name(name: &str, used: &mut HashSet, n: u32) -> String { - let mut s: String = - name.chars().enumerate().map(|(i, c)| if c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\') { c } else { '_' }).collect(); - if s.is_empty() || !s.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\') || gridcraft_core::CellRef::parse(&s).is_some() { - s = format!("Table{n}"); - } - let mut out = s.clone(); - let mut k = 2; - while !used.insert(out.to_ascii_lowercase()) { - out = format!("{s}{k}"); - k += 1; - } - out -} +pub(crate) use parts::password_attrs; +use parts::{autofilter_xml, comments_xml, protected_ranges_xml, table_columns, table_name, table_xml, thread_ids, threaded_xml, vml_xml}; +use rules::{cf_rule_xml, solid_bar_id, validation_xml, x14_cfvo}; -/// Column names for a table: one per column of its range, unique and non-empty. -fn table_columns(t: &Table) -> Vec { - let w = t.range.width() as usize; - let mut used = HashSet::new(); - (0..w) - .map(|i| { - let base = t.columns.get(i).map(|c| c.name.clone()).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| format!("Column{}", i + 1)); - let mut name = base.clone(); - let mut k = 2; - while !used.insert(name.to_lowercase()) { - name = format!("{base}{k}"); - k += 1; - } - name - }) - .collect() +fn sqref(ranges: &[RangeRef]) -> String { + ranges.iter().map(|r| r.a1()).collect::>().join(" ") } pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (String, Rels) { @@ -768,537 +727,9 @@ fn write_cell( } } -fn cfvo_xml(v: &CfValueKind) -> String { - match v { - CfValueKind::Min => "".into(), - CfValueKind::Max => "".into(), - CfValueKind::Number(n) => format!("", num(*n)), - CfValueKind::Percent(n) => format!("", num(*n)), - CfValueKind::Percentile(n) => format!("", num(*n)), - CfValueKind::Formula(f) => format!("", esc_attr(&text_to_file(f))), - } -} - -fn quote(t: &str) -> String { - format!("\"{}\"", t.replace('"', "\"\"")) -} - const X14_NS: &str = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main"; const XM_NS: &str = "http://schemas.microsoft.com/office/excel/2006/main"; -/// The id linking a solid data bar's `cfRule` to its x14 extension; unique per sheet because priorities are. -fn solid_bar_id(priority: u32) -> String { - format!("{{00000000-0000-0000-0000-{priority:012}}}") -} - -/// The `cfRule` extension that points a solid data bar at its x14 rule. -fn solid_bar_ext(priority: u32) -> String { - let id = solid_bar_id(priority); - format!("{id}") -} - -/// An x14 `cfvo`: unlike the 2006 one it has no `val` attribute, the value is an `xm:f` child. -fn x14_cfvo(v: &CfValueKind) -> String { - let (ty, val) = match v { - CfValueKind::Min => return "".into(), - CfValueKind::Max => return "".into(), - CfValueKind::Number(n) => ("num", num(*n)), - CfValueKind::Percent(n) => ("percent", num(*n)), - CfValueKind::Percentile(n) => ("percentile", num(*n)), - CfValueKind::Formula(f) => ("formula", text_to_file(f)), - }; - format!("{}", esc(&val)) -} - -fn cf_rule_xml(cf: &CondFormat, priority: u32, out: &mut Out) -> String { - let tl = cf.ranges.first().map(|r| r.start.a1()).unwrap_or_else(|| "A1".into()); - let stop = if cf.stop_if_true { " stopIfTrue=\"1\"" } else { "" }; - let mut dxf = |st: &Style| dxf_id(&mut out.dxfs, st); - let f = |x: &str| format!("{}", esc(&text_to_file(x))); - let simple = |ty: &str, st: &Style, extra: &str, body: String, dxf: &mut dyn FnMut(&Style) -> u32| { - format!("{body}", dxf(st)) - }; - match &cf.rule { - CfRule::CellIs { op, a, b, style } => { - let mut body = f(a); - if let Some(b) = b { - body.push_str(&f(b)); - } - simple("cellIs", style, &format!(" operator=\"{}\"", cf_op_name(*op)), body, &mut dxf) - } - CfRule::Expression { formula, style } => simple("expression", style, "", f(formula), &mut dxf), - CfRule::ContainsText { text, style } => simple( - "containsText", - style, - &format!(" operator=\"containsText\" text=\"{}\"", esc_attr(text)), - f(&format!("NOT(ISERROR(SEARCH({},{tl})))", quote(text))), - &mut dxf, - ), - CfRule::NotContainsText { text, style } => simple( - "notContainsText", - style, - &format!(" operator=\"notContains\" text=\"{}\"", esc_attr(text)), - f(&format!("ISERROR(SEARCH({},{tl}))", quote(text))), - &mut dxf, - ), - CfRule::BeginsWith { text, style } => simple( - "beginsWith", - style, - &format!(" operator=\"beginsWith\" text=\"{}\"", esc_attr(text)), - f(&format!("LEFT({tl},LEN({0}))={0}", quote(text))), - &mut dxf, - ), - CfRule::EndsWith { text, style } => simple( - "endsWith", - style, - &format!(" operator=\"endsWith\" text=\"{}\"", esc_attr(text)), - f(&format!("RIGHT({tl},LEN({0}))={0}", quote(text))), - &mut dxf, - ), - CfRule::Blanks { style } => simple("containsBlanks", style, "", f(&format!("LEN(TRIM({tl}))=0")), &mut dxf), - CfRule::NoBlanks { style } => simple("notContainsBlanks", style, "", f(&format!("LEN(TRIM({tl}))>0")), &mut dxf), - CfRule::Errors { style } => simple("containsErrors", style, "", f(&format!("ISERROR({tl})")), &mut dxf), - CfRule::NoErrors { style } => simple("notContainsErrors", style, "", f(&format!("NOT(ISERROR({tl}))")), &mut dxf), - CfRule::Duplicate { style } => simple("duplicateValues", style, "", String::new(), &mut dxf), - CfRule::Unique { style } => simple("uniqueValues", style, "", String::new(), &mut dxf), - CfRule::Top10 { bottom, percent, rank, style } => { - let extra = format!("{}{} rank=\"{}\"", if *percent { " percent=\"1\"" } else { "" }, if *bottom { " bottom=\"1\"" } else { "" }, rank); - simple("top10", style, &extra, String::new(), &mut dxf) - } - CfRule::AboveAverage { below, equal, std_dev, style } => { - let mut extra = String::new(); - if *below { - extra.push_str(" aboveAverage=\"0\""); - } - if *equal { - extra.push_str(" equalAverage=\"1\""); - } - if *std_dev > 0 { - let _ = write!(extra, " stdDev=\"{std_dev}\""); - } - simple("aboveAverage", style, &extra, String::new(), &mut dxf) - } - CfRule::TimePeriod { period, style } => { - let d = format!("FLOOR({tl},1)"); - let body = match period.as_str() { - "yesterday" => format!("{d}=TODAY()-1"), - "tomorrow" => format!("{d}=TODAY()+1"), - "last7Days" => format!("AND(TODAY()-{d}<=6,{d}<=TODAY())"), - "thisMonth" => format!("AND(MONTH({tl})=MONTH(TODAY()),YEAR({tl})=YEAR(TODAY()))"), - "lastMonth" => format!("AND(MONTH({tl})=MONTH(EDATE(TODAY(),0-1)),YEAR({tl})=YEAR(EDATE(TODAY(),0-1)))"), - "nextMonth" => format!("AND(MONTH({tl})=MONTH(EDATE(TODAY(),0+1)),YEAR({tl})=YEAR(EDATE(TODAY(),0+1)))"), - "thisWeek" => format!("AND(TODAY()-ROUNDDOWN({tl},0)<=WEEKDAY(TODAY())-1,ROUNDDOWN({tl},0)-TODAY()<=7-WEEKDAY(TODAY()))"), - "lastWeek" => format!("AND(TODAY()-ROUNDDOWN({tl},0)>=(WEEKDAY(TODAY())),TODAY()-ROUNDDOWN({tl},0)<(WEEKDAY(TODAY())+7))"), - "nextWeek" => format!("AND(ROUNDDOWN({tl},0)-TODAY()>(7-WEEKDAY(TODAY())),ROUNDDOWN({tl},0)-TODAY()<(15-WEEKDAY(TODAY())))"), - _ => format!("{d}=TODAY()"), - }; - simple("timePeriod", style, &format!(" timePeriod=\"{}\"", esc_attr(period)), f(&body), &mut dxf) - } - CfRule::ColorScale { stops } => { - let mut b = String::from(""); - for (v, _) in stops { - b.push_str(&cfvo_xml(v)); - } - for (_, c) in stops { - let _ = write!(b, "", color_attrs(c)); - } - b.push_str(""); - format!("{b}") - } - CfRule::DataBar { min, max, color, show_value, gradient } => format!( - "{}{}{}", - if *show_value { "" } else { " showValue=\"0\"" }, - cfvo_xml(min), - cfvo_xml(max), - if *color == Color::Auto { "rgb=\"FF638EC6\"".to_string() } else { color_attrs(color) }, - if *gradient { String::new() } else { solid_bar_ext(priority) } - ), - CfRule::IconSet { set, thresholds, reverse, show_value } => { - let mut b = format!("'); - // CT_IconSet needs one cfvo per icon (minOccurs 2); the first is the lower bound. - let k = icon_count(set); - let defaults = default_icon_thresholds(set); - let cfvos: Vec<&CfValueKind> = match thresholds.len() { - n if n == k => thresholds.iter().collect(), - n if n + 1 == k => defaults.iter().take(1).chain(thresholds).collect(), - _ => defaults.iter().collect(), - }; - for t in cfvos { - b.push_str(&cfvo_xml(t)); - } - b.push_str(""); - format!("{b}") - } - } -} - -fn validation_formula(f: &str) -> String { - let f = f.trim(); - if f.starts_with('"') { - return esc(f); - } - esc(&text_to_file(f.strip_prefix('=').unwrap_or(f))) -} - -fn validation_xml(v: &Validation) -> String { - let ty = match v.kind { - ValidationKind::Any => "none", - ValidationKind::Whole => "whole", - ValidationKind::Decimal => "decimal", - ValidationKind::List => "list", - ValidationKind::Date => "date", - ValidationKind::Time => "time", - ValidationKind::TextLength => "textLength", - ValidationKind::Custom => "custom", - }; - let mut s = format!(" {} - ErrorStyle::Warning => s.push_str(" errorStyle=\"warning\""), - ErrorStyle::Information => s.push_str(" errorStyle=\"information\""), - } - if v.op != CfOperator::Between && !matches!(v.kind, ValidationKind::List | ValidationKind::Custom | ValidationKind::Any) { - let _ = write!(s, " operator=\"{}\"", cf_op_name(v.op)); - } - if v.allow_blank { - s.push_str(" allowBlank=\"1\""); - } - if !v.in_cell_dropdown { - s.push_str(" showDropDown=\"1\""); - } - if v.show_input { - s.push_str(" showInputMessage=\"1\""); - } - if v.show_error { - s.push_str(" showErrorMessage=\"1\""); - } - for (a, t) in [("errorTitle", &v.error_title), ("error", &v.error_message), ("promptTitle", &v.input_title), ("prompt", &v.input_message)] { - if !t.is_empty() { - let _ = write!(s, " {a}=\"{}\"", esc_attr(t)); - } - } - let _ = write!(s, " sqref=\"{}\">", sqref(&v.ranges)); - if !v.f1.is_empty() { - let _ = write!(s, "{}", validation_formula(&v.f1)); - } - if let Some(f2) = v.f2.as_ref().filter(|f| !f.is_empty()) { - let _ = write!(s, "{}", validation_formula(f2)); - } - s.push_str(""); - s -} - -fn filter_operator(op: &str) -> &'static str { - match op.trim() { - "<" => " operator=\"lessThan\"", - "<=" => " operator=\"lessThanOrEqual\"", - "<>" => " operator=\"notEqual\"", - ">=" => " operator=\"greaterThanOrEqual\"", - ">" => " operator=\"greaterThan\"", - _ => "", - } -} - -fn autofilter_xml(range: RangeRef, criteria: &[(u32, FilterCriterion)], out: &mut Out) -> String { - let mut s = format!(""); - return s; - } - s.push('>'); - for (col, c) in criteria { - let _ = write!(s, ""); - match c { - FilterCriterion::Values { values, blanks } => { - let _ = write!(s, "", if *blanks { " blank=\"1\"" } else { "" }); - for v in values { - let _ = write!(s, "", esc_attr(v)); - } - s.push_str(""); - } - FilterCriterion::Custom { a, b, and } => { - let _ = write!(s, "", if *and { " and=\"1\"" } else { "" }); - for (op, v) in std::iter::once(a).chain(b.iter()) { - let _ = write!(s, "", filter_operator(op), esc_attr(v)); - } - s.push_str(""); - } - FilterCriterion::Top10 { bottom, percent, count } => { - let _ = - write!(s, "", if *bottom { " top=\"0\"" } else { "" }, if *percent { " percent=\"1\"" } else { "" }); - } - FilterCriterion::AboveAverage(above) => { - let _ = write!(s, "", if *above { "aboveAverage" } else { "belowAverage" }); - } - FilterCriterion::FillColor(c) => { - let st = Style { fill: gridcraft_model::Fill::solid(*c), ..Default::default() }; - let _ = write!(s, "", dxf_id(&mut out.dxfs, &st)); - } - FilterCriterion::FontColor(c) => { - let mut st = Style::default(); - st.font.color = *c; - let _ = write!(s, "", dxf_id(&mut out.dxfs, &st)); - } - FilterCriterion::Icon { set, id } => { - let _ = write!(s, ""); - } - } - s.push_str(""); - } - s.push_str(""); - s -} - -fn table_xml(t: &Table, cols: &[String], name: &str, id: u32, out: &mut Out) -> String { - let mut s = - format!("{XML_DECL}
'); - if t.header_row && t.filter_button { - let end_row = if t.totals_row { t.range.end.row.saturating_sub(1).max(t.range.start.row) } else { t.range.end.row }; - let r = RangeRef::new(t.range.start, CellRef::new(end_row, t.range.end.col)); - s.push_str(&autofilter_xml(r, &t.filter, out)); - } - let _ = write!(s, "", cols.len()); - for (i, name) in cols.iter().enumerate() { - let col = t.columns.get(i); - let _ = write!(s, " None, - TotalsFn::Average => Some("average"), - TotalsFn::Count => Some("count"), - TotalsFn::CountNums => Some("countNums"), - TotalsFn::Max => Some("max"), - TotalsFn::Min => Some("min"), - TotalsFn::Sum => Some("sum"), - TotalsFn::StdDev => Some("stdDev"), - TotalsFn::Var => Some("var"), - TotalsFn::Custom => Some("custom"), - }; - if let Some(f) = f { - let _ = write!(s, " totalsRowFunction=\"{f}\""); - } else if let Some(l) = col.and_then(|c| c.totals_label.as_ref()) { - let _ = write!(s, " totalsRowLabel=\"{}\"", esc_attr(l)); - } - } - let calc = col.and_then(|c| c.formula.as_ref()).filter(|f| !f.is_empty()); - let custom = if t.totals_row && totals == TotalsFn::Custom { col.and_then(|c| c.totals_label.as_ref()) } else { None }; - let xml = col.and_then(|c| c.xml.as_ref()); - if calc.is_none() && custom.is_none() && xml.is_none() { - s.push_str("/>"); - continue; - } - s.push('>'); - if let Some(f) = calc { - let _ = write!(s, "{}", esc(&text_to_file(f))); - } - if let Some(f) = custom { - let _ = write!(s, "{}", esc(&text_to_file(f))); - } - if let Some(x) = xml { - let _ = write!(s, "", esc_attr(&x.data_type)); - } - s.push_str(""); - } - s.push_str(""); - let style = if t.style.is_empty() { "TableStyleMedium2" } else { &t.style }; - let _ = write!( - s, - "
", - esc_attr(style), - t.first_col as u8, - t.last_col as u8, - t.banded_rows as u8, - t.banded_cols as u8 - ); - s -} - -/// Ids of the threaded comments of sheet `si`: the thread's id, then one per reply. GUID-shaped -/// and deterministic; they only need to be unique within the file. -fn thread_ids(sheet: &Sheet, si: usize) -> BTreeMap> { - let mut seq: u64 = 0; - let mut ids = BTreeMap::new(); - for (cell, c) in sheet.comments.iter().filter(|(_, c)| c.threaded) { - let v: Vec = (0..=c.replies.len()) - .map(|_| { - seq += 1; - format!("{{{:08X}-0000-4000-8001-{:012X}}}", si as u64 & 0xFFFF_FFFF, seq & 0xFFFF_FFFF_FFFF) - }) - .collect(); - ids.insert(*cell, v); - } - ids -} - -fn comments_xml(sheet: &Sheet, ids: &BTreeMap>) -> String { - // A threaded comment's legacy note is authored by `tc={thread id}`. - let author_of = |cell: &CellRef, c: &gridcraft_model::Comment| match ids.get(cell).and_then(|v| v.first()) { - Some(id) => format!("tc={id}"), - None => c.author.clone(), - }; - let mut authors: Vec = vec![]; - for (cell, c) in &sheet.comments { - let a = author_of(cell, c); - if !authors.contains(&a) { - authors.push(a); - } - } - let mut s = if ids.is_empty() { - format!("{XML_DECL}") - } else { - format!( - "{XML_DECL}" - ) - }; - for a in &authors { - let _ = write!(s, "{}", esc(a)); - } - s.push_str(""); - for (cell, c) in &sheet.comments { - let a = author_of(cell, c); - let aid = authors.iter().position(|x| *x == a).unwrap_or(0); - let mut text = c.text.clone(); - // Threaded replies are kept as text lines. - for (who, reply) in &c.replies { - let _ = write!(text, "\n{who}: {reply}"); - } - let link = match ids.get(cell).and_then(|v| v.first()) { - Some(id) => format!(" shapeId=\"0\" xr:uid=\"{id}\""), - None => String::new(), - }; - let _ = write!(s, "{}", cell.a1(), crate::xml::t_el(&text)); - } - s.push_str(""); - s -} - -/// `2024-05-01T09:30:00` with optional fraction and zone: what `dT` (xsd:dateTime) accepts. -/// Anything else is left out rather than written into the file. -fn iso_datetime(d: &str) -> bool { - let b = d.as_bytes(); - let digits = |r: std::ops::Range| b.get(r).is_some_and(|s| s.iter().all(u8::is_ascii_digit)); - d.len() <= 40 - && digits(0..4) - && b.get(4) == Some(&b'-') - && digits(5..7) - && b.get(7) == Some(&b'-') - && digits(8..10) - && b.get(10) == Some(&b'T') - && digits(11..13) - && b.get(13) == Some(&b':') - && digits(14..16) - && b.get(16) == Some(&b':') - && digits(17..19) - && b.get(19..).is_some_and(|rest| rest.iter().all(|c| c.is_ascii_digit() || b".:+-Z".contains(c))) -} - -/// `xl/threadedComments/threadedCommentN.xml`: each thread, then its replies (`parentId`). -fn threaded_xml(sheet: &Sheet, ids: &BTreeMap>, persons: &mut Vec) -> String { - let mut person = |name: &str| { - let i = persons.iter().position(|p| p == name).unwrap_or_else(|| { - persons.push(name.to_string()); - persons.len() - 1 - }); - crate::write::person_guid(i) - }; - let mut s = format!("{XML_DECL}"); - for (cell, id) in ids { - let Some(c) = sheet.comments.get(cell) else { continue }; - let entries = std::iter::once((&c.author, &c.text)).chain(c.replies.iter().map(|(a, t)| (a, t))); - for (k, ((author, text), own)) in entries.zip(id).enumerate() { - let date = match c.dates.get(k).filter(|d| iso_datetime(d)) { - Some(d) => format!(" dT=\"{}\"", esc_attr(d)), - None => String::new(), - }; - let tail = match (k, id.first()) { - (0, _) if c.resolved => " done=\"1\"".to_string(), - (0, _) | (_, None) => String::new(), - (_, Some(parent)) => format!(" parentId=\"{parent}\""), - }; - let _ = write!( - s, - "{}", - cell.a1(), - person(author), - esc(text) - ); - } - } - s.push_str(""); - s -} - -/// A protection password hash as attributes, each name preceded by `prefix` (see -/// [`crate::sheet_read::read_password`]). -pub(crate) fn password_attrs(h: &gridcraft_model::PasswordHash, prefix: &str) -> String { - let name = |base: &str| crate::sheet_read::password_attr(prefix, base); - match h { - gridcraft_model::PasswordHash::Legacy(h) => format!(" {}=\"{h:04X}\"", name("password")), - gridcraft_model::PasswordHash::Iterated { algorithm, hash, salt, spin_count } => format!( - " {}=\"{}\" {}=\"{}\" {}=\"{}\" {}=\"{spin_count}\"", - name("algorithmName"), - esc_attr(algorithm), - name("hashValue"), - esc_attr(hash), - name("saltValue"), - esc_attr(salt), - name("spinCount") - ), - } -} - -/// Allow Edit Ranges: `` (after `sheetProtection`). -fn protected_ranges_xml(sheet: &Sheet) -> String { - let mut s = String::new(); - for r in sheet.edit_ranges.iter().filter(|r| !r.ranges.is_empty()) { - let sqref: Vec = r.ranges.iter().map(|x| x.a1()).collect(); - let _ = write!(s, ""); - } - if s.is_empty() { s } else { format!("{s}") } -} - /// The box a note made here gets: two columns right of its cell, four rows tall, 108 x 59.25 pt. /// A box read from a file that equals it is kept as `None`, so a note's model survives a save. pub(crate) const DEFAULT_NOTE_BOX: NoteBox = NoteBox { @@ -1307,36 +738,3 @@ pub(crate) const DEFAULT_NOTE_BOX: NoteBox = NoteBox { size_pt: Some(DEFAULT_NOTE_SIZE_PT), }; const DEFAULT_NOTE_SIZE_PT: (f64, f64) = (108.0, 59.25); - -/// A note box's `` text and its width and height in points: the box read from the file -/// or [`DEFAULT_NOTE_BOX`], its corners moved with the note's `cell` and kept on the sheet. -fn note_anchor(cell: CellRef, b: Option<&NoteBox>) -> (String, (f64, f64)) { - let b = b.unwrap_or(&DEFAULT_NOTE_BOX); - let at = |base: u32, d: i64, max: u32| (i64::from(base) + d).clamp(0, i64::from(max) - 1); - let corner = |k: &NoteCorner| format!("{}, {}, {}, {}", at(cell.col, k.col, MAX_COLS), k.col_px, at(cell.row, k.row, MAX_ROWS), k.row_px); - (format!("{}, {}", corner(&b.from), corner(&b.to)), b.size_pt.unwrap_or(DEFAULT_NOTE_SIZE_PT)) -} - -/// Legacy VML shapes Excel uses to display notes. -fn vml_xml(sheet: &Sheet, n: u32) -> String { - let mut s = format!( - "" - ); - for (i, (cell, c)) in sheet.comments.iter().enumerate() { - let id = n as u64 * 1024 + 1 + i as u64; - let vis = if c.visible { "visible" } else { "hidden" }; - let (anchor, (w, h)) = note_anchor(*cell, c.note_box.as_ref()); - let _ = write!( - s, - "
{anchor}False{}{}{}
", - num(w), - num(h), - i + 1, - cell.row, - cell.col, - if c.visible { "" } else { "" } - ); - } - s.push_str("
"); - s -} diff --git a/crates/xlsx/src/sheet_write/parts.rs b/crates/xlsx/src/sheet_write/parts.rs new file mode 100644 index 0000000000..2e40d0b267 --- /dev/null +++ b/crates/xlsx/src/sheet_write/parts.rs @@ -0,0 +1,373 @@ +use super::*; + +/// Valid table name: letters, digits, `_` and `.`, starting with a letter, `_` or `\` +/// (Microsoft "Names in formulas" rules). +pub(super) fn table_name(name: &str, used: &mut HashSet, n: u32) -> String { + let mut s: String = + name.chars().enumerate().map(|(i, c)| if c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\') { c } else { '_' }).collect(); + if s.is_empty() || !s.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\') || gridcraft_core::CellRef::parse(&s).is_some() { + s = format!("Table{n}"); + } + let mut out = s.clone(); + let mut k = 2; + while !used.insert(out.to_ascii_lowercase()) { + out = format!("{s}{k}"); + k += 1; + } + out +} + +/// Column names for a table: one per column of its range, unique and non-empty. +pub(super) fn table_columns(t: &Table) -> Vec { + let w = t.range.width() as usize; + let mut used = HashSet::new(); + (0..w) + .map(|i| { + let base = t.columns.get(i).map(|c| c.name.clone()).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| format!("Column{}", i + 1)); + let mut name = base.clone(); + let mut k = 2; + while !used.insert(name.to_lowercase()) { + name = format!("{base}{k}"); + k += 1; + } + name + }) + .collect() +} + +fn filter_operator(op: &str) -> &'static str { + match op.trim() { + "<" => " operator=\"lessThan\"", + "<=" => " operator=\"lessThanOrEqual\"", + "<>" => " operator=\"notEqual\"", + ">=" => " operator=\"greaterThanOrEqual\"", + ">" => " operator=\"greaterThan\"", + _ => "", + } +} + +pub(super) fn autofilter_xml(range: RangeRef, criteria: &[(u32, FilterCriterion)], out: &mut Out) -> String { + let mut s = format!(""); + return s; + } + s.push('>'); + for (col, c) in criteria { + let _ = write!(s, ""); + match c { + FilterCriterion::Values { values, blanks } => { + let _ = write!(s, "", if *blanks { " blank=\"1\"" } else { "" }); + for v in values { + let _ = write!(s, "", esc_attr(v)); + } + s.push_str(""); + } + FilterCriterion::Custom { a, b, and } => { + let _ = write!(s, "", if *and { " and=\"1\"" } else { "" }); + for (op, v) in std::iter::once(a).chain(b.iter()) { + let _ = write!(s, "", filter_operator(op), esc_attr(v)); + } + s.push_str(""); + } + FilterCriterion::Top10 { bottom, percent, count } => { + let _ = + write!(s, "", if *bottom { " top=\"0\"" } else { "" }, if *percent { " percent=\"1\"" } else { "" }); + } + FilterCriterion::AboveAverage(above) => { + let _ = write!(s, "", if *above { "aboveAverage" } else { "belowAverage" }); + } + FilterCriterion::FillColor(c) => { + let st = Style { fill: gridcraft_model::Fill::solid(*c), ..Default::default() }; + let _ = write!(s, "", dxf_id(&mut out.dxfs, &st)); + } + FilterCriterion::FontColor(c) => { + let mut st = Style::default(); + st.font.color = *c; + let _ = write!(s, "", dxf_id(&mut out.dxfs, &st)); + } + FilterCriterion::Icon { set, id } => { + let _ = write!(s, ""); + } + } + s.push_str(""); + } + s.push_str(""); + s +} + +pub(super) fn table_xml(t: &Table, cols: &[String], name: &str, id: u32, out: &mut Out) -> String { + let mut s = + format!("{XML_DECL}'); + if t.header_row && t.filter_button { + let end_row = if t.totals_row { t.range.end.row.saturating_sub(1).max(t.range.start.row) } else { t.range.end.row }; + let r = RangeRef::new(t.range.start, CellRef::new(end_row, t.range.end.col)); + s.push_str(&autofilter_xml(r, &t.filter, out)); + } + let _ = write!(s, "", cols.len()); + for (i, name) in cols.iter().enumerate() { + let col = t.columns.get(i); + let _ = write!(s, " None, + TotalsFn::Average => Some("average"), + TotalsFn::Count => Some("count"), + TotalsFn::CountNums => Some("countNums"), + TotalsFn::Max => Some("max"), + TotalsFn::Min => Some("min"), + TotalsFn::Sum => Some("sum"), + TotalsFn::StdDev => Some("stdDev"), + TotalsFn::Var => Some("var"), + TotalsFn::Custom => Some("custom"), + }; + if let Some(f) = f { + let _ = write!(s, " totalsRowFunction=\"{f}\""); + } else if let Some(l) = col.and_then(|c| c.totals_label.as_ref()) { + let _ = write!(s, " totalsRowLabel=\"{}\"", esc_attr(l)); + } + } + let calc = col.and_then(|c| c.formula.as_ref()).filter(|f| !f.is_empty()); + let custom = if t.totals_row && totals == TotalsFn::Custom { col.and_then(|c| c.totals_label.as_ref()) } else { None }; + let xml = col.and_then(|c| c.xml.as_ref()); + if calc.is_none() && custom.is_none() && xml.is_none() { + s.push_str("/>"); + continue; + } + s.push('>'); + if let Some(f) = calc { + let _ = write!(s, "{}", esc(&text_to_file(f))); + } + if let Some(f) = custom { + let _ = write!(s, "{}", esc(&text_to_file(f))); + } + if let Some(x) = xml { + let _ = write!(s, "", esc_attr(&x.data_type)); + } + s.push_str(""); + } + s.push_str(""); + let style = if t.style.is_empty() { "TableStyleMedium2" } else { &t.style }; + let _ = write!( + s, + "
", + esc_attr(style), + t.first_col as u8, + t.last_col as u8, + t.banded_rows as u8, + t.banded_cols as u8 + ); + s +} + +/// Ids of the threaded comments of sheet `si`: the thread's id, then one per reply. GUID-shaped +/// and deterministic; they only need to be unique within the file. +pub(super) fn thread_ids(sheet: &Sheet, si: usize) -> BTreeMap> { + let mut seq: u64 = 0; + let mut ids = BTreeMap::new(); + for (cell, c) in sheet.comments.iter().filter(|(_, c)| c.threaded) { + let v: Vec = (0..=c.replies.len()) + .map(|_| { + seq += 1; + format!("{{{:08X}-0000-4000-8001-{:012X}}}", si as u64 & 0xFFFF_FFFF, seq & 0xFFFF_FFFF_FFFF) + }) + .collect(); + ids.insert(*cell, v); + } + ids +} + +pub(super) fn comments_xml(sheet: &Sheet, ids: &BTreeMap>) -> String { + // A threaded comment's legacy note is authored by `tc={thread id}`. + let author_of = |cell: &CellRef, c: &gridcraft_model::Comment| match ids.get(cell).and_then(|v| v.first()) { + Some(id) => format!("tc={id}"), + None => c.author.clone(), + }; + let mut authors: Vec = vec![]; + for (cell, c) in &sheet.comments { + let a = author_of(cell, c); + if !authors.contains(&a) { + authors.push(a); + } + } + let mut s = if ids.is_empty() { + format!("{XML_DECL}") + } else { + format!( + "{XML_DECL}" + ) + }; + for a in &authors { + let _ = write!(s, "{}", esc(a)); + } + s.push_str(""); + for (cell, c) in &sheet.comments { + let a = author_of(cell, c); + let aid = authors.iter().position(|x| *x == a).unwrap_or(0); + let mut text = c.text.clone(); + // Threaded replies are kept as text lines. + for (who, reply) in &c.replies { + let _ = write!(text, "\n{who}: {reply}"); + } + let link = match ids.get(cell).and_then(|v| v.first()) { + Some(id) => format!(" shapeId=\"0\" xr:uid=\"{id}\""), + None => String::new(), + }; + let _ = write!(s, "{}", cell.a1(), crate::xml::t_el(&text)); + } + s.push_str(""); + s +} + +/// `2024-05-01T09:30:00` with optional fraction and zone: what `dT` (xsd:dateTime) accepts. +/// Anything else is left out rather than written into the file. +fn iso_datetime(d: &str) -> bool { + let b = d.as_bytes(); + let digits = |r: std::ops::Range| b.get(r).is_some_and(|s| s.iter().all(u8::is_ascii_digit)); + d.len() <= 40 + && digits(0..4) + && b.get(4) == Some(&b'-') + && digits(5..7) + && b.get(7) == Some(&b'-') + && digits(8..10) + && b.get(10) == Some(&b'T') + && digits(11..13) + && b.get(13) == Some(&b':') + && digits(14..16) + && b.get(16) == Some(&b':') + && digits(17..19) + && b.get(19..).is_some_and(|rest| rest.iter().all(|c| c.is_ascii_digit() || b".:+-Z".contains(c))) +} + +/// `xl/threadedComments/threadedCommentN.xml`: each thread, then its replies (`parentId`). +pub(super) fn threaded_xml(sheet: &Sheet, ids: &BTreeMap>, persons: &mut Vec) -> String { + let mut person = |name: &str| { + let i = persons.iter().position(|p| p == name).unwrap_or_else(|| { + persons.push(name.to_string()); + persons.len() - 1 + }); + crate::write::person_guid(i) + }; + let mut s = format!("{XML_DECL}"); + for (cell, id) in ids { + let Some(c) = sheet.comments.get(cell) else { continue }; + let entries = std::iter::once((&c.author, &c.text)).chain(c.replies.iter().map(|(a, t)| (a, t))); + for (k, ((author, text), own)) in entries.zip(id).enumerate() { + let date = match c.dates.get(k).filter(|d| iso_datetime(d)) { + Some(d) => format!(" dT=\"{}\"", esc_attr(d)), + None => String::new(), + }; + let tail = match (k, id.first()) { + (0, _) if c.resolved => " done=\"1\"".to_string(), + (0, _) | (_, None) => String::new(), + (_, Some(parent)) => format!(" parentId=\"{parent}\""), + }; + let _ = write!( + s, + "{}", + cell.a1(), + person(author), + esc(text) + ); + } + } + s.push_str(""); + s +} + +/// A protection password hash as attributes, each name preceded by `prefix` (see +/// [`crate::sheet_read::read_password`]). +pub(crate) fn password_attrs(h: &gridcraft_model::PasswordHash, prefix: &str) -> String { + let name = |base: &str| crate::sheet_read::password_attr(prefix, base); + match h { + gridcraft_model::PasswordHash::Legacy(h) => format!(" {}=\"{h:04X}\"", name("password")), + gridcraft_model::PasswordHash::Iterated { algorithm, hash, salt, spin_count } => format!( + " {}=\"{}\" {}=\"{}\" {}=\"{}\" {}=\"{spin_count}\"", + name("algorithmName"), + esc_attr(algorithm), + name("hashValue"), + esc_attr(hash), + name("saltValue"), + esc_attr(salt), + name("spinCount") + ), + } +} + +/// Allow Edit Ranges: `` (after `sheetProtection`). +pub(super) fn protected_ranges_xml(sheet: &Sheet) -> String { + let mut s = String::new(); + for r in sheet.edit_ranges.iter().filter(|r| !r.ranges.is_empty()) { + let sqref: Vec = r.ranges.iter().map(|x| x.a1()).collect(); + let _ = write!(s, ""); + } + if s.is_empty() { s } else { format!("{s}") } +} + +/// A note box's `` text and its width and height in points: the box read from the file +/// or [`DEFAULT_NOTE_BOX`], its corners moved with the note's `cell` and kept on the sheet. +fn note_anchor(cell: CellRef, b: Option<&NoteBox>) -> (String, (f64, f64)) { + let b = b.unwrap_or(&DEFAULT_NOTE_BOX); + let at = |base: u32, d: i64, max: u32| (i64::from(base) + d).clamp(0, i64::from(max) - 1); + let corner = |k: &NoteCorner| format!("{}, {}, {}, {}", at(cell.col, k.col, MAX_COLS), k.col_px, at(cell.row, k.row, MAX_ROWS), k.row_px); + (format!("{}, {}", corner(&b.from), corner(&b.to)), b.size_pt.unwrap_or(DEFAULT_NOTE_SIZE_PT)) +} + +/// Legacy VML shapes Excel uses to display notes. +pub(super) fn vml_xml(sheet: &Sheet, n: u32) -> String { + let mut s = format!( + "" + ); + for (i, (cell, c)) in sheet.comments.iter().enumerate() { + let id = n as u64 * 1024 + 1 + i as u64; + let vis = if c.visible { "visible" } else { "hidden" }; + let (anchor, (w, h)) = note_anchor(*cell, c.note_box.as_ref()); + let _ = write!( + s, + "
{anchor}False{}{}{}
", + num(w), + num(h), + i + 1, + cell.row, + cell.col, + if c.visible { "" } else { "" } + ); + } + s.push_str("
"); + s +} diff --git a/crates/xlsx/src/sheet_write/rules.rs b/crates/xlsx/src/sheet_write/rules.rs new file mode 100644 index 0000000000..821ec4cef1 --- /dev/null +++ b/crates/xlsx/src/sheet_write/rules.rs @@ -0,0 +1,238 @@ +use super::*; + +fn cf_op_name(op: CfOperator) -> &'static str { + match op { + CfOperator::Between => "between", + CfOperator::NotBetween => "notBetween", + CfOperator::Equal => "equal", + CfOperator::NotEqual => "notEqual", + CfOperator::Greater => "greaterThan", + CfOperator::Less => "lessThan", + CfOperator::GreaterOrEqual => "greaterThanOrEqual", + CfOperator::LessOrEqual => "lessThanOrEqual", + } +} + +fn cfvo_xml(v: &CfValueKind) -> String { + match v { + CfValueKind::Min => "".into(), + CfValueKind::Max => "".into(), + CfValueKind::Number(n) => format!("", num(*n)), + CfValueKind::Percent(n) => format!("", num(*n)), + CfValueKind::Percentile(n) => format!("", num(*n)), + CfValueKind::Formula(f) => format!("", esc_attr(&text_to_file(f))), + } +} + +fn quote(t: &str) -> String { + format!("\"{}\"", t.replace('"', "\"\"")) +} + +/// The id linking a solid data bar's `cfRule` to its x14 extension; unique per sheet because priorities are. +pub(super) fn solid_bar_id(priority: u32) -> String { + format!("{{00000000-0000-0000-0000-{priority:012}}}") +} + +/// The `cfRule` extension that points a solid data bar at its x14 rule. +fn solid_bar_ext(priority: u32) -> String { + let id = solid_bar_id(priority); + format!("{id}") +} + +/// An x14 `cfvo`: unlike the 2006 one it has no `val` attribute, the value is an `xm:f` child. +pub(super) fn x14_cfvo(v: &CfValueKind) -> String { + let (ty, val) = match v { + CfValueKind::Min => return "".into(), + CfValueKind::Max => return "".into(), + CfValueKind::Number(n) => ("num", num(*n)), + CfValueKind::Percent(n) => ("percent", num(*n)), + CfValueKind::Percentile(n) => ("percentile", num(*n)), + CfValueKind::Formula(f) => ("formula", text_to_file(f)), + }; + format!("{}", esc(&val)) +} + +pub(super) fn cf_rule_xml(cf: &CondFormat, priority: u32, out: &mut Out) -> String { + let tl = cf.ranges.first().map(|r| r.start.a1()).unwrap_or_else(|| "A1".into()); + let stop = if cf.stop_if_true { " stopIfTrue=\"1\"" } else { "" }; + let mut dxf = |st: &Style| dxf_id(&mut out.dxfs, st); + let f = |x: &str| format!("{}", esc(&text_to_file(x))); + let simple = |ty: &str, st: &Style, extra: &str, body: String, dxf: &mut dyn FnMut(&Style) -> u32| { + format!("{body}", dxf(st)) + }; + match &cf.rule { + CfRule::CellIs { op, a, b, style } => { + let mut body = f(a); + if let Some(b) = b { + body.push_str(&f(b)); + } + simple("cellIs", style, &format!(" operator=\"{}\"", cf_op_name(*op)), body, &mut dxf) + } + CfRule::Expression { formula, style } => simple("expression", style, "", f(formula), &mut dxf), + CfRule::ContainsText { text, style } => simple( + "containsText", + style, + &format!(" operator=\"containsText\" text=\"{}\"", esc_attr(text)), + f(&format!("NOT(ISERROR(SEARCH({},{tl})))", quote(text))), + &mut dxf, + ), + CfRule::NotContainsText { text, style } => simple( + "notContainsText", + style, + &format!(" operator=\"notContains\" text=\"{}\"", esc_attr(text)), + f(&format!("ISERROR(SEARCH({},{tl}))", quote(text))), + &mut dxf, + ), + CfRule::BeginsWith { text, style } => simple( + "beginsWith", + style, + &format!(" operator=\"beginsWith\" text=\"{}\"", esc_attr(text)), + f(&format!("LEFT({tl},LEN({0}))={0}", quote(text))), + &mut dxf, + ), + CfRule::EndsWith { text, style } => simple( + "endsWith", + style, + &format!(" operator=\"endsWith\" text=\"{}\"", esc_attr(text)), + f(&format!("RIGHT({tl},LEN({0}))={0}", quote(text))), + &mut dxf, + ), + CfRule::Blanks { style } => simple("containsBlanks", style, "", f(&format!("LEN(TRIM({tl}))=0")), &mut dxf), + CfRule::NoBlanks { style } => simple("notContainsBlanks", style, "", f(&format!("LEN(TRIM({tl}))>0")), &mut dxf), + CfRule::Errors { style } => simple("containsErrors", style, "", f(&format!("ISERROR({tl})")), &mut dxf), + CfRule::NoErrors { style } => simple("notContainsErrors", style, "", f(&format!("NOT(ISERROR({tl}))")), &mut dxf), + CfRule::Duplicate { style } => simple("duplicateValues", style, "", String::new(), &mut dxf), + CfRule::Unique { style } => simple("uniqueValues", style, "", String::new(), &mut dxf), + CfRule::Top10 { bottom, percent, rank, style } => { + let extra = format!("{}{} rank=\"{}\"", if *percent { " percent=\"1\"" } else { "" }, if *bottom { " bottom=\"1\"" } else { "" }, rank); + simple("top10", style, &extra, String::new(), &mut dxf) + } + CfRule::AboveAverage { below, equal, std_dev, style } => { + let mut extra = String::new(); + if *below { + extra.push_str(" aboveAverage=\"0\""); + } + if *equal { + extra.push_str(" equalAverage=\"1\""); + } + if *std_dev > 0 { + let _ = write!(extra, " stdDev=\"{std_dev}\""); + } + simple("aboveAverage", style, &extra, String::new(), &mut dxf) + } + CfRule::TimePeriod { period, style } => { + let d = format!("FLOOR({tl},1)"); + let body = match period.as_str() { + "yesterday" => format!("{d}=TODAY()-1"), + "tomorrow" => format!("{d}=TODAY()+1"), + "last7Days" => format!("AND(TODAY()-{d}<=6,{d}<=TODAY())"), + "thisMonth" => format!("AND(MONTH({tl})=MONTH(TODAY()),YEAR({tl})=YEAR(TODAY()))"), + "lastMonth" => format!("AND(MONTH({tl})=MONTH(EDATE(TODAY(),0-1)),YEAR({tl})=YEAR(EDATE(TODAY(),0-1)))"), + "nextMonth" => format!("AND(MONTH({tl})=MONTH(EDATE(TODAY(),0+1)),YEAR({tl})=YEAR(EDATE(TODAY(),0+1)))"), + "thisWeek" => format!("AND(TODAY()-ROUNDDOWN({tl},0)<=WEEKDAY(TODAY())-1,ROUNDDOWN({tl},0)-TODAY()<=7-WEEKDAY(TODAY()))"), + "lastWeek" => format!("AND(TODAY()-ROUNDDOWN({tl},0)>=(WEEKDAY(TODAY())),TODAY()-ROUNDDOWN({tl},0)<(WEEKDAY(TODAY())+7))"), + "nextWeek" => format!("AND(ROUNDDOWN({tl},0)-TODAY()>(7-WEEKDAY(TODAY())),ROUNDDOWN({tl},0)-TODAY()<(15-WEEKDAY(TODAY())))"), + _ => format!("{d}=TODAY()"), + }; + simple("timePeriod", style, &format!(" timePeriod=\"{}\"", esc_attr(period)), f(&body), &mut dxf) + } + CfRule::ColorScale { stops } => { + let mut b = String::from(""); + for (v, _) in stops { + b.push_str(&cfvo_xml(v)); + } + for (_, c) in stops { + let _ = write!(b, "", color_attrs(c)); + } + b.push_str(""); + format!("{b}") + } + CfRule::DataBar { min, max, color, show_value, gradient } => format!( + "{}{}{}", + if *show_value { "" } else { " showValue=\"0\"" }, + cfvo_xml(min), + cfvo_xml(max), + if *color == Color::Auto { "rgb=\"FF638EC6\"".to_string() } else { color_attrs(color) }, + if *gradient { String::new() } else { solid_bar_ext(priority) } + ), + CfRule::IconSet { set, thresholds, reverse, show_value } => { + let mut b = format!("'); + // CT_IconSet needs one cfvo per icon (minOccurs 2); the first is the lower bound. + let k = icon_count(set); + let defaults = default_icon_thresholds(set); + let cfvos: Vec<&CfValueKind> = match thresholds.len() { + n if n == k => thresholds.iter().collect(), + n if n + 1 == k => defaults.iter().take(1).chain(thresholds).collect(), + _ => defaults.iter().collect(), + }; + for t in cfvos { + b.push_str(&cfvo_xml(t)); + } + b.push_str(""); + format!("{b}") + } + } +} + +fn validation_formula(f: &str) -> String { + let f = f.trim(); + if f.starts_with('"') { + return esc(f); + } + esc(&text_to_file(f.strip_prefix('=').unwrap_or(f))) +} + +pub(super) fn validation_xml(v: &Validation) -> String { + let ty = match v.kind { + ValidationKind::Any => "none", + ValidationKind::Whole => "whole", + ValidationKind::Decimal => "decimal", + ValidationKind::List => "list", + ValidationKind::Date => "date", + ValidationKind::Time => "time", + ValidationKind::TextLength => "textLength", + ValidationKind::Custom => "custom", + }; + let mut s = format!(" {} + ErrorStyle::Warning => s.push_str(" errorStyle=\"warning\""), + ErrorStyle::Information => s.push_str(" errorStyle=\"information\""), + } + if v.op != CfOperator::Between && !matches!(v.kind, ValidationKind::List | ValidationKind::Custom | ValidationKind::Any) { + let _ = write!(s, " operator=\"{}\"", cf_op_name(v.op)); + } + if v.allow_blank { + s.push_str(" allowBlank=\"1\""); + } + if !v.in_cell_dropdown { + s.push_str(" showDropDown=\"1\""); + } + if v.show_input { + s.push_str(" showInputMessage=\"1\""); + } + if v.show_error { + s.push_str(" showErrorMessage=\"1\""); + } + for (a, t) in [("errorTitle", &v.error_title), ("error", &v.error_message), ("promptTitle", &v.input_title), ("prompt", &v.input_message)] { + if !t.is_empty() { + let _ = write!(s, " {a}=\"{}\"", esc_attr(t)); + } + } + let _ = write!(s, " sqref=\"{}\">", sqref(&v.ranges)); + if !v.f1.is_empty() { + let _ = write!(s, "{}", validation_formula(&v.f1)); + } + if let Some(f2) = v.f2.as_ref().filter(|f| !f.is_empty()) { + let _ = write!(s, "{}", validation_formula(f2)); + } + s.push_str(""); + s +} diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests.rs similarity index 100% rename from crates/xlsx/src/tests/mod.rs rename to crates/xlsx/src/tests.rs diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls.rs similarity index 100% rename from crates/xlsx/src/xls/mod.rs rename to crates/xlsx/src/xls.rs diff --git a/xtask/src/i18n_report.rs b/xtask/src/i18n_report.rs index 344ed7569a..99cbaa9ee1 100644 --- a/xtask/src/i18n_report.rs +++ b/xtask/src/i18n_report.rs @@ -116,6 +116,55 @@ pub fn catalog_keys(source: &str) -> BTreeSet { keys } +/// The character a `\u{...}` escape stands for, if it names a valid code point. +fn unicode_escape(chars: &mut std::iter::Peekable>) -> Option { + let mut hex = String::new(); + for (_, c) in chars.by_ref() { + match c { + '}' => break, + '{' => {} + c => hex.push(c), + } + } + u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) +} + +/// The value of a string literal whose opening quote was just read, and the byte offset past +/// its closing quote (`len`, the source's length, when it is never closed). +fn string_literal(chars: &mut std::iter::Peekable>, len: usize) -> (String, usize) { + let mut value = String::new(); + let mut end = len; + while let Some((j, c)) = chars.next() { + match c { + '"' => { + end = j + 1; + break; + } + '\\' => match chars.next().map(|(_, c)| c) { + Some('n') => value.push('\n'), + Some('t') => value.push('\t'), + Some('r') => value.push('\r'), + Some('0') => value.push('\0'), + Some(c @ ('\\' | '"' | '\'')) => value.push(c), + Some('u') => { + if let Some(c) = unicode_escape(chars) { + value.push(c); + } + } + // A line continuation skips the newline and the next line's indentation. + Some('\n') => { + while chars.peek().is_some_and(|(_, c)| c.is_whitespace()) { + chars.next(); + } + } + _ => {} + }, + c => value.push(c), + } + } + (value, end) +} + /// Every string literal outside `//` comments: its decoded value, and the byte range from its /// opening quote to just past its closing one. fn literals(source: &str) -> Vec<(String, usize, usize)> { @@ -133,44 +182,7 @@ fn literals(source: &str) -> Vec<(String, usize, usize)> { if c != '"' { continue; } - let mut value = String::new(); - let mut end = source.len(); - while let Some((j, c)) = chars.next() { - match c { - '"' => { - end = j + 1; - break; - } - '\\' => match chars.next().map(|(_, c)| c) { - Some('n') => value.push('\n'), - Some('t') => value.push('\t'), - Some('r') => value.push('\r'), - Some('0') => value.push('\0'), - Some(c @ ('\\' | '"' | '\'')) => value.push(c), - Some('u') => { - let mut hex = String::new(); - for (_, c) in chars.by_ref() { - match c { - '}' => break, - '{' => {} - c => hex.push(c), - } - } - if let Some(c) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) { - value.push(c); - } - } - // A line continuation skips the newline and the next line's indentation. - Some('\n') => { - while chars.peek().is_some_and(|(_, c)| c.is_whitespace()) { - chars.next(); - } - } - _ => {} - }, - c => value.push(c), - } - } + let (value, end) = string_literal(&mut chars, source.len()); out.push((value, i, end)); } out From 631cdbbfefd392b794ac94340e5b8fed250a8425 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Tue, 27 Oct 2026 04:57:10 +0000 Subject: [PATCH 16/31] =?UTF-8?q?From=20Web:=20address=20the=20review=20?= =?UTF-8?q?=E2=80=94=20bind=20connections=20to=20the=20table=20id,=20follo?= =?UTF-8?q?w=20renames,=20background=20fetches,=20Refresh=20All=20error=20?= =?UTF-8?q?reporting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: setzor --- crates/engine/src/cmd.rs | 11 +- crates/engine/src/cmd/extra/data.rs | 11 +- crates/engine/src/cmd/insert/table.rs | 13 ++ crates/engine/src/cmd/sheet/tabs.rs | 13 ++ crates/engine/src/cmd/web.rs | 237 +++++++++++++++++++++++--- crates/model/src/features.rs | 6 +- crates/ui-egui/src/app.rs | 20 +++ crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/dialogs/web.rs | 158 +++++++++++++---- crates/ui-egui/src/lib.rs | 7 + crates/ui-egui/src/ribbon/data.rs | 15 +- crates/ui-egui/src/web_fetch.rs | 95 +++++++++++ 12 files changed, 527 insertions(+), 60 deletions(-) create mode 100644 crates/ui-egui/src/web_fetch.rs diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 92d63774e0..cbbf34ee2f 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -80,12 +80,21 @@ pub fn is_secret_param(key: &str) -> bool { key == "password" || key.ends_with("Password") } +/// Params too large for the journal: a background worker's downloaded page tables. Replaying +/// the entry re-fetches the page instead (the commands fall back to the fetch source without +/// the param). +fn is_bulk_param(key: &str) -> bool { + key == "prefetched" || key == "prefetchedByUrl" +} + /// The params as the journal records them: secret params are left out, so a password never /// reaches the journal dialog, `engine.journal` or a recorded script (saved in the workbook). /// Replaying such an entry runs the command without that param. pub fn journal_params(params: &Json) -> Json { match params { - Json::Object(o) => Json::Object(o.iter().filter(|(k, _)| !is_secret_param(k)).map(|(k, v)| (k.clone(), v.clone())).collect()), + Json::Object(o) => { + Json::Object(o.iter().filter(|(k, _)| !is_secret_param(k) && !is_bulk_param(k)).map(|(k, v)| (k.clone(), v.clone())).collect()) + } other => other.clone(), } } diff --git a/crates/engine/src/cmd/extra/data.rs b/crates/engine/src/cmd/extra/data.rs index 76bf6d267a..9561360e2e 100644 --- a/crates/engine/src/cmd/extra/data.rs +++ b/crates/engine/src/cmd/extra/data.rs @@ -130,13 +130,20 @@ pub(super) fn queries(s: &mut Session, _: &Json) -> Result { .map(move |t| json!({"name": t.name, "sheet": sh.name, "range": t.range.a1(), "rows": t.range.height().saturating_sub(1)})) }) .collect(); - // The saved From Web connections, with the source and the last sync the pane shows. + // The saved From Web connections, with the source and the last sync the pane shows. The + // sheet and name are resolved from the live table (by its stable id) so the pane and "Go to + // table" follow renames even in workbooks saved before connections tracked them. let web: Vec = d.wb.web_queries .iter() .map(|q| { + let live = q.table_id.and_then(|id| { + d.wb.sheets.iter().find_map(|sh| sh.tables.iter().find(|t| t.id == id).map(|t| (t.name.clone(), sh.name.clone()))) + }); json!({ - "name": q.name, "sheet": q.sheet, "url": q.url, "table": q.table, + "name": live.as_ref().map(|(n, _)| n).unwrap_or(&q.name), + "sheet": live.as_ref().map(|(_, s)| s).unwrap_or(&q.sheet), + "url": q.url, "table": q.table, "last_synced": q.last_synced.unwrap_or(0), "web": true, }) }) diff --git a/crates/engine/src/cmd/insert/table.rs b/crates/engine/src/cmd/insert/table.rs index 5ef2b49d4a..98624044e0 100644 --- a/crates/engine/src/cmd/insert/table.rs +++ b/crates/engine/src/cmd/insert/table.rs @@ -282,6 +282,8 @@ pub(super) fn table_rename(s: &mut Session, p: &Json) -> Result { if let Some(t) = cx.sheet_mut(si)?.tables.get_mut(ti) { t.name = name.clone(); } + // A saved From Web connection to this table follows the rename. + crate::cmd::web::rename_connection(&mut cx.wb, &old, &name); // Structured references follow the rename. for i in 0..cx.wb.sheets.len() { let keys: Vec<(CellRef, std::sync::Arc)> = @@ -325,6 +327,17 @@ pub(super) fn table_convert(s: &mut Session, p: &Json) -> Result { if let Some(sh) = wb.sheet_mut(si) { sh.tables.remove(ti); } + // A saved From Web connection whose table is gone goes with it. + let mut live = Vec::new(); + for sh in wb.sheets.iter() { + for t in sh.tables.iter() { + if t.id as usize >= live.len() { + live.resize(t.id as usize + 1, false); + } + live[t.id as usize] = true; + } + } + crate::cmd::web::retain_connections(wb, &live); cx.structural = true; Ok(Json::Null) }) diff --git a/crates/engine/src/cmd/sheet/tabs.rs b/crates/engine/src/cmd/sheet/tabs.rs index 2454047236..183b8bc472 100644 --- a/crates/engine/src/cmd/sheet/tabs.rs +++ b/crates/engine/src/cmd/sheet/tabs.rs @@ -64,6 +64,17 @@ pub(super) fn delete_sheet(s: &mut Session, p: &Json) -> Result { } } cx.wb.names.retain(|n| n.scope != Some(i)); + // Saved From Web connections whose tables lived on the deleted sheet go with it. + let mut live = Vec::new(); + for sh in cx.wb.sheets.iter() { + for t in sh.tables.iter() { + if t.id as usize >= live.len() { + live.resize(t.id as usize + 1, false); + } + live[t.id as usize] = true; + } + } + crate::cmd::web::retain_connections(&mut cx.wb, &live); for n in cx.wb.names.iter_mut() { if let Some(sc) = n.scope.as_mut() && *sc > i @@ -140,6 +151,8 @@ pub(super) fn rename_sheet(s: &mut Session, p: &Json) -> Result { n.formula = gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &old, &name)); } } + // Saved From Web connections on the sheet follow its rename. + crate::cmd::web::rename_connection_sheet(&mut cx.wb, &old, &name); cx.structural = true; Ok(Json::Null) }) diff --git a/crates/engine/src/cmd/web.rs b/crates/engine/src/cmd/web.rs index 5d39d909fa..07ad9cc273 100644 --- a/crates/engine/src/cmd/web.rs +++ b/crates/engine/src/cmd/web.rs @@ -9,7 +9,7 @@ use super::insert::write_totals; use super::*; use crate::webquery::{DEFAULT_MAX_BYTES, DEFAULT_MAX_ROWS, MAX_COLS, WebRequest, WebTable}; use gridcraft_core::MAX_ROWS; -use gridcraft_model::{TableColumn, TotalsFn}; +use gridcraft_model::{TableColumn, TotalsFn, Workbook}; /// Preview rows and columns per table (the Navigator's preview pane). const PREVIEW_ROWS: usize = 10; @@ -82,18 +82,29 @@ fn from_web(s: &mut Session, p: &Json) -> Result { return Ok(Json::Null); } let req = request(p)?; - let Some(source) = s.web_source.clone() else { - return Err(EngineError::Other("Web page import is not available in this build.".into())); - }; - let tables = source(&req).map_err(EngineError::Other)?; let want = str_param(p, "table").map(str::trim).filter(|t| !t.is_empty()); let occurrence = p.get("occurrence").and_then(Json::as_u64).unwrap_or(0) as usize; + // A background worker's download comes back through the command as `prefetched`; without + // one, the fetch runs here. + let tables = fetch_tables(s, &req, p)?; let table = pick_table(&tables, want, occurrence).ok_or_else(|| EngineError::Other(no_table_message(&tables)))?; let occurrence = tables.iter().take_while(|t| !std::ptr::eq(*t, table)).filter(|t| t.name.eq_ignore_ascii_case(&table.name)).count(); let name = str_param(p, "sheet").map(|n| n.chars().take(31).collect::()).unwrap_or_else(|| default_sheet_name(&req.url)); load_table(s, &req, table, &name, true, occurrence) } +/// The page's tables: the `prefetched` result a background worker already downloaded, else the +/// fetch source's own download. +fn fetch_tables(s: &Session, req: &WebRequest, p: &Json) -> Result> { + if p.get("prefetched").is_some() { + return prefetched_tables(p); + } + let Some(source) = s.web_source.clone() else { + return Err(EngineError::Other("Web page import is not available in this build.".into())); + }; + source(req).map_err(EngineError::Other) +} + /// The site's own name (`https://www.example.com/x` → `example`), capped at 31 characters. /// Country suffixes that register under a second level (`.co.uk`, `.com.au`) are skipped, so /// `https://sub.example.co.uk` is `example` too. @@ -180,16 +191,20 @@ fn load_table_steps(s: &mut Session, req: &WebRequest, table: &WebTable, sheet_n })?; let _ = s.execute("home.autofitColumnWidth", json!({"cols": RangeRef::cols(0, w as u32 - 1).a1()})); // The table that was actually created: its name may be a generated one when the page - // table's name was taken or not valid. The connection and the result both report it. - let created_name = { + // table's name was taken or not valid. The connection and the result both report it, and + // the connection binds to the table's stable id, not its name. + let (created_name, created_id) = { let d = s.doc()?; - d.wb.sheet(sheet).and_then(|sh| sh.tables.iter().find(|t| t.range == range).map(|t| t.name.clone())).unwrap_or_else(|| tname.clone()) + d.wb.sheet(sheet) + .and_then(|sh| sh.tables.iter().find(|t| t.range == range).map(|t| (t.name.clone(), t.id))) + .unwrap_or_else(|| (tname.clone(), 0)) }; if save { let synced = now_ms(); let name = created_name.clone(); let q = gridcraft_model::WebQuery { name, + table_id: (created_id > 0).then_some(created_id), url: req.url.clone(), table: table.name.clone(), occurrence, @@ -206,6 +221,33 @@ fn load_table_steps(s: &mut Session, req: &WebRequest, table: &WebTable, sheet_n Ok(json!({"sheet": sheet_name, "table": created_name, "rows": table.rows.len(), "cols": w, "tables_found": table_count(s)})) } +/// Keeps the saved connections pointing at their table after `table.rename`: the connection's +/// `name` follows, so the Queries & Connections pane and `data.webRefresh` name lookups stay +/// current. The stable `table_id` already finds the table; this keeps the human-facing name. +pub(crate) fn rename_connection(wb: &mut Workbook, old: &str, name: &str) { + for q in wb.web_queries.iter_mut() { + if q.name.eq_ignore_ascii_case(old) { + q.name = name.to_string(); + } + } +} + +/// Drops the saved connection whose table is gone (`table.convertToRange`, `home.deleteSheet`): +/// a connection to a deleted table must not sit in the Queries & Connections pane, and a +/// refresh must not go looking for a name another table may since have taken. +pub(crate) fn retain_connections(wb: &mut Workbook, live: &[bool]) { + wb.web_queries.retain(|q| q.table_id.is_none_or(|id| live.get(id as usize).is_some_and(|l| *l))); +} + +/// Keeps the connection's sheet name current after `sheet.rename`. +pub(crate) fn rename_connection_sheet(wb: &mut Workbook, old: &str, name: &str) { + for q in wb.web_queries.iter_mut() { + if q.sheet.eq_ignore_ascii_case(old) { + q.sheet = name.to_string(); + } + } +} + /// How many web connections the workbook holds (the import result's `tables_found`). fn table_count(s: &Session) -> usize { s.doc().map(|d| d.wb.web_queries.len()).unwrap_or(0) @@ -250,10 +292,7 @@ fn would_be_formula(c: &str) -> bool { /// (the Navigator's list and preview pane). fn web_preview(s: &mut Session, p: &Json) -> Result { let req = request(p)?; - let Some(source) = s.web_source.clone() else { - return Err(EngineError::Other("Web page import is not available in this build.".into())); - }; - let tables = source(&req).map_err(EngineError::Other)?; + let tables = fetch_tables(s, &req, p)?; let preview = |t: &WebTable| { json!({ "name": t.name, @@ -270,9 +309,9 @@ fn web_preview(s: &mut Session, p: &Json) -> Result { fn web_refresh(s: &mut Session, p: &Json) -> Result { let url = str_param(p, "url").map(str::trim).filter(|u| !u.is_empty()); let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()); - let Some(source) = s.web_source.clone() else { + if p.get("prefetched").is_none() && p.get("prefetchedByUrl").is_none() && s.web_source.is_none() { return Err(EngineError::Other("Web page import is not available in this build.".into())); - }; + } let queries: Vec = { let d = s.doc()?; d.wb.web_queries @@ -288,7 +327,9 @@ fn web_refresh(s: &mut Session, p: &Json) -> Result { let mut errors: Vec = Vec::new(); for q in queries { let req = WebRequest { url: q.url.clone(), max_rows: DEFAULT_MAX_ROWS, max_cols: MAX_COLS, max_bytes: DEFAULT_MAX_BYTES }; - match source(&req).map_err(EngineError::Other).and_then(|tables| { + // A background worker's downloads come keyed by URL; without them, the fetch runs here. + let tables = if p.get("prefetchedByUrl").is_some() { prefetched_for_url(p, &q.url) } else { fetch_tables(s, &req, p) }; + match tables.and_then(|tables| { let table = pick_table(&tables, Some(&q.table), q.occurrence).ok_or_else(|| EngineError::Other(no_table_message(&tables)))?; rewrite_table(s, &q, table) }) { @@ -307,14 +348,18 @@ fn web_refresh(s: &mut Session, p: &Json) -> Result { /// outside the range are kept. fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTable) -> Result<(usize, usize)> { let d = s.doc()?; - // Only the connection's own table: the same name on the connection's sheet, else a name - // match anywhere. Never the first unrelated table that happens to be there. - let Some((sheet, tname, old)) = - d.wb.sheets + // Only the connection's own table. The stable id decides: a renamed table keeps refreshing, + // and a different table created with the former name is never selected. Workbooks saved + // before the id fall back to the same name on the connection's sheet, else a name match + // anywhere. Never the first unrelated table that happens to be there. + let by_id = |t: &gridcraft_model::Table| q.table_id.is_some_and(|id| t.id == id); + let by_name = |t: &gridcraft_model::Table| q.table_id.is_none() && t.name.eq_ignore_ascii_case(&q.name); + let Some((sheet, tname, old)) = d.wb.sheets.iter().enumerate().find_map(|(si, sh)| { + sh.tables .iter() - .enumerate() - .find_map(|(si, sh)| sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&q.name)).map(|t| (si, t.name.clone(), t.range))) - else { + .find(|t| by_id(t) || (by_name(t) && q.sheet.is_empty()) || (by_name(t) && sh.name.eq_ignore_ascii_case(&q.sheet))) + .map(|t| (si, t.name.clone(), t.range)) + }) else { return Err(EngineError::Other(format!("The sheet or table of the connection `{}` is gone.", q.name))); }; let rows = json_rows(table); @@ -427,6 +472,36 @@ fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTabl Ok((table.rows.len(), w)) } +/// The `prefetched` param's value: tables a background worker already downloaded for this URL. +/// The UI thread runs the fetch off itself and feeds the result back through the command, so a +/// slow page never blocks the interface (Excel's queries run in the background too). +pub(crate) fn prefetched_tables(p: &Json) -> Result> { + let raw = p.get("prefetched").ok_or_else(|| EngineError::Other("missing `prefetched`".into()))?; + let mut out = Vec::new(); + for t in raw.as_array().ok_or_else(|| EngineError::Other("`prefetched` must be an array of tables".into()))? { + let name = t["name"].as_str().unwrap_or_default().to_string(); + let rows = t["rows"] + .as_array() + .map(|rs| { + rs.iter() + .map(|r| r.as_array().map(|cs| cs.iter().map(|c| c.as_str().unwrap_or_default().to_string()).collect()).unwrap_or_default()) + .collect() + }) + .unwrap_or_default(); + out.push(WebTable { name, rows }); + } + Ok(out) +} + +/// One entry of the `prefetchedByUrl` map: a page's tables (or its fetch `error`). +fn prefetched_for_url(p: &Json, url: &str) -> Result> { + let entry = p.get("prefetchedByUrl").and_then(|m| m.get(url)).ok_or_else(|| EngineError::Other(format!("no prefetched page for {url}")))?; + if let Some(e) = entry.get("error").and_then(Json::as_str) { + return Err(EngineError::Other(e.to_string())); + } + prefetched_tables(&json!({"prefetched": entry["tables"]})) +} + #[cfg(test)] mod tests { use std::sync::Arc; @@ -732,13 +807,14 @@ mod tests { let d = s.doc().unwrap(); let tname = d.wb.active().unwrap().tables[0].name.clone(); s.execute("table.rename", json!({"table": tname, "name": "Renamed"})).unwrap(); + // Convert the renamed table to a range: the connection's table is truly gone. + s.execute("table.convertToRange", json!({"table": "Renamed"})).unwrap(); // Another, unrelated table now sits on the sheet. s.execute("range.setValues", json!({"range": "E1", "sheet": "Web", "values": [["keep"]]})).unwrap(); s.execute("insert.table", json!({"range": "E1", "header": true, "name": "Unrelated"})).unwrap(); - let r = s.execute("data.webRefresh", json!({})).unwrap(); - // The connection's table is gone: refresh reports it, and E1 is untouched. - assert_eq!(r["refreshed"], 0); - assert!(!r["errors"].as_array().unwrap().is_empty()); + // The connection was dropped with its table: refresh reports nothing to do. + let err = s.execute("data.webRefresh", json!({})).unwrap_err(); + assert!(err.to_string().contains("No saved web table matches")); assert_eq!(a1(&s, "E1"), "keep"); } @@ -811,4 +887,113 @@ mod tests { let _ = s.execute(id, json!({})); } } + + #[test] + /// A renamed table keeps refreshing: the connection follows the rename by its stable id, + /// and a different table created with the former name is never selected. + fn refresh_follows_a_renamed_table_not_a_new_same_named_one() { + let mut s = session_with_source(); + s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "Web"})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Web"})).unwrap(); + let d = s.doc().unwrap(); + let tname = d.wb.active().unwrap().tables[0].name.clone(); + s.execute("table.rename", json!({"table": tname, "name": "Renamed"})).unwrap(); + // The connection's name followed the rename. + { + let d = s.active().unwrap(); + assert_eq!(d.wb.web_queries[0].name, "Renamed"); + } + // A new, unrelated table takes the connection's former name. + s.execute("range.setValues", json!({"range": "E1", "sheet": "Web", "values": [["keep"]]})).unwrap(); + s.execute("insert.table", json!({"range": "E1", "header": true, "name": tname})).unwrap(); + // The source now serves different rows: the refresh lands in the renamed table. + s.web_source = Some(Arc::new(|req: &WebRequest| { + Ok(crate::webquery::extract_tables( + "
SymPrice
Z9
", + req.max_rows, + req.max_cols, + )) + })); + let r = s.execute("data.webRefresh", json!({})).unwrap(); + assert_eq!(r["refreshed"], 1); + assert_eq!(a1(&s, "A2"), "Z"); + // The same-named decoy was not touched. + assert_eq!(a1(&s, "E1"), "keep"); + } + + #[test] + /// Renaming the imported sheet keeps the connection's sheet current. + fn sheet_rename_keeps_the_connection_s_sheet() { + let mut s = session_with_source(); + s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "Web"})).unwrap(); + s.execute("sheet.rename", json!({"sheet": "Web", "name": "Data"})).unwrap(); + { + let d = s.active().unwrap(); + assert_eq!(d.wb.web_queries[0].sheet, "Data"); + } + // The Queries & Connections pane reports the current name too. + let r = s.execute("data.queriesConnections", json!({})).unwrap(); + assert_eq!(r[0]["sheet"], "Data"); + // And a refresh still finds the table on the renamed sheet. + let r = s.execute("data.webRefresh", json!({})).unwrap(); + assert_eq!(r["refreshed"], 1); + } + + #[test] + /// Deleting the imported sheet or converting its table to a range drops the connection. + fn deleting_the_table_or_sheet_drops_the_connection() { + let mut s = session_with_source(); + s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "Web"})).unwrap(); + let tname = { + let d = s.active().unwrap(); + d.wb.active().unwrap().tables[0].name.clone() + }; + s.execute("table.convertToRange", json!({"table": tname})).unwrap(); + { + let d = s.active().unwrap(); + assert!(d.wb.web_queries.is_empty()); + } + let mut s = session_with_source(); + s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "Web"})).unwrap(); + s.execute("home.deleteSheet", json!({"sheet": "Web"})).unwrap(); + let d = s.active().unwrap(); + assert!(d.wb.web_queries.is_empty()); + } + + #[test] + /// A background worker's download re-enters through the command as `prefetched`. + fn preview_and_refresh_run_on_prefetched_tables() { + let mut s = Session::new(); + s.execute("file.new", json!({})).unwrap(); + let r = s + .execute( + "data.webPreview", + json!({ + "url": "https://example.com", + "prefetched": [{"name": "prices", "rows": [["Sym", "Price"], ["A", "1"]]}] + }), + ) + .unwrap(); + assert_eq!(r["tables"][0]["name"], "prices"); + let r = s + .execute( + "data.fromWeb", + json!({"url": "https://example.com", "table": "prices", "prefetched": [{"name": "prices", "rows": [["Sym", "Price"], ["A", "1"]]}]}), + ) + .unwrap(); + assert_eq!(r["rows"], 2); + // The prefetched pages keyed by URL refresh the saved connection. + let r = s + .execute( + "data.webRefresh", + json!({ + "prefetchedByUrl": { + "https://example.com": {"tables": [{"name": "prices", "rows": [["Sym", "Price"], ["B", "2"]]}]} + } + }), + ) + .unwrap(); + assert_eq!(r["refreshed"], 1); + assert_eq!(a1(&s, "A2"), "B"); + } } diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index 1d8d6e1ff7..a4ab6509b9 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -67,8 +67,12 @@ pub struct TableColumn { /// which `data.webRefresh` re-fetches into the same sheet. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct WebQuery { - /// The connection's name: the worksheet table it fills. + /// The connection's name: the worksheet table it fills, kept current when it's renamed. pub name: String, + /// The imported table's stable id: refresh finds the table by it, so a rename never + /// points the connection at a different table that took the former name. + #[serde(default)] + pub table_id: Option, /// The page the table came from. pub url: String, /// The page table's name, as the import saw it. diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index f2671692a5..48c58ef4df 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -37,6 +37,8 @@ impl SheetApp { synthetic_pending: false, synthetic_injected_at_pass: None, control_rx: None, + #[cfg(not(target_arch = "wasm32"))] + fetch: None, grid: grid::GridState { last_border: "bottom".into(), last_fill: "#FFFF00".into(), @@ -227,6 +229,24 @@ impl SheetApp { }); } dialogs::show(self, &ctx); + // A From Web background fetch the open dialog did not consume (ribbon Refresh All, + // or a dialog closed while its fetch ran): apply and report it here. A preview fetch + // with no dialog left is dropped — its tables have nowhere to land. + #[cfg(not(target_arch = "wasm32"))] + { + if self.fetching() { + ctx.request_repaint(); + } else if self.fetch.as_ref().is_some_and(|f| f.command.0 == "data.webRefresh") { + if let Some((_, r)) = self.poll_fetch() { + match r { + Ok(res) => dialogs::report_refresh_all(self, &res), + Err(e) => self.message = Some(("From Web".into(), crate::clean_error(&e))), + } + } + } else if self.fetch.is_some() { + self.fetch = None; + } + } widgets::message_box(self, &ctx); widgets::toast(self, &ctx); // Requests are admitted in `logic`, for the root viewport, so only its pass issues them. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b57230f652..8277b3fb64 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -28,6 +28,7 @@ mod paste; mod show; mod spelling; mod web; +pub(crate) use web::report_refresh_all; mod zoom; use analysis::toolpak_defaults; diff --git a/crates/ui-egui/src/dialogs/web.rs b/crates/ui-egui/src/dialogs/web.rs index a74dcc1012..406dd35c8d 100644 --- a/crates/ui-egui/src/dialogs/web.rs +++ b/crates/ui-egui/src/dialogs/web.rs @@ -1,10 +1,106 @@ use super::show::ok_cancel; use super::*; +/// Starts the preview fetch off the UI thread (native); the wasm build runs it synchronously. +fn start_preview_fetch(app: &mut SheetApp, url: &str) { + #[cfg(not(target_arch = "wasm32"))] + { + app.start_fetch(&[url], ("data.webPreview", json!({"url": url}))); + } + #[cfg(target_arch = "wasm32")] + { + match app.run("data.webPreview", json!({"url": url})) { + Ok(res) => { + if let Some(d) = app.dialog.as_mut() { + d.values.insert("tables".into(), res["tables"].clone()); + } + } + Err(e) => app.message = Some(("From Web".into(), crate::clean_error(&e))), + } + } +} + +/// Applies a finished background fetch's preview: the tables land in the dialog. A still-running +/// fetch keeps the dialog as it was and asks for repaints; the caller shows a spinner. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn poll_preview_fetch(app: &mut SheetApp) -> bool { + if app.fetching() { + return true; + } + if let Some((raw, r)) = app.poll_fetch() { + if let Some(d) = app.dialog.as_mut() { + // The full rows are kept for Load (the preview's own list is truncated to the pane). + d.values.insert("full_tables".into(), raw); + } + match r { + Ok(res) => { + if let Some(d) = app.dialog.as_mut() { + d.values.insert("tables".into(), res["tables"].clone()); + } + } + Err(e) => { + app.message = Some(("From Web".into(), crate::clean_error(&e))); + if let Some(d) = app.dialog.as_mut() { + d.values.insert("tables".into(), Json::Null); + } + } + } + } + false +} + +/// Reports one connection's Sync result: a toast on success, the per-connection error else. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +fn report_sync(app: &mut SheetApp, name: &str, r: &Json) { + let refreshed = r["refreshed"].as_u64().unwrap_or(0); + let errors = r["errors"].as_array().map(Vec::len).unwrap_or(0); + if refreshed > 0 && errors == 0 { + app.toast = Some((format!("Synced {name}"), crate::now_ms())); + } else if let Some(e) = r["errors"].as_array().and_then(|a| a.first()) { + let msg = e["error"].as_str().unwrap_or_default(); + app.message = Some(("Queries & Connections".into(), format!("{name}: {msg}"))); + } +} + +/// Reports a Refresh All result: a toast when everything synced, the joined errors else. +pub(crate) fn report_refresh_all(app: &mut SheetApp, r: &Json) { + let refreshed = r["refreshed"].as_u64().unwrap_or(0); + let errors = r["errors"].as_array().cloned().unwrap_or_default(); + if errors.is_empty() { + app.toast = Some((format!("Synced {refreshed} connection(s)"), crate::now_ms())); + } else { + let msg = errors.iter().filter_map(|e| e["error"].as_str()).collect::>().join("; "); + app.message = Some(("Queries & Connections".into(), msg)); + } +} + +/// Applies a finished background Sync/Refresh All and reports it like the synchronous path. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn poll_refresh_fetch(app: &mut SheetApp) -> bool { + if app.fetching() { + return true; + } + if let Some((_, r)) = app.poll_fetch() { + match r { + Ok(res) => report_refresh_all(app, &res), + Err(e) => app.message = Some(("Queries & Connections".into(), crate::clean_error(&e))), + } + } + false +} + /// Data ▸ Queries & Connections: the workbook's saved From Web connections, each with a /// one-click Sync, its last-synced time, and the refresh errors and unavailable-source /// messages in the same window (Excel's Queries & Connections pane). pub(super) fn queries_connections(app: &mut SheetApp, ui: &mut egui::Ui, _d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + // A background Sync/Refresh All lands here as soon as it finishes. + #[cfg(not(target_arch = "wasm32"))] + { + if poll_refresh_fetch(app) { + ui.ctx().request_repaint(); + ui.spinner(); + } + } let queries = app.run("data.queriesConnections", json!({})).ok().and_then(|r| r.as_array().cloned()).unwrap_or_default(); if queries.is_empty() { ui.label("No saved connections. Import a table with Data ▸ From Web to create one."); @@ -25,17 +121,15 @@ pub(super) fn queries_connections(app: &mut SheetApp, ui: &mut egui::Ui, _d: &mu }; ui.horizontal(|ui| { if ui.button("Sync").clicked() { + let urls: Vec = q.get("url").and_then(Json::as_str).map(str::to_string).into_iter().collect(); + #[cfg(not(target_arch = "wasm32"))] + { + let refs: Vec<&str> = urls.iter().map(String::as_str).collect(); + app.start_fetch(&refs, ("data.webRefresh", json!({"name": name}))); + } + #[cfg(target_arch = "wasm32")] match app.run("data.webRefresh", json!({"name": name})) { - Ok(r) => { - let refreshed = r["refreshed"].as_u64().unwrap_or(0); - let errors = r["errors"].as_array().map(Vec::len).unwrap_or(0); - if refreshed > 0 && errors == 0 { - app.toast = Some((format!("Synced {name}"), crate::now_ms())); - } else if let Some(e) = r["errors"].as_array().and_then(|a| a.first()) { - let msg = e["error"].as_str().unwrap_or_default(); - app.message = Some(("Queries & Connections".into(), format!("{name}: {msg}"))); - } - } + Ok(r) => report_sync(app, name, &r), Err(e) => app.message = Some(("Queries & Connections".into(), crate::clean_error(&e))), } } @@ -50,17 +144,15 @@ pub(super) fn queries_connections(app: &mut SheetApp, ui: &mut egui::Ui, _d: &mu }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("Refresh All").clicked() { + let urls: Vec = queries.iter().filter_map(|q| q.get("url").and_then(Json::as_str).map(str::to_string)).collect(); + #[cfg(not(target_arch = "wasm32"))] + { + let refs: Vec<&str> = urls.iter().map(String::as_str).collect(); + app.start_fetch(&refs, ("data.webRefresh", json!({}))); + } + #[cfg(target_arch = "wasm32")] match app.run("data.webRefresh", json!({})) { - Ok(r) => { - let refreshed = r["refreshed"].as_u64().unwrap_or(0); - let errors = r["errors"].as_array().cloned().unwrap_or_default(); - if errors.is_empty() { - app.toast = Some((format!("Synced {refreshed} connection(s)"), crate::now_ms())); - } else { - let msg = errors.iter().filter_map(|e| e["error"].as_str()).collect::>().join("; "); - app.message = Some(("Queries & Connections".into(), msg)); - } - } + Ok(r) => report_refresh_all(app, &r), Err(e) => app.message = Some(("Queries & Connections".into(), crate::clean_error(&e))), } } @@ -86,6 +178,7 @@ pub(super) fn from_web(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, co if d.values.get("preview_url").and_then(Json::as_str).is_some_and(|u| u != url.trim()) { d.values.remove("preview_url"); d.values.remove("tables"); + d.values.remove("full_tables"); d.values.remove("pick"); } if ui.button("Go").clicked() && !url.trim().is_empty() { @@ -93,17 +186,18 @@ pub(super) fn from_web(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, co d.values.insert("preview_url".into(), json!(url.trim())); d.values.insert("tables".into(), Json::Null); d.values.insert("pick".into(), json!(0)); - let r = app.run("data.webPreview", json!({"url": url.trim()})); - match r { - Ok(res) => d.values.insert("tables".into(), res["tables"].clone()), - Err(e) => { - app.message = Some(("From Web".into(), crate::clean_error(&e))); - d.values.insert("tables".into(), Json::Null) - } - }; + start_preview_fetch(app, url.trim()); } ui.end_row(); }); + // A background preview fetch lands here as soon as it finishes. + #[cfg(not(target_arch = "wasm32"))] + { + if poll_preview_fetch(app) { + ui.ctx().request_repaint(); + ui.spinner(); + } + } let tables: Vec = d.values.get("tables").and_then(Json::as_array).cloned().unwrap_or_default(); if !tables.is_empty() { ui.separator(); @@ -161,7 +255,13 @@ pub(super) fn from_web(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, co // A page can hold duplicate table names: which one was picked is which one to // import, and the connection saves it so refreshes keep importing the same. let occurrence = tables.iter().take(pick).filter(|o| o["name"].as_str().is_some_and(|n| n.eq_ignore_ascii_case(&t))).count(); - json!({"url": url.trim(), "table": t, "occurrence": occurrence}) + let mut p = json!({"url": url.trim(), "table": t, "occurrence": occurrence}); + // The page is already downloaded (the preview fetched it): hand the full tables + // over instead of fetching again. + if let Some(full) = d.values.get("full_tables") { + p.as_object_mut().map(|o| o.insert("prefetched".into(), full.clone())); + } + p } None => json!({"url": url.trim()}), }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8618deb86f..75f14f15e2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -38,6 +38,10 @@ pub mod tabs; pub mod text_box; pub mod theme; #[cfg(not(target_arch = "wasm32"))] +mod web_fetch; +#[cfg(not(target_arch = "wasm32"))] +pub use web_fetch::PendingFetch; +#[cfg(not(target_arch = "wasm32"))] mod updates_ui; pub mod widgets; mod window_ui; @@ -217,6 +221,9 @@ pub struct SheetApp { /// without reaching input handling, so the hook must offer the same step again. pub(crate) synthetic_injected_at_pass: Option, pub control_rx: Option>, + /// The From Web page fetch running on a worker thread (native only; see `web_fetch`). + #[cfg(not(target_arch = "wasm32"))] + pub fetch: Option, pub grid: grid::GridState, pub perf: Perf, pub fonts_ready: bool, diff --git a/crates/ui-egui/src/ribbon/data.rs b/crates/ui-egui/src/ribbon/data.rs index 93043a97bc..9234fda89c 100644 --- a/crates/ui-egui/src/ribbon/data.rs +++ b/crates/ui-egui/src/ribbon/data.rs @@ -21,7 +21,20 @@ pub(super) fn data(app: &mut SheetApp, ui: &mut Ui) { let ra = small_button(ui, Icon::Redo, "", "Refresh All (re-sync every saved web table)", false); control_keytip(app, ui, &ra, "data.webRefresh"); if ra.clicked() { - act(app, "data.webRefresh", json!({})); + // The fetch runs in the background (the ribbon keeps responding); the app-level + // poll applies and reports it. Per-connection failures come back as `errors` on a + // successful result, which `act`'s alert path never shows. + #[cfg(not(target_arch = "wasm32"))] + { + let urls: Vec = app.session.active().map(|d| d.wb.web_queries.iter().map(|q| q.url.clone()).collect()).unwrap_or_default(); + let refs: Vec<&str> = urls.iter().map(String::as_str).collect(); + app.start_fetch(&refs, ("data.webRefresh", json!({}))); + } + #[cfg(target_arch = "wasm32")] + match app.run("data.webRefresh", json!({})) { + Ok(r) => crate::dialogs::report_refresh_all(app, &r), + Err(e) => app.message = Some(("Refresh All".into(), crate::clean_error(&e))), + } } }); sep(ui); diff --git a/crates/ui-egui/src/web_fetch.rs b/crates/ui-egui/src/web_fetch.rs new file mode 100644 index 0000000000..76a8ac8d6b --- /dev/null +++ b/crates/ui-egui/src/web_fetch.rs @@ -0,0 +1,95 @@ +//! The From Web background fetch: page downloads run on a worker thread and their results +//! re-enter through the normal engine commands as `prefetchedByUrl` (tables keyed by the URL +//! they came from), so a slow page never blocks the UI (Excel runs its queries in the +//! background too). The web build has no threads: its fetches stay on the synchronous path. + +use serde_json::{Value as Json, json}; + +use crate::SheetApp; + +/// One in-flight fetch: the URLs to download and the command its results feed. +pub struct PendingFetch { + urls: Vec, + /// The command the results feed (`data.webPreview`, `data.webRefresh`). + pub(crate) command: (String, Json), + handle: Option>, +} + +/// The fetched tables of one page, as the `prefetchedByUrl` value holds them. +fn table_json(t: &gridcraft_engine::webquery::WebTable) -> Json { + json!({"name": t.name, "rows": t.rows}) +} + +impl SheetApp { + /// Starts fetching `urls` in the background (a no-op retry if the same fetch is already + /// running); [`poll_fetch`](Self::poll_fetch) runs `command` with the results once they + /// are in. + #[cfg(not(target_arch = "wasm32"))] + pub fn start_fetch(&mut self, urls: &[&str], command: (&str, Json)) { + if self.fetch.as_ref().is_some_and(|f| f.urls == urls.iter().map(|s| s.to_string()).collect::>()) { + return; + } + let source = match self.session.web_source.clone() { + Some(s) => s, + None => { + self.message = Some(("From Web".into(), "Web page import is not available in this build.".into())); + return; + } + }; + let urls: Vec = urls.iter().map(|s| s.to_string()).collect(); + let fetch_urls = urls.clone(); + let handle = std::thread::spawn(move || { + // Each page lands under its own URL; a failed page records its error instead. + let mut map = serde_json::Map::new(); + for url in &fetch_urls { + let req = gridcraft_engine::webquery::WebRequest { + url: url.clone(), + max_rows: gridcraft_engine::webquery::DEFAULT_MAX_ROWS, + max_cols: gridcraft_engine::webquery::MAX_COLS, + max_bytes: gridcraft_engine::webquery::DEFAULT_MAX_BYTES, + }; + let entry = match source(&req) { + Ok(tables) => json!({"tables": tables.iter().map(table_json).collect::>()}), + Err(e) => json!({"error": e}), + }; + map.insert(url.clone(), entry); + } + Json::Object(map) + }); + self.fetch = Some(PendingFetch { urls, command: (command.0.to_string(), command.1), handle: Some(handle) }); + } + + /// True while a fetch is still downloading, for a spinner and repaints. + pub fn fetching(&self) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + self.fetch.as_ref().is_some_and(|f| f.handle.as_ref().is_some_and(|h| !h.is_finished())) + } + #[cfg(target_arch = "wasm32")] + { + false + } + } + + /// Applies a finished fetch: the downloaded pages go to `command` as `prefetchedByUrl` and + /// both the raw map and the command's result come back to the caller. + #[cfg(not(target_arch = "wasm32"))] + pub fn poll_fetch(&mut self) -> Option<(Json, Result)> { + if !self.fetch.as_ref().is_some_and(|f| f.handle.as_ref().is_none_or(|h| h.is_finished())) { + return None; + } + let fetch = self.fetch.take()?; + let pages = fetch.handle.and_then(|h| h.join().ok()); + let (id, mut params) = fetch.command; + match pages { + Some(pages) => { + params.as_object_mut().map(|o| o.insert("prefetchedByUrl".into(), pages.clone())); + Some((pages, self.run(&id, params))) + } + None => { + self.message = Some(("From Web".into(), "The page fetch was cancelled.".into())); + Some((Json::Null, Ok(Json::Null))) + } + } + } +} From 4c07d9a8f44f3e145ab67540b1d7e6c2fb2e18ee Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Tue, 27 Oct 2026 05:29:17 +0000 Subject: [PATCH 17/31] =?UTF-8?q?From=20Web:=20review=20hardening=20?= =?UTF-8?q?=E2=80=94=20dialog-owned=20fetch=20results,=20staleness=20guard?= =?UTF-8?q?,=20choke-point=20connection=20prune,=20wasm=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: setzor --- crates/engine/src/cmd.rs | 15 +++++ crates/engine/src/cmd/insert/table.rs | 14 +---- crates/engine/src/cmd/sheet/tabs.rs | 11 ---- crates/engine/src/cmd/web.rs | 57 +++++++++-------- crates/ui-egui/src/app.rs | 7 ++- crates/ui-egui/src/dialogs/web.rs | 88 ++++++++++++++++++--------- crates/ui-egui/src/web_fetch.rs | 12 ++-- 7 files changed, 119 insertions(+), 85 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index cbbf34ee2f..8c6b148457 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -398,6 +398,21 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) if let Some(t0) = t0 { eprintln!("commit: + rows {:?}", t0.elapsed()); } + // A saved From Web connection whose table is gone goes with it, whatever deleted the table + // (deleted rows or columns collapsing its range, Convert to Range, a deleted sheet): a + // dangling id could later match a new table that reuses the id. + if !wb.web_queries.is_empty() { + let mut live = Vec::new(); + for sh in wb.sheets.iter() { + for t in sh.tables.iter() { + if t.id as usize >= live.len() { + live.resize(t.id as usize + 1, false); + } + live[t.id as usize] = true; + } + } + wb.web_queries.retain(|q| q.table_id.is_none_or(|id| live.get(id as usize).is_some_and(|l| *l))); + } d.wb = std::sync::Arc::new(wb); d.selection = sel; Ok(r) diff --git a/crates/engine/src/cmd/insert/table.rs b/crates/engine/src/cmd/insert/table.rs index 98624044e0..7a5f41c0d1 100644 --- a/crates/engine/src/cmd/insert/table.rs +++ b/crates/engine/src/cmd/insert/table.rs @@ -283,7 +283,8 @@ pub(super) fn table_rename(s: &mut Session, p: &Json) -> Result { t.name = name.clone(); } // A saved From Web connection to this table follows the rename. - crate::cmd::web::rename_connection(&mut cx.wb, &old, &name); + let sheet = cx.wb.sheet(si).map(|sh| sh.name.clone()).unwrap_or_default(); + crate::cmd::web::rename_connection(&mut cx.wb, &sheet, &old, &name); // Structured references follow the rename. for i in 0..cx.wb.sheets.len() { let keys: Vec<(CellRef, std::sync::Arc)> = @@ -327,17 +328,6 @@ pub(super) fn table_convert(s: &mut Session, p: &Json) -> Result { if let Some(sh) = wb.sheet_mut(si) { sh.tables.remove(ti); } - // A saved From Web connection whose table is gone goes with it. - let mut live = Vec::new(); - for sh in wb.sheets.iter() { - for t in sh.tables.iter() { - if t.id as usize >= live.len() { - live.resize(t.id as usize + 1, false); - } - live[t.id as usize] = true; - } - } - crate::cmd::web::retain_connections(wb, &live); cx.structural = true; Ok(Json::Null) }) diff --git a/crates/engine/src/cmd/sheet/tabs.rs b/crates/engine/src/cmd/sheet/tabs.rs index 183b8bc472..eda12e14f5 100644 --- a/crates/engine/src/cmd/sheet/tabs.rs +++ b/crates/engine/src/cmd/sheet/tabs.rs @@ -64,17 +64,6 @@ pub(super) fn delete_sheet(s: &mut Session, p: &Json) -> Result { } } cx.wb.names.retain(|n| n.scope != Some(i)); - // Saved From Web connections whose tables lived on the deleted sheet go with it. - let mut live = Vec::new(); - for sh in cx.wb.sheets.iter() { - for t in sh.tables.iter() { - if t.id as usize >= live.len() { - live.resize(t.id as usize + 1, false); - } - live[t.id as usize] = true; - } - } - crate::cmd::web::retain_connections(&mut cx.wb, &live); for n in cx.wb.names.iter_mut() { if let Some(sc) = n.scope.as_mut() && *sc > i diff --git a/crates/engine/src/cmd/web.rs b/crates/engine/src/cmd/web.rs index 07ad9cc273..3f53c211c1 100644 --- a/crates/engine/src/cmd/web.rs +++ b/crates/engine/src/cmd/web.rs @@ -93,12 +93,15 @@ fn from_web(s: &mut Session, p: &Json) -> Result { load_table(s, &req, table, &name, true, occurrence) } -/// The page's tables: the `prefetched` result a background worker already downloaded, else the -/// fetch source's own download. +/// The page's tables: a background worker's download (`prefetched` for this URL, or the +/// `prefetchedByUrl` entry for it), else the fetch source's own download. fn fetch_tables(s: &Session, req: &WebRequest, p: &Json) -> Result> { if p.get("prefetched").is_some() { return prefetched_tables(p); } + if p.get("prefetchedByUrl").is_some() { + return prefetched_for_url(p, &req.url); + } let Some(source) = s.web_source.clone() else { return Err(EngineError::Other("Web page import is not available in this build.".into())); }; @@ -224,21 +227,16 @@ fn load_table_steps(s: &mut Session, req: &WebRequest, table: &WebTable, sheet_n /// Keeps the saved connections pointing at their table after `table.rename`: the connection's /// `name` follows, so the Queries & Connections pane and `data.webRefresh` name lookups stay /// current. The stable `table_id` already finds the table; this keeps the human-facing name. -pub(crate) fn rename_connection(wb: &mut Workbook, old: &str, name: &str) { +/// Only connections on the renamed table's sheet match: another sheet's connection may name a +/// different table by the same name. +pub(crate) fn rename_connection(wb: &mut Workbook, sheet: &str, old: &str, name: &str) { for q in wb.web_queries.iter_mut() { - if q.name.eq_ignore_ascii_case(old) { + if q.sheet.eq_ignore_ascii_case(sheet) && q.name.eq_ignore_ascii_case(old) { q.name = name.to_string(); } } } -/// Drops the saved connection whose table is gone (`table.convertToRange`, `home.deleteSheet`): -/// a connection to a deleted table must not sit in the Queries & Connections pane, and a -/// refresh must not go looking for a name another table may since have taken. -pub(crate) fn retain_connections(wb: &mut Workbook, live: &[bool]) { - wb.web_queries.retain(|q| q.table_id.is_none_or(|id| live.get(id as usize).is_some_and(|l| *l))); -} - /// Keeps the connection's sheet name current after `sheet.rename`. pub(crate) fn rename_connection_sheet(wb: &mut Workbook, old: &str, name: &str) { for q in wb.web_queries.iter_mut() { @@ -309,9 +307,6 @@ fn web_preview(s: &mut Session, p: &Json) -> Result { fn web_refresh(s: &mut Session, p: &Json) -> Result { let url = str_param(p, "url").map(str::trim).filter(|u| !u.is_empty()); let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()); - if p.get("prefetched").is_none() && p.get("prefetchedByUrl").is_none() && s.web_source.is_none() { - return Err(EngineError::Other("Web page import is not available in this build.".into())); - } let queries: Vec = { let d = s.doc()?; d.wb.web_queries @@ -327,9 +322,9 @@ fn web_refresh(s: &mut Session, p: &Json) -> Result { let mut errors: Vec = Vec::new(); for q in queries { let req = WebRequest { url: q.url.clone(), max_rows: DEFAULT_MAX_ROWS, max_cols: MAX_COLS, max_bytes: DEFAULT_MAX_BYTES }; - // A background worker's downloads come keyed by URL; without them, the fetch runs here. - let tables = if p.get("prefetchedByUrl").is_some() { prefetched_for_url(p, &q.url) } else { fetch_tables(s, &req, p) }; - match tables.and_then(|tables| { + // A background worker's downloads come through `fetch_tables` (keyed by URL); without + // them, the fetch runs here. + match fetch_tables(s, &req, p).and_then(|tables| { let table = pick_table(&tables, Some(&q.table), q.occurrence).ok_or_else(|| EngineError::Other(no_table_message(&tables)))?; rewrite_table(s, &q, table) }) { @@ -350,16 +345,12 @@ fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTabl let d = s.doc()?; // Only the connection's own table. The stable id decides: a renamed table keeps refreshing, // and a different table created with the former name is never selected. Workbooks saved - // before the id fall back to the same name on the connection's sheet, else a name match - // anywhere. Never the first unrelated table that happens to be there. + // before connections held the id fall back to the name match, as they always did. let by_id = |t: &gridcraft_model::Table| q.table_id.is_some_and(|id| t.id == id); let by_name = |t: &gridcraft_model::Table| q.table_id.is_none() && t.name.eq_ignore_ascii_case(&q.name); - let Some((sheet, tname, old)) = d.wb.sheets.iter().enumerate().find_map(|(si, sh)| { - sh.tables - .iter() - .find(|t| by_id(t) || (by_name(t) && q.sheet.is_empty()) || (by_name(t) && sh.name.eq_ignore_ascii_case(&q.sheet))) - .map(|t| (si, t.name.clone(), t.range)) - }) else { + let Some((sheet, tname, old)) = + d.wb.sheets.iter().enumerate().find_map(|(si, sh)| sh.tables.iter().find(|t| by_id(t) || by_name(t)).map(|t| (si, t.name.clone(), t.range))) + else { return Err(EngineError::Other(format!("The sheet or table of the connection `{}` is gone.", q.name))); }; let rows = json_rows(table); @@ -475,7 +466,7 @@ fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTabl /// The `prefetched` param's value: tables a background worker already downloaded for this URL. /// The UI thread runs the fetch off itself and feeds the result back through the command, so a /// slow page never blocks the interface (Excel's queries run in the background too). -pub(crate) fn prefetched_tables(p: &Json) -> Result> { +fn prefetched_tables(p: &Json) -> Result> { let raw = p.get("prefetched").ok_or_else(|| EngineError::Other("missing `prefetched`".into()))?; let mut out = Vec::new(); for t in raw.as_array().ok_or_else(|| EngineError::Other("`prefetched` must be an array of tables".into()))? { @@ -960,6 +951,20 @@ mod tests { assert!(d.wb.web_queries.is_empty()); } + #[test] + /// Deleting the rows a web table spans drops its connection, whatever deleted the table + /// (the choke-point prune in `commit`), so its id cannot later match a reused id. + fn deleting_the_table_s_rows_drops_the_connection() { + let mut s = session_with_source(); + s.execute("data.fromWeb", json!({"url": "https://example.com", "sheet": "Web"})).unwrap(); + // Delete the rows the imported table spans: the table goes with them. + s.execute("sheet.activate", json!({"sheet": "Web"})).unwrap(); + s.execute("home.deleteRows", json!({"rows": "1:2"})).unwrap(); + let d = s.active().unwrap(); + assert!(d.wb.active().unwrap().tables.is_empty()); + assert!(d.wb.web_queries.is_empty()); + } + #[test] /// A background worker's download re-enters through the command as `prefetched`. fn preview_and_refresh_run_on_prefetched_tables() { diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 48c58ef4df..e3f5228ff8 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -230,8 +230,8 @@ impl SheetApp { } dialogs::show(self, &ctx); // A From Web background fetch the open dialog did not consume (ribbon Refresh All, - // or a dialog closed while its fetch ran): apply and report it here. A preview fetch - // with no dialog left is dropped — its tables have nowhere to land. + // or a dialog closed while its fetch ran): a refresh is applied and reported here; a + // preview or import whose dialog is gone is dropped — its result has nowhere to land. #[cfg(not(target_arch = "wasm32"))] { if self.fetching() { @@ -244,7 +244,8 @@ impl SheetApp { } } } else if self.fetch.is_some() { - self.fetch = None; + // Join the finished worker so its result is freed, then drop it. + let _ = self.poll_fetch(); } } widgets::message_box(self, &ctx); diff --git a/crates/ui-egui/src/dialogs/web.rs b/crates/ui-egui/src/dialogs/web.rs index 406dd35c8d..e61d2664a6 100644 --- a/crates/ui-egui/src/dialogs/web.rs +++ b/crates/ui-egui/src/dialogs/web.rs @@ -1,48 +1,58 @@ use super::show::ok_cancel; use super::*; -/// Starts the preview fetch off the UI thread (native); the wasm build runs it synchronously. -fn start_preview_fetch(app: &mut SheetApp, url: &str) { +/// Starts the preview fetch off the UI thread (native); the wasm build runs it synchronously, +/// straight into the dialog being drawn. +fn start_preview_fetch(app: &mut SheetApp, d: &mut Dialog, url: &str) { #[cfg(not(target_arch = "wasm32"))] { + let _ = d; app.start_fetch(&[url], ("data.webPreview", json!({"url": url}))); } #[cfg(target_arch = "wasm32")] - { - match app.run("data.webPreview", json!({"url": url})) { - Ok(res) => { - if let Some(d) = app.dialog.as_mut() { - d.values.insert("tables".into(), res["tables"].clone()); - } - } - Err(e) => app.message = Some(("From Web".into(), crate::clean_error(&e))), + match app.run("data.webPreview", json!({"url": url})) { + Ok(res) => { + d.values.insert("tables".into(), res["tables"].clone()); } + Err(e) => app.message = Some(("From Web".into(), crate::clean_error(&e))), } } -/// Applies a finished background fetch's preview: the tables land in the dialog. A still-running -/// fetch keeps the dialog as it was and asks for repaints; the caller shows a spinner. +/// Applies a finished background preview fetch: the tables land in the dialog the caller is +/// drawing (`app.dialog` is taken while a dialog renders, so the result must go to `d`). A +/// still-running fetch keeps the dialog as it was and asks for repaints; the caller shows a +/// spinner. A result for a URL the user has since edited away is dropped. #[cfg(not(target_arch = "wasm32"))] -pub(super) fn poll_preview_fetch(app: &mut SheetApp) -> bool { +pub(super) fn poll_preview_fetch(app: &mut SheetApp, d: &mut Dialog, url: &str, open: &mut bool) -> bool { if app.fetching() { return true; } - if let Some((raw, r)) = app.poll_fetch() { - if let Some(d) = app.dialog.as_mut() { - // The full rows are kept for Load (the preview's own list is truncated to the pane). - d.values.insert("full_tables".into(), raw); + // The URL the running fetch was started for: an address edited since invalidates the + // result (the dialog state was cleared when it changed), so an old page never lands under + // the new URL. + let fetch_url = app.fetch.as_ref().and_then(|f| f.command.1.get("url")).and_then(Json::as_str).map(str::to_string); + if let Some((pages, r)) = app.poll_fetch() { + if fetch_url.as_deref().is_some_and(|u| u != url.trim()) { + return false; } match r { Ok(res) => { - if let Some(d) = app.dialog.as_mut() { + // The full rows are kept for Load (the preview's own list is truncated to the + // pane); a `data.fromWeb` fetch (Load before Go) imports straight away. + if let Some(page) = pages.get(url.trim()) { + d.values.insert("full_tables".into(), page["tables"].clone()); + } + if res.get("sheet").is_some() { + // Load before Go landed: the import ran, the dialog closes with its toast. + app.toast = Some((format!("Imported {} rows into {}", res["rows"], res["sheet"]), crate::now_ms())); + *open = false; + } else { d.values.insert("tables".into(), res["tables"].clone()); } } Err(e) => { app.message = Some(("From Web".into(), crate::clean_error(&e))); - if let Some(d) = app.dialog.as_mut() { - d.values.insert("tables".into(), Json::Null); - } + d.values.insert("tables".into(), Json::Null); } } } @@ -50,7 +60,6 @@ pub(super) fn poll_preview_fetch(app: &mut SheetApp) -> bool { } /// Reports one connection's Sync result: a toast on success, the per-connection error else. -#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] fn report_sync(app: &mut SheetApp, name: &str, r: &Json) { let refreshed = r["refreshed"].as_u64().unwrap_or(0); let errors = r["errors"].as_array().map(Vec::len).unwrap_or(0); @@ -74,15 +83,21 @@ pub(crate) fn report_refresh_all(app: &mut SheetApp, r: &Json) { } } -/// Applies a finished background Sync/Refresh All and reports it like the synchronous path. +/// Applies a finished background Sync/Refresh All and reports it like the synchronous path: +/// a single connection's Sync reports by name, a batch reports as Refresh All. #[cfg(not(target_arch = "wasm32"))] pub(super) fn poll_refresh_fetch(app: &mut SheetApp) -> bool { if app.fetching() { return true; } + let sync_name = app.fetch.as_ref().and_then(|f| f.command.1.get("name").and_then(Json::as_str)).map(str::to_string); if let Some((_, r)) = app.poll_fetch() { match r { - Ok(res) => report_refresh_all(app, &res), + Ok(res) => match &sync_name { + // A single connection's Sync reports by its name, like the synchronous path. + Some(name) => report_sync(app, name, &res), + None => report_refresh_all(app, &res), + }, Err(e) => app.message = Some(("Queries & Connections".into(), crate::clean_error(&e))), } } @@ -119,8 +134,9 @@ pub(super) fn queries_connections(app: &mut SheetApp, ui: &mut egui::Ui, _d: &mu Some(ms) => ui.weak(format!("Last synced: {}", crate::format_sync_time(ms))), None => ui.weak("Last synced: never"), }; + let is_web = q.get("web").and_then(Json::as_bool).unwrap_or(false); ui.horizontal(|ui| { - if ui.button("Sync").clicked() { + if is_web && ui.button("Sync").clicked() { let urls: Vec = q.get("url").and_then(Json::as_str).map(str::to_string).into_iter().collect(); #[cfg(not(target_arch = "wasm32"))] { @@ -143,8 +159,13 @@ pub(super) fn queries_connections(app: &mut SheetApp, ui: &mut egui::Ui, _d: &mu } }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("Refresh All").clicked() { - let urls: Vec = queries.iter().filter_map(|q| q.get("url").and_then(Json::as_str).map(str::to_string)).collect(); + let any_web = queries.iter().any(|q| q.get("web").and_then(Json::as_bool).unwrap_or(false)); + if ui.button("Refresh All").clicked() && any_web { + let urls: Vec = queries + .iter() + .filter(|q| q.get("web").and_then(Json::as_bool).unwrap_or(false)) + .filter_map(|q| q.get("url").and_then(Json::as_str).map(str::to_string)) + .collect(); #[cfg(not(target_arch = "wasm32"))] { let refs: Vec<&str> = urls.iter().map(String::as_str).collect(); @@ -186,14 +207,14 @@ pub(super) fn from_web(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, co d.values.insert("preview_url".into(), json!(url.trim())); d.values.insert("tables".into(), Json::Null); d.values.insert("pick".into(), json!(0)); - start_preview_fetch(app, url.trim()); + start_preview_fetch(app, d, url.trim()); } ui.end_row(); }); // A background preview fetch lands here as soon as it finishes. #[cfg(not(target_arch = "wasm32"))] { - if poll_preview_fetch(app) { + if poll_preview_fetch(app, d, &url, open) { ui.ctx().request_repaint(); ui.spinner(); } @@ -247,6 +268,15 @@ pub(super) fn from_web(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, co app.message = Some(("From Web".into(), "Enter an https:// address first.".into())); *confirm = false; } + if *confirm && !url.trim().is_empty() && d.values.get("full_tables").is_none() { + // Load without a downloaded page (no Go yet, or the fetch still running): fetch in the + // background and import when it lands, instead of blocking the interface on the fetch. + #[cfg(not(target_arch = "wasm32"))] + { + app.start_fetch(&[url.trim()], ("data.fromWeb", json!({"url": url.trim()}))); + *confirm = false; + } + } if *confirm && !url.trim().is_empty() { let pick = d.values.get("pick").and_then(Json::as_u64).unwrap_or(0) as usize; let table = tables.get(pick).and_then(|t| t["name"].as_str()).map(String::from); diff --git a/crates/ui-egui/src/web_fetch.rs b/crates/ui-egui/src/web_fetch.rs index 76a8ac8d6b..04a6be6d2d 100644 --- a/crates/ui-egui/src/web_fetch.rs +++ b/crates/ui-egui/src/web_fetch.rs @@ -21,12 +21,16 @@ fn table_json(t: &gridcraft_engine::webquery::WebTable) -> Json { } impl SheetApp { - /// Starts fetching `urls` in the background (a no-op retry if the same fetch is already - /// running); [`poll_fetch`](Self::poll_fetch) runs `command` with the results once they - /// are in. + /// Starts fetching `urls` in the background (a no-op retry if the same fetch for the same + /// command is already running); [`poll_fetch`](Self::poll_fetch) runs `command` with the + /// results once they are in. An empty URL list starts nothing: there is nothing to fetch. #[cfg(not(target_arch = "wasm32"))] pub fn start_fetch(&mut self, urls: &[&str], command: (&str, Json)) { - if self.fetch.as_ref().is_some_and(|f| f.urls == urls.iter().map(|s| s.to_string()).collect::>()) { + if urls.is_empty() { + return; + } + let urls: Vec = urls.iter().map(|s| s.to_string()).collect(); + if self.fetch.as_ref().is_some_and(|f| f.urls == urls && f.command.0 == command.0 && f.command.1 == command.1) { return; } let source = match self.session.web_source.clone() { From cededfe6d3aecc269e1f9797aca9c4d598ae21f0 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Tue, 27 Oct 2026 05:58:16 +0000 Subject: [PATCH 18/31] =?UTF-8?q?From=20Web:=20review=20round=202=20?= =?UTF-8?q?=E2=80=94=20HashSet=20for=20live=20table=20ids,=20drop=20empty?= =?UTF-8?q?=20prefetched=20rows,=20keep=20finished=20fetches=20for=20the?= =?UTF-8?q?=20open=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: setzor --- crates/engine/src/cmd.rs | 15 ++++----------- crates/engine/src/cmd/web.rs | 21 +++++++++++++++++++-- crates/ui-egui/src/app.rs | 11 +++++++++-- 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 8c6b148457..26d931d7bc 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -400,18 +400,11 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) } // A saved From Web connection whose table is gone goes with it, whatever deleted the table // (deleted rows or columns collapsing its range, Convert to Range, a deleted sheet): a - // dangling id could later match a new table that reuses the id. + // dangling id could later match a new table that reuses the id. A set, not a sparse vector: + // ids come from opened files, so the allocation must depend on the table count, not the id. if !wb.web_queries.is_empty() { - let mut live = Vec::new(); - for sh in wb.sheets.iter() { - for t in sh.tables.iter() { - if t.id as usize >= live.len() { - live.resize(t.id as usize + 1, false); - } - live[t.id as usize] = true; - } - } - wb.web_queries.retain(|q| q.table_id.is_none_or(|id| live.get(id as usize).is_some_and(|l| *l))); + let live: std::collections::HashSet = wb.sheets.iter().flat_map(|sh| sh.tables.iter().map(|t| t.id)).collect(); + wb.web_queries.retain(|q| q.table_id.is_none_or(|id| live.contains(&id))); } d.wb = std::sync::Arc::new(wb); d.selection = sel; diff --git a/crates/engine/src/cmd/web.rs b/crates/engine/src/cmd/web.rs index 3f53c211c1..6a522c42d1 100644 --- a/crates/engine/src/cmd/web.rs +++ b/crates/engine/src/cmd/web.rs @@ -471,11 +471,16 @@ fn prefetched_tables(p: &Json) -> Result> { let mut out = Vec::new(); for t in raw.as_array().ok_or_else(|| EngineError::Other("`prefetched` must be an array of tables".into()))? { let name = t["name"].as_str().unwrap_or_default().to_string(); - let rows = t["rows"] + // Empty rows are dropped, not kept as zero-cell rows: the widest row then always has + // at least one cell, so the width arithmetic below cannot underflow (an all-empty + // table reaches the existing "The table is empty." refusal instead). + let rows: Vec> = t["rows"] .as_array() .map(|rs| { rs.iter() - .map(|r| r.as_array().map(|cs| cs.iter().map(|c| c.as_str().unwrap_or_default().to_string()).collect()).unwrap_or_default()) + .filter_map(|r| r.as_array()) + .map(|cs| cs.iter().map(|c| c.as_str().unwrap_or_default().to_string()).collect::>()) + .filter(|r| !r.is_empty()) .collect() }) .unwrap_or_default(); @@ -965,6 +970,18 @@ mod tests { assert!(d.wb.web_queries.is_empty()); } + #[test] + /// A prefetched table of only empty rows is refused, not a zero-width import: the width + /// arithmetic must never see an empty widest row (the u32 underflow CodeRabbit flagged). + fn prefetched_tables_with_only_empty_rows_are_refused() { + let mut s = Session::new(); + s.execute("file.new", json!({})).unwrap(); + let err = s + .execute("data.fromWeb", json!({"url": "https://example.com", "table": "t", "prefetched": [{"name": "t", "rows": [[], []]}]})) + .unwrap_err(); + assert!(err.to_string().contains("The table is empty.")); + } + #[test] /// A background worker's download re-enters through the command as `prefetched`. fn preview_and_refresh_run_on_prefetched_tables() { diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index e3f5228ff8..13697f74d2 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -244,8 +244,15 @@ impl SheetApp { } } } else if self.fetch.is_some() { - // Join the finished worker so its result is freed, then drop it. - let _ = self.poll_fetch(); + // Only the From Web dialogs poll a finished fetch into place; any other state + // (no dialog, a different dialog) joins the finished worker so its result is + // freed, then drops it. + let polls = self.dialog.as_ref().is_some_and(|d| matches!(d.name.as_str(), "fromWeb" | "queriesConnections")); + if polls { + ctx.request_repaint(); + } else { + let _ = self.poll_fetch(); + } } } widgets::message_box(self, &ctx); From 39e6944f515b27792c19a680716dc3869624fa38 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Tue, 27 Oct 2026 07:00:16 +0000 Subject: [PATCH 19/31] From Web: https-only URLs before the prefetched paths; Queries & Connections polls only its own refreshes Co-authored-by: setzor --- crates/engine/src/cmd/web.rs | 7 ++++++- crates/ui-egui/src/app.rs | 11 +++++++---- crates/ui-egui/src/dialogs/web.rs | 7 +++++-- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/web.rs b/crates/engine/src/cmd/web.rs index 6a522c42d1..7ef4d42167 100644 --- a/crates/engine/src/cmd/web.rs +++ b/crates/engine/src/cmd/web.rs @@ -66,7 +66,9 @@ fn now_ms() -> u64 { /// A page request from the command params, with the caps clamped (shared by the three commands). fn request(p: &Json) -> Result { const ID: &str = "data.fromWeb"; - let url = str_param(p, "url").map(str::trim).filter(|u| u.starts_with("http")).ok_or_else(|| bad(ID, "missing `url`"))?; + // HTTPS only, like the fetch provider itself: a plain-http address must be refused before + // the prefetched paths too, or a caller-supplied page bypasses the https-only contract. + let url = str_param(p, "url").map(str::trim).filter(|u| u.starts_with("https://")).ok_or_else(|| bad(ID, "missing `url`"))?; let max_rows = p .get("maxRows") .and_then(Json::as_u64) @@ -862,6 +864,9 @@ mod tests { assert!(err.to_string().contains("not available")); assert!(s.execute("data.fromWeb", json!({"url": "ftp://x"})).is_err()); assert!(s.execute("data.fromWeb", json!({})).is_ok()); // opens the dialog + // A plain-http address is refused even with tables supplied (prefetched), keeping the + // https-only contract; https with prefetched tables still imports. + assert!(s.execute("data.fromWeb", json!({"url": "http://x.com", "prefetched": [{"name": "t", "rows": [["a"]]}]})).is_err()); let mut s = session_with_source(); let err = s.execute("data.fromWeb", json!({"url": "https://example.com", "table": "nope"})).unwrap_err(); assert!(err.to_string().contains("no table by that name") || err.to_string().to_lowercase().contains("no table")); diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 13697f74d2..b593a8e79d 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -244,10 +244,13 @@ impl SheetApp { } } } else if self.fetch.is_some() { - // Only the From Web dialogs poll a finished fetch into place; any other state - // (no dialog, a different dialog) joins the finished worker so its result is - // freed, then drops it. - let polls = self.dialog.as_ref().is_some_and(|d| matches!(d.name.as_str(), "fromWeb" | "queriesConnections")); + // Only the From Web dialog polls a finished preview or import into place; + // Queries & Connections polls its own refreshes too (routed by command in + // `poll_refresh_fetch`). Any other state joins the finished worker so its + // result is freed, then drops it. + let polls = self.dialog.as_ref().is_some_and(|d| { + d.name == "fromWeb" || (d.name == "queriesConnections" && self.fetch.as_ref().is_some_and(|f| f.command.0 == "data.webRefresh")) + }); if polls { ctx.request_repaint(); } else { diff --git a/crates/ui-egui/src/dialogs/web.rs b/crates/ui-egui/src/dialogs/web.rs index e61d2664a6..8318e3ce77 100644 --- a/crates/ui-egui/src/dialogs/web.rs +++ b/crates/ui-egui/src/dialogs/web.rs @@ -87,8 +87,11 @@ pub(crate) fn report_refresh_all(app: &mut SheetApp, r: &Json) { /// a single connection's Sync reports by name, a batch reports as Refresh All. #[cfg(not(target_arch = "wasm32"))] pub(super) fn poll_refresh_fetch(app: &mut SheetApp) -> bool { - if app.fetching() { - return true; + // Only this dialog's own refreshes: a preview or import fetch that is still running when + // the dialog opens stays for the From Web dialog or the app-level fallback, never + // reported here as a refresh. + if app.fetching() || app.fetch.as_ref().is_none_or(|f| f.command.0 != "data.webRefresh") { + return app.fetching(); } let sync_name = app.fetch.as_ref().and_then(|f| f.command.1.get("name").and_then(Json::as_str)).map(str::to_string); if let Some((_, r)) = app.poll_fetch() { From 2851ea14d69deca32560dacffd6e2f0c6d07227e Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Tue, 27 Oct 2026 07:18:32 +0000 Subject: [PATCH 20/31] From Web: discard a canceled import's fetch without running its command Co-authored-by: setzor --- crates/ui-egui/src/app.rs | 4 +++- crates/ui-egui/src/web_fetch.rs | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index b593a8e79d..495b0c6ff0 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -254,7 +254,9 @@ impl SheetApp { if polls { ctx.request_repaint(); } else { - let _ = self.poll_fetch(); + // Discard, don't run: a canceled import (Load, then the dialog closed) + // must not create its sheet and connection behind the user's back. + self.discard_fetch(); } } } diff --git a/crates/ui-egui/src/web_fetch.rs b/crates/ui-egui/src/web_fetch.rs index 04a6be6d2d..3dc63864a4 100644 --- a/crates/ui-egui/src/web_fetch.rs +++ b/crates/ui-egui/src/web_fetch.rs @@ -75,6 +75,15 @@ impl SheetApp { } } + /// Discards a finished fetch without running its command: the worker is joined so its + /// result is freed, and the command (a canceled import, say) never runs. + #[cfg(not(target_arch = "wasm32"))] + pub fn discard_fetch(&mut self) { + if let Some(fetch) = self.fetch.take() { + let _ = fetch.handle.and_then(|h| h.join().ok()); + } + } + /// Applies a finished fetch: the downloaded pages go to `command` as `prefetchedByUrl` and /// both the raw map and the command's result come back to the caller. #[cfg(not(target_arch = "wasm32"))] From 022ebf67e806ddd6a1871dfd54ec8b2d50139a2b Mon Sep 17 00:00:00 2001 From: George Stevenson Date: Tue, 27 Oct 2026 10:57:18 +0000 Subject: [PATCH 21/31] =?UTF-8?q?From=20Web:=20review=20round=203=20?= =?UTF-8?q?=E2=80=94=20a=20session=20constructor=20for=20every=20CLI=20ses?= =?UTF-8?q?sion,=20caps=20on=20prefetched=20tables,=20linear=20entity=20de?= =?UTF-8?q?coding,=20dialog-owned=20fetch=20polling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: setzor --- apps/gridcraft-cli/src/main.rs | 17 ++++++---- crates/engine/src/cmd/web.rs | 56 +++++++++++++++++++++++-------- crates/engine/src/webquery.rs | 15 ++++++++- crates/ui-egui/src/dialogs/web.rs | 21 ++++++++---- 4 files changed, 81 insertions(+), 28 deletions(-) diff --git a/apps/gridcraft-cli/src/main.rs b/apps/gridcraft-cli/src/main.rs index 5b31b54b74..240f98ebcb 100644 --- a/apps/gridcraft-cli/src/main.rs +++ b/apps/gridcraft-cli/src/main.rs @@ -165,18 +165,23 @@ fn exec(s: &mut Session, id: &str, params: Value) -> Result { r } -/// A session with the file at `path` open, wired for web imports. -fn open(path: &str) -> Result { +/// A session wired for web imports. +fn session() -> Session { let mut s = Session::new(); s.web_source = Some(gridcraft_webquery::provider()); + s +} + +/// A session with the file at `path` open, wired for web imports. +fn open(path: &str) -> Result { + let mut s = session(); exec(&mut s, "file.open", json!({"path": path}))?; Ok(s) } /// A session with a fresh workbook, wired for web imports. fn blank() -> Session { - let mut s = Session::new(); - s.web_source = Some(gridcraft_webquery::provider()); + let mut s = session(); s.new_workbook(); s } @@ -422,7 +427,7 @@ fn run(args: &[String]) -> Result<(), String> { (Some(_), Some(_)) => return Err("give --in or --sample, not both".into()), (Some(p), None) => open(p)?, (None, Some(name)) => { - let mut s = Session::new(); + let mut s = session(); exec(&mut s, "file.new", json!({"sample": name}))?; s } @@ -544,7 +549,7 @@ fn mcp(args: &[String]) -> Result<(), String> { let session = match (a.opt("in"), a.opt("sample")) { (Some(p), _) => open(p)?, (None, Some(name)) => { - let mut s = Session::new(); + let mut s = session(); exec(&mut s, "file.new", json!({"sample": name}))?; s } diff --git a/crates/engine/src/cmd/web.rs b/crates/engine/src/cmd/web.rs index 7ef4d42167..2fe797ec7b 100644 --- a/crates/engine/src/cmd/web.rs +++ b/crates/engine/src/cmd/web.rs @@ -7,7 +7,7 @@ use serde_json::{Value as Json, json}; use super::analysis::OVERWRITE_ERROR; use super::insert::write_totals; use super::*; -use crate::webquery::{DEFAULT_MAX_BYTES, DEFAULT_MAX_ROWS, MAX_COLS, WebRequest, WebTable}; +use crate::webquery::{DEFAULT_MAX_BYTES, DEFAULT_MAX_ROWS, MAX_COLS, MAX_PAGE_CELLS, MAX_TEXT, WebRequest, WebTable}; use gridcraft_core::MAX_ROWS; use gridcraft_model::{TableColumn, TotalsFn, Workbook}; @@ -89,6 +89,13 @@ fn from_web(s: &mut Session, p: &Json) -> Result { // A background worker's download comes back through the command as `prefetched`; without // one, the fetch runs here. let tables = fetch_tables(s, &req, p)?; + // The prefetched path gets the same `max_rows` cut the HTML path applies while parsing. + let mut tables = tables; + if tables.iter().any(|t| t.rows.len() > req.max_rows) { + for t in &mut tables { + t.rows.truncate(req.max_rows); + } + } let table = pick_table(&tables, want, occurrence).ok_or_else(|| EngineError::Other(no_table_message(&tables)))?; let occurrence = tables.iter().take_while(|t| !std::ptr::eq(*t, table)).filter(|t| t.name.eq_ignore_ascii_case(&table.name)).count(); let name = str_param(p, "sheet").map(|n| n.chars().take(31).collect::()).unwrap_or_else(|| default_sheet_name(&req.url)); @@ -471,22 +478,43 @@ fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTabl fn prefetched_tables(p: &Json) -> Result> { let raw = p.get("prefetched").ok_or_else(|| EngineError::Other("missing `prefetched`".into()))?; let mut out = Vec::new(); + // The same caps as the HTML path: the caller JSON is as hostile as the page it came from. + let mut page_budget: usize = MAX_PAGE_CELLS; for t in raw.as_array().ok_or_else(|| EngineError::Other("`prefetched` must be an array of tables".into()))? { let name = t["name"].as_str().unwrap_or_default().to_string(); - // Empty rows are dropped, not kept as zero-cell rows: the widest row then always has - // at least one cell, so the width arithmetic below cannot underflow (an all-empty - // table reaches the existing "The table is empty." refusal instead). - let rows: Vec> = t["rows"] - .as_array() - .map(|rs| { - rs.iter() - .filter_map(|r| r.as_array()) - .map(|cs| cs.iter().map(|c| c.as_str().unwrap_or_default().to_string()).collect::>()) - .filter(|r| !r.is_empty()) - .collect() - }) - .unwrap_or_default(); + let mut rows: Vec> = Vec::new(); + let mut cells: usize = 0; + for r in t["rows"].as_array().into_iter().flatten() { + if rows.len() >= DEFAULT_MAX_ROWS { + break; + } + let mut row: Vec = Vec::new(); + for c in r.as_array().into_iter().flatten() { + if row.len() >= MAX_COLS { + break; + } + let text: String = c.as_str().unwrap_or_default().chars().take(MAX_TEXT).collect(); + cells += 1; + row.push(text); + if cells >= MAX_PAGE_CELLS { + break; + } + } + // Empty rows are dropped, not kept as zero-cell rows: the widest row then always has + // at least one cell, so the width arithmetic cannot underflow (an all-empty + // table reaches the existing "The table is empty." refusal instead). + if !row.is_empty() { + rows.push(row); + } + if cells >= MAX_PAGE_CELLS { + break; + } + } + page_budget = page_budget.saturating_sub(cells); out.push(WebTable { name, rows }); + if page_budget == 0 { + break; + } } Ok(out) } diff --git a/crates/engine/src/webquery.rs b/crates/engine/src/webquery.rs index eebdb3dc92..c6db48e466 100644 --- a/crates/engine/src/webquery.rs +++ b/crates/engine/src/webquery.rs @@ -334,7 +334,9 @@ fn decode_entities(s: &str) -> String { while let Some(i) = rest.find('&') { out.push_str(rest.get(..i).unwrap_or_default()); rest = rest.get(i..).unwrap_or_default(); - let end = rest.find(';').unwrap_or(rest.len()); + // Entities are short: look for `;` only nearby, so a run of bare `&` stays linear. + let window = rest.get(..rest.len().min(32)).unwrap_or(rest); + let end = window.find(';').unwrap_or(rest.len()); let ent = rest.get(..end.min(rest.len())).unwrap_or_default(); let value = match ent.strip_prefix("&#") { Some(num) => num @@ -445,6 +447,17 @@ fn find_ci(haystack: &str, needle: &str) -> Option { mod tests { use super::*; + #[test] + /// A long run of `&` with no `;` nearby is processed in linear time, not quadratically. + fn a_run_of_bare_ampersands_stays_linear() { + let ampersands = "&".repeat(8 * 1024 * 1024); + let html = format!("
{ampersands}
"); + let t = extract_tables(&html, DEFAULT_MAX_ROWS, MAX_COLS); + assert_eq!(t.len(), 1); + assert_eq!(t[0].rows[0][0].len(), MAX_TEXT); + assert!(t[0].rows[0][0].chars().all(|c| c == '&')); + } + #[test] /// A named table takes its id for a name; others are numbered. fn extracts_named_and_unnamed_tables() { diff --git a/crates/ui-egui/src/dialogs/web.rs b/crates/ui-egui/src/dialogs/web.rs index 8318e3ce77..e1614a9e08 100644 --- a/crates/ui-egui/src/dialogs/web.rs +++ b/crates/ui-egui/src/dialogs/web.rs @@ -27,14 +27,21 @@ pub(super) fn poll_preview_fetch(app: &mut SheetApp, d: &mut Dialog, url: &str, if app.fetching() { return true; } - // The URL the running fetch was started for: an address edited since invalidates the - // result (the dialog state was cleared when it changed), so an old page never lands under - // the new URL. - let fetch_url = app.fetch.as_ref().and_then(|f| f.command.1.get("url")).and_then(Json::as_str).map(str::to_string); + // Only this dialog's own fetches; a foreign command (a ribbon refresh, say) is left to + // its own poller, so it never lands in the dialog's table list. + let Some((id, fetch_url)) = app.fetch.as_ref().map(|f| (f.command.0.clone(), f.command.1.get("url").and_then(Json::as_str).map(str::to_string))) else { + return false; + }; + if id != "data.webPreview" && id != "data.fromWeb" { + return false; + } + // An address edited since invalidates the fetch: drop it without running its command, so + // an old page never lands under the new URL. + if fetch_url.as_deref().is_some_and(|u| u != url.trim()) { + app.discard_fetch(); + return false; + } if let Some((pages, r)) = app.poll_fetch() { - if fetch_url.as_deref().is_some_and(|u| u != url.trim()) { - return false; - } match r { Ok(res) => { // The full rows are kept for Load (the preview's own list is truncated to the From 1696726e41741745c71a7206a751faf45f625edc Mon Sep 17 00:00:00 2001 From: George Stevenson Date: Tue, 27 Oct 2026 11:17:01 +0000 Subject: [PATCH 22/31] =?UTF-8?q?From=20Web:=20review=20round=204=20?= =?UTF-8?q?=E2=80=94=20https-specific=20refusal=20for=20plain-http=20urls,?= =?UTF-8?q?=20byte-safe=20entity=20window,=20prefetched=20tables=20on=20th?= =?UTF-8?q?e=20no-pick=20Load=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: setzor --- crates/engine/src/cmd/web.rs | 5 ++++- crates/engine/src/webquery.rs | 17 ++++++++++++++--- crates/ui-egui/src/dialogs/web.rs | 7 ++++++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/web.rs b/crates/engine/src/cmd/web.rs index 2fe797ec7b..8dc862fea4 100644 --- a/crates/engine/src/cmd/web.rs +++ b/crates/engine/src/cmd/web.rs @@ -68,7 +68,10 @@ fn request(p: &Json) -> Result { const ID: &str = "data.fromWeb"; // HTTPS only, like the fetch provider itself: a plain-http address must be refused before // the prefetched paths too, or a caller-supplied page bypasses the https-only contract. - let url = str_param(p, "url").map(str::trim).filter(|u| u.starts_with("https://")).ok_or_else(|| bad(ID, "missing `url`"))?; + let url = str_param(p, "url").map(str::trim).filter(|u| !u.is_empty()).ok_or_else(|| bad(ID, "missing `url`"))?; + if !url.starts_with("https://") { + return Err(EngineError::Other("Only https:// addresses can be imported.".into())); + } let max_rows = p .get("maxRows") .and_then(Json::as_u64) diff --git a/crates/engine/src/webquery.rs b/crates/engine/src/webquery.rs index c6db48e466..79feecc859 100644 --- a/crates/engine/src/webquery.rs +++ b/crates/engine/src/webquery.rs @@ -334,9 +334,9 @@ fn decode_entities(s: &str) -> String { while let Some(i) = rest.find('&') { out.push_str(rest.get(..i).unwrap_or_default()); rest = rest.get(i..).unwrap_or_default(); - // Entities are short: look for `;` only nearby, so a run of bare `&` stays linear. - let window = rest.get(..rest.len().min(32)).unwrap_or(rest); - let end = window.find(';').unwrap_or(rest.len()); + // Entities are short: look for `;` only in the first 32 bytes (byte-wise, so a UTF-8 + // boundary inside a multibyte character cannot fall back to scanning the whole rest). + let end = rest.as_bytes().iter().take(32).position(|&b| b == b';').unwrap_or(rest.len()); let ent = rest.get(..end.min(rest.len())).unwrap_or_default(); let value = match ent.strip_prefix("&#") { Some(num) => num @@ -458,6 +458,17 @@ mod tests { assert!(t[0].rows[0][0].chars().all(|c| c == '&')); } + #[test] + /// The window scan is byte-safe: a multibyte character right after `&` cannot push the + /// search past 32 bytes back onto the whole remaining text. + fn the_entity_window_is_byte_safe() { + let cell = "&\u{e9}".repeat(1_000); + let html = format!("
{cell}
"); + let t = extract_tables(&html, DEFAULT_MAX_ROWS, MAX_COLS); + assert_eq!(t.len(), 1); + assert_eq!(t[0].rows[0][0].chars().filter(|c| *c == '&').count(), 1_000); + } + #[test] /// A named table takes its id for a name; others are numbered. fn extracts_named_and_unnamed_tables() { diff --git a/crates/ui-egui/src/dialogs/web.rs b/crates/ui-egui/src/dialogs/web.rs index e1614a9e08..aa84ff889c 100644 --- a/crates/ui-egui/src/dialogs/web.rs +++ b/crates/ui-egui/src/dialogs/web.rs @@ -290,7 +290,7 @@ pub(super) fn from_web(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, co if *confirm && !url.trim().is_empty() { let pick = d.values.get("pick").and_then(Json::as_u64).unwrap_or(0) as usize; let table = tables.get(pick).and_then(|t| t["name"].as_str()).map(String::from); - let params = match table { + let mut params = match table { Some(t) => { // A page can hold duplicate table names: which one was picked is which one to // import, and the connection saves it so refreshes keep importing the same. @@ -305,6 +305,11 @@ pub(super) fn from_web(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, co } None => json!({"url": url.trim()}), }; + // The page is already downloaded whenever the preview fetched it — also when it found + // no tables: hand the full tables over so Load never re-fetches on the UI thread. + if let (Some(full), Some(o)) = (d.values.get("full_tables"), params.as_object_mut()) { + o.entry("prefetched").or_insert_with(|| full.clone()); + } match app.run("data.fromWeb", params) { Ok(r) => app.toast = Some((format!("Imported {} rows into {}", r["rows"], r["sheet"]), crate::now_ms())), Err(e) => { From 9a11b87ca4e661fb41cbd4dba61bfe318c20487a Mon Sep 17 00:00:00 2001 From: George Stevenson Date: Wed, 28 Oct 2026 03:57:58 +0000 Subject: [PATCH 23/31] From Web: refuse URLs with userinfo so a secret typed into the address is never saved with the workbook Co-authored-by: setzor --- crates/engine/src/cmd/web.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/engine/src/cmd/web.rs b/crates/engine/src/cmd/web.rs index 8dc862fea4..9a53bbd67c 100644 --- a/crates/engine/src/cmd/web.rs +++ b/crates/engine/src/cmd/web.rs @@ -72,6 +72,13 @@ fn request(p: &Json) -> Result { if !url.starts_with("https://") { return Err(EngineError::Other("Only https:// addresses can be imported.".into())); } + // Userinfo (`https://user:pass@host/`) would be sent as basic auth and stored verbatim in + // the saved connection, so a secret typed into the URL ends up in the workbook. Refuse it: + // a secret belongs in a source the workbook never saves. + let authority = url.trim_start_matches("https://").split(['/', '?', '#']).next().unwrap_or_default(); + if authority.contains('@') { + return Err(EngineError::Other("Web addresses with user and password (`user:pass@host`) can't be imported: the secret would be saved with the workbook.".into())); + } let max_rows = p .get("maxRows") .and_then(Json::as_u64) @@ -1006,6 +1013,19 @@ mod tests { assert!(d.wb.web_queries.is_empty()); } + #[test] + /// Userinfo in the URL (`user:pass@host`) is refused: ureq would send it as basic auth and + /// the saved connection would store the secret verbatim in the workbook. + fn a_url_with_userinfo_is_refused() { + let mut s = Session::new(); + s.execute("file.new", json!({})).unwrap(); + for url in ["https://user:pass@example.com", "https://user@example.com"] { + let err = s.execute("data.fromWeb", json!({"url": url})).unwrap_err(); + assert!(err.to_string().contains("can't be imported"), "{url}: {err}"); + assert!(s.active().unwrap().wb.web_queries.is_empty()); + } + } + #[test] /// A prefetched table of only empty rows is refused, not a zero-width import: the width /// arithmetic must never see an empty widest row (the u32 underflow CodeRabbit flagged). From 1d4b7993e7d7bb22123c4ea64f74c83577568496 Mon Sep 17 00:00:00 2001 From: George Stevenson Date: Wed, 28 Oct 2026 04:12:45 +0000 Subject: [PATCH 24/31] =?UTF-8?q?From=20Web:=20validate=20every=20fetch's?= =?UTF-8?q?=20URL=20in=20one=20shared=20place=20=E2=80=94=20request,=20ref?= =?UTF-8?q?resh=20and=20the=20provider=20all=20refuse=20userinfo,=20and=20?= =?UTF-8?q?a=20persisted=20userinfo=20URL=20fails=20refresh=20without=20ru?= =?UTF-8?q?nning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: setzor --- crates/engine/src/cmd/web.rs | 39 ++++++++++++++++++++++++++--------- crates/engine/src/webquery.rs | 16 ++++++++++++++ crates/webquery/src/lib.rs | 6 ++++++ 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/web.rs b/crates/engine/src/cmd/web.rs index 9a53bbd67c..cabaae7b1e 100644 --- a/crates/engine/src/cmd/web.rs +++ b/crates/engine/src/cmd/web.rs @@ -69,16 +69,9 @@ fn request(p: &Json) -> Result { // HTTPS only, like the fetch provider itself: a plain-http address must be refused before // the prefetched paths too, or a caller-supplied page bypasses the https-only contract. let url = str_param(p, "url").map(str::trim).filter(|u| !u.is_empty()).ok_or_else(|| bad(ID, "missing `url`"))?; - if !url.starts_with("https://") { - return Err(EngineError::Other("Only https:// addresses can be imported.".into())); - } - // Userinfo (`https://user:pass@host/`) would be sent as basic auth and stored verbatim in - // the saved connection, so a secret typed into the URL ends up in the workbook. Refuse it: - // a secret belongs in a source the workbook never saves. - let authority = url.trim_start_matches("https://").split(['/', '?', '#']).next().unwrap_or_default(); - if authority.contains('@') { - return Err(EngineError::Other("Web addresses with user and password (`user:pass@host`) can't be imported: the secret would be saved with the workbook.".into())); - } + // HTTPS only and no userinfo, like the fetch provider itself: both are refused before the + // prefetched paths too, or a caller-supplied page bypasses the provider's contract. + crate::webquery::validate_url(url).map_err(EngineError::Other)?; let max_rows = p .get("maxRows") .and_then(Json::as_u64) @@ -340,6 +333,12 @@ fn web_refresh(s: &mut Session, p: &Json) -> Result { let mut refreshed = 0; let mut errors: Vec = Vec::new(); for q in queries { + // The URL comes from the saved connection, so a workbook the model deserialized could + // hold one no fresh import would accept: validate before the fetch, like `request` does. + if let Err(e) = crate::webquery::validate_url(&q.url) { + errors.push(json!({"table": q.name, "error": e})); + continue; + } let req = WebRequest { url: q.url.clone(), max_rows: DEFAULT_MAX_ROWS, max_cols: MAX_COLS, max_bytes: DEFAULT_MAX_BYTES }; // A background worker's downloads come through `fetch_tables` (keyed by URL); without // them, the fetch runs here. @@ -1026,6 +1025,26 @@ mod tests { } } + #[test] + /// A persisted connection whose deserialized URL holds userinfo — the JSON workbook model + /// can carry one — is refused on refresh too: the guard lives in the shared fetch path, so + /// the native provider never forwards the embedded credential, whatever stored the URL. + fn a_persisted_userinfo_url_is_refused_on_refresh() { + let mut s = session_with_source(); + s.execute("data.fromWeb", json!({"url": "https://example.com/markets", "sheet": "Markets"})).unwrap(); + // A deserialized workbook's connection: written as the JSON model would deliver it. + super::super::edit(&mut s, |cx| { + let q = cx.wb.web_queries.iter_mut().find(|q| q.sheet == "Markets").expect("the connection"); + q.url = "https://user:pass@example.com/markets".into(); + cx.structural = true; + Ok(()) + }) + .unwrap(); + let r = s.execute("data.webRefresh", json!({})).unwrap(); + assert_eq!(r["refreshed"], 0, "the userinfo refresh must not run: {r}"); + assert!(r["errors"].as_array().is_some_and(|e| e.iter().any(|e| e["error"].as_str().is_some_and(|m| m.contains("can't be imported")))), "{r}"); + } + #[test] /// A prefetched table of only empty rows is refused, not a zero-width import: the width /// arithmetic must never see an empty widest row (the u32 underflow CodeRabbit flagged). diff --git a/crates/engine/src/webquery.rs b/crates/engine/src/webquery.rs index 79feecc859..4f3975bce3 100644 --- a/crates/engine/src/webquery.rs +++ b/crates/engine/src/webquery.rs @@ -44,6 +44,22 @@ pub struct WebTable { /// Fetches a page and returns its tables. Implementations must honour the caps and never panic. pub type WebFetch = std::sync::Arc Result, String> + Send + Sync>; +/// The address a fetch is allowed to use: https, and no userinfo. A URL deserialized from a +/// saved workbook reaches the fetch path too, so every caller validates through here before a +/// `WebRequest` is built — a credential typed into the address (`user:pass@host`) would be sent +/// as basic auth and stored verbatim with the connection, and neither may happen. +pub fn validate_url(url: &str) -> Result<(), String> { + let url = url.trim(); + if !url.starts_with("https://") { + return Err("Only https:// addresses can be imported.".into()); + } + let authority = url.trim_start_matches("https://").split(['/', '?', '#']).next().unwrap_or_default(); + if authority.contains('@') { + return Err("Web addresses with user and password (`user:pass@host`) can't be imported: the secret would be saved with the workbook.".into()); + } + Ok(()) +} + /// The tables of `html`, in document order. Nested tables are flattened into the cell text of /// the enclosing table (like Excel's detection, a table inside a cell stays part of the cell). /// `max_rows`/`max_cols` cap each table's size; longer tables are reported truncated. A page diff --git a/crates/webquery/src/lib.rs b/crates/webquery/src/lib.rs index 23c9d36145..079aadff3c 100644 --- a/crates/webquery/src/lib.rs +++ b/crates/webquery/src/lib.rs @@ -51,6 +51,12 @@ fn http_get(req: &gridcraft_engine::webquery::WebRequest) -> Result Date: Wed, 28 Oct 2026 04:38:50 +0000 Subject: [PATCH 25/31] From Web: decode the page with its declared HTTP charset, and bind a background fetch to the workbook it was started for Co-authored-by: setzor --- Cargo.lock | 1 + crates/ui-egui/src/web_fetch.rs | 10 +++++++++- crates/webquery/Cargo.toml | 1 + crates/webquery/src/lib.rs | 12 ++++++++++-- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c9c2da4971..92189df0a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2123,6 +2123,7 @@ dependencies = [ name = "gridcraft-webquery" version = "0.1.0" dependencies = [ + "encoding_rs", "gridcraft-engine", "log", "ureq", diff --git a/crates/ui-egui/src/web_fetch.rs b/crates/ui-egui/src/web_fetch.rs index 3dc63864a4..6af118b798 100644 --- a/crates/ui-egui/src/web_fetch.rs +++ b/crates/ui-egui/src/web_fetch.rs @@ -10,6 +10,9 @@ use crate::SheetApp; /// One in-flight fetch: the URLs to download and the command its results feed. pub struct PendingFetch { urls: Vec, + /// The document the fetch was started for: if the user switches workbooks while the page + /// downloads, the result must not run its command against the newly active one. + document_uid: Option, /// The command the results feed (`data.webPreview`, `data.webRefresh`). pub(crate) command: (String, Json), handle: Option>, @@ -60,7 +63,7 @@ impl SheetApp { } Json::Object(map) }); - self.fetch = Some(PendingFetch { urls, command: (command.0.to_string(), command.1), handle: Some(handle) }); + self.fetch = Some(PendingFetch { urls, document_uid: self.session.active().map(|d| d.uid), command: (command.0.to_string(), command.1), handle: Some(handle) }); } /// True while a fetch is still downloading, for a spinner and repaints. @@ -96,6 +99,11 @@ impl SheetApp { let (id, mut params) = fetch.command; match pages { Some(pages) => { + // The workbook the fetch was started for: if another is active now, the import + // would land in it instead. Report the mismatch without running the command. + if self.session.active().map(|d| d.uid) != fetch.document_uid { + return Some((pages, Err("The workbook changed while the page was loading.".into()))); + } params.as_object_mut().map(|o| o.insert("prefetchedByUrl".into(), pages.clone())); Some((pages, self.run(&id, params))) } diff --git a/crates/webquery/Cargo.toml b/crates/webquery/Cargo.toml index 6826b9536d..23c2f9cbc1 100644 --- a/crates/webquery/Cargo.toml +++ b/crates/webquery/Cargo.toml @@ -13,6 +13,7 @@ log = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ureq = { version = "3", default-features = false, features = ["rustls", "gzip"] } +encoding_rs = "0.8" [lints] workspace = true diff --git a/crates/webquery/src/lib.rs b/crates/webquery/src/lib.rs index 079aadff3c..a61d7b302f 100644 --- a/crates/webquery/src/lib.rs +++ b/crates/webquery/src/lib.rs @@ -75,12 +75,20 @@ fn http_get(req: &gridcraft_engine::webquery::WebRequest) -> Result = Vec::new(); let _ = reader.by_ref().take(req.max_bytes as u64).read_to_end(&mut bytes).map_err(|e| e.to_string())?; let mut extra = [0u8; 1]; if reader.read(&mut extra).map_err(|e| e.to_string())? != 0 { return Err(format!("The page exceeds the {} byte limit.", req.max_bytes)); } - Ok(String::from_utf8_lossy(&bytes).into_owned()) + Ok(encoding.decode(&bytes).0.into_owned()) } From 873de2bbe421f86bfc7294357c7438a765012eb6 Mon Sep 17 00:00:00 2001 From: George Stevenson Date: Wed, 28 Oct 2026 05:01:07 +0000 Subject: [PATCH 26/31] From Web: parse the charset parameter case-insensitively with quotes stripped, bound the extractor by the decoded-size limit, and clear the stale preview on a workbook change Co-authored-by: setzor --- crates/engine/src/webquery.rs | 7 ++++- crates/ui-egui/src/dialogs/web.rs | 3 ++ crates/webquery/src/lib.rs | 50 ++++++++++++++++++++++++++----- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/webquery.rs b/crates/engine/src/webquery.rs index 4f3975bce3..0954a9db39 100644 --- a/crates/engine/src/webquery.rs +++ b/crates/engine/src/webquery.rs @@ -12,6 +12,11 @@ pub const DEFAULT_MAX_ROWS: usize = 1_048_575; pub const MAX_COLS: usize = 16_384; /// Page size and download timeout, in bytes and seconds, when the caller gives none. pub const DEFAULT_MAX_BYTES: usize = 16 * 1024 * 1024; +/// The largest page `extract_tables` accepts, decoded: a `DEFAULT_MAX_BYTES` download in a +/// single-byte charset can grow up to four bytes per character when decoded to UTF-8, so the +/// extractor's bound is the download cap times that, keeping the memory bound coordinated with +/// the fetch's own cap instead of rejecting exactly the pages a non-UTF-8 decode produces. +pub const MAX_DECODED_BYTES: usize = DEFAULT_MAX_BYTES * 4; pub const DEFAULT_TIMEOUT_SECS: u64 = 30; /// Longest text a cell holds (Excel's limit). pub const MAX_TEXT: usize = 32_767; @@ -68,7 +73,7 @@ pub fn validate_url(url: &str) -> Result<(), String> { /// thousands of tables or `colspan` padding. pub fn extract_tables(html: &str, max_rows: usize, max_cols: usize) -> Vec { let mut out: Vec = Vec::new(); - if html.len() > DEFAULT_MAX_BYTES { + if html.len() > MAX_DECODED_BYTES { return out; } let mut rest = html; diff --git a/crates/ui-egui/src/dialogs/web.rs b/crates/ui-egui/src/dialogs/web.rs index aa84ff889c..fb9cea7fc1 100644 --- a/crates/ui-egui/src/dialogs/web.rs +++ b/crates/ui-egui/src/dialogs/web.rs @@ -58,6 +58,9 @@ pub(super) fn poll_preview_fetch(app: &mut SheetApp, d: &mut Dialog, url: &str, } } Err(e) => { + // The fetch is gone (the workbook changed under it, say): a stale snapshot must + // not survive either, or Load would import the old preview into the new workbook. + d.values.remove("full_tables"); app.message = Some(("From Web".into(), crate::clean_error(&e))); d.values.insert("tables".into(), Json::Null); } diff --git a/crates/webquery/src/lib.rs b/crates/webquery/src/lib.rs index a61d7b302f..6f1eaf49e9 100644 --- a/crates/webquery/src/lib.rs +++ b/crates/webquery/src/lib.rs @@ -75,15 +75,13 @@ fn http_get(req: &gridcraft_engine::webquery::WebRequest) -> Result = Vec::new(); let _ = reader.by_ref().take(req.max_bytes as u64).read_to_end(&mut bytes).map_err(|e| e.to_string())?; let mut extra = [0u8; 1]; @@ -92,3 +90,39 @@ fn http_get(req: &gridcraft_engine::webquery::WebRequest) -> Result Option { + content_type + .split(';') + .filter_map(|p| p.trim().split_once('=')) + .find(|(name, _)| name.trim().eq_ignore_ascii_case("charset")) + .map(|(_, value)| value.trim().trim_matches('"').trim_matches('\'').to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + /// Parameter names match case-insensitively and quotes are stripped, where ureq's own + /// lookup would miss both and fall back to UTF-8. + fn the_charset_parameter_is_parsed_case_insensitively_and_unquoted() { + assert_eq!(charset_from("text/html; charset=windows-1252").as_deref(), Some("windows-1252")); + assert_eq!(charset_from("text/html; Charset=ISO-8859-1").as_deref(), Some("ISO-8859-1")); + assert_eq!(charset_from("text/html; charset=\"windows-1252\"").as_deref(), Some("windows-1252")); + assert_eq!(charset_from("text/html; charset='utf-8'").as_deref(), Some("utf-8")); + assert_eq!(charset_from("text/html").as_deref(), None); + assert_eq!(charset_from("text/html; boundary=none").as_deref(), None); + } + + #[test] + /// A quoted windows-1252 label resolves to that encoding, not the UTF-8 fallback. + fn a_quoted_charset_resolves_to_its_encoding() { + let c = charset_from("text/html; charset=\"windows-1252\"").unwrap(); + assert_eq!(encoding_rs::Encoding::for_label(c.as_bytes()), Some(encoding_rs::WINDOWS_1252)); + } +} From 06b4055b3df85408d60c82a6e8996cbaa7ff87f9 Mon Sep 17 00:00:00 2001 From: George Stevenson Date: Wed, 28 Oct 2026 06:34:09 +0000 Subject: [PATCH 27/31] From Web: refresh keeps the user's calculated columns, br and block tags read as cell spaces, a running fetch is not silently replaced, a 3xx names its redirect, and the provider validates through the shared URL gate Co-authored-by: setzor --- crates/engine/src/cmd/web.rs | 84 +++++++++++++++++++++++++++++++-- crates/engine/src/webquery.rs | 16 +++++++ crates/ui-egui/src/web_fetch.rs | 9 ++++ crates/webquery/src/lib.rs | 19 ++++---- 4 files changed, 114 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/web.rs b/crates/engine/src/cmd/web.rs index cabaae7b1e..ac68f94e50 100644 --- a/crates/engine/src/cmd/web.rs +++ b/crates/engine/src/cmd/web.rs @@ -376,24 +376,40 @@ fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTabl return Err(EngineError::Other("The table is empty.".into())); } let w = table.rows.iter().map(Vec::len).max().unwrap_or(1); + // Calculated columns the user added to the right of the imported data are theirs, not the + // page's: the ones beyond the fetched width with a column formula are kept in place, so a + // refresh never deletes them (Excel keeps query-table calculated columns the same way). + let kept: Vec = { + let sh = d.wb.sheet(sheet).ok_or_else(|| EngineError::Other(format!("The sheet of the connection `{}` is gone.", q.name)))?; + let Some(t) = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&tname)) else { return Err(EngineError::Other(format!("The table of the connection `{}` is gone.", q.name))) }; + t.columns.iter().skip(w).filter(|c| c.formula.is_some()).cloned().collect() + }; + let total_w = w + kept.len(); let (Some(end_row), Some(end_col)) = ( old.start.row.checked_add(table.rows.len() as u32 - 1).filter(|r| *r < MAX_ROWS), - old.start.col.checked_add(w as u32 - 1).filter(|c| *c < gridcraft_core::MAX_COLS), + old.start.col.checked_add(total_w as u32 - 1).filter(|c| *c < gridcraft_core::MAX_COLS), ) else { return Err(EngineError::Other("The refreshed table doesn't fit on the sheet below and right of the table's top-left cell.".into())); }; let new_range = RangeRef::new(old.start, CellRef::new(end_row, end_col)); // A refresh only owns the old table's own rectangle: refuse before any change if the // expanded destination would overwrite other content (a user formula, another table). The - // totals row follows the resized table, so the row below the new data is checked too. + // totals row follows the resized table, so the row below the new data is checked too. The + // kept calculated columns are expected content, not a clash: they are rewritten in place. if let Some(sh) = d.wb.sheet(sheet) { let totals_row = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&tname)).is_some_and(|t| t.totals_row); let last_row = if totals_row { (new_range.end.row + 1).min(MAX_ROWS - 1) } else { new_range.end.row }; + // The kept calculated columns' own cells are expected, not a clash — but only where + // they already are: when the page's width is unchanged they sit exactly in the kept + // strip. A width change moves them, and content at the new place still refuses. + let kept_in_place = old.width() == w as u32; + let kept_start = old.start.col + w as u32; let outside = new_range.height() > old.height() || new_range.width() > old.width() || totals_row; let clash = outside && (new_range.start.row..=last_row).any(|row| { sh.cells.row(row, new_range.start.col, new_range.end.col).any(|(c, cell)| { (row < old.start.row || row > old.end.row || c < old.start.col || c > old.end.col) + && !(kept_in_place && row <= old.end.row && c >= kept_start && c < kept_start + kept.len() as u32) && (!cell.value.is_empty() || cell.formula.is_some()) }) }); @@ -423,7 +439,7 @@ fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTabl // The names come from the fetched header row, not the cells: a numeric header // (`2024`) is a number in the cell, and reading it as text would miss the column // and drop its settings on every refresh. sync_headers writes them back as text. - let headers: Vec = (0..new_range.width() as usize) + let headers: Vec = (0..w) .map(|i| { table .rows @@ -434,8 +450,7 @@ fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTabl .unwrap_or_else(|| format!("Column{}", i + 1)) }) .collect(); - let t = &mut sh.tables[ti]; - t.columns = headers + let mut columns: Vec = headers .into_iter() .map(|name| { old_cols.iter().find(|col| col.name.eq_ignore_ascii_case(&name)).cloned().unwrap_or(TableColumn { @@ -448,10 +463,34 @@ fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTabl }) }) .collect(); + // The user's calculated columns beyond the page's width stay, with their metadata. + columns.extend(kept.iter().cloned()); + let t = &mut sh.tables[ti]; + t.columns = columns; t.range = new_range; cx.structural = true; Ok(()) })?; + // The kept calculated columns lost their cells to clearContents: refill each one's + // formula down the resized data rows, relative-shifted per row like a fill would. + if !kept.is_empty() { + edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + let Some(t) = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&tname)).cloned() else { return Ok(()) }; + for (i, col) in kept.iter().enumerate() { + let Some(code) = col.formula.as_ref() else { continue }; + let expr = gridcraft_formula::parse(code).map_err(|e| EngineError::Other(format!("The calculated column `{}` has a broken formula: {e}", col.name)))?; + let c0 = t.range.start.col + w as u32 + i as u32; + let style = sh.style_id(CellRef::new(t.range.start.row, c0)); + for row in (t.range.start.row + 1)..=t.range.end.row { + let shifted = gridcraft_formula::adjust::shift_relative(expr.clone(), row as i64 - (t.range.start.row as i64 + 1), 0); + sh.set_cell(CellRef::new(row, c0), gridcraft_model::Cell { formula: Some(std::sync::Arc::new(gridcraft_model::Formula::from_expr(shifted))), value: gridcraft_core::Value::Empty, style }); + } + } + cx.structural = true; + Ok(()) + })?; + } // The totals row sits below the resized range: restore its SUBTOTAL formulas and // labels, which clearContents took with the rest of the old rectangle. let restore_totals = { @@ -1012,6 +1051,41 @@ mod tests { assert!(d.wb.web_queries.is_empty()); } + #[test] + /// A calculated column the user added to the imported table survives a refresh: its + /// metadata stays, its formulas are refilled down the resized data rows, and the page's own + /// columns are rewritten around it (Excel keeps query-table calculated columns the same way). + fn a_calculated_column_added_by_the_user_survives_a_refresh() { + let mut s = session_with_source(); + s.execute("data.fromWeb", json!({"url": "https://example.com/markets", "sheet": "Markets"})).unwrap(); + // The user's calculated column: cells with formulas, and the column metadata a workbook + // save and load would round-trip as a `calculatedColumnFormula`. + s.execute("cell.set", json!({"cell": "C2", "input": "=B2*2"})).unwrap(); + s.execute("cell.set", json!({"cell": "C3", "input": "=B3*2"})).unwrap(); + super::super::edit(&mut s, |cx| { + let sh = cx.sheet_mut(cx.wb.sheet_index("Markets").ok_or_else(|| EngineError::Other("no Markets sheet".into()))?)?; + let ti = sh.tables.iter().position(|t| t.name == "prices").expect("the table"); + while sh.tables[ti].columns.len() < 3 { + let n = sh.tables[ti].columns.len(); + sh.tables[ti].columns.push(gridcraft_model::TableColumn { name: format!("Column{}", n + 1), totals: TotalsFn::None, totals_label: None, formula: Some("B2*2".into()), unique_name: None, xml: None }); + } + cx.structural = true; + Ok(()) + }) + .unwrap(); + s.execute("data.webRefresh", json!({})).unwrap(); + let d = s.active().unwrap(); + let sh = d.wb.active().unwrap(); + let t = sh.tables.iter().find(|t| t.name == "prices").unwrap(); + assert_eq!(t.range.width(), 3, "the calculated column stays in the table"); + assert_eq!(t.columns[2].formula.as_deref(), Some("B2*2")); + // The page's data is refreshed and the formula recalculated against it. + assert_eq!(sh.value(gridcraft_core::CellRef::parse("B2").unwrap()), gridcraft_core::Value::from(12.5)); + let c2 = sh.cell(gridcraft_core::CellRef::parse("C2").unwrap()).expect("the calculated column's cell"); + assert!(c2.formula.is_some(), "the formula is refilled"); + assert_eq!(c2.value, gridcraft_core::Value::from(25.0)); + } + #[test] /// Userinfo in the URL (`user:pass@host`) is refused: ureq would send it as basic auth and /// the saved connection would store the secret verbatim in the workbook. diff --git a/crates/engine/src/webquery.rs b/crates/engine/src/webquery.rs index 0954a9db39..a8db8a54fa 100644 --- a/crates/engine/src/webquery.rs +++ b/crates/engine/src/webquery.rs @@ -162,6 +162,13 @@ pub fn extract_tables(html: &str, max_rows: usize, max_cols: usize) -> Vec` between two words reads as a space, as in Excel. + ("br" | "p" | "div" | "li", _) => { + if let Some(t) = current.as_mut() { + t.push_text(" "); + } + } _ => {} } } @@ -479,6 +486,15 @@ mod tests { assert!(t[0].rows[0][0].chars().all(|c| c == '&')); } + #[test] + /// A `
` (or a block tag) inside a cell separates words with a space, like Excel's + /// import of the same cell — `New York
USA` must not join into `New YorkUSA`. + fn a_line_break_inside_a_cell_separates_words() { + let html = "
A
B

C

D
  • E
  • "; + let t = extract_tables(html, 100, 100); + assert_eq!(t[0].rows, vec![vec!["A B", "C D E"]]); + } + #[test] /// The window scan is byte-safe: a multibyte character right after `&` cannot push the /// search past 32 bytes back onto the whole remaining text. diff --git a/crates/ui-egui/src/web_fetch.rs b/crates/ui-egui/src/web_fetch.rs index 6af118b798..26aa369f1f 100644 --- a/crates/ui-egui/src/web_fetch.rs +++ b/crates/ui-egui/src/web_fetch.rs @@ -36,6 +36,15 @@ impl SheetApp { if self.fetch.as_ref().is_some_and(|f| f.urls == urls && f.command.0 == command.0 && f.command.1 == command.1) { return; } + // A different fetch while one runs would drop the running one's handle (its result lost, + // its dialog left claiming "No tables were found"), so refuse it instead. One deliberate + // exception: Load before Go replaces this dialog's own preview for the same address — + // the preview's result is worthless once its import is on the way. + let replaces_own_preview = self.fetch.as_ref().is_some_and(|f| f.command.0 == "data.webPreview" && command.0 == "data.fromWeb" && f.urls == urls); + if self.fetching() && !replaces_own_preview { + self.message = Some(("From Web".into(), "A page is still loading. Try again when it finishes.".into())); + return; + } let source = match self.session.web_source.clone() { Some(s) => s, None => { diff --git a/crates/webquery/src/lib.rs b/crates/webquery/src/lib.rs index 6f1eaf49e9..a3a470a762 100644 --- a/crates/webquery/src/lib.rs +++ b/crates/webquery/src/lib.rs @@ -48,15 +48,9 @@ pub fn redact(url: &str) -> String { fn http_get(req: &gridcraft_engine::webquery::WebRequest) -> Result { use std::io::Read; - if !req.url.trim_start().starts_with("https://") { - return Err("Only https:// addresses can be imported.".into()); - } - // Userinfo (`https://user:pass@host/`) would be sent as basic auth: check it here too, so - // the provider never forwards an embedded credential even if a caller skipped validation. - let authority = req.url.trim_start().trim_start_matches("https://").split(['/', '?', '#']).next().unwrap_or_default(); - if authority.contains('@') { - return Err("Web addresses with user and password (`user:pass@host`) can't be imported: the secret would be saved with the workbook.".into()); - } + // The same gate every caller applies (https, no userinfo), kept here so the provider never + // forwards an embedded credential even if a caller skipped its own validation. + gridcraft_engine::webquery::validate_url(&req.url)?; let agent = ureq::Agent::new_with_config( ureq::config::Config::builder().max_redirects(0).timeout_global(Some(std::time::Duration::from_secs(30))).build(), ); @@ -69,6 +63,13 @@ fn http_get(req: &gridcraft_engine::webquery::WebRequest) -> Result format!("The page answered {code} and could not be imported."), other => other.to_string(), })?; + // No redirects are followed, so a 3xx arrives as a normal response: name it as one before + // the content-type gate, or a redirect body would come back as "No tables were found". The + // target is redacted like every other URL that reaches a message. + if resp.status().is_redirection() { + let target = resp.headers().get("location").and_then(|v| v.to_str().ok()).map_or_else(|| "an unspecified address".into(), redact); + return Err(format!("The page moved to {target}; redirects are not followed.")); + } let content_type = resp.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or_default().to_string(); if !content_type.is_empty() && !content_type.contains("html") && !content_type.contains("xml") && !content_type.contains("text") { return Err(format!("The address serves {content_type}, not a web page.")); From aecb54a44546b4e4a131ed801fba582440deb8c9 Mon Sep 17 00:00:00 2001 From: George Stevenson Date: Wed, 28 Oct 2026 08:22:17 +0000 Subject: [PATCH 28/31] =?UTF-8?q?From=20Web:=20self-review=20pass=20?= =?UTF-8?q?=E2=80=94=20HTML5=20charset=20detection=20(BOM,=20header,=20met?= =?UTF-8?q?a=20tag),=20the=20prefetched=20table=20count=20capped,=20the=20?= =?UTF-8?q?kept-column=20refill=20keeps=20each=20cell's=20own=20style,=20a?= =?UTF-8?q?nd=20the=20dead=20conversions,=20stale=20comments=20and=20dupli?= =?UTF-8?q?cated=20insert=20removed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: setzor --- crates/engine/src/cmd/web.rs | 17 ++++--- crates/ui-egui/src/dialogs/web.rs | 14 ++---- crates/ui-egui/src/web_fetch.rs | 1 - crates/webquery/src/lib.rs | 84 ++++++++++++++++++++++++++++--- 4 files changed, 91 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/cmd/web.rs b/crates/engine/src/cmd/web.rs index ac68f94e50..430358df91 100644 --- a/crates/engine/src/cmd/web.rs +++ b/crates/engine/src/cmd/web.rs @@ -7,7 +7,7 @@ use serde_json::{Value as Json, json}; use super::analysis::OVERWRITE_ERROR; use super::insert::write_totals; use super::*; -use crate::webquery::{DEFAULT_MAX_BYTES, DEFAULT_MAX_ROWS, MAX_COLS, MAX_PAGE_CELLS, MAX_TEXT, WebRequest, WebTable}; +use crate::webquery::{DEFAULT_MAX_BYTES, DEFAULT_MAX_ROWS, MAX_COLS, MAX_PAGE_CELLS, MAX_TABLES, MAX_TEXT, WebRequest, WebTable}; use gridcraft_core::MAX_ROWS; use gridcraft_model::{TableColumn, TotalsFn, Workbook}; @@ -94,10 +94,8 @@ fn from_web(s: &mut Session, p: &Json) -> Result { let tables = fetch_tables(s, &req, p)?; // The prefetched path gets the same `max_rows` cut the HTML path applies while parsing. let mut tables = tables; - if tables.iter().any(|t| t.rows.len() > req.max_rows) { - for t in &mut tables { - t.rows.truncate(req.max_rows); - } + for t in &mut tables { + t.rows.truncate(req.max_rows); } let table = pick_table(&tables, want, occurrence).ok_or_else(|| EngineError::Other(no_table_message(&tables)))?; let occurrence = tables.iter().take_while(|t| !std::ptr::eq(*t, table)).filter(|t| t.name.eq_ignore_ascii_case(&table.name)).count(); @@ -481,10 +479,13 @@ fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTabl let Some(code) = col.formula.as_ref() else { continue }; let expr = gridcraft_formula::parse(code).map_err(|e| EngineError::Other(format!("The calculated column `{}` has a broken formula: {e}", col.name)))?; let c0 = t.range.start.col + w as u32 + i as u32; - let style = sh.style_id(CellRef::new(t.range.start.row, c0)); for row in (t.range.start.row + 1)..=t.range.end.row { + let at = CellRef::new(row, c0); + // Each cell keeps its own style (the header cell's format would be wrong + // for the data rows, and one shared style would erase per-cell formats). + let style = sh.style_id(at); let shifted = gridcraft_formula::adjust::shift_relative(expr.clone(), row as i64 - (t.range.start.row as i64 + 1), 0); - sh.set_cell(CellRef::new(row, c0), gridcraft_model::Cell { formula: Some(std::sync::Arc::new(gridcraft_model::Formula::from_expr(shifted))), value: gridcraft_core::Value::Empty, style }); + sh.set_cell(at, gridcraft_model::Cell { formula: Some(std::sync::Arc::new(gridcraft_model::Formula::from_expr(shifted))), value: gridcraft_core::Value::Empty, style }); } } cx.structural = true; @@ -560,7 +561,7 @@ fn prefetched_tables(p: &Json) -> Result> { } page_budget = page_budget.saturating_sub(cells); out.push(WebTable { name, rows }); - if page_budget == 0 { + if page_budget == 0 || out.len() >= MAX_TABLES { break; } } diff --git a/crates/ui-egui/src/dialogs/web.rs b/crates/ui-egui/src/dialogs/web.rs index fb9cea7fc1..a35f027e94 100644 --- a/crates/ui-egui/src/dialogs/web.rs +++ b/crates/ui-egui/src/dialogs/web.rs @@ -298,20 +298,16 @@ pub(super) fn from_web(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, co // A page can hold duplicate table names: which one was picked is which one to // import, and the connection saves it so refreshes keep importing the same. let occurrence = tables.iter().take(pick).filter(|o| o["name"].as_str().is_some_and(|n| n.eq_ignore_ascii_case(&t))).count(); - let mut p = json!({"url": url.trim(), "table": t, "occurrence": occurrence}); - // The page is already downloaded (the preview fetched it): hand the full tables - // over instead of fetching again. - if let Some(full) = d.values.get("full_tables") { - p.as_object_mut().map(|o| o.insert("prefetched".into(), full.clone())); - } - p + json!({"url": url.trim(), "table": t, "occurrence": occurrence}) } None => json!({"url": url.trim()}), }; // The page is already downloaded whenever the preview fetched it — also when it found // no tables: hand the full tables over so Load never re-fetches on the UI thread. - if let (Some(full), Some(o)) = (d.values.get("full_tables"), params.as_object_mut()) { - o.entry("prefetched").or_insert_with(|| full.clone()); + if let Some(full) = d.values.get("full_tables") { + if let Some(o) = params.as_object_mut() { + o.entry("prefetched").or_insert_with(|| full.clone()); + } } match app.run("data.fromWeb", params) { Ok(r) => app.toast = Some((format!("Imported {} rows into {}", r["rows"], r["sheet"]), crate::now_ms())), diff --git a/crates/ui-egui/src/web_fetch.rs b/crates/ui-egui/src/web_fetch.rs index 26aa369f1f..cb40e56a20 100644 --- a/crates/ui-egui/src/web_fetch.rs +++ b/crates/ui-egui/src/web_fetch.rs @@ -52,7 +52,6 @@ impl SheetApp { return; } }; - let urls: Vec = urls.iter().map(|s| s.to_string()).collect(); let fetch_urls = urls.clone(); let handle = std::thread::spawn(move || { // Each page lands under its own URL; a failed page records its error instead. diff --git a/crates/webquery/src/lib.rs b/crates/webquery/src/lib.rs index a3a470a762..c53ed09039 100644 --- a/crates/webquery/src/lib.rs +++ b/crates/webquery/src/lib.rs @@ -52,7 +52,10 @@ fn http_get(req: &gridcraft_engine::webquery::WebRequest) -> Result Result = Vec::new(); let _ = reader.by_ref().take(req.max_bytes as u64).read_to_end(&mut bytes).map_err(|e| e.to_string())?; @@ -89,9 +86,53 @@ fn http_get(req: &gridcraft_engine::webquery::WebRequest) -> Result` (or `` content type) in the +/// first 2 KB — most real pages declare their charset there, not in the header — else UTF-8. +fn encoding_from(content_type: &str, bytes: &[u8]) -> &'static encoding_rs::Encoding { + if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { + return encoding_rs::UTF_8; + } + if bytes.starts_with(&[0xFF, 0xFE]) { + return encoding_rs::UTF_16LE; + } + if bytes.starts_with(&[0xFE, 0xFF]) { + return encoding_rs::UTF_16BE; + } + if let Some(c) = charset_from(content_type).and_then(|c| encoding_rs::Encoding::for_label(c.as_bytes())) { + return c; + } + meta_charset(bytes).and_then(|c| encoding_rs::Encoding::for_label(c.as_bytes())).unwrap_or(encoding_rs::UTF_8) +} + +/// The first `` tag's `charset` (or a `charset=` inside one, as `http-equiv` content types +/// hold it), from the head of the page: unquoted and quoted values both parse. +fn meta_charset(bytes: &[u8]) -> Option { + let head = String::from_utf8_lossy(&bytes[..bytes.len().min(2048)]); + let lower = head.to_ascii_lowercase(); + for (i, _) in lower.match_indices("` is no meta tag). + match lower.as_bytes().get(i + 5) { + Some(b' ') | Some(b'\n') | Some(b'\t') | Some(b'/') | Some(b'>') | None => {} + _ => continue, + } + let end = lower[i..].find('>').map(|e| i + e).unwrap_or(lower.len()); + let tag = &lower[i..end]; + let Some(p) = tag.find("charset") else { continue }; + let Some(value) = tag[p + "charset".len()..].trim_start().strip_prefix('=') else { continue }; + let value = value.trim().trim_matches('"').trim_matches('\''); + let value = value.split(|c: char| c.is_whitespace()).next().unwrap_or(""); + if !value.is_empty() { + return Some(value.to_string()); + } + } + None +} + /// The `Content-Type` header's `charset` value, if it names one: parameter names match /// case-insensitively and a quoted value loses its quotes, both of which ureq's own /// `Body::charset()` leaves as-is (exact lowercase `charset=`, quotes kept), so a quoted or @@ -126,4 +167,33 @@ mod tests { let c = charset_from("text/html; charset=\"windows-1252\"").unwrap(); assert_eq!(encoding_rs::Encoding::for_label(c.as_bytes()), Some(encoding_rs::WINDOWS_1252)); } + + #[test] + /// A page declares its charset in a `` tag: quoted, unquoted and `http-equiv` forms + /// all parse, and the declaration is found only within the page's head. + fn a_meta_charset_tag_names_the_encoding() { + assert_eq!(meta_charset(b""), Some("windows-1252".into())); + assert_eq!(meta_charset(b""), Some("utf-8".into())); + assert_eq!( + meta_charset(b""), + Some("iso-8859-1".into()) + ); + assert_eq!(meta_charset(b"x"), None); + assert_eq!(meta_charset(b"

    charset= is not a tag

    "), None); + } + + #[test] + /// The encoding order is the HTML5 one: a BOM beats the header, the header beats the meta + /// tag, and a page with none of them decodes as UTF-8. + fn bom_header_and_meta_declare_the_encoding_in_that_order() { + let bom = [0xEF, 0xBB, 0xBF]; + assert_eq!(encoding_from("text/html; charset=utf-8", &bom), encoding_rs::UTF_8); + assert_eq!(encoding_from("text/html; charset=utf-8", &[0xFF, 0xFE, 0x00]), encoding_rs::UTF_16LE); + assert_eq!( + encoding_from("text/html; charset=windows-1252", b""), + encoding_rs::WINDOWS_1252 + ); + assert_eq!(encoding_from("text/html", b""), encoding_rs::WINDOWS_1252); + assert_eq!(encoding_from("text/html", b"plain"), encoding_rs::UTF_8); + } } From 507a6ae50830b8f969c39f5c2413c14c4136dd0c Mon Sep 17 00:00:00 2001 From: George Stevenson Date: Wed, 28 Oct 2026 08:59:22 +0000 Subject: [PATCH 29/31] From Web: Refresh All with no saved connections says so instead of silently doing nothing Co-authored-by: setzor --- crates/ui-egui/src/ribbon/data.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/ribbon/data.rs b/crates/ui-egui/src/ribbon/data.rs index 9234fda89c..da319a9f47 100644 --- a/crates/ui-egui/src/ribbon/data.rs +++ b/crates/ui-egui/src/ribbon/data.rs @@ -27,8 +27,12 @@ pub(super) fn data(app: &mut SheetApp, ui: &mut Ui) { #[cfg(not(target_arch = "wasm32"))] { let urls: Vec = app.session.active().map(|d| d.wb.web_queries.iter().map(|q| q.url.clone()).collect()).unwrap_or_default(); - let refs: Vec<&str> = urls.iter().map(String::as_str).collect(); - app.start_fetch(&refs, ("data.webRefresh", json!({}))); + if urls.is_empty() { + app.message = Some(("Refresh All".into(), "No saved connections. Import a table with Data ▸ From Web to create one.".into())); + } else { + let refs: Vec<&str> = urls.iter().map(String::as_str).collect(); + app.start_fetch(&refs, ("data.webRefresh", json!({}))); + } } #[cfg(target_arch = "wasm32")] match app.run("data.webRefresh", json!({})) { From a1128cc3ce7f97ecd8b033b2826119422c4665d5 Mon Sep 17 00:00:00 2001 From: George Stevenson Date: Wed, 28 Oct 2026 14:07:46 +0000 Subject: [PATCH 30/31] From Web: select the kept calculated columns by name, so a wider page moves them right instead of overwriting them Co-authored-by: setzor --- crates/engine/src/cmd/web.rs | 100 ++++++++++++++++++++++++++--------- 1 file changed, 75 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/cmd/web.rs b/crates/engine/src/cmd/web.rs index 430358df91..28e2317642 100644 --- a/crates/engine/src/cmd/web.rs +++ b/crates/engine/src/cmd/web.rs @@ -374,13 +374,33 @@ fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTabl return Err(EngineError::Other("The table is empty.".into())); } let w = table.rows.iter().map(Vec::len).max().unwrap_or(1); - // Calculated columns the user added to the right of the imported data are theirs, not the - // page's: the ones beyond the fetched width with a column formula are kept in place, so a - // refresh never deletes them (Excel keeps query-table calculated columns the same way). - let kept: Vec = { + // The fetched header names, as the edit below spells them. + let page_headers: Vec = (0..w) + .map(|i| { + table + .rows + .first() + .and_then(|row| row.get(i)) + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .unwrap_or_else(|| format!("Column{}", i + 1)) + }) + .collect(); + // Calculated columns the user added are theirs, not the page's: the ones with a column + // formula whose name the fetched headers do not supply are kept, wherever the page's width + // leaves room (a wider page moves them right, a narrower one left, like Excel). Selection is + // by name, not position: a page that grows into a calculated column's old position must + // move it, not overwrite it. Each one's old column is remembered so the clash check below + // can tell its own cells from foreign content at the new place. + let kept: Vec<(u32, TableColumn)> = { let sh = d.wb.sheet(sheet).ok_or_else(|| EngineError::Other(format!("The sheet of the connection `{}` is gone.", q.name)))?; let Some(t) = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&tname)) else { return Err(EngineError::Other(format!("The table of the connection `{}` is gone.", q.name))) }; - t.columns.iter().skip(w).filter(|c| c.formula.is_some()).cloned().collect() + t.columns + .iter() + .enumerate() + .filter(|(_, c)| c.formula.is_some() && !page_headers.iter().any(|h| h.eq_ignore_ascii_case(&c.name))) + .map(|(i, c)| (i as u32, c.clone())) + .collect() }; let total_w = w + kept.len(); let (Some(end_row), Some(end_col)) = ( @@ -397,17 +417,15 @@ fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTabl if let Some(sh) = d.wb.sheet(sheet) { let totals_row = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&tname)).is_some_and(|t| t.totals_row); let last_row = if totals_row { (new_range.end.row + 1).min(MAX_ROWS - 1) } else { new_range.end.row }; - // The kept calculated columns' own cells are expected, not a clash — but only where - // they already are: when the page's width is unchanged they sit exactly in the kept - // strip. A width change moves them, and content at the new place still refuses. - let kept_in_place = old.width() == w as u32; - let kept_start = old.start.col + w as u32; + // The kept calculated columns' own cells (at their old columns, within the old rows) are + // expected, not a clash; the new places they move to must be empty, or the refresh + // refuses rather than overwrite whatever sits there. let outside = new_range.height() > old.height() || new_range.width() > old.width() || totals_row; let clash = outside && (new_range.start.row..=last_row).any(|row| { sh.cells.row(row, new_range.start.col, new_range.end.col).any(|(c, cell)| { (row < old.start.row || row > old.end.row || c < old.start.col || c > old.end.col) - && !(kept_in_place && row <= old.end.row && c >= kept_start && c < kept_start + kept.len() as u32) + && !(row <= old.end.row && kept.iter().any(|(i, _)| old.start.col + i == c)) && (!cell.value.is_empty() || cell.formula.is_some()) }) }); @@ -437,18 +455,8 @@ fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTabl // The names come from the fetched header row, not the cells: a numeric header // (`2024`) is a number in the cell, and reading it as text would miss the column // and drop its settings on every refresh. sync_headers writes them back as text. - let headers: Vec = (0..w) - .map(|i| { - table - .rows - .first() - .and_then(|row| row.get(i)) - .map(|t| t.trim().to_string()) - .filter(|t| !t.is_empty()) - .unwrap_or_else(|| format!("Column{}", i + 1)) - }) - .collect(); - let mut columns: Vec = headers + let mut columns: Vec = page_headers + .clone() .into_iter() .map(|name| { old_cols.iter().find(|col| col.name.eq_ignore_ascii_case(&name)).cloned().unwrap_or(TableColumn { @@ -462,7 +470,7 @@ fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTabl }) .collect(); // The user's calculated columns beyond the page's width stay, with their metadata. - columns.extend(kept.iter().cloned()); + columns.extend(kept.iter().map(|(_, c)| c.clone())); let t = &mut sh.tables[ti]; t.columns = columns; t.range = new_range; @@ -475,7 +483,7 @@ fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTabl edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; let Some(t) = sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(&tname)).cloned() else { return Ok(()) }; - for (i, col) in kept.iter().enumerate() { + for (i, (_, col)) in kept.iter().enumerate() { let Some(code) = col.formula.as_ref() else { continue }; let expr = gridcraft_formula::parse(code).map_err(|e| EngineError::Other(format!("The calculated column `{}` has a broken formula: {e}", col.name)))?; let c0 = t.range.start.col + w as u32 + i as u32; @@ -1087,6 +1095,48 @@ mod tests { assert_eq!(c2.value, gridcraft_core::Value::from(25.0)); } + #[test] + /// A wider page moves the user's calculated column right instead of overwriting it: the + /// selection is by name, so the page's new third column takes position 3 and the calculated + /// column (and its refilled formulas) moves to position 4. + fn a_wider_page_moves_the_calculated_column_not_deletes_it() { + let mut s = session_with_source(); + s.execute("data.fromWeb", json!({"url": "https://example.com/markets", "sheet": "Markets"})).unwrap(); + // The user's calculated column, third of three. + s.execute("cell.set", json!({"cell": "C2", "input": "=B2*2"})).unwrap(); + super::super::edit(&mut s, |cx| { + let sh = cx.sheet_mut(cx.wb.sheet_index("Markets").ok_or_else(|| EngineError::Other("no Markets sheet".into()))?)?; + let ti = sh.tables.iter().position(|t| t.name == "prices").expect("the table"); + while sh.tables[ti].columns.len() < 3 { + let n = sh.tables[ti].columns.len(); + sh.tables[ti].columns.push(gridcraft_model::TableColumn { name: format!("Total{}", n), totals: TotalsFn::None, totals_label: None, formula: Some("B2*2".into()), unique_name: None, xml: None }); + } + cx.structural = true; + Ok(()) + }) + .unwrap(); + // A page three columns wide: the third column would sit where the calculated column is. + s.web_source = Some(Arc::new(|req: &WebRequest| { + Ok(crate::webquery::extract_tables( + "
    SymPriceVolume
    ABCD12.50100
    ", + req.max_rows, + req.max_cols, + )) + })); + s.execute("data.webRefresh", json!({})).unwrap(); + let d = s.active().unwrap(); + let sh = d.wb.active().unwrap(); + let t = sh.tables.iter().find(|t| t.name == "prices").unwrap(); + assert_eq!(t.range.width(), 4, "the page's three columns plus the calculated one"); + assert_eq!(t.columns[2].name, "Volume", "the page's third column takes position 3"); + assert_eq!(t.columns[3].formula.as_deref(), Some("B2*2"), "the calculated column moved right, metadata kept"); + // The page's data landed in its columns and the calculated column's cells refilled. + assert_eq!(sh.value(gridcraft_core::CellRef::parse("C2").unwrap()), gridcraft_core::Value::from(100.0)); + let d2 = sh.cell(gridcraft_core::CellRef::parse("D2").unwrap()).expect("the calculated column's new cell"); + assert!(d2.formula.is_some(), "the formula is refilled at the new place"); + assert_eq!(d2.value, gridcraft_core::Value::from(25.0)); + } + #[test] /// Userinfo in the URL (`user:pass@host`) is refused: ureq would send it as basic auth and /// the saved connection would store the secret verbatim in the workbook. From eb3cfdd4f06fcda9f3256d81b5757082b1c512f3 Mon Sep 17 00:00:00 2001 From: George Stevenson Date: Wed, 28 Oct 2026 14:19:55 +0000 Subject: [PATCH 31/31] From Web: write a kept calculated column's name into its new header cell before sync_headers renames it Co-authored-by: setzor --- crates/engine/src/cmd/web.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/engine/src/cmd/web.rs b/crates/engine/src/cmd/web.rs index 28e2317642..042acbdeb0 100644 --- a/crates/engine/src/cmd/web.rs +++ b/crates/engine/src/cmd/web.rs @@ -474,6 +474,14 @@ fn rewrite_table(s: &mut Session, q: &gridcraft_model::WebQuery, table: &WebTabl let t = &mut sh.tables[ti]; t.columns = columns; t.range = new_range; + // Write each kept column's name into its new header cell before sync_headers runs: + // an empty header there would be renamed to a generated Column{n}, breaking the + // name the metadata (and the connection) still holds. + for (i, (_, col)) in kept.iter().enumerate() { + let c = CellRef::new(new_range.start.row, new_range.start.col + w as u32 + i as u32); + let style = sh.style_id(c); + sh.set_cell(c, gridcraft_model::Cell { value: gridcraft_core::Value::text(col.name.as_str()), style, ..Default::default() }); + } cx.structural = true; Ok(()) })?; @@ -1130,6 +1138,8 @@ mod tests { assert_eq!(t.range.width(), 4, "the page's three columns plus the calculated one"); assert_eq!(t.columns[2].name, "Volume", "the page's third column takes position 3"); assert_eq!(t.columns[3].formula.as_deref(), Some("B2*2"), "the calculated column moved right, metadata kept"); + assert_eq!(t.columns[3].name, "Total2", "sync_headers keeps the column's name"); + assert_eq!(sh.value(gridcraft_core::CellRef::parse("D1").unwrap()), gridcraft_core::Value::text("Total2"), "the moved column's header cell holds its name"); // The page's data landed in its columns and the calculated column's cells refilled. assert_eq!(sh.value(gridcraft_core::CellRef::parse("C2").unwrap()), gridcraft_core::Value::from(100.0)); let d2 = sh.cell(gridcraft_core::CellRef::parse("D2").unwrap()).expect("the calculated column's new cell");