Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 2b7bd0c315 chore(clippy): enforce coding standards via workspace lints 2026-10-27 01:35:59 +00:00
245 changed files with 4017 additions and 6751 deletions
-24
View File
@@ -21,27 +21,3 @@ jobs:
lint:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
windows-install-policy:
timeout-minutes: 10
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install the pinned packaging toolchain
shell: pwsh
run: |
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
dotnet tool install --tool-path $tools wix --version 5.0.2
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
$tools >> $env:GITHUB_PATH
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
foreach ($extension in @($policy.extensions)) {
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
}
- name: Build and check non-executable installer fixtures
shell: pwsh
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
+2 -4
View File
@@ -19,10 +19,8 @@ permissions:
jobs:
windows-arm64:
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: gridcraft
app-dir: GridCraft
install-relative-path: Programs/ArtCraft/GridCraft
install-scope: perUser
+1 -1
View File
@@ -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<T, E>`** through the crate's error type and `?`.
- **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).
- **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).
- **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`).
+1 -1
View File
@@ -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`).
- 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]`.
- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## 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.
Generated
+22 -25
View File
@@ -889,12 +889,12 @@ dependencies = [
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -917,7 +917,7 @@ dependencies = [
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
dependencies = [
"craft-winprint-gdi",
"miniz_oxide 0.9.1",
@@ -930,7 +930,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -950,7 +950,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
dependencies = [
"chrono",
"dunce",
@@ -975,7 +975,7 @@ dependencies = [
[[package]]
name = "craft-window"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
dependencies = [
"egui",
"objc2 0.6.5",
@@ -1935,7 +1935,7 @@ dependencies = [
[[package]]
name = "gridcraft"
version = "0.13.0"
version = "0.12.1"
dependencies = [
"arboard",
"chrono",
@@ -1962,7 +1962,7 @@ dependencies = [
[[package]]
name = "gridcraft-calc"
version = "0.13.0"
version = "0.12.1"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -1974,7 +1974,7 @@ dependencies = [
[[package]]
name = "gridcraft-chart"
version = "0.13.0"
version = "0.12.1"
dependencies = [
"gridcraft-calc",
"gridcraft-core",
@@ -1985,7 +1985,7 @@ dependencies = [
[[package]]
name = "gridcraft-cli"
version = "0.13.0"
version = "0.12.1"
dependencies = [
"gridcraft-engine",
"gridcraft-mcp",
@@ -1997,7 +1997,7 @@ dependencies = [
[[package]]
name = "gridcraft-core"
version = "0.13.0"
version = "0.12.1"
dependencies = [
"chrono",
"js-sys",
@@ -2008,7 +2008,7 @@ dependencies = [
[[package]]
name = "gridcraft-engine"
version = "0.13.0"
version = "0.12.1"
dependencies = [
"gridcraft-calc",
"gridcraft-chart",
@@ -2024,13 +2024,12 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"web-time",
"zip 3.0.0",
]
[[package]]
name = "gridcraft-formula"
version = "0.13.0"
version = "0.12.1"
dependencies = [
"gridcraft-core",
"serde",
@@ -2039,7 +2038,7 @@ dependencies = [
[[package]]
name = "gridcraft-functions"
version = "0.13.0"
version = "0.12.1"
dependencies = [
"gridcraft-core",
"regex",
@@ -2047,7 +2046,7 @@ dependencies = [
[[package]]
name = "gridcraft-mcp"
version = "0.13.0"
version = "0.12.1"
dependencies = [
"gridcraft-engine",
"log",
@@ -2056,7 +2055,7 @@ dependencies = [
[[package]]
name = "gridcraft-model"
version = "0.13.0"
version = "0.12.1"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -2068,18 +2067,18 @@ dependencies = [
[[package]]
name = "gridcraft-numfmt"
version = "0.13.0"
version = "0.12.1"
dependencies = [
"gridcraft-core",
]
[[package]]
name = "gridcraft-pdf"
version = "0.13.0"
version = "0.12.1"
[[package]]
name = "gridcraft-ui-egui"
version = "0.13.0"
version = "0.12.1"
dependencies = [
"craft-ui",
"craft-update",
@@ -2098,13 +2097,12 @@ dependencies = [
"sys-locale",
"toml",
"web-sys",
"web-time",
"zbus",
]
[[package]]
name = "gridcraft-web"
version = "0.13.0"
version = "0.12.1"
dependencies = [
"eframe",
"egui",
@@ -2121,12 +2119,11 @@ dependencies = [
[[package]]
name = "gridcraft-xlsx"
version = "0.13.0"
version = "0.12.1"
dependencies = [
"aes",
"gridcraft-core",
"gridcraft-formula",
"gridcraft-functions",
"gridcraft-model",
"quick-xml 0.41.0",
"sha1 0.11.0",
@@ -6402,7 +6399,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.13.0"
version = "0.12.1"
dependencies = [
"gridcraft-engine",
"serde_json",
+5 -5
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
[workspace.package]
version = "0.13.0"
version = "0.12.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -26,7 +26,7 @@ gridcraft-mcp = { path = "crates/mcp" }
gridcraft-pdf = { path = "crates/pdf" }
# Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in
# the app) comes from one storytold/craft-libs revision.
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.1.0", features = ["egui"] }
# foundation
thiserror = "2"
@@ -34,8 +34,7 @@ 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", "clock"] }
web-time = "1.1.0"
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }
quick-xml = "0.41"
@@ -62,7 +61,7 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] }
epaint_default_fonts = "0.37"
# One native window per workbook: the window list, Window menu titles and the immediate-viewport
# driver. Same craft-libs revision as craft-update above.
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false, features = ["egui"] }
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", default-features = false, features = ["egui"] }
[workspace.lints.rust]
unsafe_code = "forbid"
@@ -73,6 +72,7 @@ too_many_arguments = "deny"
wildcard_imports = "deny"
dbg_macro = "warn"
todo = "warn"
type_complexity = "allow"
[profile.dev.package."*"]
opt-level = 2
+1 -1
View File
@@ -1,6 +1,6 @@
use super::calc::formula_text;
use super::package::{REBUILT_KINDS, relationship_kinds, validate_package};
use super::{BTreeSet, Category, Cell, CellError, CellRef, Diffs, Options, RoundTrip, Sheet, Value, Workbook, number_text, quote_sheet};
use super::*;
// ---------------------------------------------------------------- round trip
+1 -3
View File
@@ -20,9 +20,7 @@ fn local_name(name: &[u8]) -> &[u8] {
}
/// `(element local name, attributes)` for every start/empty element of an XML part.
type XmlElement = (String, Vec<(String, String)>);
fn xml_elements(xml: &[u8]) -> Result<Vec<XmlElement>, String> {
fn xml_elements(xml: &[u8]) -> Result<Vec<(String, Vec<(String, String)>)>, String> {
use quick_xml::events::Event;
let mut reader = quick_xml::Reader::from_reader(xml);
let mut buf = Vec::new();
+3 -19
View File
@@ -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::<String>()) }
}
fn print_file_reports(reports: &[FileReport]) {
pub(super) fn print_human(reports: &[FileReport], opts: &Options) {
for r in reports {
outln!("== {}", r.path);
if let Some(e) = &r.error {
@@ -186,9 +186,7 @@ fn print_file_reports(reports: &[FileReport]) {
}
}
}
}
fn print_file_table(reports: &[FileReport]) {
outln!("");
let w = reports.iter().map(|r| r.path.chars().count()).max().unwrap_or(4).clamp(4, 50);
outln!(
"{:<w$} {:>8} {:>8} {:>8} {:>8} {:>9} {:>8} round trip",
@@ -222,9 +220,7 @@ fn print_file_table(reports: &[FileReport]) {
c.volatile
);
}
}
fn print_rankings(t: &Totals, opts: &Options) {
let t = totals(reports);
let top = ranked(&t.functions);
if !top.is_empty() {
outln!("");
@@ -243,9 +239,6 @@ fn print_rankings(t: &Totals, 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!(
@@ -269,12 +262,3 @@ fn print_totals(t: &Totals) {
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);
}
+2
View File
@@ -1,3 +1,5 @@
//! 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;
+1 -1
View File
@@ -30,7 +30,7 @@ winit = { version = "0.30", default-features = false, features = ["x11"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# Same craft-libs revision as the workspace's craft-update and craft-window (see the root
# Cargo.toml).
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" }
craft-update = { workspace = true }
rfd = { workspace = true }
arboard = { workspace = true }
+3 -4
View File
@@ -268,10 +268,9 @@ 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();
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(&timestamp(logged_at), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
#[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it
let line =
format_line(&timestamp(SystemTime::now()), 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());
+1
View File
@@ -7,6 +7,7 @@
// 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")]
-4
View File
@@ -10,8 +10,4 @@ disallowed-methods = [
{ path = "std::time::Instant::now", reason = "traps on wasm; read the clock only off wasm" },
{ path = "std::time::SystemTime::now", reason = "traps on wasm; read the clock only off wasm" },
]
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
+1 -1
View File
@@ -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, Reference, SheetSel, StructItem, StructRef, UnOp};
use gridcraft_formula::{BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, UnOp};
use gridcraft_functions::{Arg, Ctx};
use gridcraft_model::Workbook;
+1 -1
View File
@@ -1,4 +1,4 @@
use super::{Array, BinOp, CellError, MAX_CELLS, Value, compare};
use super::*;
/// The aggregates that can fold a reference without building an array.
#[derive(Clone, Copy, PartialEq, Eq)]
+147 -158
View File
@@ -1,13 +1,40 @@
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::{Area, Arg, Array, CellError, Ev, Evaluator, Expr, FnCtx, Lambda, Pipe, Value, compare, err};
use std::sync::Arc;
use super::*;
impl<'h> Evaluator<'h> {
pub(super) fn call(&mut self, name: &str, args: &[Expr]) -> Ev {
match name {
"IF" => self.if_call(args),
"IF" => {
if args.is_empty() || args.len() > 3 {
return err(CellError::Value);
}
let c = self.arg_val(args, 0).unwrap_or_default();
if let Value::Array(arr) = &c {
// Array condition: element-wise.
let t = self.arg_val(args, 1).unwrap_or(Value::Bool(true));
let f = if args.len() > 2 { self.arg_val(args, 2).unwrap_or(Value::Bool(false)) } else { Value::Bool(false) };
return Ev::V(zip3(arr, &t, &f, |c, t, f| match c.to_bool() {
Ok(true) => t.clone(),
Ok(false) => f.clone(),
Err(e) => Value::Error(e),
}));
}
match c.to_bool() {
Ok(true) => match args.get(1) {
Some(Expr::Missing) => Ev::V(Value::Number(0.0)),
Some(e) => self.eval(e),
None => Ev::V(Value::Bool(true)),
},
Ok(false) => match args.get(2) {
Some(Expr::Missing) => Ev::V(Value::Number(0.0)),
Some(e) => self.eval(e),
None => Ev::V(Value::Bool(false)),
},
Err(e) => err(e),
}
}
"IFS" => {
if args.len() < 2 || !args.len().is_multiple_of(2) {
return err(CellError::Value);
@@ -23,10 +50,101 @@ impl<'h> Evaluator<'h> {
}
err(CellError::NA)
}
"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),
"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<Value> = (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<Value> = (r.start.col..=r.end.col).map(|x| Value::number(x as f64 + 1.0)).collect();
if cols.len() == 1 { Ev::V(cols.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::row(cols).into()) }
}
}
Ev::V(Value::Error(e)) => err(e),
_ => err(CellError::Value),
}
}
"ROWS" | "COLUMNS" => {
if args.len() != 1 {
return err(CellError::Value);
@@ -62,7 +180,28 @@ impl<'h> Evaluator<'h> {
}
}
}
"SHEET" | "SHEETS" => self.sheet_call(name, args),
"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<usize> = a.iter().map(|x| x.sheet).collect();
s.dedup();
Ev::V(Value::number(s.len() as f64))
}
}
Ev::V(Value::Text(t)) if name == "SHEET" => {
let wb = self.host.workbook();
wb.sheet_index(&t).map_or(err(CellError::NA), |i| Ev::V(Value::number(i as f64 + 1.0)))
}
_ => err(CellError::NA),
}
}
"OFFSET" => self.offset(args),
"INDIRECT" => self.indirect(args),
"INDEX" => self.index(args),
@@ -134,156 +273,6 @@ 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<Value> = (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<Value> = (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<usize> = 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.
+1 -2
View File
@@ -1,5 +1,4 @@
use super::{CellError, Value};
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS};
use super::*;
/// CELL("format") code for a number format.
pub(super) fn cell_format_code(code: &str) -> String {
+1 -3
View File
@@ -1,7 +1,5 @@
use super::MAX_LAMBDA_CALLS;
use super::array::{broadcast_len, single};
use super::{Array, CellError, Ev, Evaluator, Expr, Lambda, MAX_CELLS, Value, err};
use std::sync::Arc;
use super::*;
impl<'h> Evaluator<'h> {
pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev {
+1 -3
View File
@@ -1,7 +1,5 @@
use super::format::{cell_format_code, r1c1_to_a1};
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;
use super::*;
impl<'h> Evaluator<'h> {
/// Whole rows/columns trimmed for ROW()/COLUMN() arrays.
+4 -2
View File
@@ -1,5 +1,4 @@
use super::{Area, BinOp, Ev, Expr, Reference, SheetSel, Value, Workbook};
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
use super::*;
/// SUMIF and AVERAGEIF with a sum range: Excel sizes the sum range like the criteria range.
pub(super) fn resizes_sum_range(name: &str, args: usize) -> bool {
@@ -163,3 +162,6 @@ 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) {}
+110 -107
View File
@@ -10,13 +10,14 @@
//! and the formulas that read one are evaluated again until the loop settles
//! (`PassHost::iterate_loops`).
use std::collections::VecDeque;
use std::collections::{BTreeSet, VecDeque};
use std::hash::{BuildHasherDefault, Hasher};
use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value};
use gridcraft_formula::Expr;
use gridcraft_model::{CalcMode, Workbook};
use crate::eval::{Evaluator, Host, precedents, stack_position};
use crate::eval::{Area, Evaluator, Host, precedents, stack_position};
mod graph;
mod pass_host;
@@ -367,13 +368,105 @@ impl Calc {
self.circular.retain(|k| !evaluated.contains(k));
}
self.circular.extend(cycles);
let mut spill_changes = self.write_back(wb, &dirty, results, &spills);
// Write back.
let mut spill_changes: Vec<Key> = 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));
}
}
// 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.
}
@@ -395,105 +488,6 @@ impl Calc {
self.circular.retain(|k| self.graph.nodes.contains_key(k));
}
}
fn write_back(
&mut self,
wb: &mut Workbook,
dirty: &[Key],
results: HashMap<Key, Value>,
spills: &HashMap<Key, std::sync::Arc<Array>>,
) -> Vec<Key> {
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).
@@ -519,13 +513,21 @@ 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 {
gridcraft_core::date::unix_now_ms().unwrap_or(0.0)
#[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
}
}
#[cfg(test)]
pub(crate) mod tests {
use std::time::Duration;
use std::time::{Duration, Instant};
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{Cell, Formula, Workbook};
@@ -638,10 +640,11 @@ 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 started = gridcraft_core::date::unix_now_ms().unwrap_or(0.0);
let t = Instant::now();
calc.recalc_all(&mut wb);
let values: Vec<Key> = (0..rows).map(|r| (0, CellRef::new(r, 4))).collect();
if let Some(sh) = wb.sheet_mut(0) {
@@ -652,8 +655,7 @@ pub(crate) mod tests {
calc.cells_changed(&mut wb, &values);
let formulas: Vec<Key> = (0..rows).map(|r| (0, CellRef::new(r, 10))).collect();
calc.cells_changed(&mut wb, &formulas);
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 elapsed = t.elapsed();
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::<u32>()))));
@@ -668,6 +670,7 @@ 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
+2 -9
View File
@@ -1,8 +1,4 @@
use super::RangeRef;
use crate::eval::Area;
use gridcraft_core::CellRef;
use std::collections::BTreeSet;
use std::hash::{BuildHasherDefault, Hasher};
use super::*;
/// A fast, non-cryptographic hasher for cell keys (FxHash-style multiply-rotate).
#[derive(Default, Clone, Copy)]
@@ -183,9 +179,6 @@ pub struct Graph {
unsettled_wide: HashSet<usize>,
}
/// A formula's precedents split by shape: single cells, column intervals and wide ranges.
type Entries = (Vec<Key>, Vec<(usize, u32, u32, u32)>, Vec<(usize, RangeRef)>);
impl Graph {
pub fn len(&self) -> usize {
self.nodes.len()
@@ -196,7 +189,7 @@ impl Graph {
/// Index entries of a formula's precedents, without duplicates: single cells, column
/// intervals and wide ranges.
fn entries(areas: &[Area]) -> Entries {
fn entries(areas: &[Area]) -> (Vec<Key>, Vec<(usize, u32, u32, u32)>, Vec<(usize, RangeRef)>) {
let (mut cells, mut cols, mut wide) = (Vec::new(), Vec::new(), Vec::new());
for a in areas {
if a.range.is_single() {
+1 -3
View File
@@ -1,7 +1,5 @@
use super::graph::{HashMap, HashSet, Key};
use super::{Evaluator, Host, Workbook, stack_position};
use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value};
use gridcraft_formula::Expr;
use super::*;
/// Formulas a formula may calculate on demand one inside the other (each needing one another's
/// value before it is done). Each level takes stack, so a chain deeper than this is suspended and
+5 -3
View File
@@ -1,8 +1,9 @@
//! 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, LineDash, QuartileMethod, Theme, TrendlineKind};
use gridcraft_model::{Chart, ChartKind, LegendPos, 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};
@@ -69,7 +70,8 @@ impl Ctx<'_> {
}
String::new()
}
fn text(&mut self, x: f32, y: f32, s: &str, size: f32, color: Rgba, (align, valign): (HAlign, VAlign)) {
#[allow(clippy::too_many_arguments)]
fn text(&mut self, x: f32, y: f32, s: &str, size: f32, color: Rgba, align: HAlign, valign: VAlign) {
if s.is_empty() {
return;
}
@@ -133,7 +135,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;
+42 -63
View File
@@ -2,10 +2,7 @@ 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::{
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,
};
use super::*;
// ---------------------------------------------------------------- cartesian (column/bar/line/area/combo)
@@ -21,9 +18,6 @@ enum Fam {
Area100,
}
/// Stack accumulator: (axis, family) key with positive and negative running sums.
type Stack = ((bool, Fam), Vec<f64>, Vec<f64>);
fn family(k: ChartKind) -> Fam {
use ChartKind::*;
match k {
@@ -51,7 +45,16 @@ fn is_line(f: Fam) -> bool {
matches!(f, Fam::Line | Fam::LineStack)
}
fn series_spans(data: &ChartData, fams: &[Fam], sec: &[bool], ncat: usize) -> Vec<Vec<Option<(f64, f64)>>> {
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<ChartKind> = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).collect();
let fams: Vec<Fam> = kinds.iter().map(|k| family(*k)).collect();
let any_primary = data.series.iter().any(|s| !s.secondary);
let sec: Vec<bool> = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect();
// Totals for 100% stacking, per axis and family (100% columns and 100% areas stack apart).
let mut totals: Vec<((bool, Fam), Vec<f64>)> = Vec::new();
for (i, s) in data.series.iter().enumerate() {
@@ -78,7 +81,7 @@ fn series_spans(data: &ChartData, fams: &[Fam], sec: &[bool], ncat: usize) -> Ve
if t > 0.0 { v / t } else { 0.0 }
};
// Stack accumulators keyed by (axis, family): positive and negative running sums.
let mut stacks: Vec<Stack> = Vec::new();
let mut stacks: Vec<((bool, Fam), Vec<f64>, Vec<f64>)> = Vec::new();
let mut spans: Vec<Vec<Option<(f64, f64)>>> = Vec::with_capacity(data.series.len());
for (i, s) in data.series.iter().enumerate() {
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
@@ -123,10 +126,6 @@ fn series_spans(data: &ChartData, fams: &[Fam], sec: &[bool], ncat: usize) -> Ve
spans.push(row);
}
spans
}
fn axis_scales(data: &ChartData, fams: &[Fam], sec: &[bool], spans: &[Vec<Option<(f64, f64)>>]) -> Vec<(Scale, Fmt)> {
// Axis scales.
let mut axes: Vec<(Scale, Fmt)> = Vec::new();
for axis in [false, true] {
@@ -152,46 +151,6 @@ fn axis_scales(data: &ChartData, fams: &[Fam], sec: &[bool], spans: &[Vec<Option
break;
}
}
axes
}
fn column_slots(fams: &[Fam], sec: &[bool]) -> (Vec<usize>, 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<ChartKind> = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).collect();
let fams: Vec<Fam> = kinds.iter().map(|k| family(*k)).collect();
let any_primary = data.series.iter().any(|s| !s.secondary);
let sec: Vec<bool> = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect();
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 });
@@ -218,7 +177,27 @@ 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 };
let (slot_of, nslots) = column_slots(&fams, &sec);
// Column slots: each clustered series gets one, each stack group shares one.
let mut slot_keys: Vec<(bool, Fam, usize)> = Vec::new();
let mut slot_of = vec![0usize; data.series.len()];
for (i, f) in fams.iter().enumerate() {
if !is_col(*f) {
continue;
}
let a = sec.get(i).copied().unwrap_or(false);
let key = if *f == Fam::Col { (a, *f, i) } else { (a, *f, usize::MAX) };
let k = match slot_keys.iter().position(|k| *k == key) {
Some(p) => p,
None => {
slot_keys.push(key);
slot_keys.len() - 1
}
};
if let Some(s) = slot_of.get_mut(i) {
*s = k;
}
}
let nslots = slot_keys.len().max(1) as f32;
let bw = cw / (nslots + 1.5);
let labels_on = chart.data_labels && ncat <= MAX_LABELS;
// Area labels wait until every area is filled, so a later (stacked) area can't paint over them.
@@ -296,20 +275,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);
}
}
}
@@ -325,7 +304,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);
}
}
}
@@ -333,7 +312,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.
@@ -433,7 +412,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);
}
}
}
+10 -10
View File
@@ -1,4 +1,4 @@
use super::{AXIS, AXIS_TITLE_SIZE, Chart, ChartData, Ctx, DARK_TEXT, Fmt, GRID, HAlign, LABEL_SIZE, PI, Prim, Rgba, Scale, TEXT, VAlign, WHITE};
use super::*;
#[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);
+10 -13
View File
@@ -1,9 +1,6 @@
use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range};
use super::legend::{cat_label, point_color};
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,
};
use super::*;
// ---------------------------------------------------------------- histogram
@@ -50,7 +47,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);
}
}
}
@@ -113,7 +110,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);
}
}
}
@@ -153,10 +150,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);
}
}
}
@@ -197,7 +194,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);
}
}
@@ -284,11 +281,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);
}
}
}
@@ -328,7 +325,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);
@@ -352,7 +349,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();
+3 -7
View File
@@ -1,10 +1,6 @@
use super::frame::Bx;
use super::trend::supports_trendline;
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;
use super::*;
/// How a legend entry's swatch is drawn.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -136,7 +132,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;
}
@@ -156,7 +152,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 } }
+1 -2
View File
@@ -1,6 +1,5 @@
use super::frame::Bx;
use super::{Ctx, MAX_LINE_PTS, MAX_MARKERS, Prim, Rgba, WHITE};
use std::collections::HashSet;
use super::*;
// ---------------------------------------------------------------- helpers
+4 -8
View File
@@ -1,9 +1,6 @@
use super::frame::{Bx, value_range};
use super::lines::decimate;
use super::{
ChartKind, Ctx, HAlign, LABEL_SIZE, LineDash, MAX_CATS, MAX_LINE_PTS, MAX_TRENDLINES, Prim, SeriesData, TEXT, TrendlineKind, VAlign,
fit_trendline, moving_average,
};
use super::*;
// ---------------------------------------------------------------- trendlines
@@ -39,11 +36,10 @@ 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.
fn clip_segment(a: (f64, f64), b: (f64, f64)) -> Option<ClippedSegment> {
#[allow(clippy::type_complexity)]
fn clip_segment(a: (f64, f64), b: (f64, f64)) -> Option<((f64, f64), (f64, f64), bool, bool)> {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let (mut t0, mut t1) = (0.0f64, 1.0f64);
for (p, q) in [(-dx, a.0), (dx, 1.0 - a.0), (-dy, a.1), (dy, 1.0 - a.1)] {
@@ -165,6 +161,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);
}
}
+20 -24
View File
@@ -295,16 +295,15 @@ mod tests {
use super::*;
#[test]
fn range_parse_rejects_signed_rows() -> Result<(), std::io::Error> {
fn range_parse_rejects_signed_rows() {
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").ok_or_else(|| std::io::Error::other("valid row range"))?;
let r = RangeRef::parse("$1:$3").unwrap();
assert_eq!((r.start.row, r.end.row), (0, 2));
Ok(())
}
#[test]
@@ -390,20 +389,18 @@ mod tests {
}
#[test]
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")?;
fn ranges() {
let r = RangeRef::parse("C3:A1").unwrap();
assert_eq!(r.a1(), "A1:C3");
assert_eq!(r.count(), 9);
assert_eq!(parse("A:C")?.a1(), "A:C");
assert_eq!(parse("3:5")?.a1(), "3:5");
assert_eq!(RangeRef::parse("A:C").unwrap().a1(), "A:C");
assert_eq!(RangeRef::parse("3:5").unwrap().a1(), "3:5");
assert!(RangeRef::parse("0:5").is_none());
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");
let a = RangeRef::parse("A1:C3").unwrap();
let b = RangeRef::parse("B2:D4").unwrap();
assert_eq!(a.intersection(&b).unwrap().a1(), "B2:C3");
assert_eq!(a.union(&b).a1(), "A1:D4");
assert_eq!(a.iter().count(), 9);
Ok(())
}
#[test]
@@ -461,19 +458,18 @@ mod category_level_tests {
use super::*;
#[test]
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");
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");
// 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");
Ok(())
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");
}
}
+11 -11
View File
@@ -47,11 +47,13 @@ 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.
/// Chrono uses the browser clock on wasm32, where the standard library clock methods trap.
/// 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
pub fn unix_now_ms() -> Option<f64> {
#[cfg(not(target_arch = "wasm32"))]
{
Some(chrono::Utc::now().timestamp_millis() as f64)
let d = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok()?;
Some(d.as_secs_f64() * 1000.0)
}
#[cfg(target_arch = "wasm32")]
{
@@ -321,7 +323,7 @@ mod tests {
}
#[test]
fn fictitious_1900_02_29() -> Result<(), std::io::Error> {
fn fictitious_1900_02_29() {
// Excel's 1900 calendar has a 29 February 1900 (serial 60); day and month overflow count
// through it.
let s = DateSystem::D1900;
@@ -343,7 +345,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).ok_or_else(|| std::io::Error::other("serial should convert"))?;
let d = datetime_from_serial(s, serial as f64).unwrap();
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);
@@ -356,7 +358,6 @@ 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]
@@ -370,18 +371,17 @@ mod tests {
}
#[test]
fn roundtrip() -> Result<(), std::io::Error> {
fn roundtrip() {
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).ok_or_else(|| std::io::Error::other("serial should convert"))?;
let d = datetime_from_serial(s, serial).unwrap();
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).ok_or_else(|| std::io::Error::other("leap-day serial should convert"))?;
let d = datetime_from_serial(s, 60.0).unwrap();
assert_eq!((d.year, d.month, d.day), (1900, 2, 29));
let d = datetime_from_serial(s, 46302.75).ok_or_else(|| std::io::Error::other("time serial should convert"))?;
let d = datetime_from_serial(s, 46302.75).unwrap();
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).ok_or_else(|| std::io::Error::other("first serial should convert"))?;
let d = datetime_from_serial(s, 1.0).unwrap();
assert_eq!(d.weekday, 0); // 1900-01-01 was a Sunday in Excel's calendar
Ok(())
}
}
+2 -3
View File
@@ -454,12 +454,11 @@ mod tests {
}
#[test]
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"))?;
fn arrays() {
let a = Array::new(2, 2, vec![1.0.into(), 2.0.into(), 3.0.into(), 4.0.into()]).unwrap();
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(())
}
}
-1
View File
@@ -17,7 +17,6 @@ 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 }
+77 -95
View File
@@ -136,95 +136,6 @@ 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()
@@ -250,8 +161,52 @@ impl CfCache {
None => &no_stats,
};
let text = v.display();
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 {
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())
}
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 };
@@ -274,8 +229,36 @@ impl CfCache {
None => None,
},
CfRule::TimePeriod { period, style } => match v.as_f64() {
Some(n) if time_period_matches(wb, period, n) => Some(style.as_ref()),
_ => None,
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,
},
CfRule::ColorScale { stops } => {
if let Some(n) = v.as_f64() {
@@ -346,8 +329,7 @@ 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
+8 -11
View File
@@ -205,6 +205,7 @@ 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(),
@@ -348,15 +349,11 @@ pub(crate) fn edit<R>(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result<R>) ->
})
}
fn wall_clock() -> std::time::Duration {
std::time::SystemTime::UNIX_EPOCH.elapsed().unwrap_or_default()
}
pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>) -> Result<R> {
// `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);
// `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()) };
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)?;
@@ -371,7 +368,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));
}
if let Some(t0) = t0 {
eprintln!("commit: edit {:?}, {} changed", since(t0), changed.len());
eprintln!("commit: edit {:?}, {} changed", t0.elapsed(), changed.len());
}
if structural {
d.calc.recalc_all(&mut wb);
@@ -379,7 +376,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
d.calc.cells_changed(&mut wb, &changed);
}
if let Some(t0) = t0 {
eprintln!("commit: + recalc {:?}", since(t0));
eprintln!("commit: + recalc {:?}", t0.elapsed());
}
fit_rows.sort_unstable();
fit_rows.dedup();
@@ -387,7 +384,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
auto_row_height(&mut wb, si, row);
}
if let Some(t0) = t0 {
eprintln!("commit: + rows {:?}", since(t0));
eprintln!("commit: + rows {:?}", t0.elapsed());
}
d.wb = std::sync::Arc::new(wb);
d.selection = sel;
+1 -1
View File
@@ -14,7 +14,7 @@ use gridcraft_model::{Cell, NumFmt, Sheet};
use serde_json::{Value as Json, json};
use super::whatif::parse_range;
use super::{CommandSpec, EngineError, Result, Session, bad, cmd, edit, has_doc, str_param};
use super::*;
const CMD: &str = "data.analysisToolPak";
/// Most input cells a tool reads.
+2 -8
View File
@@ -4,16 +4,10 @@ use std::cmp::Ordering;
use std::sync::Arc;
use gridcraft_core::{CellRef, RangeRef, Value, sort_compare};
use gridcraft_model::{
Anchor, AnchorMode, AutoFilter, Cell, CfOperator, Comment, ErrorStyle, FilterCriterion, Formula, Hyperlink, LineInfo, Sheet, Validation,
ValidationKind, Workbook,
};
use gridcraft_model::*;
use serde_json::{Value as Json, json};
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 super::*;
use crate::outline;
use crate::selection::current_region;
+1 -4
View File
@@ -1,8 +1,5 @@
use super::sort::sort_key;
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,
};
use super::*;
// ---------------------------------------------------------------- filter
+1 -4
View File
@@ -1,7 +1,4 @@
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,
};
use super::*;
// ---------------------------------------------------------------- outline
+1 -4
View File
@@ -1,7 +1,4 @@
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,
};
use super::*;
#[derive(Clone)]
struct SortKey {
+1 -4
View File
@@ -1,7 +1,4 @@
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,
};
use super::*;
// ---------------------------------------------------------------- tools
+1 -4
View File
@@ -1,7 +1,4 @@
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,
};
use super::*;
// ---------------------------------------------------------------- validation
+2 -2
View File
@@ -1,10 +1,10 @@
//! Draw tab: pen and eraser tools, ink strokes, Ink to Shape.
use gridcraft_core::CellRef;
use gridcraft_model::{Anchor, Color, Shape, ShapeKind};
use gridcraft_model::*;
use serde_json::{Value as Json, json};
use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, ok, str_param, u32_param};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
+1 -4
View File
@@ -4,10 +4,7 @@ use gridcraft_core::{CellRef, MAX_ROWS, RangeRef, Value};
use gridcraft_model::{Cell, Formula, StyleId};
use serde_json::{Value as Json, json};
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 super::*;
use crate::selection::{current_region, jump};
use crate::{Clipboard, Session};
+1 -4
View File
@@ -1,7 +1,4 @@
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,
};
use super::*;
// ---------------------------------------------------------------- entry
+1 -3
View File
@@ -1,8 +1,6 @@
use super::cell::input_to_cell;
use super::paste_special::paste_special;
use super::{
Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, cell_param_on, edit, json, ok, str_param, target_ranges, target_sheet,
};
use super::*;
// ---------------------------------------------------------------- clipboard
+2 -2
View File
@@ -1,4 +1,4 @@
use super::{CellRef, EngineError, Json, RangeRef, Result, Session, bad, edit, f64_param, json, str_param, target_range, target_sheet};
use super::*;
// ---------------------------------------------------------------- fill
@@ -78,7 +78,7 @@ pub(super) fn fill_series(s: &mut Session, p: &Json) -> Result<Json> {
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, crate::fill::SeriesOptions { range: r, rows, kind: &kind, step, stop, unit: &unit })?;
crate::fill::series(cx, sheet, r, rows, &kind, step, stop, &unit)?;
Ok(Json::Null)
})
}
+1 -1
View File
@@ -1,5 +1,5 @@
use super::cell::input_to_cell;
use super::{Cell, CellRef, EngineError, Json, Result, Session, Value, bad, bool_param, edit, json, str_param};
use super::*;
// ---------------------------------------------------------------- find & replace
+1 -1
View File
@@ -1,5 +1,5 @@
use super::selection::{select_region, selection_set};
use super::{CellRef, EngineError, Json, RangeRef, Result, Session, bad, bool_param, coalesce, json, split_sheet, str_param};
use super::*;
/// How many references the Go To dialog keeps for going back to.
pub(super) const GO_TO_RECENT: usize = 10;
+195 -202
View File
@@ -1,76 +1,5 @@
use super::clipboard::What;
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<Item = (u32, u32, u32, u32)> + 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<char> {
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<RangeRef> = 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(())
}
use super::*;
pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) };
@@ -145,145 +74,209 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
}
moved_formulas = taken;
}
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<std::sync::Arc<Formula>> {
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)
for tr in 0..tiles_r {
for tc in 0..tiles_c {
for r in 0..src.height() {
for c in 0..src.width() {
let sc = CellRef::new(src.start.row + r, src.start.col + c);
let (dr, dc) = if transpose { (c, r) } else { (r, c) };
let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue };
let src_cell = if cut_move {
moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone())
} else {
st
src_sheet.cell(sc).cloned()
};
}
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 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::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);
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<std::sync::Arc<Formula>> {
let f = cell.formula.as_ref()?;
let e = f.expr()?;
if cut_move {
// A moved formula still reads cells on its original sheet. Qualify
// those references before the workbook-wide move adjustment below.
let e = if dest_sheet != clip.sheet {
e.map(&mut |x| match x {
gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => {
r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone());
gridcraft_formula::Expr::Ref(r)
}
other => other,
})
} else {
e
};
let mut moved = Formula::from_expr(e);
// The array range moves with its formula.
let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?)));
moved.dynamic = f.dynamic;
return Some(std::sync::Arc::new(moved));
}
let moved =
gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) }))
};
if link {
let sheet_prefix = if dest_sheet == clip.sheet && same_doc {
String::new()
} else {
format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name))
};
new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1()))));
new.value = Value::Empty;
new_picture = None;
} else {
let sc_cell = src_cell.clone().unwrap_or_default();
match what {
What::All | What::AllExceptBorders => {
new_picture = src_picture.clone();
new.formula = shift(&sc_cell);
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
let st = map_style(sc_cell.style, &mut cx.wb);
new.style = if what == What::AllExceptBorders {
let borders = cx.wb.styles.get(old.style).borders;
cx.wb.styles.derive(st, |s| s.borders = borders)
} else {
st
};
}
What::Formulas | What::FormulasAndNumberFormats => {
new_picture = src_picture.clone();
new.formula = shift(&sc_cell);
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
if what == What::FormulasAndNumberFormats {
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
}
}
What::Values | What::ValuesAndNumberFormats => {
new_picture = src_picture.clone();
new.formula = None;
new.value = src_val.clone();
if what == What::ValuesAndNumberFormats {
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
}
}
What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb),
What::Comments | What::Validation | What::ColumnWidths => {}
}
// Operations combine numbers with what is already there.
if op != "none"
&& matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas)
&& new.formula.is_none()
{
if old_picture.is_some() || new_picture.is_some() {
new_picture = None;
new.value = Value::Error(gridcraft_core::CellError::Value);
}
let a = old.value.clone();
let b = new.value.clone();
// Numbers stored as text take part (multiplying by 1 turns them into
// numbers); other text and errors stay as they are.
if let (Ok(x), Ok(y)) = (a.to_number(), b.to_number())
&& (a.is_number() || a.is_empty() || a.is_text())
&& (b.is_number() || b.is_empty())
{
new.value = match op.as_str() {
"add" => Value::number(x + y),
"subtract" => Value::number(x - y),
"multiply" => Value::number(x * y),
"divide" => {
if y == 0.0 {
Value::Error(gridcraft_core::CellError::Div0)
} else {
Value::number(x / y)
}
}
_ => b.clone(),
};
} else if (a.is_text() || a.is_error()) && (b.is_number() || b.is_empty()) {
new.value = a;
}
// A formula already there is kept and combined with the pasted number.
if let Some(f) = &old.formula
&& let Ok(y) = b.to_number()
&& (b.is_number() || b.is_empty())
&& let Some(sym) = match op.as_str() {
"add" => Some('+'),
"subtract" => Some('-'),
"multiply" => Some('*'),
"divide" => Some('/'),
_ => None,
}
{
let text = format!("=({}){sym}{}", f.text, gridcraft_core::number_to_text(y));
new.formula = Some(std::sync::Arc::new(Formula::new(&text)));
new.value = Value::Empty;
}
}
}
// The copied cell's validation rule replaces whatever rule the destination had.
if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) {
let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned();
let sh = cx.sheet_mut(dest_sheet)?;
crate::cmd::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]);
if let Some(mut dv) = rule {
// Relative references move with the rule, as pasted formulas do.
let first = dv.ranges.first().map_or(sc, |r| r.start);
dv.f1 = crate::cmd::data::move_rule_formula(&dv.f1, first, dest);
dv.f2 = dv.f2.map(|f| crate::cmd::data::move_rule_formula(&f, first, dest));
dv.ranges = vec![RangeRef::new(dest, dest)];
sh.validations.push(dv);
}
}
match what {
What::Comments => {
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
}
}
What::ColumnWidths => {
if let Some(info) = src_sheet.cols.get(&sc.col).copied() {
cx.sheet_mut(dest_sheet)?.cols.insert(dest.col, info);
}
}
What::All => {
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
}
if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() {
cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h);
}
cx.sheet_mut(dest_sheet)?.set_cell(dest, new);
}
_ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new),
}
if let Some(picture) = new_picture {
cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture);
}
cx.touch(dest_sheet, dest);
}
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.
copy_merges(cx, dest_sheet, src_sheet, src, at, what, transpose)?;
if matches!(what, What::All | What::Formats) && !transpose {
let merges: Vec<RangeRef> = src_sheet.merges.iter().filter(|m| src.contains_range(m)).copied().collect();
for m in merges {
let dr = at.row as i64 - src.start.row as i64;
let dc = at.col as i64 - src.start.col as i64;
if let (Some(a), Some(b)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) {
let nm = RangeRef::new(a, b);
let sh = cx.sheet_mut(dest_sheet)?;
sh.merges.retain(|x| !x.intersects(&nm));
sh.merges.push(nm);
}
}
}
if cut_move {
// References elsewhere to the moved block now point to its new place.
let dest_name = cx.wb.sheet(dest_sheet).map(|s| s.name.clone()).unwrap_or_default();
+1 -4
View File
@@ -1,7 +1,4 @@
use super::{
CellRef, EngineError, Json, RangeRef, Result, Session, bool_param, cell_param, current_region, json, jump, split_sheet, str_param, target_ranges,
target_sheet,
};
use super::*;
// ---------------------------------------------------------------- selection
+2 -8
View File
@@ -2,16 +2,10 @@
//! tools, Get Data, scripts (Automate), cell checkboxes, theme colours and fonts.
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{
Anchor, Chart, Color, DefinedName, HAlign, LegendPos, NumFmt, Shape, Sheet, SheetViewMode, TableColumn, TotalsFn, Trendline, TrendlineKind,
Workbook,
};
use gridcraft_model::*;
use serde_json::{Value as Json, json};
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,
};
use super::*;
mod cells;
mod chart;
+1 -4
View File
@@ -1,7 +1,4 @@
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,
};
use super::*;
/// Whether `c` has checkbox formatting.
pub(crate) fn is_checkbox_cell(wb: &gridcraft_model::Workbook, sh: &gridcraft_model::Sheet, c: CellRef) -> bool {
+1 -4
View File
@@ -1,7 +1,4 @@
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,
};
use super::*;
fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
let d = s.doc()?;
+1 -1
View File
@@ -1,4 +1,4 @@
use super::{CellRef, EngineError, Json, RangeRef, Result, Session, TableColumn, TotalsFn, Value, bad, edit, json, ok, str_param, target_range};
use super::*;
pub(super) fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
let r = str_param(p, "range").and_then(RangeRef::parse).ok_or_else(|| bad("table.resize", "missing `range`"))?;
+1 -3
View File
@@ -1,7 +1,5 @@
use super::chart::format_series;
use super::{
Anchor, CellRef, Color, EngineError, Json, Result, Session, Shape, Sheet, anchor_mode_name, bad, bool_param, edit, json, str_param, u32_param,
};
use super::*;
pub(super) fn shape_fill(s: &mut Session, p: &Json) -> Result<Json> {
if str_param(p, "kind") == Some("shape") {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::{Json, Result, SCRIPTS_NAME, Session, Workbook, bad, bool_param, edit, json, stash_get, stash_map, stash_set, str_param};
use super::*;
pub(super) fn record(s: &mut Session, p: &Json) -> Result<Json> {
if bool_param(p, "stop").unwrap_or(false) || s.recording.is_some() {
+1 -4
View File
@@ -1,7 +1,4 @@
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,
};
use super::*;
/// Switches a sheet's view. Like zoom, the view is saved with the file but is not an edit: it
/// neither records an undo step nor marks a clean workbook as changed.
+1 -1
View File
@@ -2,7 +2,7 @@
use serde_json::{Value as Json, json};
use super::{CommandSpec, Result, Session, always, bad, bool_param, cmd, edit, has_doc, ok, str_param, target_sheet, u32_param};
use super::*;
use crate::DocState;
pub fn specs() -> Vec<CommandSpec> {
+1 -1
View File
@@ -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::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, has_doc, split_sheet, str_param};
use super::*;
use crate::selection::current_region;
const CMD: &str = "data.forecastSheet";
+2 -5
View File
@@ -1,13 +1,10 @@
//! Formatting: Home › Font, Alignment, Number, Styles, Cells › Format.
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_model::{BorderLine, BorderStyle, Borders, Color, Fill, HAlign, NumFmt, Style, StyleId, Theme, Underline, VAlign, VertAlign};
use gridcraft_model::*;
use serde_json::{Value as Json, json};
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,
};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
+1 -3
View File
@@ -1,6 +1,4 @@
use super::{
ACCOUNTING, BorderLine, BorderStyle, Borders, COMMA, Color, Fill, Json, NumFmt, Result, Session, Style, Theme, apply_style, bad, str_param,
};
use super::*;
/// Built-in cell styles (our own palette).
pub fn builtin_cell_style(name: &str, theme: &Theme) -> Option<Style> {
+1 -3
View File
@@ -1,6 +1,4 @@
use super::{
CellRef, EngineError, Json, RangeRef, Result, Session, StyleId, bad, edit, f64_param, parse_lines, split_sheet, str_param, target_sheet,
};
use super::*;
// ---------------------------------------------------------------- sizes
+1 -4
View File
@@ -4,10 +4,7 @@ use gridcraft_core::{CellError, CellRef, RangeRef, Value};
use gridcraft_model::{CalcMode, Cell, DefinedName, Formula, Workbook};
use serde_json::{Value as Json, json};
use super::{
CommandSpec, EngineError, Result, Session, always, bad, bool_param, cell_param, cmd, edit, f64_param, has_doc, ok, stash, str_param,
target_range, target_ranges, target_sheet, u32_param,
};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
+2 -9
View File
@@ -3,18 +3,11 @@
use gridcraft_core::{CellRef, MAX_ROWS, RangeRef};
use gridcraft_formula::adjust::{Axis, Edit};
use gridcraft_model::{
Anchor, BorderLine, BorderStyle, Borders, Cell, CellPicture, CfOperator, CfRule, CfValueKind, Chart, ChartKind, Color, Comment, CondFormat, Fill,
Formula, Hyperlink, Image, LegendPos, Series, Shape, ShapeKind, Sheet, Sparkline, SparklineKind, Style, Table, TableColumn, TotalsFn, Underline,
Workbook,
};
use gridcraft_model::*;
use serde_json::{Value as Json, json};
use super::sheet::{rewrite, shift_cell_features};
use super::{
CommandSpec, EngineError, PROTECTED, Result, Session, anchor_mode_name, anchor_mode_param, bad, bool_param, cell_param, cell_param_on, cmd, edit,
f64_param, has_doc, locked_cell_error, ok, split_sheet, str_param, target_range, target_ranges, target_sheet, u32_param,
};
use super::*;
use crate::selection::current_region;
mod chart;
+1 -4
View File
@@ -1,7 +1,4 @@
use super::{
Anchor, CellRef, Chart, ChartKind, EngineError, Json, LegendPos, RangeRef, Result, Series, Session, Sheet, anchor_mode_param, bad, block,
bool_param, cell_param_on, edit, f64_param, json, split_sheet, str_param, target_ranges, target_sheet, u32_param,
};
use super::*;
fn chart_areas(s: &Session, p: &Json) -> Result<Vec<RangeRef>> {
if p.get("range").is_some() {
+1 -4
View File
@@ -1,7 +1,4 @@
use super::{
Anchor, BorderLine, BorderStyle, Borders, CfOperator, CfRule, CfValueKind, Color, CondFormat, EngineError, Fill, Json, Result, Session, Shape,
ShapeKind, Style, anchor_mode_param, bad, bool_param, cell_param, edit, f64_param, json, ok, str_param, target_ranges, target_sheet, u32_param,
};
use super::*;
// ---------------------------------------------------------------- conditional formatting
+1 -4
View File
@@ -1,7 +1,4 @@
use super::{
Cell, Color, Comment, EngineError, Hyperlink, Json, RangeRef, Result, Session, Underline, Workbook, bool_param, cell_param_on, edit, json, ok,
split_sheet, str_param, target_ranges, target_sheet,
};
use super::*;
// ---------------------------------------------------------------- links & comments
+1 -4
View File
@@ -1,7 +1,4 @@
use super::{
Anchor, CellPicture, CellRef, Color, EngineError, Image, Json, PROTECTED, Result, Session, Shape, ShapeKind, Sparkline, SparklineKind,
anchor_mode_name, anchor_mode_param, bad, bool_param, cell_param, edit, f64_param, json, locked_cell_error, ok, str_param, u32_param,
};
use super::*;
pub(super) fn insert_sparkline(s: &mut Session, p: &Json) -> Result<Json> {
let src = str_param(p, "range").ok_or_else(|| bad("insert.sparkline", "missing `range`"))?.to_string();
+1 -4
View File
@@ -1,7 +1,4 @@
use super::{
Axis, Cell, CellRef, Edit, EngineError, Formula, Json, MAX_ROWS, RangeRef, Result, Session, Sheet, Style, Table, TableColumn, TotalsFn, bad,
block, bool_param, edit, json, rewrite, shift_cell_features, str_param, target_range, target_sheet, with,
};
use super::*;
// ---------------------------------------------------------------- tables
+1 -1
View File
@@ -3,7 +3,7 @@
use gridcraft_core::{CellRef, RangeRef, Value};
use serde_json::{Value as Json, json};
use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_param, cell_param_on, cmd, has_doc, str_param, target_range, target_sheet};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
+2 -8
View File
@@ -7,16 +7,10 @@
use std::sync::Arc;
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_model::{
Anchor, Cell, Chart, ChartKind, LegendPos, NumFmt, PivotCalcField, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs,
PivotSort, PivotTable, PivotValue, Sheet, StyleId, Workbook,
};
use gridcraft_model::*;
use serde_json::{Value as Json, json};
use super::{
CommandSpec, Ctx, EngineError, Result, Session, anchor_mode_param, bad, bool_param, cell_param, cmd, edit, f64_param, has_doc, ok, str_param,
u32_param,
};
use super::*;
use crate::pivot as pv;
use crate::selection::current_region;
+1 -4
View File
@@ -1,8 +1,5 @@
use super::create::{insert_pivot, source_param};
use super::{
Anchor, CellRef, Chart, ChartKind, EngineError, Json, LegendPos, PivotDateGroup, Result, Session, Workbook, anchor_mode_param, bad, cell_param,
edit, f64_param, find_pivot, json, other, pv, str_param,
};
use super::*;
// ---------------------------------------------------------------- PivotChart
+4 -13
View File
@@ -1,9 +1,6 @@
use super::fields::{AreaPlacement, add_to_area, in_axes};
use super::fields::{add_to_area, in_axes};
use super::params::{func_name, func_param, group_name, group_param, layout_name, show_as_name, sort_name};
use super::{
Arc, Cell, CellRef, Ctx, EngineError, Json, NumFmt, PivotDateGroup, PivotTable, RangeRef, Result, Session, Sheet, StyleId, Workbook, bad,
bool_param, cell_param, current_region, edit, find_pivot, json, other, pv, str_param, with,
};
use super::*;
// ---------------------------------------------------------------- insert
@@ -118,9 +115,7 @@ pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
let add = |pt: &mut PivotTable, list: &[Json], area: &str| -> Result<()> {
for f in list {
match f {
Json::String(n) => {
add_to_area(pt, &src, AreaPlacement { cmd: "insert.pivotTable", field: n, area, func: None, name: None, position: None })?
}
Json::String(n) => add_to_area("insert.pivotTable", pt, &src, n, area, None, None, None)?,
Json::Object(_) => {
let n = str_param(f, "field").ok_or_else(|| bad("insert.pivotTable", "a field object needs `field`"))?;
let func = match str_param(f, "func") {
@@ -129,11 +124,7 @@ pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
}
None => None,
};
add_to_area(
pt,
&src,
AreaPlacement { cmd: "insert.pivotTable", field: n, area, func, name: str_param(f, "name"), position: None },
)?;
add_to_area("insert.pivotTable", pt, &src, n, area, func, str_param(f, "name"), None)?;
let by = group_param(str_param(f, "group"));
if let Some(by) = by.filter(|b| *b != PivotDateGroup::None)
&& matches!(area, "rows" | "columns")
+14 -18
View File
@@ -1,9 +1,6 @@
use super::create::pivot_json;
use super::params::{area_param, func_param, position_param, show_as_param};
use super::{
Json, PivotCalcField, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotShowAs, PivotTable, PivotValue, Result, Session, Workbook, bad,
bool_param, edit, find_pivot, json, ok, other, pv, str_param, u32_param,
};
use super::*;
// ---------------------------------------------------------------- layout editing (pure)
@@ -107,17 +104,16 @@ fn auto_name(f: PivotFunc, field: &str) -> String {
}
/// Adds `field` to an area (moving it out of the other axis areas).
pub(super) struct AreaPlacement<'a> {
pub cmd: &'a str,
pub field: &'a str,
pub area: &'a str,
pub func: Option<PivotFunc>,
pub name: Option<&'a str>,
pub position: Option<usize>,
}
pub(super) fn add_to_area(pt: &mut PivotTable, src: &pv::Source, placement: AreaPlacement<'_>) -> Result<()> {
let AreaPlacement { cmd, field, area, func, name, position } = placement;
pub(super) fn add_to_area(
cmd: &str,
pt: &mut PivotTable,
src: &pv::Source,
field: &str,
area: &str,
func: Option<PivotFunc>,
name: Option<&str>,
position: Option<usize>,
) -> Result<()> {
let is_calc = src.col(field).is_none() && calc_field(pt, field).is_some();
if is_calc && area != "values" {
return Err(other("A calculated field can only be placed in the Values area."));
@@ -304,7 +300,7 @@ pub(super) fn add_field(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").map(str::to_string);
let pos = position_param(p);
update(s, p, |pt, src, _| {
add_to_area(pt, src, AreaPlacement { cmd: C, field: &field, area, func, name: name.as_deref(), position: pos })?;
add_to_area(C, pt, src, &field, area, func, name.as_deref(), pos)?;
Ok(json!({"field": canonical_any(C, pt, src, &field)?, "area": area}))
})
}
@@ -368,7 +364,7 @@ pub(super) fn calculated_field(s: &mut Session, p: &Json) -> Result<Json> {
return Err(bad(C, format!("`{canon}` refers to itself through its formula")));
}
if existing.is_none() && to_values && !pt.values.iter().any(|v| v.source_col.eq_ignore_ascii_case(&canon)) {
add_to_area(pt, src, AreaPlacement { cmd: C, field: &canon, area: "values", func: Some(PivotFunc::Sum), name: None, position: None })?;
add_to_area(C, pt, src, &canon, "values", Some(PivotFunc::Sum), None, None)?;
}
Ok(json!({
"name": canon,
@@ -456,7 +452,7 @@ pub(super) fn move_field(s: &mut Session, p: &Json) -> Result<Json> {
let kept = pt.rows.iter().chain(pt.columns.iter()).find(|f| f.source_col.eq_ignore_ascii_case(&field)).cloned();
let hidden = pt.filters.iter().find(|f| f.source_col.eq_ignore_ascii_case(&field)).cloned();
let src_field = remove_from_area(C, pt, &field, from)?;
add_to_area(pt, src, AreaPlacement { cmd: C, field: &src_field, area: to, func: None, name: None, position: pos })?;
add_to_area(C, pt, src, &src_field, to, None, None, pos)?;
if matches!(to, "rows" | "columns") {
let list = if to == "rows" { &mut pt.rows } else { &mut pt.columns };
if let (Some(k), Some(slot)) = (kept, list.iter_mut().find(|f| f.source_col.eq_ignore_ascii_case(&src_field))) {
+1 -4
View File
@@ -1,10 +1,7 @@
use super::create::source_param;
use super::fields::{canonical, in_axes, shift_values, update};
use super::params::{group_param, layout_name};
use super::{
Chart, Json, PivotDateGroup, PivotField, PivotFilter, PivotLayout, PivotSort, PivotTable, Result, Session, bad, bool_param, edit, find_pivot,
json, other, pv, str_param,
};
use super::*;
pub(super) fn filter(s: &mut Session, p: &Json) -> Result<Json> {
const C: &str = "pivot.filter";
+1 -1
View File
@@ -1,4 +1,4 @@
use super::{Json, PivotDateGroup, PivotFunc, PivotLayout, PivotShowAs, PivotSort, Result, bad, u32_param};
use super::*;
pub(super) fn area_param(cmd: &str, v: Option<&str>) -> Result<&'static str> {
match v.map(str::to_ascii_lowercase) {
+97 -121
View File
@@ -243,70 +243,6 @@ fn compact_vs_tabular_shapes() {
assert_eq!(text(&s, "A5"), "Apples");
}
fn assert_values_on_rows_default(s: &Session) {
let p = pt(s);
assert!(p.values_on_rows);
assert_eq!(p.values_position, None);
let r = p.last_range.unwrap();
assert_eq!(r.width(), 2);
// header + 3 × (region + 2 values) + 2 grand totals
assert_eq!(r.height(), 1 + 9 + 2);
assert_eq!(text(s, "A4"), "East");
assert_eq!(val(s, "B4"), Value::Empty);
assert_eq!(text(s, "A5"), "Sum of Sales");
assert_eq!(val(s, "B5"), Value::Number(415.0));
assert_eq!(text(s, "A6"), "Sum of Units");
assert_eq!(val(s, "B6"), Value::Number(33.0));
assert_eq!(text(s, "A13"), "Total Sum of Sales");
assert_eq!(val(s, "B13"), Value::Number(TOTAL));
assert_eq!(text(s, "A14"), "Total Sum of Units");
assert_eq!(val(s, "B14"), Value::Number(72.0));
}
fn assert_values_on_rows_tabular(s: &mut Session) {
// Tabular, no column field: "Total" heads the data column, as Excel saves it.
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
assert_eq!(text(s, "A3"), "Region");
assert_eq!(text(s, "B3"), "Values");
assert_eq!(text(s, "C3"), "Total");
}
fn assert_values_on_rows_position(s: &mut Session) {
s.execute("pivot.layout", json!({"layout": "compact"})).unwrap();
// Σ Values before Region: the value fields are the outer items.
s.execute("pivot.moveField", json!({"field": "Values", "from": "rows", "to": "rows", "position": 0})).unwrap();
assert_eq!(pt(s).values_position, Some(0));
assert_eq!(text(s, "A4"), "Sum of Sales");
assert_eq!(val(s, "B4"), Value::Empty);
assert_eq!(text(s, "A5"), "East");
assert_eq!(val(s, "B5"), Value::Number(415.0));
assert_eq!(text(s, "A8"), "Sum of Units");
assert_eq!(text(s, "A9"), "East");
assert_eq!(val(s, "B9"), Value::Number(33.0));
assert_eq!(text(s, "A12"), "Total Sum of Sales");
assert_eq!(val(s, "B13"), Value::Number(72.0));
// Adding a row field in front keeps Σ Values ahead of Region.
s.execute("pivot.addField", json!({"field": "Product", "area": "rows", "position": 0})).unwrap();
assert_eq!(pt(s).values_position, Some(1));
s.execute("pivot.removeField", json!({"field": "Product", "area": "rows"})).unwrap();
assert_eq!(pt(s).values_position, Some(0));
// No row field: one line per value field and no grand total.
s.execute("pivot.removeField", json!({"field": "Region", "area": "rows"})).unwrap();
let r = pt(s).last_range.unwrap();
assert_eq!(r.height(), 1 + 2);
assert_eq!(text(s, "A4"), "Sum of Sales");
assert_eq!(val(s, "B4"), Value::Number(TOTAL));
assert_eq!(text(s, "A5"), "Sum of Units");
assert_eq!(val(s, "B5"), Value::Number(72.0));
// Back to Columns.
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).unwrap();
assert!(!pt(s).values_on_rows);
assert_eq!(text(s, "A3"), "Sum of Sales");
assert_eq!(text(s, "B3"), "Sum of Units");
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "filters"})).is_err());
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).is_err());
}
#[test]
fn values_on_rows_layouts() {
let mut s = sales();
@@ -314,9 +250,61 @@ fn values_on_rows_layouts() {
assert_eq!(text(&s, "C3"), "Sum of Units");
// Σ Values after Region: each region holds one line per value field.
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap();
assert_values_on_rows_default(&s);
assert_values_on_rows_tabular(&mut s);
assert_values_on_rows_position(&mut s);
let p = pt(&s);
assert!(p.values_on_rows);
assert_eq!(p.values_position, None);
let r = p.last_range.unwrap();
assert_eq!(r.width(), 2);
// header + 3 × (region + 2 values) + 2 grand totals
assert_eq!(r.height(), 1 + 9 + 2);
assert_eq!(text(&s, "A4"), "East");
assert_eq!(val(&s, "B4"), Value::Empty);
assert_eq!(text(&s, "A5"), "Sum of Sales");
assert_eq!(val(&s, "B5"), Value::Number(415.0));
assert_eq!(text(&s, "A6"), "Sum of Units");
assert_eq!(val(&s, "B6"), Value::Number(33.0));
assert_eq!(text(&s, "A13"), "Total Sum of Sales");
assert_eq!(val(&s, "B13"), Value::Number(TOTAL));
assert_eq!(text(&s, "A14"), "Total Sum of Units");
assert_eq!(val(&s, "B14"), Value::Number(72.0));
// Tabular, no column field: "Total" heads the data column, as Excel saves it.
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
assert_eq!(text(&s, "A3"), "Region");
assert_eq!(text(&s, "B3"), "Values");
assert_eq!(text(&s, "C3"), "Total");
s.execute("pivot.layout", json!({"layout": "compact"})).unwrap();
// Σ Values before Region: the value fields are the outer items.
s.execute("pivot.moveField", json!({"field": "Values", "from": "rows", "to": "rows", "position": 0})).unwrap();
assert_eq!(pt(&s).values_position, Some(0));
assert_eq!(text(&s, "A4"), "Sum of Sales");
assert_eq!(val(&s, "B4"), Value::Empty);
assert_eq!(text(&s, "A5"), "East");
assert_eq!(val(&s, "B5"), Value::Number(415.0));
assert_eq!(text(&s, "A8"), "Sum of Units");
assert_eq!(text(&s, "A9"), "East");
assert_eq!(val(&s, "B9"), Value::Number(33.0));
assert_eq!(text(&s, "A12"), "Total Sum of Sales");
assert_eq!(val(&s, "B13"), Value::Number(72.0));
// Adding a row field in front keeps Σ Values ahead of Region.
s.execute("pivot.addField", json!({"field": "Product", "area": "rows", "position": 0})).unwrap();
assert_eq!(pt(&s).values_position, Some(1));
s.execute("pivot.removeField", json!({"field": "Product", "area": "rows"})).unwrap();
assert_eq!(pt(&s).values_position, Some(0));
// No row field: one line per value field and no grand total.
s.execute("pivot.removeField", json!({"field": "Region", "area": "rows"})).unwrap();
let r = pt(&s).last_range.unwrap();
assert_eq!(r.height(), 1 + 2);
assert_eq!(text(&s, "A4"), "Sum of Sales");
assert_eq!(val(&s, "B4"), Value::Number(TOTAL));
assert_eq!(text(&s, "A5"), "Sum of Units");
assert_eq!(val(&s, "B5"), Value::Number(72.0));
// Back to Columns.
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).unwrap();
assert!(!pt(&s).values_on_rows);
assert_eq!(text(&s, "A3"), "Sum of Sales");
assert_eq!(text(&s, "B3"), "Sum of Units");
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "filters"})).is_err());
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).is_err());
}
#[test]
@@ -367,65 +355,53 @@ fn change_source_keeps_values_ahead_of_its_field() {
assert_eq!(text(&s, "A5"), "Apples");
}
fn assert_values_between_compact_rows(s: &Session) {
// Compact: East, then per value field its products, then per-value subtotals of East.
assert_eq!(text(s, "A4"), "East");
assert_eq!(text(s, "A5"), "Sum of Sales");
assert_eq!(text(s, "A6"), "Apples");
assert_eq!(val(s, "B6"), Value::Number(160.0));
assert_eq!(text(s, "A9"), "Sum of Units");
assert_eq!(text(s, "A10"), "Apples");
assert_eq!(val(s, "B10"), Value::Number(16.0));
assert_eq!(text(s, "A13"), "East Sum of Sales");
assert_eq!(val(s, "B13"), Value::Number(415.0));
assert_eq!(text(s, "A14"), "East Sum of Units");
assert_eq!(val(s, "B14"), Value::Number(33.0));
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A5").unwrap())).align.indent, 1);
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A6").unwrap())).align.indent, 2);
}
fn assert_values_between_outline(s: &mut Session) {
// Outline: one column per level, "Values" heads the Σ Values column.
s.execute("pivot.layout", json!({"layout": "outline"})).unwrap();
assert_eq!(pt(s).last_range.unwrap().width(), 4);
assert_eq!(text(s, "A3"), "Region");
assert_eq!(text(s, "B3"), "Values");
assert_eq!(text(s, "C3"), "Product");
assert_eq!(text(s, "B5"), "Sum of Sales");
assert_eq!(text(s, "C6"), "Apples");
assert_eq!(val(s, "D6"), Value::Number(160.0));
}
fn assert_values_between_tabular(s: &mut Session) {
// Tabular: labels start on their parent's row; totals per value field below each group.
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
assert_eq!(text(s, "A4"), "East");
assert_eq!(text(s, "B4"), "Sum of Sales");
assert_eq!(text(s, "C4"), "Apples");
assert_eq!(val(s, "D4"), Value::Number(160.0));
assert_eq!(text(s, "B7"), "Sum of Units");
assert_eq!(text(s, "C7"), "Apples");
assert_eq!(val(s, "D7"), Value::Number(16.0));
assert_eq!(text(s, "A10"), "East Sum of Sales");
assert_eq!(val(s, "D10"), Value::Number(415.0));
assert_eq!(text(s, "A11"), "East Sum of Units");
let r = pt(s).last_range.unwrap();
let last = r.end.row + 1;
assert_eq!(text(s, &format!("A{}", last - 1)), "Total Sum of Sales");
assert_eq!(text(s, &format!("A{last}")), "Total Sum of Units");
assert_eq!(val(s, &format!("D{last}")), Value::Number(72.0));
}
#[test]
fn values_on_rows_between_row_fields() {
let mut s = sales();
s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap();
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows", "position": 1})).unwrap();
assert_values_between_compact_rows(&s);
assert_values_between_outline(&mut s);
assert_values_between_tabular(&mut s);
// Compact: East, then per value field its products, then per-value subtotals of East.
assert_eq!(text(&s, "A4"), "East");
assert_eq!(text(&s, "A5"), "Sum of Sales");
assert_eq!(text(&s, "A6"), "Apples");
assert_eq!(val(&s, "B6"), Value::Number(160.0));
assert_eq!(text(&s, "A9"), "Sum of Units");
assert_eq!(text(&s, "A10"), "Apples");
assert_eq!(val(&s, "B10"), Value::Number(16.0));
assert_eq!(text(&s, "A13"), "East Sum of Sales");
assert_eq!(val(&s, "B13"), Value::Number(415.0));
assert_eq!(text(&s, "A14"), "East Sum of Units");
assert_eq!(val(&s, "B14"), Value::Number(33.0));
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A5").unwrap())).align.indent, 1);
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A6").unwrap())).align.indent, 2);
// Outline: one column per level, "Values" heads the Σ Values column.
s.execute("pivot.layout", json!({"layout": "outline"})).unwrap();
assert_eq!(pt(&s).last_range.unwrap().width(), 4);
assert_eq!(text(&s, "A3"), "Region");
assert_eq!(text(&s, "B3"), "Values");
assert_eq!(text(&s, "C3"), "Product");
assert_eq!(text(&s, "B5"), "Sum of Sales");
assert_eq!(text(&s, "C6"), "Apples");
assert_eq!(val(&s, "D6"), Value::Number(160.0));
// Tabular: labels start on their parent's row; totals per value field below each group.
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
assert_eq!(text(&s, "A4"), "East");
assert_eq!(text(&s, "B4"), "Sum of Sales");
assert_eq!(text(&s, "C4"), "Apples");
assert_eq!(val(&s, "D4"), Value::Number(160.0));
assert_eq!(text(&s, "B7"), "Sum of Units");
assert_eq!(text(&s, "C7"), "Apples");
assert_eq!(val(&s, "D7"), Value::Number(16.0));
assert_eq!(text(&s, "A10"), "East Sum of Sales");
assert_eq!(val(&s, "D10"), Value::Number(415.0));
assert_eq!(text(&s, "A11"), "East Sum of Units");
let r = pt(&s).last_range.unwrap();
let last = r.end.row + 1;
assert_eq!(text(&s, &format!("A{}", last - 1)), "Total Sum of Sales");
assert_eq!(text(&s, &format!("A{last}")), "Total Sum of Units");
assert_eq!(val(&s, &format!("D{last}")), Value::Number(72.0));
}
#[test]
+6 -14
View File
@@ -10,16 +10,11 @@
//! codes.
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
#[cfg(test)]
use gridcraft_model::{Alignment, Image, SheetViewMode};
use gridcraft_model::{
Anchor, BorderLine, BorderStyle, Borders, Color, HAlign, Orientation, PatternType, PrintSettings, ShapeKind, Sheet, Style, StyleId, StyleTable,
Underline, VAlign, Visibility, Workbook,
};
use gridcraft_pdf::{Font, Page, PdfDoc, Point, Rect, Rgb};
use gridcraft_model::*;
use gridcraft_pdf::{Font, Page, PdfDoc, Rgb};
use serde_json::{Value as Json, json};
use super::{CommandSpec, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, split_sheet, str_param, target_sheet, u32_param};
use super::*;
mod draw;
mod header_footer;
@@ -32,9 +27,6 @@ use header_footer::draw_header_footer;
pub use header_footer::{
HF_FIELDS, HfContext, HfSection, codes_to_fields, expand_header_footer, fields_to_codes, hf_date_time, join_header_footer, split_header_footer,
};
/// Decoded pictures (bytes, width, height) keyed by (sheet, picture); `None` = undecodable.
type PicCache = std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>>;
#[cfg(test)]
use layout::bands;
pub(crate) use layout::split_bands;
@@ -102,7 +94,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
let mut printed_blocks = Vec::new();
// Decoded pictures are reused across the pages their anchor covers: a floating picture
// spanning 40 pages was decoded (and pushed into the PDF) 40 times (#2389).
let mut pics = PicCache::new();
let mut pics: std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>> = std::collections::HashMap::new();
for lay in layouts {
let Some(sh) = wb.sheet(lay.sheet) else { continue };
let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) };
@@ -124,7 +116,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
for (rsi, rs) in rsegs.iter().enumerate() {
let mut x = ox + lay.heading_w * k;
for (csi, cs) in csegs.iter().enumerate() {
let b = make_block(wb, lay.sheet, sh, *rs, *cs, (x, y), lay);
let b = make_block(wb, lay.sheet, sh, *rs, *cs, x, y, lay);
draw_block(pg, &b, lay.settings.gridlines, &mut cf);
draw_block_links(pg, &b, page_index, &mut pending_links);
printed_blocks.push(PrintedBlock { page: page_index, sheet: lay.sheet, rows: *rs, cols: *cs, x, y, k });
@@ -163,7 +155,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
&& let Some(page) = doc.page_mut(link.page)
{
let (lx, ly, w, h) = link.rect;
page.link_page(Rect::new(lx, ly, w, h), target_page, Point::new(x, y));
page.link_page(lx, ly, w, h, target_page, x, y);
}
}
let n = doc.page_count();
+14 -18
View File
@@ -1,10 +1,7 @@
use super::layout::{DENSE_BLOCK, SheetLayout};
use super::objects::draw_cell_picture;
use super::text::{TextPlacement, draw_text};
use super::{
BorderLine, BorderStyle, Borders, CellRef, Color, Font, Page, PatternType, Point, RangeRef, Rgb, Sheet, Style, StyleId, Underline, Workbook,
split_sheet,
};
use super::text::draw_text;
use super::*;
// ---------------------------------------------------------------- drawing
@@ -154,7 +151,7 @@ pub(super) fn draw_prims(pg: &mut Page, prims: &[gridcraft_chart::Prim], xf: Xf)
};
let (sn, cs) = if rotation.is_finite() { rotation.sin_cos() } else { (0.0, 1.0) };
let (ax, ay) = xf.p(*x, *y);
pg.text_rotated(Point::new(ax + dx * cs - dy * sn, ay + dx * sn + dy * cs), size, font, blend(*color), text, *rotation);
pg.text_rotated(ax + dx * cs - dy * sn, ay + dx * sn + dy * cs, size, font, blend(*color), text, *rotation);
}
}
}
@@ -210,11 +207,10 @@ fn border_dash(style: BorderStyle, k: f32) -> Vec<f32> {
}
}
fn draw_border(pg: &mut Page, b: &BorderLine, wb: &Workbook, start: Point, end: Point, k: f32) {
fn draw_border(pg: &mut Page, b: &BorderLine, wb: &Workbook, x0: f32, y0: f32, x1: f32, y1: f32, k: f32) {
if b.is_none() {
return;
}
let (x0, y0, x1, y1) = (start.x, start.y, end.x, end.y);
let color = rgb_of(b.color, wb).unwrap_or([0, 0, 0]);
if b.style == BorderStyle::Double {
let off = 1.0 * k;
@@ -225,7 +221,7 @@ fn draw_border(pg: &mut Page, b: &BorderLine, wb: &Workbook, start: Point, end:
return;
}
let lw = (b.style.width() * k).clamp(0.25, 6.0);
pg.stroke_line_dashed(Point::new(x0, y0), Point::new(x1, y1), lw, color, &border_dash(b.style, k));
pg.stroke_line_dashed(x0, y0, x1, y1, lw, color, &border_dash(b.style, k));
}
/// Draws the cells of a block: fills, gridlines, data bars, borders and text.
@@ -328,12 +324,12 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
),
None => (bd.right, bd.bottom),
};
draw_border(pg, &bd.top, b.wb, Point::new(x, y), Point::new(x + w, y), b.k);
draw_border(pg, &bottom, b.wb, Point::new(x, y + h), Point::new(x + w, y + h), b.k);
draw_border(pg, &bd.left, b.wb, Point::new(x, y), Point::new(x, y + h), b.k);
draw_border(pg, &right, b.wb, Point::new(x + w, y), Point::new(x + w, y + h), b.k);
draw_border(pg, &bd.diag_down, b.wb, Point::new(x, y), Point::new(x + w, y + h), b.k);
draw_border(pg, &bd.diag_up, b.wb, Point::new(x, y + h), Point::new(x + w, y), b.k);
draw_border(pg, &bd.top, b.wb, x, y, x + w, y, b.k);
draw_border(pg, &bottom, b.wb, x, y + h, x + w, y + h, b.k);
draw_border(pg, &bd.left, b.wb, x, y, x, y + h, b.k);
draw_border(pg, &right, b.wb, x + w, y, x + w, y + h, b.k);
draw_border(pg, &bd.diag_down, b.wb, x, y, x + w, y + h, b.k);
draw_border(pg, &bd.diag_up, b.wb, x, y + h, x + w, y, b.k);
}
// Text.
for (c, l) in &looks {
@@ -344,7 +340,7 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
if let Some(picture) = sh.cell_pictures.get(c) {
draw_cell_picture(pg, &picture.data, (x, y, w, h), b.k);
} else {
draw_text(pg, b, TextPlacement { cell: *c, look: l, rect: (x, y, w, h), merged, merges: &merges, limits: (bx, bx + bw) });
draw_text(pg, b, *c, l, (x, y, w, h), merged, &merges, (bx, bx + bw));
}
}
pg.restore();
@@ -448,10 +444,10 @@ pub(super) fn make_block<'a>(
sh: &'a Sheet,
rows: (u32, u32),
cols: (u32, u32),
origin: (f32, f32),
x: f32,
y: f32,
lay: &SheetLayout,
) -> Block<'a> {
let (x, y) = origin;
let k = lay.k();
let mut xs = Vec::with_capacity((cols.1 - cols.0 + 2) as usize);
let mut acc = x;
+1 -1
View File
@@ -1,5 +1,5 @@
use super::layout::SheetLayout;
use super::{Font, Page, Value, Workbook};
use super::*;
/// Side margin of headers and footers, in points, when Align with Page Margins is off.
pub(super) const HF_FIXED_SIDE_MARGIN: f32 = 0.7 * 72.0;
+2 -8
View File
@@ -1,8 +1,5 @@
use super::header_footer::HF_FIXED_SIDE_MARGIN;
use super::{
Anchor, CellRef, Json, MAX_COLS, MAX_PAGES, MAX_ROWS, Orientation, PX_TO_PT, PrintSettings, RangeRef, Result, Sheet, Visibility, Workbook, bad,
bool_param, split_sheet, str_param,
};
use super::*;
/// Cells in a page block above which only stored cells are visited (not every grid position).
pub(super) const DENSE_BLOCK: u64 = 60_000;
@@ -290,13 +287,10 @@ pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_overrid
Some(lay)
}
/// Inclusive-start/end index spans of the pages along one axis.
pub(crate) type Bands = Vec<(u32, u32)>;
/// Splits a layout's area into row and column bands that fit its pages (at most `cap` of each),
/// honouring manual breaks (when `lay.manual_breaks`), print headings and repeated titles. The
/// one pagination rule shared by printing, PDF export, Page Break Preview and Page Layout view.
pub(crate) fn split_bands(sh: &Sheet, lay: &SheetLayout, cap: usize) -> (Bands, Bands) {
pub(crate) fn split_bands(sh: &Sheet, lay: &SheetLayout, cap: usize) -> (Vec<(u32, u32)>, Vec<(u32, u32)>) {
let k = lay.k();
let (aw, ah) = lay.avail();
let (aw, ah) = (aw / k, ah / k);
+11 -4
View File
@@ -1,6 +1,6 @@
use super::draw::{Block, PdfMeasure, Xf, draw_prims, rgb_of};
use super::text::wrap_lines;
use super::{Anchor, Font, Page, RangeRef, Rect, ShapeKind, Sheet, Value, Workbook};
use super::*;
/// Cell pictures fit the current cell (or merged-cell) rectangle without changing aspect ratio.
pub(super) fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32, f32), k: f32) {
@@ -25,7 +25,7 @@ pub(super) fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32
image.thumbnail(((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32).to_rgba8();
let fit = (w / iw as f32).min(h / ih as f32);
let (pw, ph) = (iw as f32 * fit, ih as f32 * fit);
pg.image_rgba(Rect::new(x + pad + (w - pw) / 2.0, y + pad + (h - ph) / 2.0, pw, ph), image.as_raw(), [image.width(), image.height()]);
pg.image_rgba(x + pad + (w - pw) / 2.0, y + pad + (h - ph) / 2.0, pw, ph, image.as_raw(), image.width(), image.height());
}
/// Sheet pixel rectangle of an anchor.
@@ -49,7 +49,14 @@ fn decode_image(data: &[u8], max_w: u32, max_h: u32) -> Option<(Vec<u8>, u32, u3
/// Draws charts, pictures, shapes and sparklines overlapping the body block of a page.
/// `pics` memoizes decoded pictures for the whole render (one page at a time draws each
/// picture again, but the decode happens once).
pub(super) fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block, pics: &mut super::PicCache) {
pub(super) fn draw_objects(
pg: &mut Page,
wb: &Workbook,
si: usize,
sh: &Sheet,
b: &Block,
pics: &mut std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>>,
) {
let block = b.range();
let (sx0, sy0) = (sh.col_left(block.start.col), sh.row_top(block.start.row));
let (sx1, sy1) = (sh.col_left(block.end.col.saturating_add(1)), sh.row_top(block.end.row.saturating_add(1)));
@@ -70,7 +77,7 @@ pub(super) fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet,
let (mw, mh) = (((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32);
let decoded = pics.entry((si, imi)).or_insert_with(|| decode_image(&im.data, mw, mh));
if let Some((px, pw, ph)) = decoded.as_ref() {
pg.image_rgba(Rect::new(x, y, w, h), px, [*pw, *ph]);
pg.image_rgba(x, y, w, h, px, *pw, *ph);
}
}
for shp in &sh.shapes {
+52 -59
View File
@@ -1,5 +1,5 @@
use super::draw::{Block, Look, rgb_of};
use super::{CellRef, Color, Font, HAlign, MAX_COLS, Page, Point, RangeRef, Sheet, Style, StyleTable, Underline, VAlign, Value};
use super::*;
const PAD_PX: f32 = 2.0;
@@ -51,55 +51,11 @@ fn font_for(style: &Style) -> Font {
Font::styled(Font::family_for(&style.font.name), style.font.bold, style.font.italic)
}
fn text_align(align: HAlign, value: &Value) -> HAlign {
match align {
HAlign::General => match value {
Value::Number(_) => HAlign::Right,
Value::Bool(_) | Value::Error(_) => HAlign::Center,
_ => HAlign::Left,
},
HAlign::CenterAcross | HAlign::Distributed => HAlign::Center,
HAlign::Justify | HAlign::Fill => HAlign::Left,
other => other,
}
}
/// Is a cell free for text to overflow into?
fn empty_for_overflow(sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> bool {
sh.value_ref(c).is_none_or(|v| v.is_empty()) && !merges.iter().any(|m| m.contains(c))
}
fn extend_text_bounds(sh: &Sheet, c: CellRef, merges: &[RangeRef], k: f32, need: f32, align: HAlign, bounds: (f32, f32)) -> (f32, f32) {
let (mut clip_l, mut clip_r) = bounds;
let grow = |dir: i64, mut remaining: f32| -> f32 {
let mut ext = 0.0;
let mut col = c.col as i64 + dir;
while remaining > 0.0 && col >= 0 && col < MAX_COLS as i64 {
let nc = CellRef::new(c.row, col as u32);
if !empty_for_overflow(sh, nc, merges) {
break;
}
let width = sh.col_width(nc.col) * k;
ext += width;
remaining -= width;
col += dir;
if ext > 20_000.0 {
break;
}
}
ext
};
match align {
HAlign::Right => clip_l -= grow(-1, need),
HAlign::Center => {
clip_l -= grow(-1, need / 2.0);
clip_r += grow(1, need / 2.0);
}
_ => clip_r += grow(1, need),
}
(clip_l, clip_r)
}
/// Unscaled width of the empty Center Across Selection cells right of `c`, which its text is centred over too.
pub(super) fn center_across_width(styles: &StyleTable, sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> f32 {
let mut ext = 0.0;
@@ -115,17 +71,16 @@ pub(super) fn center_across_width(styles: &StyleTable, sh: &Sheet, c: CellRef, m
ext
}
pub(super) struct TextPlacement<'a> {
pub cell: CellRef,
pub look: &'a Look,
pub rect: (f32, f32, f32, f32),
pub merged: bool,
pub merges: &'a [RangeRef],
pub limits: (f32, f32),
}
pub(super) fn draw_text(pg: &mut Page, b: &Block, placement: TextPlacement<'_>) {
let TextPlacement { cell: c, look: l, rect, merged, merges, limits } = placement;
pub(super) fn draw_text(
pg: &mut Page,
b: &Block,
c: CellRef,
l: &Look,
rect: (f32, f32, f32, f32),
merged: bool,
merges: &[RangeRef],
limits: (f32, f32),
) {
let (x, y, w, h) = rect;
let sh = b.sh;
let wb = b.wb;
@@ -178,7 +133,20 @@ pub(super) fn draw_text(pg: &mut Page, b: &Block, placement: TextPlacement<'_>)
text = "#".repeat(((avail / hash).floor().max(1.0) as usize).min(255));
tw = gridcraft_pdf::text_width(font, size, &text);
}
let h_align = text_align(st.align.h, &v);
let h_align = match st.align.h {
HAlign::General => {
if is_num {
HAlign::Right
} else if matches!(v, Value::Bool(_) | Value::Error(_)) {
HAlign::Center
} else {
HAlign::Left
}
}
HAlign::CenterAcross | HAlign::Distributed => HAlign::Center,
HAlign::Justify | HAlign::Fill => HAlign::Left,
other => other,
};
let rot = st.align.rotation;
let rotated = rot != 0 && rot != 255;
// Rotated text wraps along its own direction: upright (±90°) text to the row height.
@@ -202,7 +170,32 @@ pub(super) fn draw_text(pg: &mut Page, b: &Block, placement: TextPlacement<'_>)
let (mut clip_l, mut clip_r) = (x, x + w + across);
if !wrap && !merged && !is_num && rot.abs() != 90 && across <= 0.0 && tw + 2.0 * pad + indent > w {
let need = tw + 2.0 * pad + indent - w;
(clip_l, clip_r) = extend_text_bounds(sh, c, merges, b.k, need, h_align, (clip_l, clip_r));
let grow = |dir: i64, mut need: f32| -> f32 {
let mut ext = 0.0;
let mut col = c.col as i64 + dir;
while need > 0.0 && col >= 0 && col < MAX_COLS as i64 {
let nc = CellRef::new(c.row, col as u32);
if !empty_for_overflow(sh, nc, merges) {
break;
}
let cw = sh.col_width(nc.col) * b.k;
ext += cw;
need -= cw;
col += dir;
if ext > 20_000.0 {
break;
}
}
ext
};
match h_align {
HAlign::Right => clip_l -= grow(-1, need),
HAlign::Center => {
clip_l -= grow(-1, need / 2.0);
clip_r += grow(1, need / 2.0);
}
_ => clip_r += grow(1, need),
}
}
let clip_l = clip_l.max(limits.0);
let clip_r = clip_r.min(limits.1);
@@ -222,7 +215,7 @@ pub(super) fn draw_text(pg: &mut Page, b: &Block, placement: TextPlacement<'_>)
} else {
(x + pad + (last - i) * line_h, y + pad + size * 0.8)
};
pg.text_rotated(Point::new(ox, oy), size, font, color, t, angle);
pg.text_rotated(ox, oy, size, font, color, t, angle);
}
pg.restore();
return;
+1 -1
View File
@@ -5,7 +5,7 @@ use gridcraft_core::CellRef;
use gridcraft_model::{EditRange, PasswordHash, SheetProtection, Visibility, password_hash};
use serde_json::{Value as Json, json};
use super::{CommandSpec, DocState, EngineError, Result, Session, bad, bool_param, cmd, edit, has_doc, str_param, target_ranges, target_sheet};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
+1 -1
View File
@@ -1,4 +1,4 @@
use super::{Arc, CellRef, Ctx, EngineError, Json, Result, Session, Sheet, Visibility, bad, bool_param, edit, json, ok, str_param, target_sheet};
use super::*;
pub(super) fn insert_sheet(s: &mut Session, p: &Json) -> Result<Json> {
if s.doc()?.wb.protected_structure {
+2 -4
View File
@@ -6,12 +6,10 @@
//! `table.insertSlicer`) selects everything, i.e. applies no filter.
use gridcraft_core::CellRef;
use gridcraft_model::{Anchor, AnchorMode, AutoFilter, FilterCriterion, Slicer, Workbook};
use gridcraft_model::*;
use serde_json::{Value as Json, json};
use super::{
CommandSpec, EngineError, Result, Session, bad, bool_param, cell_param_on, cmd, edit, f64_param, has_doc, str_param, target_sheet, u32_param,
};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
+3 -12
View File
@@ -97,17 +97,9 @@ struct Solve {
evo_end: Option<EvoEnd>,
}
/// Variable bounds and integrality flags.
#[derive(Clone, Copy)]
struct Bounds<'a> {
lo: &'a [f64],
hi: &'a [f64],
integer: &'a [bool],
}
/// Runs the model's method for `goal` within `lo..hi` from `x0`.
fn optimise(ev: &mut Evaluator, m: &Model, goal: Goal, x0: &[f64], bounds: Bounds, gap: f64) -> Solve {
let Bounds { lo, hi, integer } = bounds;
#[allow(clippy::too_many_arguments)]
fn optimise(ev: &mut Evaluator, m: &Model, goal: Goal, x0: &[f64], lo: &[f64], hi: &[f64], integer: &[bool], gap: f64) -> Solve {
let any_int = integer.iter().any(|b| *b);
let mut not_linear = false;
let mut evo_end = None;
@@ -211,8 +203,7 @@ fn run(s: &mut Session, p: &Json) -> Result<Json> {
.into(),
));
}
let Solve { outcome, not_linear, evo_end } =
optimise(&mut ev, &model, model.goal, &x0, Bounds { lo: &prob.lo, hi: &prob.hi, integer: &prob.integer }, gap);
let Solve { outcome, not_linear, evo_end } = optimise(&mut ev, &model, model.goal, &x0, &prob.lo, &prob.hi, &prob.integer, gap);
let (status, message, solution) = match outcome {
_ if not_linear => ("notLinear", "The model isn't linear in the variable cells, so Simplex LP can't solve it. Try GRG Nonlinear.", None),
Outcome::Solved(x, _, true) if model.method == Method::Evolutionary => match evo_end {
+1 -1
View File
@@ -2,7 +2,7 @@ use super::lp::{Outcome, Row, Sense};
use super::model::{EvoOptions, Goal};
use super::nlp::{project, shape, violation};
use super::problem::Evaluator;
use super::{MAX_POPULATION, MIN_POPULATION, PRECISION};
use super::*;
// ---------------------------------------------------------------- evolutionary
+155 -199
View File
@@ -1,13 +1,8 @@
use super::problem::Evaluator;
use super::{CellRef, EPS};
use super::*;
// ---------------------------------------------------------------- evaluation
type BoundRow = (Vec<(usize, f64)>, Sense, f64);
/// Objective (gradient, constant) and per-row (gradient, constant).
pub(super) type Linearized = ((Vec<f64>, f64), Vec<(Vec<f64>, f64)>);
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) enum Sense {
Le,
@@ -34,188 +29,6 @@ pub(super) enum Outcome {
Limit,
}
struct Map {
var: usize,
sign: f64,
}
struct VariableMap {
columns: Vec<Map>,
offset: Vec<f64>,
bound_rows: Vec<BoundRow>,
}
struct Tableau {
rows: Vec<Vec<f64>>,
basis: Vec<usize>,
width: usize,
decision_cols: usize,
slack_cols: usize,
artificial_cols: usize,
}
fn map_variables(n: usize, lo: &[f64], hi: &[f64]) -> VariableMap {
let mut columns = Vec::new();
let mut offset = vec![0.0; n];
let mut bound_rows = Vec::new();
for j in 0..n {
let lower = lo.get(j).copied().unwrap_or(f64::NEG_INFINITY);
let upper = hi.get(j).copied().unwrap_or(f64::INFINITY);
if lower.is_finite() {
if let Some(value) = offset.get_mut(j) {
*value = lower;
}
columns.push(Map { var: j, sign: 1.0 });
if upper.is_finite() {
bound_rows.push((vec![(columns.len() - 1, 1.0)], Sense::Le, upper - lower));
}
} else if upper.is_finite() {
if let Some(value) = offset.get_mut(j) {
*value = upper;
}
columns.push(Map { var: j, sign: -1.0 });
} else {
columns.push(Map { var: j, sign: 1.0 });
columns.push(Map { var: j, sign: -1.0 });
}
}
VariableMap { columns, offset, bound_rows }
}
fn normalize_rows(rows: &[(Vec<f64>, Sense, f64)], bound_rows: Vec<BoundRow>, columns: &[Map], offset: &[f64]) -> Vec<(Vec<f64>, Sense, f64)> {
let width = columns.len();
let mut transformed = Vec::with_capacity(rows.len() + bound_rows.len());
for (coefficients, sense, rhs) in rows {
let mut row = vec![0.0; width];
for (index, mapping) in columns.iter().enumerate() {
if let Some(value) = row.get_mut(index) {
*value = coefficients.get(mapping.var).copied().unwrap_or(0.0) * mapping.sign;
}
}
let shift: f64 = coefficients.iter().zip(offset).map(|(coefficient, base)| coefficient * base).sum();
transformed.push((row, *sense, rhs - shift));
}
for (entries, sense, rhs) in bound_rows {
let mut row = vec![0.0; width];
for (index, value) in entries {
if let Some(slot) = row.get_mut(index) {
*slot = value;
}
}
transformed.push((row, sense, rhs));
}
// Rows with a negative right side are negated so every right side is >= 0.
transformed
.into_iter()
.map(|(row, sense, rhs)| {
if rhs < 0.0 {
let sense = match sense {
Sense::Le => Sense::Ge,
Sense::Ge => Sense::Le,
Sense::Eq => Sense::Eq,
};
(row.iter().map(|v| -v).collect(), sense, -rhs)
} else {
(row, sense, rhs)
}
})
.collect()
}
fn build_tableau(normalized: &[(Vec<f64>, Sense, f64)], decision_cols: usize, slack_cols: usize, artificial_cols: usize) -> Tableau {
let width = decision_cols + slack_cols + artificial_cols;
let mut rows = Vec::with_capacity(normalized.len());
let mut basis = vec![0usize; normalized.len()];
let (mut slack, mut artificial) = (decision_cols, decision_cols + slack_cols);
for (i, (coefficients, sense, rhs)) in normalized.iter().enumerate() {
let mut row = vec![0.0; width + 1];
for (value, coefficient) in row.iter_mut().zip(coefficients) {
*value = *coefficient;
}
let basic = match sense {
Sense::Le => {
if let Some(value) = row.get_mut(slack) {
*value = 1.0;
}
let basic = slack;
slack += 1;
basic
}
Sense::Ge => {
if let Some(value) = row.get_mut(slack) {
*value = -1.0;
}
slack += 1;
if let Some(value) = row.get_mut(artificial) {
*value = 1.0;
}
let basic = artificial;
artificial += 1;
basic
}
Sense::Eq => {
if let Some(value) = row.get_mut(artificial) {
*value = 1.0;
}
let basic = artificial;
artificial += 1;
basic
}
};
if let Some(value) = row.get_mut(width) {
*value = *rhs;
}
if let Some(slot) = basis.get_mut(i) {
*slot = basic;
}
rows.push(row);
}
Tableau { rows, basis, width, decision_cols, slack_cols, artificial_cols }
}
fn phase_one(tableau: &mut Tableau, normalized: &[(Vec<f64>, Sense, f64)], budget: &mut u64) -> Result<(), Outcome> {
if tableau.artificial_cols == 0 {
return Ok(());
}
let mut objective = vec![0.0; tableau.width + 1];
for column in tableau.decision_cols + tableau.slack_cols..tableau.width {
if let Some(value) = objective.get_mut(column) {
*value = 1.0;
}
}
for (i, row) in tableau.rows.iter().enumerate() {
if tableau.basis.get(i).is_some_and(|basic| *basic >= tableau.decision_cols + tableau.slack_cols) {
for (value, coefficient) in objective.iter_mut().zip(row) {
*value -= coefficient;
}
}
}
let all = vec![true; tableau.width];
if matches!(simplex(&mut tableau.rows, &mut tableau.basis, &mut objective, &all, budget), Step::Limit) {
return Err(Outcome::Limit);
}
let scale = normalized.iter().map(|row| row.2.abs()).fold(1.0f64, f64::max);
if -objective.get(tableau.width).copied().unwrap_or(0.0) > 1e-7 * scale {
return Err(Outcome::Infeasible);
}
for i in 0..tableau.rows.len() {
if !tableau.basis.get(i).is_some_and(|basic| *basic >= tableau.decision_cols + tableau.slack_cols) {
continue;
}
let pivot_col = tableau
.rows
.get(i)
.and_then(|row| (0..tableau.decision_cols + tableau.slack_cols).find(|&column| row.get(column).is_some_and(|value| value.abs() > EPS)));
if let Some(column) = pivot_col {
pivot(&mut tableau.rows, &mut objective, i, column);
if let Some(basic) = tableau.basis.get_mut(i) {
*basic = column;
}
}
}
Ok(())
}
/// Depth-first branch and bound over a relaxation solver; returns the best integer point.
pub(super) fn branch_and_bound(
lo: &[f64],
@@ -388,23 +201,165 @@ fn simplex(t: &mut [Vec<f64>], basis: &mut [usize], obj: &mut [f64], allowed: &[
/// Minimises `c·x` subject to `rows` (coefficients, sense, rhs) and `lo ≤ x ≤ hi`.
pub(super) fn solve_lp(c: &[f64], rows: &[(Vec<f64>, Sense, f64)], lo: &[f64], hi: &[f64], budget: &mut u64) -> Outcome {
let n = c.len();
if lo.iter().zip(hi).any(|(l, h)| l > h) {
return Outcome::Infeasible;
}
// x_j = offset + Σ sign·y_k with y ≥ 0.
let mapping = map_variables(c.len(), lo, hi);
let VariableMap { columns: cols, offset, bound_rows: extra } = mapping;
struct Map {
var: usize,
sign: f64,
}
let mut cols: Vec<Map> = Vec::new();
let mut offset = vec![0.0; n];
let mut extra: Vec<(Vec<(usize, f64)>, Sense, f64)> = Vec::new();
for j in 0..n {
let (l, h) = (lo.get(j).copied().unwrap_or(f64::NEG_INFINITY), hi.get(j).copied().unwrap_or(f64::INFINITY));
if l.is_finite() {
if let Some(o) = offset.get_mut(j) {
*o = l;
}
cols.push(Map { var: j, sign: 1.0 });
if h.is_finite() {
extra.push((vec![(cols.len() - 1, 1.0)], Sense::Le, h - l));
}
} else if h.is_finite() {
if let Some(o) = offset.get_mut(j) {
*o = h;
}
cols.push(Map { var: j, sign: -1.0 });
} else {
cols.push(Map { var: j, sign: 1.0 });
cols.push(Map { var: j, sign: -1.0 });
}
}
let ny = cols.len();
// Rows over y: (coefficients, sense, rhs).
let normalized = normalize_rows(rows, extra, &cols, &offset);
let n_slack = normalized.iter().filter(|row| row.1 != Sense::Eq).count();
let n_art = normalized.iter().filter(|row| row.1 != Sense::Le).count();
let mut tableau = build_tableau(&normalized, ny, n_slack, n_art);
if let Err(outcome) = phase_one(&mut tableau, &normalized, budget) {
return outcome;
let mut yrows: Vec<(Vec<f64>, Sense, f64)> = Vec::with_capacity(rows.len() + extra.len());
for (a, sense, b) in rows {
let mut coef = vec![0.0; ny];
for (k, m) in cols.iter().enumerate() {
if let Some(v) = coef.get_mut(k) {
*v = a.get(m.var).copied().unwrap_or(0.0) * m.sign;
}
}
let shift: f64 = a.iter().zip(&offset).map(|(x, o)| x * o).sum();
yrows.push((coef, *sense, b - shift));
}
for (entries, sense, b) in extra {
let mut coef = vec![0.0; ny];
for (k, v) in entries {
if let Some(e) = coef.get_mut(k) {
*e = v;
}
}
yrows.push((coef, sense, b));
}
let m = yrows.len();
let n_slack = yrows.iter().filter(|r| r.1 != Sense::Eq).count();
// Rows with b < 0 are negated so every right side is ≥ 0.
let normalized: Vec<(Vec<f64>, Sense, f64)> = yrows
.into_iter()
.map(|(a, s, b)| {
if b < 0.0 {
let s = match s {
Sense::Le => Sense::Ge,
Sense::Ge => Sense::Le,
Sense::Eq => Sense::Eq,
};
(a.iter().map(|v| -v).collect(), s, -b)
} else {
(a, s, b)
}
})
.collect();
let n_art = normalized.iter().filter(|r| r.1 != Sense::Le).count();
let width = ny + n_slack + n_art;
let mut t: Vec<Vec<f64>> = Vec::with_capacity(m);
let mut basis = vec![0usize; m];
let (mut si, mut ai) = (ny, ny + n_slack);
for (i, (a, s, b)) in normalized.iter().enumerate() {
let mut row = vec![0.0; width + 1];
for (v, x) in row.iter_mut().zip(a) {
*v = *x;
}
match s {
Sense::Le => {
if let Some(v) = row.get_mut(si) {
*v = 1.0;
}
if let Some(bi) = basis.get_mut(i) {
*bi = si;
}
si += 1;
}
Sense::Ge => {
if let Some(v) = row.get_mut(si) {
*v = -1.0;
}
si += 1;
if let Some(v) = row.get_mut(ai) {
*v = 1.0;
}
if let Some(bi) = basis.get_mut(i) {
*bi = ai;
}
ai += 1;
}
Sense::Eq => {
if let Some(v) = row.get_mut(ai) {
*v = 1.0;
}
if let Some(bi) = basis.get_mut(i) {
*bi = ai;
}
ai += 1;
}
}
if let Some(v) = row.get_mut(width) {
*v = *b;
}
t.push(row);
}
let is_art = |j: usize| j >= ny + n_slack && j < width;
// Phase 1: minimise the sum of the artificials.
let mut obj = vec![0.0; width + 1];
for j in (ny + n_slack)..width {
if let Some(v) = obj.get_mut(j) {
*v = 1.0;
}
}
for (i, row) in t.iter().enumerate() {
if basis.get(i).is_some_and(|b| is_art(*b)) {
for (v, x) in obj.iter_mut().zip(row) {
*v -= x;
}
}
}
let all: Vec<bool> = vec![true; width];
if n_art > 0 {
match simplex(&mut t, &mut basis, &mut obj, &all, budget) {
Step::Limit => return Outcome::Limit,
Step::Unbounded | Step::Optimal => {}
}
let scale = normalized.iter().map(|r| r.2.abs()).fold(1.0f64, f64::max);
if -obj.get(width).copied().unwrap_or(0.0) > 1e-7 * scale {
return Outcome::Infeasible;
}
// Drive zero-valued artificials out of the basis where possible.
for i in 0..m {
if !basis.get(i).is_some_and(|b| is_art(*b)) {
continue;
}
let j = t.get(i).and_then(|row| (0..ny + n_slack).find(|&j| row.get(j).is_some_and(|v| v.abs() > EPS)));
if let Some(j) = j {
pivot(&mut t, &mut obj, i, j);
if let Some(b) = basis.get_mut(i) {
*b = j;
}
}
}
}
let Tableau { rows: mut t, mut basis, width, decision_cols: ny, slack_cols: n_slack, .. } = tableau;
let is_art = |column: usize| column >= ny + n_slack && column < width;
// Phase 2: the real costs; artificials may not re-enter.
let mut cost = vec![0.0; width + 1];
for (k, mp) in cols.iter().enumerate() {
@@ -447,7 +402,8 @@ pub(super) fn solve_lp(c: &[f64], rows: &[(Vec<f64>, Sense, f64)], lo: &[f64], h
/// Linearises the model at `x0` (n + 1 evaluations) and checks it at one more point. Returns the
/// objective gradient and constant, and per row the gradient and constant of `lhs − rhs`.
pub(super) fn linearize(ev: &mut Evaluator, x0: &[f64]) -> std::result::Result<Linearized, &'static str> {
#[allow(clippy::type_complexity)]
pub(super) fn linearize(ev: &mut Evaluator, x0: &[f64]) -> std::result::Result<((Vec<f64>, f64), Vec<(Vec<f64>, f64)>), &'static str> {
let n = x0.len();
let p0 = ev.eval(x0).ok_or("budget")?;
if !p0.f.is_finite() || p0.g.iter().any(|v| !v.is_finite()) {
+1 -4
View File
@@ -1,7 +1,4 @@
use super::{
CMD, CellRef, DefinedName, EngineError, Json, MAX_CONSTRAINTS, MAX_POPULATION, MAX_VARS, MIN_POPULATION, RangeRef, Result, Session, Workbook,
bad, bool_param, f64_param, json, split_sheet, str_param,
};
use super::*;
// ---------------------------------------------------------------- model
+1 -1
View File
@@ -1,7 +1,7 @@
use super::PRECISION;
use super::lp::{Outcome, Row, Sense};
use super::model::Goal;
use super::problem::{Evaluator, Point};
use super::*;
// ---------------------------------------------------------------- nonlinear
+1 -1
View File
@@ -1,6 +1,6 @@
use super::lp::{Row, Sense};
use super::model::{Model, Rel, Rhs};
use super::{CellRef, Trial, Value};
use super::*;
/// The values the solvers see at a point: the objective and `lhs − rhs` per row.
#[derive(Clone, Debug)]
+2 -5
View File
@@ -1,10 +1,7 @@
use super::lp::Outcome;
use super::model::{Constraint, Goal, Method, Model, Rel, Rhs, parse_model, saved_model};
use super::problem::{Evaluator, Problem, as_num, build_problem};
use super::{
Cell, CellRef, Ctx, DEFAULT_EVALS, DocState, EngineError, Json, MAX_EVALS, PRECISION, REPORT_CMD, Result, Session, Sheet, Trial, Value, bad,
edit, json, optimise, str_param,
};
use super::*;
// ---------------------------------------------------------------- reports
@@ -229,7 +226,7 @@ fn limits_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Result<Lines> {
ev.target = Some(j);
ev.evals = 0;
ev.budget = per;
let solve = optimise(&mut ev, m, goal, x, super::Bounds { lo: &lo, hi: &hi, integer: &inp.prob.integer }, 0.0);
let solve = optimise(&mut ev, m, goal, x, &lo, &hi, &inp.prob.integer, 0.0);
ends.push(match solve.outcome {
Outcome::Solved(p, _, _) => {
let v = p.get(j).copied().unwrap_or(f64::NAN);
+1 -1
View File
@@ -7,7 +7,7 @@ use std::sync::OnceLock;
use gridcraft_core::{RangeRef, Value};
use serde_json::{Value as Json, json};
use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cell_param, cmd, has_doc, ok, str_param, target_range, target_sheet};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
+5 -5
View File
@@ -1,13 +1,10 @@
//! View tab (sheet view state that's saved with the file) and Page Layout settings.
use gridcraft_core::RangeRef;
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_model::Orientation;
use serde_json::{Value as Json, json};
use super::{
CommandSpec, EngineError, Result, Session, always, bad, bool_param, cell_param, cell_param_on, cmd, edit, f64_param, has_doc, str_param,
target_range, target_sheet, u32_param,
};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -496,6 +493,9 @@ fn theme(s: &mut Session, p: &Json) -> Result<Json> {
})
}
#[allow(dead_code)]
fn unused(_: CellRef) {}
#[cfg(test)]
mod tests {
use super::*;
+1 -1
View File
@@ -14,7 +14,7 @@ use gridcraft_model::{CalcMode, Cell, DefinedName, Sheet, StyleId, Workbook};
use serde::{Deserialize, Serialize};
use serde_json::{Value as Json, json};
use super::{CommandSpec, DocState, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, split_sheet, str_param};
use super::*;
mod advanced_filter;
mod consolidate;
@@ -1,4 +1,4 @@
use super::{Cell, CellRef, EngineError, HashSet, Json, RangeRef, Result, Session, Value, bad, bool_param, edit, json, parse_range, str_param};
use super::*;
// ---------------------------------------------------------------- advanced filter

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