diff --git a/AGENTS.md b/AGENTS.md index 289cb12d0..01a8da9b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md). - **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). - **Errors are `Result`** through the crate's error type and `?`. -- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references). +- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): validate indexes and ranges before access, use checked access when validity is not established, use checked/saturating arithmetic, and cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references). - **Bound recursion** (formula nesting, dependency chains, names referring to names). - **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence. - **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`). diff --git a/CLAUDE.md b/CLAUDE.md index 8bf527c54..266016a1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr - **Bound recursion** (formula nesting, dependency chains, names referring to names). - **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence. - **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`). -- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. +- Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; every new workspace crate must also include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. ## Non-negotiables - **Clean-room.** Microsoft Excel is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic workbooks, take screenshots (by window id) stored only under `plan/excel/screenshots/`. Never read, disassemble or copy anything inside the Office bundle, never commit files produced by Excel, never copy GPL/AGPL/LGPL code (LibreOffice, Gnumeric…). Function behaviour comes from public documentation and observation. diff --git a/Cargo.lock b/Cargo.lock index 87ff12215..f5a6a6328 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2024,6 +2024,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "web-time", "zip 3.0.0", ] @@ -2097,6 +2098,7 @@ dependencies = [ "sys-locale", "toml", "web-sys", + "web-time", "zbus", ] diff --git a/Cargo.toml b/Cargo.toml index 32823c84a..4bafc9b27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,8 @@ serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" # All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). -chrono = { version = "0.4.45", default-features = false, features = ["std"] } +chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] } +web-time = "1.1.0" rayon = "1" regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] } quick-xml = "0.41" @@ -67,10 +68,11 @@ craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-li unsafe_code = "forbid" [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" -too_many_arguments = "allow" -type_complexity = "allow" [profile.dev.package."*"] opt-level = 2 diff --git a/apps/gridcraft-cli/src/check/fidelity.rs b/apps/gridcraft-cli/src/check/fidelity.rs index 446a8949a..d8def2747 100644 --- a/apps/gridcraft-cli/src/check/fidelity.rs +++ b/apps/gridcraft-cli/src/check/fidelity.rs @@ -1,6 +1,6 @@ use super::calc::formula_text; use super::package::{REBUILT_KINDS, relationship_kinds, validate_package}; -use super::*; +use super::{BTreeSet, Category, Cell, CellError, CellRef, Diffs, Options, RoundTrip, Sheet, Value, Workbook, number_text, quote_sheet}; // ---------------------------------------------------------------- round trip diff --git a/apps/gridcraft-cli/src/check/package.rs b/apps/gridcraft-cli/src/check/package.rs index bcbbef10f..6b97fa495 100644 --- a/apps/gridcraft-cli/src/check/package.rs +++ b/apps/gridcraft-cli/src/check/package.rs @@ -20,7 +20,9 @@ fn local_name(name: &[u8]) -> &[u8] { } /// `(element local name, attributes)` for every start/empty element of an XML part. -fn xml_elements(xml: &[u8]) -> Result)>, String> { +type XmlElement = (String, Vec<(String, String)>); + +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(); diff --git a/apps/gridcraft-cli/src/check/report.rs b/apps/gridcraft-cli/src/check/report.rs index 54227e797..6c0d6b36d 100644 --- a/apps/gridcraft-cli/src/check/report.rs +++ b/apps/gridcraft-cli/src/check/report.rs @@ -131,7 +131,7 @@ fn fit(s: &str, w: usize) -> String { if n <= w { s.to_string() } else { format!("…{}", s.chars().skip(n + 1 - w).collect::()) } } -pub(super) fn print_human(reports: &[FileReport], opts: &Options) { +fn print_file_reports(reports: &[FileReport]) { for r in reports { outln!("== {}", r.path); if let Some(e) = &r.error { @@ -186,7 +186,9 @@ pub(super) fn print_human(reports: &[FileReport], opts: &Options) { } } } - outln!(""); +} + +fn print_file_table(reports: &[FileReport]) { 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", @@ -220,7 +222,9 @@ pub(super) fn print_human(reports: &[FileReport], opts: &Options) { c.volatile ); } - let t = totals(reports); +} + +fn print_rankings(t: &Totals, opts: &Options) { let top = ranked(&t.functions); if !top.is_empty() { outln!(""); @@ -239,6 +243,9 @@ pub(super) fn print_human(reports: &[FileReport], opts: &Options) { outln!(" {n:>8} {sig}"); } } +} + +fn print_totals(t: &Totals) { outln!(""); outln!("files: {} ({} opened, {} failed to open)", t.files, t.opened, t.failed); outln!( @@ -262,3 +269,12 @@ pub(super) fn print_human(reports: &[FileReport], opts: &Options) { if cats.is_empty() { String::new() } else { format!("; categories (files): {}", cats.join(", ")) } ); } + +pub(super) fn print_human(reports: &[FileReport], opts: &Options) { + print_file_reports(reports); + outln!(""); + print_file_table(reports); + let t = totals(reports); + print_rankings(&t, opts); + print_totals(&t); +} diff --git a/apps/gridcraft-cli/tests/it/main.rs b/apps/gridcraft-cli/tests/it/main.rs index 17f8db87f..5906b3967 100644 --- a/apps/gridcraft-cli/tests/it/main.rs +++ b/apps/gridcraft-cli/tests/it/main.rs @@ -1,5 +1,3 @@ //! The crate's integration tests, one module per file, built as one test binary. - -#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod cli; mod text_to_columns_quotes; diff --git a/apps/gridcraft/src/logging.rs b/apps/gridcraft/src/logging.rs index 413797378..ca7d05717 100644 --- a/apps/gridcraft/src/logging.rs +++ b/apps/gridcraft/src/logging.rs @@ -268,9 +268,10 @@ impl log::Log for AppLogger { } // Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock. let thread = std::thread::current(); - #[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it - let line = - format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string()); + let logged_at = gridcraft_engine::core::date::unix_now_ms() + .and_then(|milliseconds| SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_millis(milliseconds.max(0.0) as u64))) + .unwrap_or(SystemTime::UNIX_EPOCH); + let line = format_line(×tamp(logged_at), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string()); if self.stderr { // No terminal (a Windows GUI build, a closed pipe) is not an error worth reporting. let _ = std::io::stderr().write_all(line.as_bytes()); diff --git a/apps/gridcraft/tests/mac_menu_appkit.rs b/apps/gridcraft/tests/mac_menu_appkit.rs index e713f6699..8b132fd05 100644 --- a/apps/gridcraft/tests/mac_menu_appkit.rs +++ b/apps/gridcraft/tests/mac_menu_appkit.rs @@ -7,7 +7,6 @@ // unit tests compile here too without a harness to run them). #[cfg(target_os = "macos")] #[path = "../src/native_menu.rs"] -#[allow(unused_imports, dead_code)] mod native_menu; #[cfg(target_os = "macos")] diff --git a/clippy.toml b/clippy.toml index de0f11db4..c6fbddafd 100644 --- a/clippy.toml +++ b/clippy.toml @@ -14,3 +14,4 @@ disallowed-methods = [ disallowed-types = [ { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, ] +cognitive-complexity-threshold = 30 diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 45d81ccca..4972623fc 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use gridcraft_core::{Array, CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, compare}; -use gridcraft_formula::{BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, UnOp}; +use gridcraft_formula::{BinOp, Expr, Reference, SheetSel, StructItem, StructRef, UnOp}; use gridcraft_functions::{Arg, Ctx}; use gridcraft_model::Workbook; diff --git a/crates/calc/src/eval/array.rs b/crates/calc/src/eval/array.rs index b17a8c70f..ab6ac1b06 100644 --- a/crates/calc/src/eval/array.rs +++ b/crates/calc/src/eval/array.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Array, BinOp, CellError, MAX_CELLS, Value, compare}; /// The aggregates that can fold a reference without building an array. #[derive(Clone, Copy, PartialEq, Eq)] diff --git a/crates/calc/src/eval/call.rs b/crates/calc/src/eval/call.rs index 9013ca882..08d563d43 100644 --- a/crates/calc/src/eval/call.rs +++ b/crates/calc/src/eval/call.rs @@ -1,40 +1,13 @@ 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::*; +use super::{Area, Arg, Array, CellError, Ev, Evaluator, Expr, FnCtx, Lambda, Pipe, Value, compare, err}; +use std::sync::Arc; 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), - } - } + "IF" => self.if_call(args), "IFS" => { if args.len() < 2 || !args.len().is_multiple_of(2) { return err(CellError::Value); @@ -50,101 +23,10 @@ impl<'h> Evaluator<'h> { } 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), - } - } + "IFERROR" | "IFNA" => self.if_error_call(name, args), + "CHOOSE" => self.choose_call(args), + "SWITCH" => self.switch_call(args), + "ROW" | "COLUMN" => self.row_column_call(name, args), "ROWS" | "COLUMNS" => { if args.len() != 1 { return err(CellError::Value); @@ -180,28 +62,7 @@ impl<'h> Evaluator<'h> { } } } - "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), - } - } + "SHEET" | "SHEETS" => self.sheet_call(name, args), "OFFSET" => self.offset(args), "INDIRECT" => self.indirect(args), "INDEX" => self.index(args), @@ -273,6 +134,156 @@ impl<'h> Evaluator<'h> { } } + fn if_call(&mut self, args: &[Expr]) -> Ev { + if args.is_empty() || args.len() > 3 { + return err(CellError::Value); + } + let condition = self.arg_val(args, 0).unwrap_or_default(); + if let Value::Array(array) = &condition { + let when_true = self.arg_val(args, 1).unwrap_or(Value::Bool(true)); + let when_false = if args.len() > 2 { self.arg_val(args, 2).unwrap_or(Value::Bool(false)) } else { Value::Bool(false) }; + return Ev::V(zip3(array, &when_true, &when_false, |condition, when_true, when_false| match condition.to_bool() { + Ok(true) => when_true.clone(), + Ok(false) => when_false.clone(), + Err(error) => Value::Error(error), + })); + } + match condition.to_bool() { + Ok(true) => match args.get(1) { + Some(Expr::Missing) => Ev::V(Value::Number(0.0)), + Some(expression) => self.eval(expression), + None => Ev::V(Value::Bool(true)), + }, + Ok(false) => match args.get(2) { + Some(Expr::Missing) => Ev::V(Value::Number(0.0)), + Some(expression) => self.eval(expression), + None => Ev::V(Value::Bool(false)), + }, + Err(error) => err(error), + } + } + + fn if_error_call(&mut self, name: &str, args: &[Expr]) -> Ev { + if args.len() != 2 { + return err(CellError::Value); + } + let value = self.arg_ev(args, 0).unwrap_or(Ev::V(Value::Empty)); + let na_only = name == "IFNA"; + let catches = |error: CellError| !na_only || error == CellError::NA; + match &value { + Ev::V(Value::Error(error)) if catches(*error) => self.eval(&args[1]), + Ev::V(Value::Array(array)) if array.data.iter().any(|item| item.as_error().is_some_and(catches)) => { + let alternate = self.arg_val(args, 1).unwrap_or_default(); + let data = + array.data.iter().map(|item| if item.as_error().is_some_and(catches) { alternate.scalar() } else { item.clone() }).collect(); + Ev::V(Array::new(array.rows, array.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))) + } + Ev::R(_) => self.if_error_reference(value.clone(), args, catches), + _ => value, + } + } + + fn if_error_reference(&mut self, value: Ev, args: &[Expr], catches: impl Fn(CellError) -> bool + Copy) -> Ev { + match self.deref(value.clone()) { + Value::Error(error) if catches(error) => self.eval(&args[1]), + Value::Array(ref array) if array.data.iter().any(|item| item.as_error().is_some_and(catches)) => { + let alternate = self.arg_val(args, 1).unwrap_or_default(); + let data = + array.data.iter().map(|item| if item.as_error().is_some_and(catches) { alternate.scalar() } else { item.clone() }).collect(); + Ev::V(Array::new(array.rows, array.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))) + } + _ => value, + } + } + + fn choose_call(&mut self, args: &[Expr]) -> Ev { + if args.len() < 2 { + return err(CellError::Value); + } + let index = self.value(&args[0]); + if let Value::Array(_) = index { + let Some(spec) = gridcraft_functions::lookup("CHOOSE") else { return err(CellError::Name) }; + let mut evaluated = vec![Arg { value: index, from_ref: false }]; + for argument in &args[1..] { + evaluated.push(Arg { value: self.value(argument), from_ref: false }); + } + let mut context = FnCtx { ev: self, criteria: None, lambda: None }; + return gridcraft_functions::call(spec, &evaluated, &mut context).pipe(Ev::V); + } + let index = match index.to_number() { + Ok(number) => number.trunc() as i64, + Err(error) => return err(error), + }; + if index < 1 || index as usize >= args.len() { + return err(CellError::Value); + } + self.eval(&args[index as usize]) + } + + fn switch_call(&mut self, args: &[Expr]) -> Ev { + if args.len() < 3 { + return err(CellError::Value); + } + let value = self.arg_val(args, 0).unwrap_or_default().scalar(); + if let Value::Error(error) = value { + return err(error); + } + let choices = &args[1..]; + for pair in choices.as_chunks::<2>().0 { + let (candidate, result) = (&pair[0], &pair[1]); + let candidate = self.value(candidate).scalar(); + if !candidate.is_error() && compare(&value, &candidate) == std::cmp::Ordering::Equal && same_kind(&value, &candidate) { + return self.eval(result); + } + } + if let Some(default) = choices.get(choices.len().saturating_sub(1)).filter(|_| choices.len() % 2 == 1) { + self.eval(default) + } else { + err(CellError::NA) + } + } + + fn row_column_call(&mut self, name: &str, args: &[Expr]) -> Ev { + 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)); + } + let areas = match self.eval(&args[0]) { + Ev::R(areas) => areas, + Ev::V(Value::Error(error)) => return err(error), + _ => return err(CellError::Value), + }; + let Some(area) = areas.first() else { return err(CellError::Ref) }; + let range = self.trim_lines(area); + if is_row { + let values: Vec = (range.start.row..=range.end.row).map(|row| Value::number(row as f64 + 1.0)).collect(); + if values.len() == 1 { Ev::V(values.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::column(values).into()) } + } else { + let values: Vec = (range.start.col..=range.end.col).map(|col| Value::number(col as f64 + 1.0)).collect(); + if values.len() == 1 { Ev::V(values.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::row(values).into()) } + } + } + + fn sheet_call(&mut self, name: &str, args: &[Expr]) -> Ev { + let is_sheet = name == "SHEET"; + if args.is_empty() { + let count = if is_sheet { self.sheet + 1 } else { self.host.workbook().sheets.len() }; + return Ev::V(Value::number(count as f64)); + } + match self.eval(&args[0]) { + Ev::R(areas) if is_sheet => areas.first().map_or(err(CellError::Ref), |area| Ev::V(Value::number(area.sheet as f64 + 1.0))), + Ev::R(areas) => { + let mut sheets: Vec = areas.iter().map(|area| area.sheet).collect(); + sheets.dedup(); + Ev::V(Value::number(sheets.len() as f64)) + } + Ev::V(Value::Text(name)) if is_sheet => { + self.host.workbook().sheet_index(&name).map_or(err(CellError::NA), |index| Ev::V(Value::number(index as f64 + 1.0))) + } + _ => err(CellError::NA), + } + } + 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. diff --git a/crates/calc/src/eval/format.rs b/crates/calc/src/eval/format.rs index a4c52b492..a288b410e 100644 --- a/crates/calc/src/eval/format.rs +++ b/crates/calc/src/eval/format.rs @@ -1,4 +1,5 @@ -use super::*; +use super::{CellError, Value}; +use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS}; /// CELL("format") code for a number format. pub(super) fn cell_format_code(code: &str) -> String { diff --git a/crates/calc/src/eval/lambda.rs b/crates/calc/src/eval/lambda.rs index 45f99b488..dfb30029a 100644 --- a/crates/calc/src/eval/lambda.rs +++ b/crates/calc/src/eval/lambda.rs @@ -1,5 +1,7 @@ +use super::MAX_LAMBDA_CALLS; use super::array::{broadcast_len, single}; -use super::*; +use super::{Array, CellError, Ev, Evaluator, Expr, Lambda, MAX_CELLS, Value, err}; +use std::sync::Arc; impl<'h> Evaluator<'h> { pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev { diff --git a/crates/calc/src/eval/reference_fns.rs b/crates/calc/src/eval/reference_fns.rs index a21797c23..4f5154c2d 100644 --- a/crates/calc/src/eval/reference_fns.rs +++ b/crates/calc/src/eval/reference_fns.rs @@ -1,5 +1,7 @@ use super::format::{cell_format_code, r1c1_to_a1}; -use super::*; +use super::{Area, Arg, Array, CellError, Ev, Evaluator, Expr, FnCtx, MAX_CELLS, Value, err}; +use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef}; +use std::sync::Arc; impl<'h> Evaluator<'h> { /// Whole rows/columns trimmed for ROW()/COLUMN() arrays. diff --git a/crates/calc/src/eval/special.rs b/crates/calc/src/eval/special.rs index f68badbfc..0151ea2f9 100644 --- a/crates/calc/src/eval/special.rs +++ b/crates/calc/src/eval/special.rs @@ -1,4 +1,5 @@ -use super::*; +use super::{Area, BinOp, Ev, Expr, Reference, SheetSel, Value, Workbook}; +use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef}; /// 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 { @@ -162,6 +163,3 @@ pub const SPECIAL_FUNCTIONS: &[&str] = &[ 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 b7f403131..eb11045db 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -10,14 +10,13 @@ //! and the formulas that read one are evaluated again until the loop settles //! (`PassHost::iterate_loops`). -use std::collections::{BTreeSet, VecDeque}; -use std::hash::{BuildHasherDefault, Hasher}; +use std::collections::VecDeque; 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}; +use crate::eval::{Evaluator, Host, precedents, stack_position}; mod graph; mod pass_host; @@ -368,105 +367,13 @@ impl Calc { self.circular.retain(|k| !evaluated.contains(k)); } self.circular.extend(cycles); - // Write back. - let mut spill_changes: Vec = Vec::new(); - let mut freed: Vec<(usize, RangeRef)> = Vec::new(); - for (k, v) in results { - let Some(sheet) = wb.sheet_mut(k.0) else { continue }; - if let Some(cell) = sheet.cells.get_mut(k.1) - && cell.value != v - { - cell.value = v; - } - } - for k in &dirty { - let new = spills.get(k); - let Some(sheet) = wb.sheet_mut(k.0) else { continue }; - let old = sheet.spill_ranges.get(&k.1).copied(); - if old.is_none() && new.is_none() { - continue; - } - // An array that spills exactly as before changes nothing its dependents read, so - // they aren't queued again. Otherwise two arrays that read each other's spill areas - // queue each other in every round, up to the round limit. - if let (Some(r), Some(arr)) = (old, new) - && spills_as_before(sheet, k.1, r, arr) - { - continue; - } - if let Some(r) = old { - for c in r.iter() { - sheet.spill.remove(&c); - } - sheet.spill_ranges.remove(&k.1); - } - let mut changed_range = old; - if let Some(arr) = new { - let end = CellRef::new(k.1.row.saturating_add(arr.rows as u32 - 1), k.1.col.saturating_add(arr.cols as u32 - 1)); - let range = RangeRef::new(k.1, end); - let blocked = end.row >= gridcraft_core::MAX_ROWS - || end.col >= gridcraft_core::MAX_COLS - || range.iter().any(|c| c != k.1 && (sheet.cells.has(c) || sheet.spill.contains_key(&c))) - || sheet.merges.iter().any(|m| m.intersects(&range)) - || sheet.tables.iter().any(|t| t.range.contains(k.1)); - if blocked { - if let Some(cell) = sheet.cells.get_mut(k.1) { - cell.value = Value::Error(CellError::Spill); - } - // Not blocked while the pass ran (it would have been #SPILL! there), so - // blocked by another array of this pass: the formulas that read this one, - // at its anchor or over the area it would have spilled into, read #SPILL! - // or empty cells now. - let mut deps = Vec::new(); - self.graph.dependents_of_range(k.0, range, &mut deps); - spill_changes.extend(deps.into_iter().filter(|d| d != k)); - } else { - for (i, c) in range.iter().enumerate() { - if c != k.1 { - sheet.spill.insert(c, arr.data.get(i).cloned().unwrap_or_default()); - } - } - sheet.spill_ranges.insert(k.1, range); - changed_range = Some(changed_range.map_or(range, |o| o.union(&range))); - } - } - if let Some(r) = changed_range { - let mut deps = Vec::new(); - self.graph.dependents_of_range(k.0, r, &mut deps); - // Not the anchor itself (that would read its own array), unless its old array - // is gone or partly freed: its new formula may have read the old values, still - // laid out while it was evaluated. - let freed = match (old, sheet.spill_ranges.get(&k.1)) { - (Some(o), Some(n)) => !(n.contains(o.start) && n.contains(o.end)), - (_, None) => true, - (None, Some(_)) => false, - }; - spill_changes.extend(deps.into_iter().filter(|d| d != k || freed)); - } - if let Some(o) = old - && !sheet.spill_ranges.get(&k.1).is_some_and(|n| n.contains(o.start) && n.contains(o.end)) - { - freed.push((k.0, o)); - } - } + let mut spill_changes = self.write_back(wb, &dirty, results, &spills); // Formulas with references only known while evaluating (INDIRECT, OFFSET) may have read // a spill area before it was laid out: they are evaluated once more. if dynamic_again && !spills.is_empty() { dynamic_again = false; spill_changes.extend(self.graph.dynamic.iter().copied()); } - // An area a formula no longer spills over may unblock another formula's array. - for (si, o) in freed { - for &(s, c) in self.graph.nodes.keys() { - if s == si - && c.row <= o.end.row - && c.col <= o.end.col - && wb.sheet(s).is_some_and(|sh| sh.value(c) == Value::Error(CellError::Spill)) - { - spill_changes.push((s, c)); - } - } - } if full { // Everything was already evaluated once; spill dependents need another pass. } @@ -488,6 +395,105 @@ impl Calc { self.circular.retain(|k| self.graph.nodes.contains_key(k)); } } + + fn write_back( + &mut self, + wb: &mut Workbook, + dirty: &[Key], + results: HashMap, + spills: &HashMap>, + ) -> Vec { + let mut spill_changes = Vec::new(); + let mut freed = Vec::new(); + for (key, value) in results { + let Some(sheet) = wb.sheet_mut(key.0) else { continue }; + if let Some(cell) = sheet.cells.get_mut(key.1) + && cell.value != value + { + cell.value = value; + } + } + for key in dirty { + let new = spills.get(key); + let Some(sheet) = wb.sheet_mut(key.0) else { continue }; + let old = sheet.spill_ranges.get(&key.1).copied(); + if old.is_none() && new.is_none() { + continue; + } + // An unchanged spill does not alter any values its dependents read. + if let (Some(range), Some(array)) = (old, new) + && spills_as_before(sheet, key.1, range, array) + { + continue; + } + if let Some(range) = old { + for cell in range.iter() { + sheet.spill.remove(&cell); + } + sheet.spill_ranges.remove(&key.1); + } + let mut changed_range = old; + if let Some(array) = new { + let end = CellRef::new(key.1.row.saturating_add(array.rows as u32 - 1), key.1.col.saturating_add(array.cols as u32 - 1)); + let range = RangeRef::new(key.1, end); + let blocked = spill_is_blocked(sheet, key.1, range, end); + if blocked { + if let Some(cell) = sheet.cells.get_mut(key.1) { + cell.value = Value::Error(CellError::Spill); + } + let mut blocked_dependents = Vec::new(); + self.graph.dependents_of_range(key.0, range, &mut blocked_dependents); + spill_changes.extend(blocked_dependents.into_iter().filter(|dependent| dependent != key)); + } else { + fill_spill(sheet, key.1, range, array); + changed_range = Some(changed_range.map_or(range, |previous| previous.union(&range))); + } + } + if let Some(range) = changed_range { + let mut dependents = Vec::new(); + self.graph.dependents_of_range(key.0, range, &mut dependents); + let freed_old_range = match (old, sheet.spill_ranges.get(&key.1)) { + (Some(previous), Some(current)) => !(current.contains(previous.start) && current.contains(previous.end)), + (_, None) => true, + (None, Some(_)) => false, + }; + spill_changes.extend(dependents.into_iter().filter(|dependent| dependent != key || freed_old_range)); + } + if let Some(previous) = old + && !sheet.spill_ranges.get(&key.1).is_some_and(|current| current.contains(previous.start) && current.contains(previous.end)) + { + freed.push((key.0, previous)); + } + } + for (sheet_index, range) in freed { + for &(sheet, cell) in self.graph.nodes.keys() { + let was_blocked_cell = cell.row <= range.end.row + && cell.col <= range.end.col + && wb.sheet(sheet).is_some_and(|sheet| sheet.value(cell) == Value::Error(CellError::Spill)); + if sheet == sheet_index && was_blocked_cell { + spill_changes.push((sheet, cell)); + } + } + } + spill_changes + } +} + +fn spill_is_blocked(sheet: &gridcraft_model::Sheet, anchor: CellRef, range: RangeRef, end: CellRef) -> bool { + end.row >= gridcraft_core::MAX_ROWS + || end.col >= gridcraft_core::MAX_COLS + || range.iter().any(|cell| cell != anchor && (sheet.cells.has(cell) || sheet.spill.contains_key(&cell))) + || sheet.merges.iter().any(|merge| merge.intersects(&range)) + || sheet.tables.iter().any(|table| table.range.contains(anchor)) +} + +fn fill_spill(sheet: &mut gridcraft_model::Sheet, anchor: CellRef, range: RangeRef, array: &Array) { + for (index, cell) in range.iter().enumerate() { + if cell != anchor { + sheet.spill.insert(cell, array.data.get(index).cloned().unwrap_or_default()); + } + } + sheet.spill_ranges.insert(anchor, range); } /// Current date-time as a serial (1900 system), from the system clock (browser clock on wasm). @@ -513,21 +519,13 @@ pub fn evaluate_expr(wb: &Workbook, sheet: usize, at: CellRef, expr: &Expr) -> V ev.value(expr) } -#[allow(clippy::disallowed_methods)] // the clock is read only off wasm fn prof_now() -> f64 { - #[cfg(not(target_arch = "wasm32"))] - { - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) - } - #[cfg(target_arch = "wasm32")] - { - 0.0 - } + gridcraft_core::date::unix_now_ms().unwrap_or(0.0) } #[cfg(test)] pub(crate) mod tests { - use std::time::{Duration, Instant}; + use std::time::Duration; use gridcraft_core::{CellRef, RangeRef, Value}; use gridcraft_model::{Cell, Formula, Workbook}; @@ -640,11 +638,10 @@ pub(crate) mod tests { } /// Opening (full recalc), changing every value of a column, and re-entering every formula. - #[allow(clippy::disallowed_methods)] // a native-only timing test: the clock is read off wasm fn open_and_bulk_edit(rows: u32) -> Duration { let mut wb = sum_rows(rows); let mut calc = Calc::new(); - let t = Instant::now(); + let started = gridcraft_core::date::unix_now_ms().unwrap_or(0.0); calc.recalc_all(&mut wb); let values: Vec = (0..rows).map(|r| (0, CellRef::new(r, 4))).collect(); if let Some(sh) = wb.sheet_mut(0) { @@ -655,7 +652,8 @@ pub(crate) mod tests { calc.cells_changed(&mut wb, &values); let formulas: Vec = (0..rows).map(|r| (0, CellRef::new(r, 10))).collect(); calc.cells_changed(&mut wb, &formulas); - let elapsed = t.elapsed(); + let ended = gridcraft_core::date::unix_now_ms().unwrap_or(started); + let elapsed = Duration::from_secs_f64((ended - started).max(0.0) / 1000.0); let last = wb.sheet(0).map(|s| s.value(CellRef::new(rows - 1, 10))); let r = rows - 1; assert_eq!(last, Some(Value::Number(f64::from(1 + (5..10).map(|c| r % 97 + c).sum::())))); @@ -670,7 +668,6 @@ pub(crate) mod tests { #[test] #[ignore = "timing-sensitive; run with --ignored in release"] - #[allow(clippy::disallowed_methods)] fn recalc_with_range_formulas_scales_linearly() { // Each row's SUM reads a range in the same columns. Recalculating on open used to walk // every row per range read (the used range), and finding a cell's dependents scanned diff --git a/crates/calc/src/recalc/graph.rs b/crates/calc/src/recalc/graph.rs index aee4b46d5..ab034f8cc 100644 --- a/crates/calc/src/recalc/graph.rs +++ b/crates/calc/src/recalc/graph.rs @@ -1,4 +1,8 @@ -use super::*; +use super::RangeRef; +use crate::eval::Area; +use gridcraft_core::CellRef; +use std::collections::BTreeSet; +use std::hash::{BuildHasherDefault, Hasher}; /// A fast, non-cryptographic hasher for cell keys (FxHash-style multiply-rotate). #[derive(Default, Clone, Copy)] @@ -179,6 +183,9 @@ pub struct Graph { unsettled_wide: HashSet, } +/// A formula's precedents split by shape: single cells, column intervals and wide ranges. +type Entries = (Vec, Vec<(usize, u32, u32, u32)>, Vec<(usize, RangeRef)>); + impl Graph { pub fn len(&self) -> usize { self.nodes.len() @@ -189,7 +196,7 @@ impl Graph { /// 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)>) { + fn entries(areas: &[Area]) -> Entries { let (mut cells, mut cols, mut wide) = (Vec::new(), Vec::new(), Vec::new()); for a in areas { if a.range.is_single() { diff --git a/crates/calc/src/recalc/pass_host.rs b/crates/calc/src/recalc/pass_host.rs index 00e92e82b..6aa8a1204 100644 --- a/crates/calc/src/recalc/pass_host.rs +++ b/crates/calc/src/recalc/pass_host.rs @@ -1,5 +1,7 @@ use super::graph::{HashMap, HashSet, Key}; -use super::*; +use super::{Evaluator, Host, Workbook, stack_position}; +use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value}; +use gridcraft_formula::Expr; /// 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 diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 76a1619e3..6a64558a1 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -1,9 +1,8 @@ //! Chart layout: title, legend, axes and every chart kind, drawn into [`Prim`]s. -use std::collections::HashSet; use std::f32::consts::{PI, TAU}; -use gridcraft_model::{Chart, ChartKind, LegendPos, LineDash, QuartileMethod, Theme, TrendlineKind}; +use gridcraft_model::{Chart, ChartKind, LineDash, QuartileMethod, Theme, TrendlineKind}; use crate::axis::{Fmt, Scale, clean, fixed_scale, nice_scale}; use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, series_color}; @@ -70,8 +69,7 @@ impl Ctx<'_> { } String::new() } - #[allow(clippy::too_many_arguments)] - fn text(&mut self, x: f32, y: f32, s: &str, size: f32, color: Rgba, align: HAlign, valign: VAlign) { + fn text(&mut self, x: f32, y: f32, s: &str, size: f32, color: Rgba, (align, valign): (HAlign, VAlign)) { if s.is_empty() { return; } @@ -135,7 +133,7 @@ pub fn render(chart: &Chart, data: &ChartData, w: f32, h: f32, m: &dyn Measure) if let Some(t) = chart_title(chart, &data) { let size = TITLE_SIZE.min(h * 0.12).max(4.0); let t = ctx.fit(&t, size, area.w); - ctx.text(w / 2.0, area.y, &t, size, TEXT, HAlign::Center, VAlign::Top); + ctx.text(w / 2.0, area.y, &t, size, TEXT, (HAlign::Center, VAlign::Top)); let used = size * 1.3 + 4.0; area.y += used; area.h -= used; diff --git a/crates/chart/src/render/cartesian.rs b/crates/chart/src/render/cartesian.rs index 85a577b08..d449498e7 100644 --- a/crates/chart/src/render/cartesian.rs +++ b/crates/chart/src/render/cartesian.rs @@ -2,7 +2,10 @@ 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::*; +use super::{ + Chart, ChartData, ChartKind, Ctx, Fmt, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_LINE_PTS, MAX_MARKERS, Prim, Scale, SeriesData, TEXT, + VAlign, WHITE, clean, fixed_scale, nice_scale, +}; // ---------------------------------------------------------------- cartesian (column/bar/line/area/combo) @@ -18,6 +21,9 @@ enum Fam { Area100, } +/// Stack accumulator: (axis, family) key with positive and negative running sums. +type Stack = ((bool, Fam), Vec, Vec); + fn family(k: ChartKind) -> Fam { use ChartKind::*; match k { @@ -45,16 +51,7 @@ 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(); - +fn series_spans(data: &ChartData, fams: &[Fam], sec: &[bool], ncat: usize) -> Vec>> { // 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() { @@ -81,7 +78,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx 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 stacks: 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); @@ -126,6 +123,10 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx spans.push(row); } + spans +} + +fn axis_scales(data: &ChartData, fams: &[Fam], sec: &[bool], spans: &[Vec>]) -> Vec<(Scale, Fmt)> { // Axis scales. let mut axes: Vec<(Scale, Fmt)> = Vec::new(); for axis in [false, true] { @@ -151,6 +152,46 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx break; } } + axes +} + +fn column_slots(fams: &[Fam], sec: &[bool]) -> (Vec, f32) { + // 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; fams.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; + } + } + (slot_of, slot_keys.len().max(1) as f32) +} + +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(); + + let spans = series_spans(data, &fams, &sec, ncat); + + let axes = axis_scales(data, &fams, &sec, &spans); 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 }); @@ -177,27 +218,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx 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 (slot_of, nslots) = column_slots(&fams, &sec); 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. @@ -275,20 +296,20 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx 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); + 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); + 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); + 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); + 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); + 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); + ctx.text(x, lo + len + 2.0, &t, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Top)); } } } @@ -304,7 +325,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx 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); + ctx.text(p[0], p[1] - 5.0, &fmt.format(v, None), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom)); } } } @@ -312,7 +333,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx } } for (x, y, t) in area_labels.drain(..) { - ctx.text(x, y, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + ctx.text(x, y, &t, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom)); } } // Trendlines on top: x is the category number 1..n. @@ -412,7 +433,7 @@ pub(super) fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) 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); + ctx.text(p[0], p[1] - 6.0, &fy.format(*y, None), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom)); } } } diff --git a/crates/chart/src/render/frame.rs b/crates/chart/src/render/frame.rs index 7fa2a54a1..0117423b3 100644 --- a/crates/chart/src/render/frame.rs +++ b/crates/chart/src/render/frame.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{AXIS, AXIS_TITLE_SIZE, Chart, ChartData, Ctx, DARK_TEXT, Fmt, GRID, HAlign, LABEL_SIZE, PI, Prim, Rgba, Scale, TEXT, VAlign, WHITE}; #[derive(Clone, Copy, Debug)] pub(super) struct Bx { @@ -44,7 +44,7 @@ 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); + 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()) { @@ -123,12 +123,12 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti 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); + 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); + 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 }; @@ -142,7 +142,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti 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); + 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. @@ -152,7 +152,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti 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); + 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); @@ -169,7 +169,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti 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); + 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; @@ -202,7 +202,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti 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); + 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 }; @@ -213,7 +213,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti 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); + 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. @@ -224,7 +224,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti 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); + 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); diff --git a/crates/chart/src/render/kinds.rs b/crates/chart/src/render/kinds.rs index 2ffbfa3dd..54ce6bec8 100644 --- a/crates/chart/src/render/kinds.rs +++ b/crates/chart/src/render/kinds.rs @@ -1,6 +1,9 @@ use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range}; use super::legend::{cat_label, point_color}; -use super::*; +use super::{ + AXIS, Chart, ChartData, Ctx, DOWN, Fmt, GRID, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_MARKERS, Prim, QuartileMethod, STOCK, SeriesData, + TAU, TEXT, UP, VAlign, WHITE, clean, nice_scale, +}; // ---------------------------------------------------------------- histogram @@ -47,7 +50,7 @@ pub(super) fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx 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); + ctx.text(x + cw / 2.0, top - 2.0, &c.to_string(), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom)); } } } @@ -110,7 +113,7 @@ pub(super) fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx } 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); + ctx.text(x + bw / 2.0, yb.min(yt) - 2.0, &f.format(v, None), LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom)); } } } @@ -150,10 +153,10 @@ pub(super) fn funnel(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { 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); + 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); + ctx.text(plot.x - 6.0, yc, &l, LABEL_SIZE, TEXT, (HAlign::Right, VAlign::Middle)); } } } @@ -194,7 +197,7 @@ pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inne a += sweep; } for (x, y, t, c) in labels { - ctx.text(x, y, &t, LABEL_SIZE, c, HAlign::Center, VAlign::Middle); + ctx.text(x, y, &t, LABEL_SIZE, c, (HAlign::Center, VAlign::Middle)); } } @@ -281,11 +284,11 @@ pub(super) fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) 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); + 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); + ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top)); } } } @@ -325,7 +328,7 @@ pub(super) fn radar(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { 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); + 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); @@ -349,7 +352,7 @@ pub(super) fn radar(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { 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); + 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(); diff --git a/crates/chart/src/render/legend.rs b/crates/chart/src/render/legend.rs index 95cb39d48..fec1e4549 100644 --- a/crates/chart/src/render/legend.rs +++ b/crates/chart/src/render/legend.rs @@ -1,6 +1,10 @@ use super::frame::Bx; use super::trend::supports_trendline; -use super::*; +use super::{ + Chart, ChartData, ChartKind, Ctx, DOWN, HAlign, LABEL_SIZE, MAX_CATS, MAX_SERIES, MAX_TRENDLINES, Prim, Rgba, SeriesData, TEXT, Theme, UP, + VAlign, auto_name, series_color, +}; +use gridcraft_model::LegendPos; /// How a legend entry's swatch is drawn. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -132,7 +136,7 @@ pub(super) fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos 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); + ctx.text(x + sx, yc, &t, size, TEXT, (HAlign::Left, VAlign::Middle)); } x += iw + spacing; } @@ -152,7 +156,7 @@ pub(super) fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos 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); + 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 index 9cef4608c..ae0229cfb 100644 --- a/crates/chart/src/render/lines.rs +++ b/crates/chart/src/render/lines.rs @@ -1,5 +1,6 @@ use super::frame::Bx; -use super::*; +use super::{Ctx, MAX_LINE_PTS, MAX_MARKERS, Prim, Rgba, WHITE}; +use std::collections::HashSet; // ---------------------------------------------------------------- helpers diff --git a/crates/chart/src/render/trend.rs b/crates/chart/src/render/trend.rs index 38e435a47..b642df262 100644 --- a/crates/chart/src/render/trend.rs +++ b/crates/chart/src/render/trend.rs @@ -1,6 +1,9 @@ use super::frame::{Bx, value_range}; use super::lines::decimate; -use super::*; +use super::{ + ChartKind, Ctx, HAlign, LABEL_SIZE, LineDash, MAX_CATS, MAX_LINE_PTS, MAX_TRENDLINES, Prim, SeriesData, TEXT, TrendlineKind, VAlign, + fit_trendline, moving_average, +}; // ---------------------------------------------------------------- trendlines @@ -36,10 +39,11 @@ 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) } +type ClippedSegment = ((f64, f64), (f64, f64), bool, bool); + /// 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)> { +fn clip_segment(a: (f64, f64), b: (f64, f64)) -> Option { 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)] { @@ -161,6 +165,6 @@ pub(super) fn draw_trend_labels(ctx: &mut Ctx, plot: Bx, labels: &[String]) { 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); + ctx.text(plot.right() - 4.0, y, &t, LABEL_SIZE, TEXT, (HAlign::Right, VAlign::Top)); } } diff --git a/crates/core/src/addr.rs b/crates/core/src/addr.rs index fd8ffec11..9d10ea87c 100644 --- a/crates/core/src/addr.rs +++ b/crates/core/src/addr.rs @@ -295,15 +295,16 @@ mod tests { use super::*; #[test] - fn range_parse_rejects_signed_rows() { + fn range_parse_rejects_signed_rows() -> Result<(), std::io::Error> { assert!(RangeRef::parse("+1:+3").is_none()); assert!(RangeRef::parse("-1:3").is_none()); assert!(RangeRef::parse("$$1:3").is_none()); assert!(RangeRef::parse(":3").is_none()); assert!(RangeRef::parse("$$A:$$C").is_none()); assert!(RangeRef::parse("$A:$C").is_some()); - let r = RangeRef::parse("$1:$3").unwrap(); + let r = RangeRef::parse("$1:$3").ok_or_else(|| std::io::Error::other("valid row range"))?; assert_eq!((r.start.row, r.end.row), (0, 2)); + Ok(()) } #[test] @@ -389,18 +390,20 @@ mod tests { } #[test] - fn ranges() { - let r = RangeRef::parse("C3:A1").unwrap(); + fn ranges() -> Result<(), std::io::Error> { + let parse = |s| RangeRef::parse(s).ok_or_else(|| std::io::Error::other("valid range")); + let r = parse("C3:A1")?; assert_eq!(r.a1(), "A1:C3"); assert_eq!(r.count(), 9); - assert_eq!(RangeRef::parse("A:C").unwrap().a1(), "A:C"); - assert_eq!(RangeRef::parse("3:5").unwrap().a1(), "3:5"); + assert_eq!(parse("A:C")?.a1(), "A:C"); + assert_eq!(parse("3:5")?.a1(), "3:5"); assert!(RangeRef::parse("0:5").is_none()); - let a = RangeRef::parse("A1:C3").unwrap(); - let b = RangeRef::parse("B2:D4").unwrap(); - assert_eq!(a.intersection(&b).unwrap().a1(), "B2:C3"); + let a = parse("A1:C3")?; + let b = parse("B2:D4")?; + assert_eq!(a.intersection(&b).ok_or_else(|| std::io::Error::other("overlap expected"))?.a1(), "B2:C3"); assert_eq!(a.union(&b).a1(), "A1:D4"); assert_eq!(a.iter().count(), 9); + Ok(()) } #[test] @@ -458,18 +461,19 @@ mod category_level_tests { use super::*; #[test] - fn a_wide_range_beside_a_column_of_values_is_a_multi_level_axis() { - let r = |s: &str| RangeRef::parse(s).unwrap(); - assert_eq!(category_levels(&r("A4:B39"), Some(&r("C4:C39"))), Some(CategoryLevels { by_columns: true, outer: 1 })); - assert_eq!(category_levels(&r("A4:D39"), Some(&r("C4:C39"))), Some(CategoryLevels { by_columns: true, outer: 3 })); - assert_eq!(category_levels(&r("A4:F39"), Some(&r("C4:C39"))).map(|l| l.outer), Some(3), "capped"); - assert_eq!(category_levels(&r("A4:A39"), Some(&r("C4:C39"))), None, "one column: a plain list"); + fn a_wide_range_beside_a_column_of_values_is_a_multi_level_axis() -> Result<(), std::io::Error> { + let r = |s| RangeRef::parse(s).ok_or_else(|| std::io::Error::other("valid test range")); + assert_eq!(category_levels(&r("A4:B39")?, Some(&r("C4:C39")?)), Some(CategoryLevels { by_columns: true, outer: 1 })); + assert_eq!(category_levels(&r("A4:D39")?, Some(&r("C4:C39")?)), Some(CategoryLevels { by_columns: true, outer: 3 })); + assert_eq!(category_levels(&r("A4:F39")?, Some(&r("C4:C39")?)).map(|l| l.outer), Some(3), "capped"); + assert_eq!(category_levels(&r("A4:A39")?, Some(&r("C4:C39")?)), None, "one column: a plain list"); // A row of values: levels are rows. - assert_eq!(category_levels(&r("B2:M3"), Some(&r("B4:M4"))), Some(CategoryLevels { by_columns: false, outer: 1 })); - assert_eq!(category_levels(&r("B3:M3"), Some(&r("B4:M4"))), None); + assert_eq!(category_levels(&r("B2:M3")?, Some(&r("B4:M4")?)), Some(CategoryLevels { by_columns: false, outer: 1 })); + assert_eq!(category_levels(&r("B3:M3")?, Some(&r("B4:M4")?)), None); // Literal values: the categories' own shape decides. - assert_eq!(category_levels(&r("A1:B6"), None), Some(CategoryLevels { by_columns: true, outer: 1 })); - assert_eq!(category_levels(&r("A1:F2"), None), Some(CategoryLevels { by_columns: false, outer: 1 })); - assert_eq!(category_levels(&r("A:B"), Some(&r("C:C"))), None, "whole columns"); + assert_eq!(category_levels(&r("A1:B6")?, None), Some(CategoryLevels { by_columns: true, outer: 1 })); + assert_eq!(category_levels(&r("A1:F2")?, None), Some(CategoryLevels { by_columns: false, outer: 1 })); + assert_eq!(category_levels(&r("A:B")?, Some(&r("C:C")?)), None, "whole columns"); + Ok(()) } } diff --git a/crates/core/src/date.rs b/crates/core/src/date.rs index 8e68695f1..606d3dacf 100644 --- a/crates/core/src/date.rs +++ b/crates/core/src/date.rs @@ -47,13 +47,11 @@ pub fn days_from_civil(y: i32, m: u32, d: u32) -> i64 { } /// Milliseconds since the Unix epoch (UTC) from the system clock; `None` when the clock is unavailable. -/// On wasm32 `std::time::SystemTime::now` traps, so the browser clock is read through `Date.now()`. -#[allow(clippy::disallowed_methods)] // the std clock is read only off wasm +/// Chrono uses the browser clock on wasm32, where the standard library clock methods trap. pub fn unix_now_ms() -> Option { #[cfg(not(target_arch = "wasm32"))] { - let d = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?; - Some(d.as_secs_f64() * 1000.0) + Some(chrono::Utc::now().timestamp_millis() as f64) } #[cfg(target_arch = "wasm32")] { @@ -323,7 +321,7 @@ mod tests { } #[test] - fn fictitious_1900_02_29() { + fn fictitious_1900_02_29() -> Result<(), std::io::Error> { // Excel's 1900 calendar has a 29 February 1900 (serial 60); day and month overflow count // through it. let s = DateSystem::D1900; @@ -345,7 +343,7 @@ mod tests { assert_eq!(serial_from_ymd(s, 1904, 2, 29), Some(1521.0)); // Every serial up to 1900-12-31 comes back from its own year, month and day. for serial in 0..=366 { - let d = datetime_from_serial(s, serial as f64).unwrap(); + let d = datetime_from_serial(s, serial as f64).ok_or_else(|| std::io::Error::other("serial should convert"))?; assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial as f64), "{d:?}"); } assert_eq!(days_in_month_in(s, 1900, 2), 29); @@ -358,6 +356,7 @@ mod tests { assert_eq!(serial_from_ymd(s, 1904, 3, 1), Some(60.0)); assert_eq!(serial_from_ymd(s, 1904, 1, 0), None); assert_eq!(serial_from_ymd(s, 1900, 2, 29), None); + Ok(()) } #[test] @@ -371,17 +370,18 @@ mod tests { } #[test] - fn roundtrip() { + fn roundtrip() -> Result<(), std::io::Error> { let s = DateSystem::D1900; for serial in [1.0, 59.0, 60.0, 61.0, 36526.0, 46302.0, 2958465.0] { - let d = datetime_from_serial(s, serial).unwrap(); + let d = datetime_from_serial(s, serial).ok_or_else(|| std::io::Error::other("serial should convert"))?; assert_eq!(serial_from_ymd(s, d.year as i64, d.month as i64, d.day as i64), Some(serial)); } - let d = datetime_from_serial(s, 60.0).unwrap(); + let d = datetime_from_serial(s, 60.0).ok_or_else(|| std::io::Error::other("leap-day serial should convert"))?; assert_eq!((d.year, d.month, d.day), (1900, 2, 29)); - let d = datetime_from_serial(s, 46302.75).unwrap(); + let d = datetime_from_serial(s, 46302.75).ok_or_else(|| std::io::Error::other("time serial should convert"))?; assert_eq!((d.hour, d.minute, d.weekday), (18, 0, 3)); // 2026-10-07 is a Wednesday - let d = datetime_from_serial(s, 1.0).unwrap(); + let d = datetime_from_serial(s, 1.0).ok_or_else(|| std::io::Error::other("first serial should convert"))?; assert_eq!(d.weekday, 0); // 1900-01-01 was a Sunday in Excel's calendar + Ok(()) } } diff --git a/crates/core/src/value.rs b/crates/core/src/value.rs index c531de4dc..46721beda 100644 --- a/crates/core/src/value.rs +++ b/crates/core/src/value.rs @@ -454,11 +454,12 @@ mod tests { } #[test] - fn arrays() { - let a = Array::new(2, 2, vec![1.0.into(), 2.0.into(), 3.0.into(), 4.0.into()]).unwrap(); + fn arrays() -> Result<(), std::io::Error> { + let a = Array::new(2, 2, vec![1.0.into(), 2.0.into(), 3.0.into(), 4.0.into()]).ok_or_else(|| std::io::Error::other("valid array"))?; assert_eq!(a.transpose().get(0, 1), Some(&Value::Number(3.0))); assert!(Array::new(2, 2, vec![]).is_none()); let row = Array::row(vec![1.0.into(), 2.0.into()]); assert_eq!(row.get_broadcast(5, 1), Value::Number(2.0)); + Ok(()) } } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index e8ea91c26..1de94c60c 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -17,6 +17,7 @@ gridcraft-model = { workspace = true } gridcraft-calc = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +web-time = { workspace = true } thiserror = { workspace = true } log = { workspace = true } gridcraft-xlsx = { workspace = true } diff --git a/crates/engine/src/cf.rs b/crates/engine/src/cf.rs index 0c0f9683e..4c9325e36 100644 --- a/crates/engine/src/cf.rs +++ b/crates/engine/src/cf.rs @@ -136,6 +136,95 @@ fn lerp(a: [u8; 3], b: [u8; 3], t: f64) -> [u8; 3] { pub use gridcraft_model::icon_count; +struct RuleEval<'a> { + wb: &'a Workbook, + sheet: usize, + cell: CellRef, + first: CellRef, + value: &'a Value, + text: &'a str, + stats: &'a RuleStats, +} + +fn simple_rule_style<'a>(ctx: &RuleEval<'_>, rule: &'a CfRule) -> Option<&'a Style> { + let RuleEval { wb, sheet, cell, first, value, text, stats } = ctx; + match rule { + CfRule::CellIs { op, a, b, style } => { + let eval = |f: &str| gridcraft_calc::evaluate(wb, *sheet, *cell, &relative(f, *first, *cell)); + let av = eval(a); + let matches = match op { + CfOperator::Between | CfOperator::NotBetween => { + let bv = b.as_deref().map(eval).unwrap_or_default(); + let (lo, hi) = if compare(&av, &bv) == std::cmp::Ordering::Greater { (bv, av) } else { (av, bv) }; + let within = compare(value, &lo) != std::cmp::Ordering::Less && compare(value, &hi) != std::cmp::Ordering::Greater; + if *op == CfOperator::Between { within } else { !within } + } + CfOperator::Equal => compare(value, &av) == std::cmp::Ordering::Equal, + CfOperator::NotEqual => compare(value, &av) != std::cmp::Ordering::Equal, + CfOperator::Greater => compare(value, &av) == std::cmp::Ordering::Greater, + CfOperator::Less => compare(value, &av) == std::cmp::Ordering::Less, + CfOperator::GreaterOrEqual => compare(value, &av) != std::cmp::Ordering::Less, + CfOperator::LessOrEqual => compare(value, &av) != std::cmp::Ordering::Greater, + }; + (matches && !value.is_error()).then_some(style.as_ref()) + } + CfRule::Expression { formula, style } => { + gridcraft_calc::evaluate(wb, *sheet, *cell, &relative(formula, *first, *cell)).to_bool().unwrap_or(false).then_some(style.as_ref()) + } + CfRule::ContainsText { text: needle, style } => { + (!value.is_error() && text.to_lowercase().contains(&needle.to_lowercase())).then_some(style.as_ref()) + } + CfRule::NotContainsText { text: needle, style } => { + (value.is_error() || !text.to_lowercase().contains(&needle.to_lowercase())).then_some(style.as_ref()) + } + CfRule::BeginsWith { text: needle, style } => { + (!value.is_error() && text.to_lowercase().starts_with(&needle.to_lowercase())).then_some(style.as_ref()) + } + CfRule::EndsWith { text: needle, style } => { + (!value.is_error() && text.to_lowercase().ends_with(&needle.to_lowercase())).then_some(style.as_ref()) + } + CfRule::Blanks { style } => (value.is_empty() || text.trim().is_empty()).then_some(style.as_ref()), + CfRule::NoBlanks { style } => (!value.is_empty() && !text.trim().is_empty()).then_some(style.as_ref()), + CfRule::Errors { style } => value.is_error().then_some(style.as_ref()), + CfRule::NoErrors { style } => (!value.is_error()).then_some(style.as_ref()), + CfRule::Duplicate { style } => { + (!value.is_empty() && stats.counts.get(&text.to_lowercase()).copied().unwrap_or(0) > 1).then_some(style.as_ref()) + } + CfRule::Unique { style } => { + (!value.is_empty() && stats.counts.get(&text.to_lowercase()).copied().unwrap_or(0) == 1).then_some(style.as_ref()) + } + _ => None, + } +} + +fn time_period_matches(wb: &Workbook, period: &str, serial: f64) -> bool { + let today = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial()).floor(); + let day = serial.floor(); + let date = |serial| gridcraft_core::date::datetime_from_serial(wb.date_system, serial); + let week_start = today - date(today).map(|value| value.weekday as f64).unwrap_or(0.0); + let (year, month) = date(today).map(|value| (value.year, value.month)).unwrap_or((2026, 1)); + let year_month = date(day).map(|value| (value.year, value.month)); + let month_matches = |offset: i32| { + let shifted_month = month as i32 + offset; + let shifted_year = year + (shifted_month - 1).div_euclid(12); + let shifted_month = (shifted_month - 1).rem_euclid(12) + 1; + year_month == Some((shifted_year, shifted_month as u32)) + }; + match period { + "yesterday" => day == today - 1.0, + "today" => day == today, + "tomorrow" => day == today + 1.0, + "last7Days" => day > today - 7.0 && day <= today, + "lastWeek" => day >= week_start - 7.0 && day < week_start, + "thisWeek" => day >= week_start && day < week_start + 7.0, + "nextWeek" => day >= week_start + 7.0 && day < week_start + 14.0, + "lastMonth" => month_matches(-1), + "thisMonth" => month_matches(0), + "nextMonth" => month_matches(1), + _ => false, + } +} + impl CfCache { pub fn new() -> Self { Self::default() @@ -161,52 +250,8 @@ impl CfCache { None => &no_stats, }; let text = v.display(); - let matched_style: Option<&Style> = match &cf.rule { - CfRule::CellIs { op, a, b, style } => { - let eval = |f: &str| gridcraft_calc::evaluate(wb, sheet, c, &relative(f, first, c)); - let av = eval(a); - let ok = match op { - CfOperator::Between | CfOperator::NotBetween => { - let bv = b.as_deref().map(eval).unwrap_or_default(); - let (lo, hi) = if compare(&av, &bv) == std::cmp::Ordering::Greater { (bv, av) } else { (av, bv) }; - let inside = compare(&v, &lo) != std::cmp::Ordering::Less && compare(&v, &hi) != std::cmp::Ordering::Greater; - if *op == CfOperator::Between { inside } else { !inside } - } - CfOperator::Equal => compare(&v, &av) == std::cmp::Ordering::Equal, - CfOperator::NotEqual => compare(&v, &av) != std::cmp::Ordering::Equal, - CfOperator::Greater => compare(&v, &av) == std::cmp::Ordering::Greater, - CfOperator::Less => compare(&v, &av) == std::cmp::Ordering::Less, - CfOperator::GreaterOrEqual => compare(&v, &av) != std::cmp::Ordering::Less, - CfOperator::LessOrEqual => compare(&v, &av) != std::cmp::Ordering::Greater, - }; - // As in Excel, a blank cell compares as 0 (or ""), and an error never matches. - (ok && !v.is_error()).then_some(style.as_ref()) - } - CfRule::Expression { formula, style } => { - gridcraft_calc::evaluate(wb, sheet, c, &relative(formula, first, c)).to_bool().unwrap_or(false).then_some(style.as_ref()) - } - // As in Excel, an error value never contains, begins or ends with anything (its rule - // formulas SEARCH/LEFT/RIGHT fail on it), so it always "does not contain" (#1162). - CfRule::ContainsText { text: t, style } => { - (!v.is_error() && text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref()) - } - CfRule::NotContainsText { text: t, style } => { - (v.is_error() || !text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref()) - } - CfRule::BeginsWith { text: t, style } => { - (!v.is_error() && text.to_lowercase().starts_with(&t.to_lowercase())).then_some(style.as_ref()) - } - CfRule::EndsWith { text: t, style } => (!v.is_error() && text.to_lowercase().ends_with(&t.to_lowercase())).then_some(style.as_ref()), - CfRule::Blanks { style } => (v.is_empty() || text.trim().is_empty()).then_some(style.as_ref()), - CfRule::NoBlanks { style } => (!v.is_empty() && !text.trim().is_empty()).then_some(style.as_ref()), - CfRule::Errors { style } => v.is_error().then_some(style.as_ref()), - CfRule::NoErrors { style } => (!v.is_error()).then_some(style.as_ref()), - CfRule::Duplicate { style } => { - (!v.is_empty() && st.counts.get(&text.to_lowercase()).copied().unwrap_or(0) > 1).then_some(style.as_ref()) - } - CfRule::Unique { style } => { - (!v.is_empty() && st.counts.get(&text.to_lowercase()).copied().unwrap_or(0) == 1).then_some(style.as_ref()) - } + let context = RuleEval { wb, sheet, cell: c, first, value: &v, text: &text, stats: st }; + let matched_style = simple_rule_style(&context, &cf.rule).or_else(|| match &cf.rule { CfRule::Top10 { bottom, percent, rank, style } => match v.as_f64() { Some(n) if !st.sorted.is_empty() => { let k = if *percent { ((st.sorted.len() as f64) * (*rank as f64) / 100.0).floor().max(1.0) as usize } else { *rank as usize }; @@ -229,36 +274,8 @@ impl CfCache { None => None, }, CfRule::TimePeriod { period, style } => match v.as_f64() { - Some(n) => { - let today = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial()).floor(); - let d = n.floor(); - let wd = gridcraft_core::date::datetime_from_serial(wb.date_system, today).map(|x| x.weekday as f64).unwrap_or(0.0); - let week_start = today - wd; - let (ty, tm) = - gridcraft_core::date::datetime_from_serial(wb.date_system, today).map(|x| (x.year, x.month)).unwrap_or((2026, 1)); - let ym = gridcraft_core::date::datetime_from_serial(wb.date_system, d).map(|x| (x.year, x.month)); - let month_off = |k: i32| { - let m = tm as i32 + k; - let y = ty + (m - 1).div_euclid(12); - let m = (m - 1).rem_euclid(12) + 1; - ym == Some((y, m as u32)) - }; - let ok = match period.as_str() { - "yesterday" => d == today - 1.0, - "today" => d == today, - "tomorrow" => d == today + 1.0, - "last7Days" => d > today - 7.0 && d <= today, - "lastWeek" => d >= week_start - 7.0 && d < week_start, - "thisWeek" => d >= week_start && d < week_start + 7.0, - "nextWeek" => d >= week_start + 7.0 && d < week_start + 14.0, - "lastMonth" => month_off(-1), - "thisMonth" => month_off(0), - "nextMonth" => month_off(1), - _ => false, - }; - ok.then_some(style.as_ref()) - } - None => None, + Some(n) if time_period_matches(wb, period, n) => Some(style.as_ref()), + _ => None, }, CfRule::ColorScale { stops } => { if let Some(n) = v.as_f64() { @@ -329,7 +346,8 @@ impl CfCache { } None } - }; + _ => None, + }); if let Some(st) = matched_style { any = true; // Every matching rule applies; where two set the same part, the rule with the diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index ef6714eea..90c8a5227 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -205,7 +205,6 @@ pub(crate) fn register_undoable_test_command(id: &'static str, run: Run) { } fn test_commands() -> std::sync::MutexGuard<'static, Vec<&'static CommandSpec>> { - #[allow(clippy::unwrap_used)] // a poisoned test registry has no meaningful recovery match TEST_COMMANDS.lock() { Ok(guard) => guard, Err(poisoned) => poisoned.into_inner(), @@ -349,11 +348,15 @@ pub(crate) fn edit(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result) -> }) } +fn wall_clock() -> std::time::Duration { + std::time::SystemTime::UNIX_EPOCH.elapsed().unwrap_or_default() +} + pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) -> Result { - // `GRIDCRAFT_PROFILE` timing. Read the clock only when profiling, and never on wasm: there - // `Instant::now` traps (`RuntimeError: unreachable`), which broke every edit in the web app. - #[allow(clippy::disallowed_methods)] - let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| std::time::Instant::now()) }; + // `GRIDCRAFT_PROFILE` timing, never on wasm where the clock traps. `elapsed` on the epoch + // reads the wall clock without the (disallowed) `now`. + let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| wall_clock()) }; + let since = |t0: std::time::Duration| wall_clock().saturating_sub(t0); let mut sel = d.selection.clone(); let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new(), protection_checked: false }; let r = f(&mut ctx)?; @@ -368,7 +371,7 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row))); } if let Some(t0) = t0 { - eprintln!("commit: edit {:?}, {} changed", t0.elapsed(), changed.len()); + eprintln!("commit: edit {:?}, {} changed", since(t0), changed.len()); } if structural { d.calc.recalc_all(&mut wb); @@ -376,7 +379,7 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) d.calc.cells_changed(&mut wb, &changed); } if let Some(t0) = t0 { - eprintln!("commit: + recalc {:?}", t0.elapsed()); + eprintln!("commit: + recalc {:?}", since(t0)); } fit_rows.sort_unstable(); fit_rows.dedup(); @@ -384,7 +387,7 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) auto_row_height(&mut wb, si, row); } if let Some(t0) = t0 { - eprintln!("commit: + rows {:?}", t0.elapsed()); + eprintln!("commit: + rows {:?}", since(t0)); } d.wb = std::sync::Arc::new(wb); d.selection = sel; diff --git a/crates/engine/src/cmd/analysis.rs b/crates/engine/src/cmd/analysis.rs index 6225b4934..9552e95f7 100644 --- a/crates/engine/src/cmd/analysis.rs +++ b/crates/engine/src/cmd/analysis.rs @@ -14,7 +14,7 @@ use gridcraft_model::{Cell, NumFmt, Sheet}; use serde_json::{Value as Json, json}; use super::whatif::parse_range; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, cmd, edit, has_doc, str_param}; const CMD: &str = "data.analysisToolPak"; /// Most input cells a tool reads. diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 5fa076e64..143763a5e 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -4,10 +4,16 @@ use std::cmp::Ordering; use std::sync::Arc; use gridcraft_core::{CellRef, RangeRef, Value, sort_compare}; -use gridcraft_model::*; +use gridcraft_model::{ + Anchor, AnchorMode, AutoFilter, Cell, CfOperator, Comment, ErrorStyle, FilterCriterion, Formula, Hyperlink, LineInfo, Sheet, Validation, + ValidationKind, Workbook, +}; use serde_json::{Value as Json, json}; -use super::*; +use super::{ + CommandSpec, EngineError, Result, Session, bad, bool_param, cell_param, cell_param_on, cmd, edit, has_doc, ok, parse_lines, split_sheet, + str_param, target_range, target_ranges, target_sheet, +}; use crate::outline; use crate::selection::current_region; diff --git a/crates/engine/src/cmd/data/filter.rs b/crates/engine/src/cmd/data/filter.rs index 71aa698f4..1b52e4537 100644 --- a/crates/engine/src/cmd/data/filter.rs +++ b/crates/engine/src/cmd/data/filter.rs @@ -1,5 +1,8 @@ use super::sort::sort_key; -use super::*; +use super::{ + AutoFilter, CellRef, EngineError, FilterCriterion, Json, Ordering, RangeRef, Result, Session, Sheet, Value, Workbook, bad, bool_param, col_param, + current_region, data_target, edit, json, +}; // ---------------------------------------------------------------- filter diff --git a/crates/engine/src/cmd/data/grouping.rs b/crates/engine/src/cmd/data/grouping.rs index 7b5a2254c..d2e183ae1 100644 --- a/crates/engine/src/cmd/data/grouping.rs +++ b/crates/engine/src/cmd/data/grouping.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + CellRef, EngineError, Json, LineInfo, RangeRef, Result, Session, bad, bool_param, col_param, data_target, edit, json, outline, parse_lines, + str_param, target_sheet, +}; // ---------------------------------------------------------------- outline diff --git a/crates/engine/src/cmd/data/sort.rs b/crates/engine/src/cmd/data/sort.rs index 919de1734..a36527650 100644 --- a/crates/engine/src/cmd/data/sort.rs +++ b/crates/engine/src/cmd/data/sort.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Anchor, AnchorMode, Arc, Cell, CellRef, Comment, EngineError, Formula, Hyperlink, Json, LineInfo, Ordering, RangeRef, Result, Session, Sheet, + Value, bad, bool_param, col_param, data_range, edit, guess_header, json, ok, sort_compare, split_sheet, str_param, target_sheet, +}; #[derive(Clone)] struct SortKey { diff --git a/crates/engine/src/cmd/data/tools.rs b/crates/engine/src/cmd/data/tools.rs index 1603b2cb4..2cbca17bd 100644 --- a/crates/engine/src/cmd/data/tools.rs +++ b/crates/engine/src/cmd/data/tools.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Cell, CellRef, EngineError, Json, RangeRef, Result, Session, bad, bool_param, cell_param_on, col_param, data_target, edit, guess_header, json, + ok, str_param, target_range, target_sheet, +}; // ---------------------------------------------------------------- tools diff --git a/crates/engine/src/cmd/data/validation.rs b/crates/engine/src/cmd/data/validation.rs index 6d4ae73cf..ce0d7e3b8 100644 --- a/crates/engine/src/cmd/data/validation.rs +++ b/crates/engine/src/cmd/data/validation.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Cell, CellRef, CfOperator, EngineError, ErrorStyle, Json, RangeRef, Result, Session, Validation, ValidationKind, Value, Workbook, bad, + bool_param, cell_param, edit, json, ok, str_param, target_ranges, target_sheet, +}; // ---------------------------------------------------------------- validation diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index a86be3099..f6184509c 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -1,10 +1,10 @@ //! Draw tab: pen and eraser tools, ink strokes, Ink to Shape. use gridcraft_core::CellRef; -use gridcraft_model::*; +use gridcraft_model::{Anchor, Color, Shape, ShapeKind}; use serde_json::{Value as Json, json}; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, ok, str_param, u32_param}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e48fe8838..f977a95ce 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -4,7 +4,10 @@ use gridcraft_core::{CellRef, MAX_ROWS, RangeRef, Value}; use gridcraft_model::{Cell, Formula, StyleId}; use serde_json::{Value as Json, json}; -use super::*; +use super::{ + CommandSpec, EngineError, Result, bad, bool_param, can_redo, can_undo, cell_param, cell_param_on, cmd, edit, f64_param, has_clipboard, has_doc, + locked_cell_error, ok, split_sheet, str_param, target_range, target_ranges, target_sheet, u32_param, +}; use crate::selection::{current_region, jump}; use crate::{Clipboard, Session}; diff --git a/crates/engine/src/cmd/edit/cell.rs b/crates/engine/src/cmd/edit/cell.rs index 62a6b38bd..c57afae97 100644 --- a/crates/engine/src/cmd/edit/cell.rs +++ b/crates/engine/src/cmd/edit/cell.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Cell, CellRef, EngineError, Formula, Json, MAX_ROWS, RangeRef, Result, Session, StyleId, Value, bad, bool_param, cell_param_on, edit, json, + locked_cell_error, str_param, target_range, target_ranges, target_sheet, +}; // ---------------------------------------------------------------- entry diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs index 60c05c687..de921226e 100644 --- a/crates/engine/src/cmd/edit/clipboard.rs +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -1,6 +1,8 @@ use super::cell::input_to_cell; use super::paste_special::paste_special; -use super::*; +use super::{ + Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, cell_param_on, edit, json, ok, str_param, target_ranges, target_sheet, +}; // ---------------------------------------------------------------- clipboard diff --git a/crates/engine/src/cmd/edit/fill.rs b/crates/engine/src/cmd/edit/fill.rs index 84f4cd6d3..8cd1e3d88 100644 --- a/crates/engine/src/cmd/edit/fill.rs +++ b/crates/engine/src/cmd/edit/fill.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{CellRef, EngineError, Json, RangeRef, Result, Session, bad, edit, f64_param, json, str_param, target_range, target_sheet}; // ---------------------------------------------------------------- fill @@ -78,7 +78,7 @@ pub(super) fn fill_series(s: &mut Session, p: &Json) -> Result { 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)?; + crate::fill::series(cx, sheet, crate::fill::SeriesOptions { range: r, rows, kind: &kind, step, stop, unit: &unit })?; Ok(Json::Null) }) } diff --git a/crates/engine/src/cmd/edit/find.rs b/crates/engine/src/cmd/edit/find.rs index 2dcbfa886..bce0ba728 100644 --- a/crates/engine/src/cmd/edit/find.rs +++ b/crates/engine/src/cmd/edit/find.rs @@ -1,5 +1,5 @@ use super::cell::input_to_cell; -use super::*; +use super::{Cell, CellRef, EngineError, Json, Result, Session, Value, bad, bool_param, edit, json, str_param}; // ---------------------------------------------------------------- find & replace diff --git a/crates/engine/src/cmd/edit/go_to.rs b/crates/engine/src/cmd/edit/go_to.rs index b94bcd221..bc4f414db 100644 --- a/crates/engine/src/cmd/edit/go_to.rs +++ b/crates/engine/src/cmd/edit/go_to.rs @@ -1,5 +1,5 @@ use super::selection::{select_region, selection_set}; -use super::*; +use super::{CellRef, EngineError, Json, RangeRef, Result, Session, bad, bool_param, coalesce, json, split_sheet, str_param}; /// How many references the Go To dialog keeps for going back to. pub(super) const GO_TO_RECENT: usize = 10; diff --git a/crates/engine/src/cmd/edit/paste_special.rs b/crates/engine/src/cmd/edit/paste_special.rs index eba1b429e..0a6d14247 100644 --- a/crates/engine/src/cmd/edit/paste_special.rs +++ b/crates/engine/src/cmd/edit/paste_special.rs @@ -1,5 +1,76 @@ use super::clipboard::What; -use super::*; +use super::{ + Cell, CellRef, EngineError, Formula, Json, RangeRef, Result, Session, StyleId, Value, bad, bool_param, cell_param_on, edit, json, str_param, + target_sheet, +}; + +fn tiled_cells(rows: u32, cols: u32, tiles_r: u32, tiles_c: u32) -> impl Iterator + Clone { + (0..tiles_r).flat_map(move |tr| (0..tiles_c).flat_map(move |tc| (0..rows).flat_map(move |r| (0..cols).map(move |c| (tr, tc, r, c))))) +} + +fn apply_operation(op: &str, old: &Cell, new: &mut Cell) { + let operand = new.value.clone(); + if let (Ok(x), Ok(y)) = (old.value.to_number(), operand.to_number()) + && (old.value.is_number() || old.value.is_empty() || old.value.is_text()) + && (operand.is_number() || operand.is_empty()) + { + new.value = match op { + "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), + "divide" => Value::number(x / y), + _ => operand.clone(), + }; + } else if (old.value.is_text() || old.value.is_error()) && (operand.is_number() || operand.is_empty()) { + new.value = old.value.clone(); + } + if let Some(formula) = &old.formula + && let Ok(y) = operand.to_number() + && (operand.is_number() || operand.is_empty()) + && let Some(symbol) = operation_symbol(op) + { + let text = format!("=({}){symbol}{}", formula.text, gridcraft_core::number_to_text(y)); + new.formula = Some(std::sync::Arc::new(Formula::new(&text))); + new.value = Value::Empty; + } +} + +fn operation_symbol(op: &str) -> Option { + match op { + "add" => Some('+'), + "subtract" => Some('-'), + "multiply" => Some('*'), + "divide" => Some('/'), + _ => None, + } +} + +fn copy_merges( + cx: &mut crate::cmd::Ctx, + dest_sheet: usize, + src_sheet: &gridcraft_model::Sheet, + src: RangeRef, + at: CellRef, + what: What, + transpose: bool, +) -> Result<()> { + if !matches!(what, What::All | What::Formats) || transpose { + return Ok(()); + } + let merges: Vec = src_sheet.merges.iter().filter(|merge| src.contains_range(merge)).copied().collect(); + for merge in merges { + let row_offset = at.row as i64 - src.start.row as i64; + let col_offset = at.col as i64 - src.start.col as i64; + if let (Some(start), Some(end)) = (merge.start.offset(row_offset, col_offset), merge.end.offset(row_offset, col_offset)) { + let moved = RangeRef::new(start, end); + let sheet = cx.sheet_mut(dest_sheet)?; + sheet.merges.retain(|existing| !existing.intersects(&moved)); + sheet.merges.push(moved); + } + } + Ok(()) +} 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())) }; @@ -74,209 +145,145 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result { } 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()) + for (tr, tc, r, c) in tiled_cells(src.height(), src.width(), tiles_r, tiles_c) { + 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 { - src_sheet.cell(sc).cloned() + st }; - 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; + } + 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); } - 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; - } - } + } + 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); } - // 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); + } + 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); + } + // Numbers stored as text participate; other text and errors stay as they are. + apply_operation(&op, &old, &mut new); + } + } + // 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); - } - } - } + copy_merges(cx, dest_sheet, src_sheet, src, at, what, transpose)?; 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(); diff --git a/crates/engine/src/cmd/edit/selection.rs b/crates/engine/src/cmd/edit/selection.rs index 95793f338..ede66a34d 100644 --- a/crates/engine/src/cmd/edit/selection.rs +++ b/crates/engine/src/cmd/edit/selection.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + CellRef, EngineError, Json, RangeRef, Result, Session, bool_param, cell_param, current_region, json, jump, split_sheet, str_param, target_ranges, + target_sheet, +}; // ---------------------------------------------------------------- selection diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index bf21433ab..c9a1582a2 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -2,10 +2,16 @@ //! tools, Get Data, scripts (Automate), cell checkboxes, theme colours and fonts. use gridcraft_core::{CellRef, RangeRef, Value}; -use gridcraft_model::*; +use gridcraft_model::{ + Anchor, Chart, Color, DefinedName, HAlign, LegendPos, NumFmt, Shape, Sheet, SheetViewMode, TableColumn, TotalsFn, Trendline, TrendlineKind, + Workbook, +}; use serde_json::{Value as Json, json}; -use super::*; +use super::{ + CommandSpec, EngineError, Result, Session, always, anchor_mode_name, bad, bool_param, cell_param, cell_param_on, cmd, edit, f64_param, has_doc, + ok, split_sheet, str_param, target_range, target_ranges, target_sheet, u32_param, +}; mod cells; mod chart; diff --git a/crates/engine/src/cmd/extra/cells.rs b/crates/engine/src/cmd/extra/cells.rs index 5cddf0982..ff0d5b65b 100644 --- a/crates/engine/src/cmd/extra/cells.rs +++ b/crates/engine/src/cmd/extra/cells.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + CellRef, EngineError, HAlign, Json, NumFmt, Result, Session, Value, bad, bool_param, cell_param, cell_param_on, edit, json, object_params, ok, + str_param, target_ranges, target_sheet, +}; /// Whether `c` has checkbox formatting. pub(crate) fn is_checkbox_cell(wb: &gridcraft_model::Workbook, sh: &gridcraft_model::Sheet, c: CellRef) -> bool { diff --git a/crates/engine/src/cmd/extra/chart.rs b/crates/engine/src/cmd/extra/chart.rs index 2e16befa6..b6761f1a9 100644 --- a/crates/engine/src/cmd/extra/chart.rs +++ b/crates/engine/src/cmd/extra/chart.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + CellRef, Chart, Color, EngineError, Json, LegendPos, RangeRef, Result, Session, Trendline, TrendlineKind, bad, bool_param, cell_param_on, edit, + f64_param, json, split_sheet, str_param, u32_param, +}; fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> { let d = s.doc()?; diff --git a/crates/engine/src/cmd/extra/data.rs b/crates/engine/src/cmd/extra/data.rs index cef840f21..9a1ae7417 100644 --- a/crates/engine/src/cmd/extra/data.rs +++ b/crates/engine/src/cmd/extra/data.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{CellRef, EngineError, Json, RangeRef, Result, Session, TableColumn, TotalsFn, Value, bad, edit, json, ok, str_param, target_range}; 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`"))?; diff --git a/crates/engine/src/cmd/extra/objects.rs b/crates/engine/src/cmd/extra/objects.rs index 59ab8447a..30725f81e 100644 --- a/crates/engine/src/cmd/extra/objects.rs +++ b/crates/engine/src/cmd/extra/objects.rs @@ -1,5 +1,7 @@ use super::chart::format_series; -use super::*; +use super::{ + Anchor, CellRef, Color, EngineError, Json, Result, Session, Shape, Sheet, anchor_mode_name, bad, bool_param, edit, json, str_param, u32_param, +}; pub(super) fn shape_fill(s: &mut Session, p: &Json) -> Result { if str_param(p, "kind") == Some("shape") { diff --git a/crates/engine/src/cmd/extra/scripts.rs b/crates/engine/src/cmd/extra/scripts.rs index 9f86837ab..f1118410f 100644 --- a/crates/engine/src/cmd/extra/scripts.rs +++ b/crates/engine/src/cmd/extra/scripts.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Json, Result, SCRIPTS_NAME, Session, Workbook, bad, bool_param, edit, json, stash_get, stash_map, stash_set, str_param}; pub(super) fn record(s: &mut Session, p: &Json) -> Result { if bool_param(p, "stop").unwrap_or(false) || s.recording.is_some() { diff --git a/crates/engine/src/cmd/extra/window.rs b/crates/engine/src/cmd/extra/window.rs index 19e92743f..b20a00a61 100644 --- a/crates/engine/src/cmd/extra/window.rs +++ b/crates/engine/src/cmd/extra/window.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + CellRef, EngineError, Json, Result, Session, SheetViewMode, VIEWS_NAME, WATCH_NAME, Workbook, bad, bool_param, edit, json, ok, split_sheet, + stash_get, stash_map, stash_set, str_param, target_sheet, u32_param, +}; /// 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. diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 9af85b9dd..8d28c25a5 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -2,7 +2,7 @@ use serde_json::{Value as Json, json}; -use super::*; +use super::{CommandSpec, Result, Session, always, bad, bool_param, cmd, edit, has_doc, ok, str_param, target_sheet, u32_param}; use crate::DocState; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs index b7472fd22..9bb15936b 100644 --- a/crates/engine/src/cmd/forecast.rs +++ b/crates/engine/src/cmd/forecast.rs @@ -11,7 +11,7 @@ use gridcraft_functions::Timeline; use gridcraft_model::{Anchor, Cell, Chart, ChartKind, Formula, LegendPos, Series, Sheet, StyleId, Table, TableColumn, TotalsFn}; use serde_json::{Value as Json, json}; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, has_doc, split_sheet, str_param}; use crate::selection::current_region; const CMD: &str = "data.forecastSheet"; diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 744b6d136..69123dcfa 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -1,10 +1,13 @@ //! Formatting: Home › Font, Alignment, Number, Styles, Cells › Format. use gridcraft_core::{CellRef, RangeRef}; -use gridcraft_model::*; +use gridcraft_model::{BorderLine, BorderStyle, Borders, Color, Fill, HAlign, NumFmt, Style, StyleId, Theme, Underline, VAlign, VertAlign}; use serde_json::{Value as Json, json}; -use super::*; +use super::{ + CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, ok, parse_lines, split_sheet, str_param, + target_ranges, target_sheet, +}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/format/cell_styles.rs b/crates/engine/src/cmd/format/cell_styles.rs index 53d378202..5ad0ccc15 100644 --- a/crates/engine/src/cmd/format/cell_styles.rs +++ b/crates/engine/src/cmd/format/cell_styles.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{ + ACCOUNTING, BorderLine, BorderStyle, Borders, COMMA, Color, Fill, Json, NumFmt, Result, Session, Style, Theme, apply_style, bad, str_param, +}; /// Built-in cell styles (our own palette). pub fn builtin_cell_style(name: &str, theme: &Theme) -> Option