From 1794c389e58ce42e538a88468d7fe6a426dff387 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 05:01:47 +0000 Subject: [PATCH 01/76] Keep the cell draft in its own workbook; Switch Windows lists open workbooks A cell being typed when File > New ran (macOS menu, control channel) stayed open over the new workbook, and Enter wrote it there; the first workbook stayed clean and nothing in the UI reached it. - Every file.* and window command (window.activate, view.newWindow, view.hideWindow, view.unhideWindow, view.switchWindows with a doc) first commits the cell and text-box drafts as Enter would, or drops a draft its workbook refuses, still reporting why. Finder, argv and dropped opens go through file.open and get the same. - The cell editor records its workbook's uid; the grid draws a draft only over that workbook, and a commit always goes into it. - view.switchWindows takes a `doc` uid to bring that workbook forward and lists index, doc, title, dirty, active and hidden. Menu-style calls with no doc open the View tab's new Switch Windows dropdown. - macOS: the Window menu lists the open workbooks (unsaved bullet, active check), and Quit closes the window so unsaved workbooks are asked about. --- apps/gridcraft/src/main.rs | 2 +- apps/gridcraft/src/native_menu.rs | 68 ++++++++++++++-- crates/engine/src/cmd/extra.rs | 28 ++++++- crates/engine/src/lib.rs | 6 ++ crates/engine/src/tests.rs | 31 ++++++++ crates/ui-egui/src/editor.rs | 15 ++-- crates/ui-egui/src/grid.rs | 3 +- crates/ui-egui/src/lib.rs | 127 ++++++++++++++++++++++++------ crates/ui-egui/src/ribbon.rs | 19 +++++ crates/ui-egui/tests/file_menu.rs | 77 ++++++++++++++++++ 10 files changed, 337 insertions(+), 39 deletions(-) diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 7c0eb04e6..8aad9aeb8 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -34,7 +34,7 @@ impl eframe::App for App { if self.1.is_none() && std::env::var_os("GRIDCRAFT_NO_NATIVE_MENU").is_none() { self.1 = Some(native_menu::NativeMenu::install(ctx)); } - if let Some(m) = &self.1 { + if let Some(m) = &mut self.1 { m.poll(&mut self.0, ctx); } apple_events::poll(&self.2, &mut self.0, ctx); diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index 6985af3c0..2c2866379 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -4,7 +4,7 @@ use std::str::FromStr; use std::sync::mpsc::{Receiver, channel}; -use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::{OpenWindow, SheetApp}; use muda::accelerator::Accelerator; use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; use serde_json::{Value, json}; @@ -168,7 +168,16 @@ fn tree() -> Vec<(&'static str, Vec)> { ("Calculate Now", "formulas.calculateNow", None), ], ), - ("Window", vec![("New Window", "view.newWindow", None), ("Next Sheet", "sheet.next", None), ("Previous Sheet", "sheet.previous", None)]), + // The open workbooks follow the separator (`NativeMenu::sync_windows`). + ( + "Window", + vec![ + ("New Window", "view.newWindow", None), + ("Next Sheet", "sheet.next", None), + ("Previous Sheet", "sheet.previous", None), + ("-", "", None), + ], + ), ( "Help", vec![ @@ -185,8 +194,19 @@ pub struct NativeMenu { _menu: Menu, events: Receiver, theme_items: Vec<(&'static str, CheckMenuItem)>, + window_menu: Option, + /// The Window menu's workbook items and what they last showed. + window_items: Vec, + windows: Vec, } +/// Menu id of the Window menu item that brings workbook `doc` forward. +const WINDOW_ITEM: &str = "window:doc:"; + +/// The Quit item: closing the window asks about unsaved workbooks first, which AppKit's own +/// Quit (terminate:) would skip. +const QUIT: &str = "app.quit"; + fn select_theme(app: &mut SheetApp, id: &str) -> bool { let mode = match id { "theme:system" => "system", @@ -212,7 +232,7 @@ impl NativeMenu { &PredefinedMenuItem::hide_others(None), &PredefinedMenuItem::show_all(None), &PredefinedMenuItem::separator(), - &PredefinedMenuItem::quit(None), + &MenuItem::with_id(QUIT, "Quit GridCraft", true, Accelerator::from_str("CMD+Q").ok()), ]); let _ = menu.append(&app_menu); let mut theme_items = Vec::new(); @@ -247,7 +267,7 @@ impl NativeMenu { // NSApp's windowsMenu is where macOS adds Move & Resize, Fill and Center, the targets of the // system tiling shortcuts (Fn+Ctrl+arrows, Fn+F). Only once the menu is NSApp's: muda looks // the submenu up in the installed main menu. - if let Some(window) = window_menu { + if let Some(window) = &window_menu { window.set_as_windows_menu_for_nsapp(); } let (tx, rx) = channel(); @@ -256,12 +276,47 @@ impl NativeMenu { let _ = tx.send(e); ctx.request_repaint(); })); - NativeMenu { _menu: menu, events: rx, theme_items } + NativeMenu { _menu: menu, events: rx, theme_items, window_menu, window_items: Vec::new(), windows: Vec::new() } } - pub fn poll(&self, app: &mut SheetApp, ctx: &egui::Context) { + /// Keeps the Window menu's list of open workbooks (title, unsaved bullet, active check) in + /// step with the session. + fn sync_windows(&mut self, app: &SheetApp) { + let windows = app.open_windows(); + if windows == self.windows { + return; + } + let Some(menu) = &self.window_menu else { return }; + let same_docs = windows.len() == self.windows.len() && windows.iter().zip(&self.windows).all(|(a, b)| a.doc == b.doc); + if same_docs { + for (item, w) in self.window_items.iter().zip(&windows) { + item.set_text(w.label()); + item.set_checked(w.active); + } + } else { + for item in self.window_items.drain(..) { + let _ = menu.remove(&item); + } + for w in &windows { + let item = CheckMenuItem::with_id(format!("{WINDOW_ITEM}{}", w.doc), w.label(), true, w.active, None); + let _ = menu.append(&item); + self.window_items.push(item); + } + } + self.windows = windows; + } + + pub fn poll(&mut self, app: &mut SheetApp, ctx: &egui::Context) { while let Ok(ev) = self.events.try_recv() { let id = ev.id.as_ref().to_string(); + if id == QUIT { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + continue; + } + if let Some(doc) = id.strip_prefix(WINDOW_ITEM).and_then(|d| d.parse::().ok()) { + app.run_or_alert("view.switchWindows", json!({"doc": doc})); + continue; + } // Text selection belongs to the inline editor, not the worksheet clipboard/undo. if app.text_box_editor.is_some() { let clipboard = match id.as_str() { @@ -321,6 +376,7 @@ impl NativeMenu { item.set_checked(checked); } } + self.sync_windows(app); } } diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index c44094cdd..debb62eeb 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -145,7 +145,15 @@ pub fn specs() -> Vec { cmd!(noundo "view.arrangeAll", "Arrange All", ["View", "Window"], None, "{}", has_doc, |_, _| ok()), cmd!(noundo "view.hideWindow", "Hide", ["View", "Window"], None, "{}", has_doc, hide_window), cmd!(noundo "view.unhideWindow", "Unhide…", ["View", "Window"], None, "{index?}", has_doc, unhide_window), - cmd!(query "view.switchWindows", "Switch Windows", ["View", "Window"], None, "{} → open windows", always, switch_windows), + cmd!( + noundo "view.switchWindows", + "Switch Windows", + ["View", "Window"], + None, + "{doc?} → open workbooks [{index, doc, title, dirty, active, hidden}]; with `doc` (a workbook's `doc` id from the list) makes that workbook active first", + always, + switch_windows + ), cmd!(noundo "view.ruler", "Ruler", ["View", "Show"], None, "{on?} → {on}: rulers in Page Layout view (an application option)", has_doc, ruler), cmd!(query "view.macros", "Macros", ["View", "Macros"], None, "{} → scripts", has_doc, all_scripts), // Table tools @@ -808,12 +816,26 @@ fn unhide_window(s: &mut Session, p: &Json) -> Result { ok() } -fn switch_windows(s: &mut Session, _: &Json) -> Result { +fn switch_windows(s: &mut Session, p: &Json) -> Result { + if let Some(doc) = p.get("doc").filter(|v| !v.is_null()) { + let i = doc.as_u64().and_then(|uid| s.document_index(uid)).ok_or_else(|| bad("view.switchWindows", "no open workbook has that `doc` id"))?; + s.hidden_windows.retain(|x| *x != i); + s.set_active(i); + } Ok(Json::Array( s.documents() .iter() .enumerate() - .map(|(i, d)| json!({"index": i, "title": d.display_title(), "active": i == s.active_index(), "hidden": s.hidden_windows.contains(&i)})) + .map(|(i, d)| { + json!({ + "index": i, + "doc": d.uid, + "title": d.display_title(), + "dirty": d.is_dirty(), + "active": i == s.active_index(), + "hidden": s.hidden_windows.contains(&i), + }) + }) .collect(), )) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 10c6cb87c..aeeaa9bdb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -106,6 +106,8 @@ pub struct DocState { pub path: Option, pub saved: Arc, pub revision: u64, + /// Identifies this open workbook for the rest of the session: it never changes while the + /// workbook is open (saving, renaming, reordering) and is never reused for another one. pub uid: u64, pub title: String, /// Per-sheet selection memory. @@ -336,6 +338,10 @@ impl Session { pub fn active(&self) -> Option<&DocState> { self.docs.get(self.active) } + /// Where the open workbook with this [`DocState::uid`] is in [`Session::documents`]. + pub fn document_index(&self, uid: u64) -> Option { + self.docs.iter().position(|d| d.uid == uid) + } pub fn active_mut(&mut self) -> Option<&mut DocState> { self.docs.get_mut(self.active) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d6a99be91..07a32209f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4459,3 +4459,34 @@ fn html_export_without_pictures_or_charts_has_no_fixed_geometry() { let html = crate::io::to_html(&s.doc().unwrap().wb, 0).unwrap(); assert!(!html.contains("") && !html.contains("table-layout") && !html.contains(" EditState { + pub fn new(doc: u64, sheet: usize, cell: CellRef, text: String, enter_mode: bool, from_formula_bar: bool) -> EditState { let caret = text.chars().count(); EditState { + doc, sheet, locale: Default::default(), cell, @@ -365,7 +368,7 @@ mod tests { #[test] fn point_mode() { - let mut e = EditState::new(0, CellRef::new(0, 0), "=SUM(".into(), true, false); + let mut e = EditState::new(1, 0, CellRef::new(0, 0), "=SUM(".into(), true, false); assert!(e.can_point()); e.insert_ref("A1"); assert_eq!(e.text, "=SUM(A1"); @@ -375,19 +378,19 @@ mod tests { e.caret += 1; e.point = None; assert!(e.can_point()); - let e2 = EditState::new(0, CellRef::new(0, 0), "hello".into(), true, false); + let e2 = EditState::new(1, 0, CellRef::new(0, 0), "hello".into(), true, false); assert!(!e2.can_point()); } #[test] fn autocomplete_and_hints() { let names: Vec = ["SUM", "SUMIF", "SUMPRODUCT", "AVERAGE"].iter().map(|s| s.to_string()).collect(); - let mut e = EditState::new(0, CellRef::new(0, 0), "=su".into(), true, false); + let mut e = EditState::new(1, 0, CellRef::new(0, 0), "=su".into(), true, false); e.update_autocomplete(&names); assert_eq!(e.autocomplete.len(), 3); assert!(e.accept_autocomplete()); assert_eq!(e.text, "=SUM("); - let e = EditState::new(0, CellRef::new(0, 0), "=IF(A1>0,SUM(B1,".into(), true, false); + let e = EditState::new(1, 0, CellRef::new(0, 0), "=IF(A1>0,SUM(B1,".into(), true, false); assert_eq!(e.current_function(), Some(("SUM".into(), 1))); } @@ -423,7 +426,7 @@ mod tests { #[test] fn inserting_a_union_keeps_each_reference_separately_pointed() { - let mut edit = EditState::new(0, CellRef::new(0, 0), "=SUM(".into(), true, false); + let mut edit = EditState::new(1, 0, CellRef::new(0, 0), "=SUM(".into(), true, false); edit.insert_ref("C:C"); edit.append_ref("E:E"); assert_eq!(edit.text, "=SUM(C:C,E:E"); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index a0efbfae7..145b9f583 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1744,6 +1744,7 @@ pub(crate) fn paint_selection( // Editing a formula: outline its references instead of the selection. if let Some(ed) = &app.editor && ed.is_formula() + && app.session.active().is_some_and(|d| d.uid == ed.doc) { let origin = app.session.active().and_then(|d| d.wb.sheet(ed.sheet)).map_or(sh.name.as_str(), |o| o.name.as_str()); for (_, _, r, sheet, idx) in crate::editor::formula_refs(&ed.text) { @@ -3045,7 +3046,7 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, wb: &Workbook) { let Some(ed) = app.editor.as_ref() else { return }; - if ed.sheet != wb.active_sheet { + if app.view_key() != Some((ed.doc, ed.sheet)) { return; } let cell = ed.cell; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index faae9bfae..001abaad5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -166,6 +166,23 @@ pub struct Notice { pub action: Option<(String, String)>, } +/// One open workbook, as View › Switch Windows and the macOS Window menu list it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OpenWindow { + /// The workbook's `DocState::uid`, the `doc` param of `view.switchWindows`. + pub doc: u64, + pub title: String, + pub dirty: bool, + pub active: bool, +} + +impl OpenWindow { + /// The title, with a bullet after it while the workbook has unsaved changes. + pub fn label(&self) -> String { + if self.dirty { format!("{} •", self.title) } else { self.title.clone() } + } +} + /// Host services the platform shell provides (file pickers, opening URLs). #[derive(Default)] pub struct Services { @@ -296,6 +313,9 @@ pub struct SheetApp { /// Every unsaved workbook has been answered for quitting (or `app.quit` asked): the next /// window close goes through. pub allow_close: bool, + /// View › Switch Windows opens its list on the next frame the View tab draws (asked for by a + /// key tip, the command search or a menu, possibly while another tab was showing). + pub(crate) open_switch_windows: bool, } impl SheetApp { @@ -342,6 +362,7 @@ impl SheetApp { tab_run: None, started: now_ms(), allow_close: false, + open_switch_windows: false, } } @@ -387,30 +408,30 @@ impl SheetApp { } fn run_command(&mut self, id: &str, params: Json) -> Result { + let switches_window = id.starts_with("window.") + || matches!(id, "view.newWindow" | "view.hideWindow" | "view.unhideWindow") + || (id == "view.switchWindows" && params.get("doc").is_some_and(|d| !d.is_null())); + if id.starts_with("file.") || switches_window { + self.settle_drafts(); + } if matches!(id, "file.save" | "file.saveAs" | "file.saveBytes") { self.store_view_state(); } - if id.starts_with("file.") - || matches!( - id, - "window.activate" - | "sheet.activate" - | "sheet.next" - | "sheet.previous" - | "sheet.move" - | "sheet.hide" - | "sheet.unhide" - | "view.newWindow" - | "view.hideWindow" - | "view.unhideWindow" - | "home.insertSheet" - | "home.deleteSheet" - | "edit.undo" - | "edit.redo" - | "object.delete" - | "object.move" - ) - { + if matches!( + id, + "sheet.activate" + | "sheet.next" + | "sheet.previous" + | "sheet.move" + | "sheet.hide" + | "sheet.unhide" + | "home.insertSheet" + | "home.deleteSheet" + | "edit.undo" + | "edit.redo" + | "object.delete" + | "object.move" + ) { self.commit_text_box_edit()?; } if let Some(r) = self.run_ui_command(id, ¶ms) { @@ -546,6 +567,14 @@ impl SheetApp { if id == "insert.recommendedCharts" && params.get("type").is_none() { return self.open_dialog("recommendedCharts", json!({})); } + // Switch Windows with no workbook named (key tip, command search) opens the View tab's + // list to choose from; programmatic callers of `run` get the list back. + if id == "view.switchWindows" && params.get("doc").is_none_or(Json::is_null) { + self.ui.ribbon_tab = "View".into(); + self.ui.ribbon_collapsed = false; + self.open_switch_windows = true; + return; + } if let Err(e) = self.run(id, params) { self.alert(e); } @@ -882,6 +911,17 @@ impl SheetApp { } } + /// The open workbooks in order, for the Switch Windows lists. + pub fn open_windows(&self) -> Vec { + let active = self.session.active_index(); + self.session + .documents() + .iter() + .enumerate() + .map(|(i, d)| OpenWindow { doc: d.uid, title: d.display_title(), dirty: d.is_dirty(), active: i == active }) + .collect() + } + pub fn view_key(&self) -> Option<(u64, usize)> { self.session.active().map(|d| (d.uid, d.wb.active_sheet)) } @@ -961,7 +1001,7 @@ impl SheetApp { Some(t) => (t, true), None => (current.clone(), false), }; - let mut ed = editor::EditState::new(d.wb.active_sheet, at, text, replace, from_formula_bar); + let mut ed = editor::EditState::new(d.uid, d.wb.active_sheet, at, text, replace, from_formula_bar); ed.locale = locale; if replace && self.ui.autocomplete && !ed.is_formula() { ed.completion = editor::column_completion(sh, at, &ed.text); @@ -971,7 +1011,45 @@ impl SheetApp { } /// Commits the edit; `then` moves the selection (dr, dc) afterwards. + /// + /// The draft goes into the workbook it was typed in. If another workbook is showing, that + /// workbook stays active afterwards, and a draft its own workbook refuses (validation, a bad + /// formula) is dropped rather than left open over the wrong grid. pub fn commit_edit(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool { + let Some(doc) = self.editor.as_ref().map(|ed| ed.doc) else { return true }; + let shown = self.session.active().map(|d| d.uid); + if shown == Some(doc) { + return self.commit_edit_here(dr, dc, array, fill_selection); + } + let Some(owner) = self.session.document_index(doc) else { + self.cancel_edit(); + return true; + }; + let back = self.session.active_index(); + self.session.set_active(owner); + if !self.commit_edit_here(dr, dc, array, fill_selection) { + self.cancel_edit(); + } + self.session.set_active(back); + true + } + + /// Settles the cell and text-box drafts before a command that can show another workbook or + /// write this one to a file: each is committed as Enter would, or dropped when it can't be + /// (the refusal is still reported), so a draft never lands in another workbook. + fn settle_drafts(&mut self) { + if !self.commit_edit(0, 0, false, false) { + self.cancel_edit(); + } + if let Err(e) = self.commit_text_box_edit() { + self.cancel_text_box_edit(); + if self.message.is_none() { + self.message = Some(("Text Box".into(), clean_error(&e))); + } + } + } + + fn commit_edit_here(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool { let Some(ed) = self.editor.take() else { return true }; self.session.mode = gridcraft_engine::Mode::Ready; let text = match &ed.completion { @@ -1276,6 +1354,11 @@ impl SheetApp { } let t0 = now_ms(); text_box::before_ui(self, &ctx); + // Another workbook came forward without going through a command (which settles drafts + // first): the draft goes into its own workbook instead of floating over this one. + if self.editor.as_ref().is_some_and(|ed| self.session.active().map(|d| d.uid) != Some(ed.doc)) { + self.commit_edit(0, 0, false, false); + } // The language the widgets translate with this frame (see `i18n::current`). i18n::set_current(&ctx, self.ui.language); let t = theme::Tokens::get(&ctx); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 356381d66..d90b642ea 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2336,6 +2336,7 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { ], ); }); + switch_windows(app, ui); ui.vertical(|ui| { ui.label(lang.tr("Display theme")); let mode = app.ui.theme_mode(); @@ -2391,6 +2392,24 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { }); } +/// View › Switch Windows: the open workbooks, the active one checked and unsaved ones marked; +/// choosing one brings it forward. +fn switch_windows(app: &mut SheetApp, ui: &mut Ui) { + let anchor = big_button(ui, Icon::Book, "Switch\nWindows", "Switch Windows", true); + anchor.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Switch Windows")); + control_keytip(app, ui, &anchor, "view.switchWindows"); + let forced = std::mem::take(&mut app.open_switch_windows); + egui::Popup::menu(&anchor).open_memory(popup_command(forced, anchor.clicked())).show(|ui| { + for w in app.open_windows() { + let label = format!("{}{}", if w.active { "✓ " } else { " " }, w.label()); + if ui.add(egui::Button::selectable(w.active, label).min_size(vec2(220.0, 22.0))).clicked() { + act(app, "view.switchWindows", json!({"doc": w.doc})); + ui.close(); + } + } + }); +} + fn automate(app: &mut SheetApp, ui: &mut Ui) { if big_button(ui, Icon::Script, "Macros", "Macros and scripts in this workbook (Alt+F8)", false).clicked() { app.open_dialog("macros", json!({})); diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs index 047efe1b1..415e09902 100644 --- a/crates/ui-egui/tests/file_menu.rs +++ b/crates/ui-egui/tests/file_menu.rs @@ -289,6 +289,83 @@ fn closing_the_window_can_save_first_and_a_saved_workbook_closes_at_once() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn quitting_with_one_of_two_workbooks_unsaved_asks_once() { + let mut h = harness(Services::default()); + h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap(); + h.state_mut().run("file.new", serde_json::json!({})).unwrap(); + h.run_steps(2); + + let sent = request_window_close(&mut h); + assert!(sent.contains(&egui::ViewportCommand::CancelClose), "{sent:?}"); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges")); + assert_eq!(h.state().session.active_index(), 0, "the unsaved workbook comes forward"); + let sent = click_commands(&mut h, "Cancel"); + assert!(!sent.contains(&egui::ViewportCommand::Close), "Cancel stops the quit"); + assert_eq!(h.state().session.documents().len(), 2, "Cancel keeps both workbooks"); + + request_window_close(&mut h); + let sent = click_commands(&mut h, "Don't Save"); + assert!(sent.contains(&egui::ViewportCommand::Close), "the saved workbook isn't asked about: {sent:?}"); + assert!(h.state().dialog.is_none()); +} + +/// Book1 has a cell being typed when File › New runs (the macOS menu runs the command directly). +fn typing_in_book1_then_new(h: &mut egui_kittest::Harness<'static, SheetApp>) -> u64 { + h.input_mut().events.push(egui::Event::Text("hello".into())); + h.run_steps(2); + assert!(h.state().editor.is_some()); + let book1 = h.state().session.doc().unwrap().uid; + h.state_mut().run_or_alert("file.new", serde_json::json!({})); + h.run_steps(3); + book1 +} + +fn a1(h: &egui_kittest::Harness<'static, SheetApp>, doc: usize) -> Value { + h.state().session.documents()[doc].wb.active().unwrap().value(CellRef::new(0, 0)) +} + +#[test] +fn new_workbook_keeps_the_typed_cell_in_the_first_and_switch_windows_brings_it_back() { + let mut h = harness(Services::default()); + let book1 = typing_in_book1_then_new(&mut h); + assert!(h.state().editor.is_none(), "the draft doesn't follow into Book2"); + assert_eq!(h.state().session.documents().len(), 2); + assert_eq!(h.state().session.active_index(), 1); + assert_eq!(a1(&h, 0), Value::text("hello"), "the draft is committed into Book1, as Enter would"); + assert!(h.state().session.documents()[0].is_dirty(), "so quitting asks about Book1"); + assert_eq!(a1(&h, 1), Value::Empty); + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert_eq!(a1(&h, 1), Value::Empty, "Enter in Book2 writes nothing left over"); + + // The key tip or command search opens the View tab's list; Book1 shows its unsaved bullet. + h.state_mut().run_or_alert("view.switchWindows", serde_json::json!({})); + h.run_steps(3); + assert_eq!(h.state().ui.ribbon_tab, "View"); + h.get_by_label_contains("Book1 •").click(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().uid, book1); + assert_eq!(h.state().session.documents().len(), 2); + assert!(h.state().message.is_none()); +} + +#[test] +fn a_draft_its_workbook_refuses_is_dropped_when_switching_away() { + let mut h = harness(Services::default()); + let book1 = typing_in_book1_then_new(&mut h); + h.state_mut().session.execute("data.validation", serde_json::json!({"range": "A1", "type": "list", "formula1": "\"Red,Green\""})).unwrap(); + h.input_mut().events.push(egui::Event::Text("Purple".into())); + h.run_steps(2); + assert!(h.state().editor.is_some()); + h.state_mut().run_or_alert("view.switchWindows", serde_json::json!({"doc": book1})); + h.run_steps(2); + assert!(h.state().editor.is_none()); + assert_eq!(h.state().session.doc().unwrap().uid, book1, "the switch still happens"); + assert_eq!(a1(&h, 1), Value::Empty, "Book2 is unchanged"); + assert!(h.state().message.is_some(), "the refusal is reported"); +} + fn pdf_picker_harness(dir: &std::path::Path) -> (Arc>>, egui_kittest::Harness<'static, SheetApp>) { let asked = Arc::new(Mutex::new(Vec::::new())); let (capture, target) = (asked.clone(), dir.to_path_buf()); From a0d54b49d47c081b33dc18c44b65da74a3916413 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 05:12:23 +0000 Subject: [PATCH 02/76] A refused cell draft stops the command; an untouched F2 draft changes nothing - File and window commands stop when the workbook refuses the cell or text-box draft (validation Stop, bad formula, protected text box): the draft stays open in its cell with the reason shown, to fix or Esc. The per-frame backstop brings a refused draft's workbook back to the front. - An F2 draft committed with its text unchanged writes nothing: no undo step, and the workbook stays clean through Save, Print or New. - macOS Window menu: the active workbook's check is put back every poll, since choosing a check item flips it. --- apps/gridcraft/src/native_menu.rs | 39 ++++++++++++++++------------ crates/ui-egui/src/editor.rs | 5 ++++ crates/ui-egui/src/lib.rs | 43 ++++++++++++++++++------------- crates/ui-egui/tests/file_menu.rs | 38 ++++++++++++++++++++++++--- 4 files changed, 86 insertions(+), 39 deletions(-) diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index 2c2866379..877c82fd8 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -283,27 +283,32 @@ impl NativeMenu { /// step with the session. fn sync_windows(&mut self, app: &SheetApp) { let windows = app.open_windows(); - if windows == self.windows { - return; + if windows != self.windows + && let Some(menu) = &self.window_menu + { + let same_docs = windows.len() == self.windows.len() && windows.iter().zip(&self.windows).all(|(a, b)| a.doc == b.doc); + if same_docs { + for (item, w) in self.window_items.iter().zip(&windows) { + item.set_text(w.label()); + } + } else { + for item in self.window_items.drain(..) { + let _ = menu.remove(&item); + } + for w in &windows { + let item = CheckMenuItem::with_id(format!("{WINDOW_ITEM}{}", w.doc), w.label(), true, w.active, None); + let _ = menu.append(&item); + self.window_items.push(item); + } + } + self.windows = windows; } - let Some(menu) = &self.window_menu else { return }; - let same_docs = windows.len() == self.windows.len() && windows.iter().zip(&self.windows).all(|(a, b)| a.doc == b.doc); - if same_docs { - for (item, w) in self.window_items.iter().zip(&windows) { - item.set_text(w.label()); + // AppKit flips a check item when it is chosen, so the active check is put back every poll. + for (item, w) in self.window_items.iter().zip(&self.windows) { + if item.is_checked() != w.active { item.set_checked(w.active); } - } else { - for item in self.window_items.drain(..) { - let _ = menu.remove(&item); - } - for w in &windows { - let item = CheckMenuItem::with_id(format!("{WINDOW_ITEM}{}", w.doc), w.label(), true, w.active, None); - let _ = menu.append(&item); - self.window_items.push(item); - } } - self.windows = windows; } pub fn poll(&mut self, app: &mut SheetApp, ctx: &egui::Context) { diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index eeeaf12da..3f23e8bed 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -45,11 +45,15 @@ pub struct EditState { pub completion: Option, /// Move the caret to the end on the next frame. pub caret_to_end: bool, + /// The cell's text when an F2-style edit began. Committing it unchanged leaves the cell alone + /// (no undo step, the workbook stays clean). `None` for an edit started by typing. + pub original: Option, } impl EditState { pub fn new(doc: u64, sheet: usize, cell: CellRef, text: String, enter_mode: bool, from_formula_bar: bool) -> EditState { let caret = text.chars().count(); + let original = (!enter_mode).then(|| text.clone()); EditState { doc, sheet, @@ -68,6 +72,7 @@ impl EditState { sync_caret: true, caret_to_end: true, completion: None, + original, } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 001abaad5..cc478168e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -412,7 +412,7 @@ impl SheetApp { || matches!(id, "view.newWindow" | "view.hideWindow" | "view.unhideWindow") || (id == "view.switchWindows" && params.get("doc").is_some_and(|d| !d.is_null())); if id.starts_with("file.") || switches_window { - self.settle_drafts(); + self.settle_drafts()?; } if matches!(id, "file.save" | "file.saveAs" | "file.saveBytes") { self.store_view_state(); @@ -575,8 +575,11 @@ impl SheetApp { self.open_switch_windows = true; return; } - if let Err(e) = self.run(id, params) { - self.alert(e); + match self.run(id, params) { + // A refused cell draft already shows its own message (with the rule's title). + Err(e) if self.message.as_ref().is_some_and(|(_, m)| *m == e) => {} + Err(e) => self.alert(e), + Ok(_) => {} } } @@ -1013,8 +1016,8 @@ impl SheetApp { /// Commits the edit; `then` moves the selection (dr, dc) afterwards. /// /// The draft goes into the workbook it was typed in. If another workbook is showing, that - /// workbook stays active afterwards, and a draft its own workbook refuses (validation, a bad - /// formula) is dropped rather than left open over the wrong grid. + /// workbook stays active afterwards; a draft its own workbook refuses (validation, a bad + /// formula) brings that workbook back to the front with the draft still open. pub fn commit_edit(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool { let Some(doc) = self.editor.as_ref().map(|ed| ed.doc) else { return true }; let shown = self.session.active().map(|d| d.uid); @@ -1028,25 +1031,21 @@ impl SheetApp { let back = self.session.active_index(); self.session.set_active(owner); if !self.commit_edit_here(dr, dc, array, fill_selection) { - self.cancel_edit(); + // Refused: its workbook stays in front with the draft open and the reason shown. + return false; } self.session.set_active(back); true } - /// Settles the cell and text-box drafts before a command that can show another workbook or - /// write this one to a file: each is committed as Enter would, or dropped when it can't be - /// (the refusal is still reported), so a draft never lands in another workbook. - fn settle_drafts(&mut self) { + /// Commits the cell and text-box drafts, as Enter would, before a command that can show + /// another workbook or write this one to a file, so a draft never lands in another workbook. + /// A draft its workbook refuses stays open with the reason shown, and the command doesn't run. + fn settle_drafts(&mut self) -> Result<(), String> { if !self.commit_edit(0, 0, false, false) { - self.cancel_edit(); - } - if let Err(e) = self.commit_text_box_edit() { - self.cancel_text_box_edit(); - if self.message.is_none() { - self.message = Some(("Text Box".into(), clean_error(&e))); - } + return Err(self.message.as_ref().map(|(_, m)| m.clone()).unwrap_or_else(|| "The cell being edited can't be entered.".into())); } + self.commit_text_box_edit() } fn commit_edit_here(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool { @@ -1056,6 +1055,13 @@ impl SheetApp { Some(full) if full.to_lowercase().starts_with(&ed.text.to_lowercase()) => full.clone(), _ => ed.text.clone(), }; + // F2 then Enter (or a file command) on an untouched draft: nothing to write. + if !fill_selection && !array && ed.original.as_ref() == Some(&text) { + if dr != 0 || dc != 0 { + self.move_after_enter(dr, dc); + } + return true; + } let text = match self.session.active().map(|d| formula_locale::canonical(&text, ed.locale, &d.wb, ed.sheet, ed.cell)) { Some(Ok(text)) => text, Some(Err(error)) => { @@ -1355,7 +1361,8 @@ impl SheetApp { let t0 = now_ms(); text_box::before_ui(self, &ctx); // Another workbook came forward without going through a command (which settles drafts - // first): the draft goes into its own workbook instead of floating over this one. + // first): the draft goes into its own workbook instead of floating over this one. If that + // workbook refuses it, it comes back to the front with the draft open. if self.editor.as_ref().is_some_and(|ed| self.session.active().map(|d| d.uid) != Some(ed.doc)) { self.commit_edit(0, 0, false, false); } diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs index 415e09902..5c447cb63 100644 --- a/crates/ui-egui/tests/file_menu.rs +++ b/crates/ui-egui/tests/file_menu.rs @@ -351,19 +351,49 @@ fn new_workbook_keeps_the_typed_cell_in_the_first_and_switch_windows_brings_it_b } #[test] -fn a_draft_its_workbook_refuses_is_dropped_when_switching_away() { +fn a_draft_its_workbook_refuses_stops_the_switch_and_stays_open() { let mut h = harness(Services::default()); let book1 = typing_in_book1_then_new(&mut h); + let book2 = h.state().session.doc().unwrap().uid; h.state_mut().session.execute("data.validation", serde_json::json!({"range": "A1", "type": "list", "formula1": "\"Red,Green\""})).unwrap(); h.input_mut().events.push(egui::Event::Text("Purple".into())); h.run_steps(2); - assert!(h.state().editor.is_some()); h.state_mut().run_or_alert("view.switchWindows", serde_json::json!({"doc": book1})); h.run_steps(2); - assert!(h.state().editor.is_none()); - assert_eq!(h.state().session.doc().unwrap().uid, book1, "the switch still happens"); + assert_eq!(h.state().session.doc().unwrap().uid, book2, "the switch doesn't happen"); + assert_eq!(h.state().editor.as_ref().map(|ed| ed.text.as_str()), Some("Purple"), "the draft stays open to fix"); assert_eq!(a1(&h, 1), Value::Empty, "Book2 is unchanged"); assert!(h.state().message.is_some(), "the refusal is reported"); + + // Esc drops the draft; then the switch goes through. + h.state_mut().message = None; + h.state_mut().cancel_edit(); + h.state_mut().run_or_alert("view.switchWindows", serde_json::json!({"doc": book1})); + h.run_steps(2); + assert_eq!(h.state().session.doc().unwrap().uid, book1); + assert!(h.state().message.is_none()); +} + +#[test] +fn an_untouched_f2_draft_leaves_the_workbook_clean_on_a_file_command() { + let dir = std::env::temp_dir().join(format!("gridcraft-f2-draft-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Book.xlsx").to_string_lossy().into_owned(); + let mut h = harness(Services::default()); + h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "=1+2"})).unwrap(); + h.state_mut().run("file.save", serde_json::json!({"path": &path})).unwrap(); + h.run_steps(2); + let undo = h.state().session.doc().unwrap().undo.len(); + h.key_press(egui::Key::F2); + h.run_steps(2); + assert_eq!(h.state().editor.as_ref().map(|ed| ed.text.as_str()), Some("=1+2")); + h.state_mut().run_or_alert("file.new", serde_json::json!({})); + h.run_steps(2); + assert!(h.state().editor.is_none()); + let book1 = &h.state().session.documents()[0]; + assert!(!book1.is_dirty(), "nothing was typed, so nothing changed"); + assert_eq!(book1.undo.len(), undo, "and no undo step was added"); + let _ = std::fs::remove_dir_all(&dir); } fn pdf_picker_harness(dir: &std::path::Path) -> (Arc>>, egui_kittest::Harness<'static, SheetApp>) { From 4bafd4e8ee5f38b068720156b9057eed5ed82a80 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 05:12:23 +0000 Subject: [PATCH 03/76] Docs: Switch Windows and drafts kept in their workbook (UI parity, progress log) --- ROADMAP.md | 4 +++- docs/ui-parity.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2e04e9123..9617ef1e6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** minor (no lost workbook after New: drafts stay in their workbook, Switch Windows, macOS Quit asks to save) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-22 | Typing in a cell and then choosing File › New no longer strands the first workbook: the entry is committed into its own workbook before any File or window command (a refused entry stays open and stops the command), View › Switch Windows and the macOS Window menu list the open workbooks with unsaved markers, `view.switchWindows {doc}` switches by a stable workbook id, and the macOS Quit item asks about unsaved workbooks | | 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here | | 2026-10-20 | Japanese interface catalog covers every key the Brazilian Portuguese catalog translates: the 398-label shared core plus 545 dialog, message, Backstage and menu strings (943 total, ~43% of ~2,200). A test parses `pt.rs`'s source, so the two catalogs stay in step | | 2026-10-20 | Interface translated into Dutch (518 labels: ribbon, context menus, File view, dialog titles, Options); `nl`, `nl-NL`, `nl-BE` locale tags resolve to Dutch | @@ -220,6 +221,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Progress log: drafts stay in their workbook, Switch Windows, macOS Quit asks to save | | 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips | | 2026-10-20 | minor | Dutch (nl) listed under other shipped languages; progress log row | | 2026-10-19 | minor | Progress log: long flat operator chains (#916) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 07797d70d..b70682ac3 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Switch Windows lists the open workbooks; a cell being typed stays in its own workbook through New, Open and window switches; macOS Quit asks about unsaved workbooks) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -34,6 +34,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Dark mode, system appearance | yes | yes | done (10-09) | | Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | margins drag on the rulers (#792, with header and footer margins); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) | | Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) | +| Switch Windows, unsaved workbooks on quit | yes | yes (10-22): View › Switch Windows and the macOS Window menu list the open workbooks (active one checked, unsaved ones marked •) and bring the chosen one forward (`view.switchWindows {doc}`); a cell being typed is entered into its own workbook before New, Open, Close, Save, Print and window switches, and a refused entry stays open and stops the command; quitting (window close, Cmd+Q, the macOS Quit item) asks Save / Don't Save / Cancel for each unsaved workbook | one OS window for all workbooks (#162) | ## Grid interaction @@ -176,6 +177,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | New workbook no longer loses the first one: the cell being typed is entered into its own workbook before any File or window command (an entry the workbook refuses stays open and the command doesn't run; an untouched F2 draft changes nothing), the editor is tied to its workbook, View › Switch Windows and the macOS Window menu list the open workbooks, and the macOS Quit item asks about unsaved workbooks like the close button | | 2026-10-21 | minor | Background error checking, first part (#1994): a constant whose text would be a number if typed (signs, thousands, currency, percent, exponent, parenthesised negative) gets Excel's green corner triangle; the active cell then shows a warning button whose menu names the problem and offers Convert to Number for the selection (`formulas.convertToNumber`). Dates, times and fractions kept as text and formula results are not flagged, as in Excel | | 2026-10-21 | minor | Paste Special laid out as Excel's: Paste and Operation options each in two columns inside a frame, Skip blanks and Transpose side by side, Paste Link as a button that pastes references at once; labels translated (the generic form didn't translate them) and the window no longer runs past the screen | | 2026-10-21 | minor | Form dialogs (conditional-format quick rules, New Name, Remove Duplicates, Text to Columns and every other `Dialog::form`) fit their content: OK and Cancel sit in a row under the last field instead of a right-to-left layout that took the screen's whole height and left the buttons floating halfway down | From 45d1d1acc0dc87d0104d0634c42deae009721775 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:50:53 +0000 Subject: [PATCH 04/76] G2: windows on workbooks in the engine; New Window shares the workbook Session keeps a window list (stable ids, per-window selection, active sheet and per-sheet selections swapped into the workbook without dirtying it) in place of the index-based hidden list and the copying New Window. Titles read Name:1 / Name:2 for a workbook with several windows. File > Close closes one window of several; window.close, window.list and window ids for window.activate, view.switchWindows, view.hideWindow and view.unhideWindow. Control channel and MCP tools take window/doc ids; scroll positions and cell drafts belong to their window. --- ROADMAP.md | 4 +- apps/gridcraft/src/native_menu.rs | 16 +- crates/engine/src/cmd/extra.rs | 72 ++-- crates/engine/src/cmd/file.rs | 50 ++- crates/engine/src/lib.rs | 39 ++- crates/engine/src/tests.rs | 114 ++++++- crates/engine/src/windows.rs | 342 +++++++++++++++++++ crates/engine/tests/command_sequence_fuzz.rs | 3 +- crates/mcp/src/headless.rs | 24 +- crates/mcp/src/tests.rs | 29 ++ crates/mcp/src/tools.rs | 64 +++- crates/ui-egui/src/control.rs | 44 ++- crates/ui-egui/src/dialogs.rs | 4 +- crates/ui-egui/src/editor.rs | 4 + crates/ui-egui/src/lib.rs | 84 +++-- crates/ui-egui/src/ribbon.rs | 2 +- crates/ui-egui/tests/file_menu.rs | 62 ++++ docs/control-protocol.md | 20 +- docs/mcp.md | 5 + docs/ui-parity.md | 5 +- 20 files changed, 872 insertions(+), 115 deletions(-) create mode 100644 crates/engine/src/windows.rs diff --git a/ROADMAP.md b/ROADMAP.md index 9617ef1e6..bf25cc3a9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** minor (no lost workbook after New: drafts stay in their workbook, Switch Windows, macOS Quit asks to save) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** minor (no lost workbook after New: drafts stay in their workbook, Switch Windows, macOS Quit asks to save; New Window shares the workbook) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-22 | View › New Window opens a second window on the same workbook (it used to open a copy whose Save overwrote the original): each window has its own selection, active sheet and scroll, titles read "Book1:1" / "Book1:2", and Switch Windows, Hide, Unhide and Close act per window. The control channel, CLI `send` and MCP tools take an optional `window` or `doc` id and default to the active window; `window.list` (MCP `list_windows`) lists the windows | | 2026-10-22 | Typing in a cell and then choosing File › New no longer strands the first workbook: the entry is committed into its own workbook before any File or window command (a refused entry stays open and stops the command), View › Switch Windows and the macOS Window menu list the open workbooks with unsaved markers, `view.switchWindows {doc}` switches by a stable workbook id, and the macOS Quit item asks about unsaved workbooks | | 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here | | 2026-10-20 | Japanese interface catalog covers every key the Brazilian Portuguese catalog translates: the 398-label shared core plus 545 dialog, message, Backstage and menu strings (943 total, ~43% of ~2,200). A test parses `pt.rs`'s source, so the two catalogs stay in step | @@ -221,6 +222,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Progress log: New Window shares the workbook; window ids for scripts and MCP | | 2026-10-22 | minor | Progress log: drafts stay in their workbook, Switch Windows, macOS Quit asks to save | | 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips | | 2026-10-20 | minor | Dutch (nl) listed under other shipped languages; progress log row | diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index 877c82fd8..826a12b31 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -195,13 +195,13 @@ pub struct NativeMenu { events: Receiver, theme_items: Vec<(&'static str, CheckMenuItem)>, window_menu: Option, - /// The Window menu's workbook items and what they last showed. + /// The Window menu's window items and what they last showed. window_items: Vec, windows: Vec, } /// Menu id of the Window menu item that brings workbook `doc` forward. -const WINDOW_ITEM: &str = "window:doc:"; +const WINDOW_ITEM: &str = "window:id:"; /// The Quit item: closing the window asks about unsaved workbooks first, which AppKit's own /// Quit (terminate:) would skip. @@ -279,15 +279,15 @@ impl NativeMenu { NativeMenu { _menu: menu, events: rx, theme_items, window_menu, window_items: Vec::new(), windows: Vec::new() } } - /// Keeps the Window menu's list of open workbooks (title, unsaved bullet, active check) in + /// Keeps the Window menu's list of open windows (title, unsaved bullet, active check) in /// step with the session. fn sync_windows(&mut self, app: &SheetApp) { let windows = app.open_windows(); if windows != self.windows && let Some(menu) = &self.window_menu { - let same_docs = windows.len() == self.windows.len() && windows.iter().zip(&self.windows).all(|(a, b)| a.doc == b.doc); - if same_docs { + let same_windows = windows.len() == self.windows.len() && windows.iter().zip(&self.windows).all(|(a, b)| a.window == b.window); + if same_windows { for (item, w) in self.window_items.iter().zip(&windows) { item.set_text(w.label()); } @@ -296,7 +296,7 @@ impl NativeMenu { let _ = menu.remove(&item); } for w in &windows { - let item = CheckMenuItem::with_id(format!("{WINDOW_ITEM}{}", w.doc), w.label(), true, w.active, None); + let item = CheckMenuItem::with_id(format!("{WINDOW_ITEM}{}", w.window), w.label(), true, w.active, None); let _ = menu.append(&item); self.window_items.push(item); } @@ -318,8 +318,8 @@ impl NativeMenu { ctx.send_viewport_cmd(egui::ViewportCommand::Close); continue; } - if let Some(doc) = id.strip_prefix(WINDOW_ITEM).and_then(|d| d.parse::().ok()) { - app.run_or_alert("view.switchWindows", json!({"doc": doc})); + if let Some(window) = id.strip_prefix(WINDOW_ITEM).and_then(|w| w.parse::().ok()) { + app.run_or_alert("view.switchWindows", json!({"window": window})); continue; } // Text selection belongs to the inline editor, not the worksheet clipboard/undo. diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index debb62eeb..395345543 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -141,16 +141,16 @@ pub fn specs() -> Vec { ), cmd!(noundo "view.customViews", "Custom Views…", ["View", "Workbook Views"], None, "{save?: name, show?: name, delete?: name} → list", has_doc, custom_views), cmd!(noundo "view.split", "Split", ["View", "Window"], None, "{} (toggles a split at the active cell; shown as frozen panes in this version)", has_doc, split), - cmd!(noundo "view.newWindow", "New Window", ["View", "Window"], None, "{} opens a second window on the same workbook (a linked copy)", has_doc, new_window), + cmd!(noundo "view.newWindow", "New Window", ["View", "Window"], None, "{} → {window, doc, title}: opens another window on the active workbook (the same workbook: saving from either window saves it)", has_doc, new_window), cmd!(noundo "view.arrangeAll", "Arrange All", ["View", "Window"], None, "{}", has_doc, |_, _| ok()), - cmd!(noundo "view.hideWindow", "Hide", ["View", "Window"], None, "{}", has_doc, hide_window), - cmd!(noundo "view.unhideWindow", "Unhide…", ["View", "Window"], None, "{index?}", has_doc, unhide_window), + cmd!(noundo "view.hideWindow", "Hide", ["View", "Window"], None, "{window?, doc?} (the active window by default)", has_doc, hide_window), + cmd!(noundo "view.unhideWindow", "Unhide…", ["View", "Window"], None, "{window?, doc?, index?} (the most recently hidden window by default)", has_doc, unhide_window), cmd!( noundo "view.switchWindows", "Switch Windows", ["View", "Window"], None, - "{doc?} → open workbooks [{index, doc, title, dirty, active, hidden}]; with `doc` (a workbook's `doc` id from the list) makes that workbook active first", + "{window?, doc?} → open windows [{window, doc, index, title, dirty, active, hidden}]; with `window` (an id from the list), or `doc` (a workbook id: its most recent window), shows and activates that window first", always, switch_windows ), @@ -790,54 +790,46 @@ fn split(s: &mut Session, _: &Json) -> Result { } fn new_window(s: &mut Session, _: &Json) -> Result { - let d = s.doc()?.clone(); - let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title())); - copy.selection = d.selection.clone(); - let i = s.add_document(copy); - Ok(json!({"index": i})) + let id = s.new_window()?; + let doc = s.window(id).map(|w| w.doc_uid); + Ok(json!({"window": id, "doc": doc, "title": s.window_title(id)})) } -fn hide_window(s: &mut Session, _: &Json) -> Result { - s.hidden_windows.push(s.active_index()); - let n = s.documents().len(); - if n > 1 { - let next = (0..n).find(|i| !s.hidden_windows.contains(i)).unwrap_or(0); - s.set_active(next); - } +/// The window a window command names: `window` (an id), else `doc` (a workbook id: its most +/// recent window), else the active window. +pub(crate) fn window_param(s: &Session, cmd: &str, p: &Json) -> Result { + let t = crate::Target::from_json(p).map_err(|e| bad(cmd, e.to_string()))?; + s.target_window(t).map_err(|e| bad(cmd, e.to_string())) +} + +fn hide_window(s: &mut Session, p: &Json) -> Result { + let id = window_param(s, "view.hideWindow", p)?; + s.hide_window(id); ok() } fn unhide_window(s: &mut Session, p: &Json) -> Result { - let i = u32_param(p, "index").map(|v| v as usize).or_else(|| s.hidden_windows.last().copied()); - if let Some(i) = i { - s.hidden_windows.retain(|x| *x != i); - s.set_active(i); + let id = if p.get("window").is_some_and(|v| !v.is_null()) || p.get("doc").is_some_and(|v| !v.is_null()) { + Some(window_param(s, "view.unhideWindow", p)?) + } else if let Some(i) = u32_param(p, "index") { + // A workbook's position in the document list. + let uid = s.documents().get(i as usize).map(|d| d.uid).ok_or_else(|| bad("view.unhideWindow", "no such window"))?; + s.windows().iter().filter(|w| w.doc_uid == uid && w.hidden).map(|w| w.id).next_back().or_else(|| s.doc_window(uid)) + } else { + s.last_hidden_window() + }; + if let Some(id) = id { + s.unhide_window(id); } ok() } fn switch_windows(s: &mut Session, p: &Json) -> Result { - if let Some(doc) = p.get("doc").filter(|v| !v.is_null()) { - let i = doc.as_u64().and_then(|uid| s.document_index(uid)).ok_or_else(|| bad("view.switchWindows", "no open workbook has that `doc` id"))?; - s.hidden_windows.retain(|x| *x != i); - s.set_active(i); + if p.get("window").is_some_and(|v| !v.is_null()) || p.get("doc").is_some_and(|v| !v.is_null()) { + let id = window_param(s, "view.switchWindows", p)?; + s.unhide_window(id); } - Ok(Json::Array( - s.documents() - .iter() - .enumerate() - .map(|(i, d)| { - json!({ - "index": i, - "doc": d.uid, - "title": d.display_title(), - "dirty": d.is_dirty(), - "active": i == s.active_index(), - "hidden": s.hidden_windows.contains(&i), - }) - }) - .collect(), - )) + Ok(s.window_list()) } fn table_resize(s: &mut Session, p: &Json) -> Result { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index a61c6298f..1f50b908b 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -12,7 +12,7 @@ pub fn specs() -> Vec { cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html/.pdf by extension)", has_doc, save), cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} (.xlsx, .csv, .tsv, .json, .html, or .pdf: a PDF of the active sheet)", has_doc, save_as), cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes), - cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{force?: bool}", has_doc, close), + cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{force?: bool, document?: bool} (closes the active window, or with `document` the workbook and all its windows; the workbook closes with its last window)", has_doc, close), cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")), cmd!(noundo "file.exportHtml", "Save as Web Page", ["File", "Export"], None, "{path}", has_doc, |s, p| export(s, p, "html")), cmd!("file.properties", "Properties", ["File"], None, "{title?, subject?, author?, company?, keywords?, description?}", has_doc, properties), @@ -25,7 +25,9 @@ pub fn specs() -> Vec { always, options ), - cmd!(noundo "window.activate", "Switch Window", ["Window"], None, "{index}", has_doc, activate_window), + cmd!(noundo "window.activate", "Switch Window", ["Window"], None, "{window} | {doc} | {index} (a window id, a workbook id, or the workbook's position in the list)", has_doc, activate_window), + cmd!(noundo "window.close", "Close Window", ["Window"], None, "{window?, doc?, force?: bool} → {closed, document}: closes a window (the active one by default); its workbook closes with its last window, asking first when it has unsaved changes unless `force`", has_doc, close_window), + cmd!(query "window.list", "Windows", ["Window"], None, "{} → [{window, doc, index, title, dirty, active, hidden}]", always, |s, _| Ok(s.window_list())), ] } @@ -159,7 +161,16 @@ fn save_bytes(s: &mut Session, p: &Json) -> Result { Ok(json!({"name": name, "base64": crate::io::base64_encode(&bytes), "bytes": bytes.len()})) } +/// File › Close closes the active window; the workbook closes with its last window. fn close(s: &mut Session, p: &Json) -> Result { + if !bool_param(p, "document").unwrap_or(false) + && let Some(id) = s.active_window() + && let Some(doc) = s.window(id).map(|w| w.doc_uid) + && s.windows().iter().filter(|w| w.doc_uid == doc).count() > 1 + { + s.close_window(id); + return Ok(json!({"closed": true, "document": false})); + } let force = bool_param(p, "force").unwrap_or(false); let d = s.doc()?; if d.is_dirty() && !force { @@ -220,10 +231,35 @@ fn options(s: &mut Session, _: &Json) -> Result { } fn activate_window(s: &mut Session, p: &Json) -> Result { - let i = u32_param(p, "index").ok_or_else(|| bad("window.activate", "missing `index`"))? as usize; - if i >= s.documents().len() { - return Err(bad("window.activate", "no such window")); - } - s.set_active(i); + let id = match u32_param(p, "index").filter(|_| p.get("window").is_none_or(Json::is_null) && p.get("doc").is_none_or(Json::is_null)) { + Some(i) => { + let uid = s.documents().get(i as usize).map(|d| d.uid).ok_or_else(|| bad("window.activate", "no such window"))?; + s.doc_window(uid).ok_or_else(|| bad("window.activate", "no such window"))? + } + None if p.get("window").is_none_or(Json::is_null) && p.get("doc").is_none_or(Json::is_null) => { + return Err(bad("window.activate", "missing `window`")); + } + None => super::extra::window_param(s, "window.activate", p)?, + }; + s.unhide_window(id); ok() } + +fn close_window(s: &mut Session, p: &Json) -> Result { + let id = super::extra::window_param(s, "window.close", p)?; + let doc = s.window(id).map(|w| w.doc_uid).unwrap_or_default(); + if s.windows().iter().filter(|w| w.doc_uid == doc).count() > 1 { + s.close_window(id); + return Ok(json!({"closed": true, "document": false})); + } + let force = bool_param(p, "force").unwrap_or(false); + let d = s.documents().iter().find(|d| d.uid == doc).ok_or(crate::EngineError::NoDocument)?; + if d.is_dirty() && !force { + let title = d.display_title(); + s.activate_window(id); + s.ui_requests.push(crate::UiRequest::Dialog("saveChanges".into(), json!({"title": title}))); + return Ok(json!({"closed": false, "dirty": true})); + } + s.close_window(id); + Ok(json!({"closed": true, "document": true})) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index aeeaa9bdb..74924d542 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -22,6 +22,7 @@ pub mod pivot_calc; pub mod sample; pub mod selection; pub mod tables; +pub mod windows; use std::sync::Arc; @@ -38,6 +39,7 @@ pub use gridcraft_formula as formula; pub use gridcraft_model as model; pub use gridcraft_numfmt as numfmt; pub use selection::Selection; +pub use windows::{Target, TargetGuard, WindowState, WindowView}; #[derive(Debug, thiserror::Error)] pub enum EngineError { @@ -286,7 +288,15 @@ pub type TextWidth = Arc f32 + Send + Sync>; #[derive(Default)] pub struct Session { docs: Vec, + /// Index in `docs` of the active window's workbook. active: usize, + /// Windows on the open workbooks ([`windows`]); every workbook has at least one. + windows: Vec, + active_window: Option, + /// Last window id handed out. + next_window: u64, + /// Window activation counter (most recently used order). + activation: u64, pub clipboard: Option, pub prefs: Prefs, pub mode: Mode, @@ -304,8 +314,6 @@ pub struct Session { pub hide_ruler: bool, /// Journal position where action recording started. pub recording: Option, - /// Windows hidden with View › Hide. - pub hidden_windows: Vec, /// Draw tab tool: "" (select), "pen" or "eraser"; pen colour and width. pub draw_tool: String, pub draw_color: String, @@ -330,9 +338,15 @@ impl Session { pub fn active_index(&self) -> usize { self.active } + /// Makes the workbook at `i` active, in its most recently active window (unless the active + /// window already shows it). pub fn set_active(&mut self, i: usize) { - if i < self.docs.len() { - self.active = i; + let Some(uid) = self.docs.get(i).map(|d| d.uid) else { return }; + if self.active == i && self.active_window.and_then(|w| self.window(w)).is_some_and(|w| w.doc_uid == uid) { + return; + } + if let Some(w) = self.doc_window(uid) { + self.activate_window(w); } } pub fn active(&self) -> Option<&DocState> { @@ -351,16 +365,23 @@ impl Session { pub fn doc_mut(&mut self) -> Result<&mut DocState> { self.active_mut().ok_or(EngineError::NoDocument) } + /// Opens a workbook in a new window, which becomes the active one. pub fn add_document(&mut self, d: DocState) -> usize { + let uid = d.uid; self.docs.push(d); - self.active = self.docs.len() - 1; - self.active + let w = self.push_window(uid); + self.activate_window(w); + self.docs.len() - 1 } + /// Closes a workbook and its windows; the most recently active remaining window comes forward. pub fn close_document(&mut self, i: usize) { if i < self.docs.len() { - self.docs.remove(i); - if self.active >= self.docs.len() { - self.active = self.docs.len().saturating_sub(1); + let d = self.docs.remove(i); + self.windows.retain(|w| w.doc_uid != d.uid); + if self.active_window.is_some_and(|w| self.window(w).is_none()) { + self.activate_recent(); + } else if let Some(di) = self.active_window.and_then(|w| self.window(w)).and_then(|w| self.document_index(w.doc_uid)) { + self.active = di; } } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 07a32209f..6f7f7f4bc 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2501,7 +2501,10 @@ fn every_command_survives_empty_params() { let mut s = Session::new(); s.execute("file.new", json!({"sample": "sales"})).unwrap(); for spec in crate::command_specs() { - if matches!(spec.id, "file.close" | "file.open" | "file.save" | "file.saveAs" | "file.exportCsv" | "file.exportHtml" | "insert.picture") { + if matches!( + spec.id, + "file.close" | "window.close" | "file.open" | "file.save" | "file.saveAs" | "file.exportCsv" | "file.exportHtml" | "insert.picture" + ) { continue; } let _ = s.execute(spec.id, json!({})); @@ -4490,3 +4493,112 @@ fn switch_windows_lists_workbooks_by_stable_id_and_switches_to_one() { assert!(s.execute("view.switchWindows", json!({"doc": "Book1"})).is_err()); assert_eq!(s.doc().unwrap().uid, book1, "a bad id leaves the active workbook alone"); } + +#[test] +fn new_window_shares_the_workbook_and_closes_window_by_window() { + let mut s = s(); + let book = s.doc().unwrap().uid; + assert_eq!(s.windows().len(), 1, "a new workbook opens in one window"); + let first = s.active_window().unwrap(); + assert_eq!(s.window_title(first).as_deref(), Some("Book1")); + + let r = s.execute("view.newWindow", json!({})).unwrap(); + let second = r["window"].as_u64().unwrap(); + assert_eq!(s.documents().len(), 1, "no copy of the workbook"); + assert_eq!((s.windows().len(), s.active_window()), (2, Some(second))); + assert_eq!(s.window_title(first).as_deref(), Some("Book1:1")); + assert_eq!(s.window_title(second).as_deref(), Some("Book1:2")); + assert!(!s.doc().unwrap().is_dirty()); + + // An edit in window 2 is in window 1's workbook, and saving from window 2 saves it. + s.execute("cell.set", json!({"cell": "A1", "input": "shared"})).unwrap(); + let path = std::env::temp_dir().join(format!("gridcraft-windows-{}.xlsx", std::process::id())); + s.execute("file.save", json!({"path": path})).unwrap(); + s.execute("window.activate", json!({"window": first})).unwrap(); + assert_eq!((s.doc().unwrap().uid, v(&s, "A1")), (book, Value::text("shared"))); + assert!(!s.doc().unwrap().is_dirty(), "saved from the other window"); + assert_eq!(s.doc().unwrap().path.as_deref(), path.to_str()); + let _ = std::fs::remove_file(&path); + + // Closing one window keeps the workbook and renumbers the titles; the last one closes it. + s.execute("cell.set", json!({"cell": "A2", "input": "unsaved"})).unwrap(); + let r = s.execute("file.close", json!({})).unwrap(); + assert_eq!((r["closed"].as_bool(), r["document"].as_bool()), (Some(true), Some(false)), "no prompt for one of two windows"); + assert_eq!((s.windows().len(), s.active_window()), (1, Some(second))); + assert_eq!(s.window_title(second).as_deref(), Some(s.doc().unwrap().display_title().as_str())); + let r = s.execute("window.close", json!({})).unwrap(); + assert_eq!(r["closed"].as_bool(), Some(false), "the last window asks about unsaved changes"); + assert_eq!(s.documents().len(), 1); + s.execute("window.close", json!({"force": true})).unwrap(); + assert!(s.documents().is_empty() && s.windows().is_empty() && s.active_window().is_none()); +} + +#[test] +fn switching_windows_swaps_their_views_without_dirtying_the_workbook() { + let mut s = s(); + s.execute("home.insertSheet", json!({})).unwrap(); + let path = std::env::temp_dir().join(format!("gridcraft-window-views-{}.xlsx", std::process::id())); + s.execute("file.save", json!({"path": path})).unwrap(); + let _ = std::fs::remove_file(&path); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + s.execute("selection.set", json!({"range": "B2:C3"})).unwrap(); + let first = s.active_window().unwrap(); + let second = s.execute("view.newWindow", json!({})).unwrap()["window"].as_u64().unwrap(); + s.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + s.execute("selection.set", json!({"range": "D4"})).unwrap(); + assert!(!s.doc().unwrap().is_dirty()); + + s.execute("window.activate", json!({"window": first})).unwrap(); + let d = s.doc().unwrap(); + assert_eq!((d.wb.active_sheet, d.selection.a1()), (0, "B2:C3".to_string())); + assert!(!d.is_dirty(), "a window's sheet and selection are not document changes"); + s.execute("view.switchWindows", json!({"window": second})).unwrap(); + let d = s.doc().unwrap(); + assert_eq!((d.wb.active_sheet, d.selection.a1()), (1, "D4".to_string())); + assert!(!d.is_dirty()); + + // A routed command acts in its window and leaves the active one showing. + s.execute_in(crate::Target::Window(first), "cell.set", json!({"cell": "A1", "input": "in window 1"})).unwrap(); + assert_eq!(s.active_window(), Some(second)); + assert_eq!(s.doc().unwrap().wb.sheets[0].value(CellRef::parse("A1").unwrap()), Value::text("in window 1")); + assert_eq!(s.doc().unwrap().selection.a1(), "D4"); +} + +#[test] +fn hide_unhide_and_switch_by_window_id() { + let mut s = s(); + let book1 = s.doc().unwrap().uid; + let w1 = s.active_window().unwrap(); + let w2 = s.execute("view.newWindow", json!({})).unwrap()["window"].as_u64().unwrap(); + s.execute("file.new", json!({})).unwrap(); + let w3 = s.active_window().unwrap(); + s.execute("view.hideWindow", json!({})).unwrap(); + assert_eq!(s.active_window(), Some(w2), "the most recent visible window comes forward"); + let list = s.execute("window.list", json!({})).unwrap(); + let rows = list.as_array().unwrap(); + assert_eq!(rows.len(), 3); + assert_eq!((rows[0]["window"].as_u64(), rows[0]["title"].as_str(), rows[0]["doc"].as_u64()), (Some(w1), Some("Book1:1"), Some(book1))); + assert_eq!((rows[2]["title"].as_str(), rows[2]["hidden"].as_bool()), (Some("Book2"), Some(true))); + assert_eq!(rows[1]["active"].as_bool(), Some(true)); + s.execute("view.unhideWindow", json!({})).unwrap(); + assert_eq!(s.active_window(), Some(w3)); + assert!(!s.window(w3).unwrap().hidden); + // A workbook id goes to that workbook's most recent window. + s.execute("view.switchWindows", json!({"doc": book1})).unwrap(); + assert_eq!(s.active_window(), Some(w2)); + assert!(s.execute("window.activate", json!({"window": 999})).is_err()); + assert_eq!(s.active_window(), Some(w2)); +} + +#[test] +fn open_replaces_an_untouched_blank_workbook_and_its_windows() { + let mut s = s(); + s.execute("view.newWindow", json!({})).unwrap(); + let path = std::env::temp_dir().join(format!("gridcraft-open-blank-{}.csv", std::process::id())); + std::fs::write(&path, "a,b\n1,2\n").unwrap(); + s.execute("file.open", json!({"path": path})).unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(s.documents().len(), 1); + assert_eq!(s.windows().len(), 1, "the blank workbook's windows closed with it"); + assert_eq!(s.windows()[0].doc_uid, s.doc().unwrap().uid); +} diff --git a/crates/engine/src/windows.rs b/crates/engine/src/windows.rs new file mode 100644 index 000000000..8dcb92011 --- /dev/null +++ b/crates/engine/src/windows.rs @@ -0,0 +1,342 @@ +//! Windows on open workbooks. +//! +//! Every open workbook has one or more windows (View › New Window adds another on the same +//! workbook). A window has its own selection, active sheet and per-sheet selections. The active +//! window's view lives in its [`DocState`] (`selection`, `wb.active_sheet`, `sheet_selections`), +//! where every command reads it; each other window keeps its view in [`WindowState`] until it is +//! activated. Swapping a view in or out never makes the workbook dirty: per-window view state is +//! not a document change. + +use serde_json::{Value as Json, json}; + +use crate::{DocState, EngineError, Result, Selection, Session}; + +/// The view state one window keeps while another window on its workbook is showing. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct WindowView { + pub selection: Selection, + pub active_sheet: usize, + /// The active sheet's name, so a sheet inserted, deleted or moved meanwhile in another + /// window still finds it. + pub sheet_name: String, + pub sheet_selections: Vec, +} + +/// One window on an open workbook. +#[derive(Clone, Debug)] +pub struct WindowState { + /// Identifies the window for the rest of the session; never reused. + pub id: u64, + /// The [`DocState::uid`] of the workbook it shows. + pub doc_uid: u64, + /// View › Hide. + pub hidden: bool, + /// `None` while this window's view is the one in the document (the active window, or the + /// workbook's most recently active window); exactly one window per workbook has `None`. + view: Option, + /// Activation order: higher was active more recently. + last_active: u64, +} + +impl WindowView { + fn capture(d: &DocState) -> WindowView { + WindowView { + selection: d.selection.clone(), + active_sheet: d.wb.active_sheet, + sheet_name: d.wb.sheets.get(d.wb.active_sheet).map(|s| s.name.clone()).unwrap_or_default(), + sheet_selections: d.sheet_selections.clone(), + } + } + + fn apply(self, d: &mut DocState) { + let sheets = &d.wb.sheets; + let at = if sheets.get(self.active_sheet).is_some_and(|s| s.name == self.sheet_name) { + self.active_sheet + } else { + sheets.iter().position(|s| s.name == self.sheet_name).unwrap_or(self.active_sheet) + }; + let at = at.min(sheets.len().saturating_sub(1)); + if d.wb.active_sheet != at { + let dirty = d.is_dirty(); + std::sync::Arc::make_mut(&mut d.wb).active_sheet = at; + if !dirty { + d.saved = d.wb.clone(); + } + } + d.selection = self.selection; + d.sheet_selections = self.sheet_selections; + } +} + +/// Which window a command from a script or agent acts on. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Target { + /// The active window. + #[default] + Active, + /// A window by [`WindowState::id`]. + Window(u64), + /// A workbook by [`DocState::uid`]: its most recently active window. + Doc(u64), +} + +impl Target { + /// Reads `window` or `doc` from a JSON object (control channel envelopes, MCP arguments). + /// Neither present is [`Target::Active`]; a value that isn't an id is an error. + pub fn from_json(p: &Json) -> Result { + let id = |k: &str| -> Result> { + match p.get(k) { + None | Some(Json::Null) => Ok(None), + Some(v) => v.as_u64().map(Some).ok_or_else(|| EngineError::Other(format!("`{k}` must be a window or workbook id number"))), + } + }; + Ok(match (id("window")?, id("doc")?) { + (Some(w), _) => Target::Window(w), + (None, Some(d)) => Target::Doc(d), + (None, None) => Target::Active, + }) + } +} + +/// What [`Session::leave_target`] restores. +#[derive(Debug)] +#[must_use] +pub struct TargetGuard { + id: u64, + back: Option, +} + +impl Session { + /// The open windows, in the order they were opened. + pub fn windows(&self) -> &[WindowState] { + &self.windows + } + + /// The active window's id (`None` with no workbook open). + pub fn active_window(&self) -> Option { + self.active_window + } + + pub fn window(&self, id: u64) -> Option<&WindowState> { + self.windows.iter().find(|w| w.id == id) + } + + /// A workbook's windows in opening order. + fn windows_of(&self, doc: u64) -> impl Iterator { + self.windows.iter().filter(move |w| w.doc_uid == doc) + } + + /// The window title: the workbook's name when it has one window, "Name:1", "Name:2"… in + /// opening order when it has several. + pub fn window_title(&self, id: u64) -> Option { + let w = self.window(id)?; + let d = self.docs.iter().find(|d| d.uid == w.doc_uid)?; + let name = d.display_title(); + let mut n = 0; + let mut at = 0; + for x in self.windows_of(w.doc_uid) { + n += 1; + if x.id == id { + at = n; + } + } + Some(if n > 1 { format!("{name}:{at}") } else { name }) + } + + /// The most recently active window of a workbook, preferring one that isn't hidden. + pub fn doc_window(&self, doc: u64) -> Option { + self.windows_of(doc).max_by_key(|w| (!w.hidden, w.last_active)).map(|w| w.id) + } + + /// Resolves a [`Target`] to a window id. + pub fn target_window(&self, t: Target) -> Result { + match t { + Target::Active => self.active_window.ok_or(EngineError::NoDocument), + Target::Window(id) => self.window(id).map(|w| w.id).ok_or_else(|| EngineError::Other(format!("no open window has id {id}"))), + Target::Doc(uid) => self.doc_window(uid).ok_or_else(|| EngineError::Other(format!("no open workbook has id {uid}"))), + } + } + + pub(crate) fn push_window(&mut self, doc_uid: u64) -> u64 { + self.next_window = self.next_window.saturating_add(1); + let id = self.next_window; + self.windows.push(WindowState { id, doc_uid, hidden: false, view: None, last_active: 0 }); + id + } + + /// Makes a window active, swapping its view into its workbook. Unknown ids are ignored. + pub fn activate_window(&mut self, id: u64) { + let Some(wi) = self.windows.iter().position(|w| w.id == id) else { return }; + let Some(w) = self.windows.get(wi) else { return }; + let doc = w.doc_uid; + if w.view.is_some() + && let Some(di) = self.document_index(doc) + { + let live = self.windows.iter().position(|x| x.doc_uid == doc && x.view.is_none()); + let incoming = self.windows.get_mut(wi).and_then(|w| w.view.take()); + if let Some(d) = self.docs.get_mut(di) { + if let Some(li) = live + && let Some(lw) = self.windows.get_mut(li) + { + lw.view = Some(WindowView::capture(d)); + } + if let Some(v) = incoming { + v.apply(d); + } + } + } + self.activation = self.activation.saturating_add(1); + let stamp = self.activation; + if let Some(w) = self.windows.get_mut(wi) { + w.last_active = stamp; + } + self.active_window = Some(id); + if let Some(di) = self.document_index(doc) { + self.active = di; + } + } + + /// Activates the most recently active window that isn't hidden, else any window, else none. + pub(crate) fn activate_recent(&mut self) { + let next = self.windows.iter().max_by_key(|w| (!w.hidden, w.last_active)).map(|w| w.id); + match next { + Some(id) => self.activate_window(id), + None => { + self.active_window = None; + self.active = 0; + } + } + } + + /// View › New Window: another window on the active window's workbook, showing what it shows. + pub fn new_window(&mut self) -> Result { + let current = self.active_window.ok_or(EngineError::NoDocument)?; + let doc = self.window(current).map(|w| w.doc_uid).ok_or(EngineError::NoDocument)?; + let view = self.doc().map(WindowView::capture)?; + // The new window takes over the live view (identical at this point); the old one keeps a copy. + if let Some(w) = self.windows.iter_mut().find(|w| w.doc_uid == doc && w.view.is_none()) { + w.view = Some(view); + } + let id = self.push_window(doc); + self.activate_window(id); + Ok(id) + } + + /// Closes one window. The workbook stays open while it has another window; closing its last + /// window closes the workbook (unsaved changes are the caller's to ask about). + pub fn close_window(&mut self, id: u64) { + let Some(wi) = self.windows.iter().position(|w| w.id == id) else { return }; + let doc = self.windows.get(wi).map(|w| w.doc_uid).unwrap_or_default(); + if self.windows_of(doc).count() <= 1 { + if let Some(di) = self.document_index(doc) { + self.close_document(di); + } + return; + } + let closed = self.windows.remove(wi); + if closed.view.is_none() { + // Its view was the one in the document: the workbook's next window takes it over. + if let Some(next) = self.windows.iter().filter(|w| w.doc_uid == doc).max_by_key(|w| w.last_active).map(|w| w.id) { + let incoming = self.windows.iter_mut().find(|w| w.id == next).and_then(|w| w.view.take()); + if let Some(v) = incoming + && let Some(d) = self.docs.iter_mut().find(|d| d.uid == doc) + { + v.apply(d); + } + } + } + if self.active_window == Some(id) { + self.activate_recent(); + } + } + + /// View › Hide on a window; the most recent visible window comes forward. + pub fn hide_window(&mut self, id: u64) { + let Some(w) = self.windows.iter_mut().find(|w| w.id == id) else { return }; + w.hidden = true; + if self.active_window == Some(id) && self.windows.iter().any(|w| !w.hidden) { + self.activate_recent(); + } + } + + /// View › Unhide: shows a hidden window and activates it. + pub fn unhide_window(&mut self, id: u64) { + let Some(w) = self.windows.iter_mut().find(|w| w.id == id) else { return }; + w.hidden = false; + self.activate_window(id); + } + + /// The most recently hidden window. + pub fn last_hidden_window(&self) -> Option { + self.windows.iter().filter(|w| w.hidden).max_by_key(|w| w.last_active).map(|w| w.id) + } + + /// The windows as scripts and the Switch Windows lists see them: + /// `[{window, doc, index, title, dirty, active, hidden}]`, `index` being the workbook's + /// position in [`Session::documents`]. + pub fn window_list(&self) -> Json { + Json::Array( + self.windows + .iter() + .filter_map(|w| { + let index = self.document_index(w.doc_uid)?; + let d = self.docs.get(index)?; + Some(json!({ + "window": w.id, + "doc": w.doc_uid, + "index": index, + "title": self.window_title(w.id).unwrap_or_default(), + "dirty": d.is_dirty(), + "active": self.active_window == Some(w.id), + "hidden": w.hidden, + })) + }) + .collect(), + ) + } + + /// Runs `f` with the target window active, then brings back the window that was active, so a + /// script acting on another window doesn't change what the user is looking at. When `f` + /// itself switched windows (`window.activate`, `file.new`, a close), its choice stands. + pub fn with_target(&mut self, t: Target, f: impl FnOnce(&mut Session) -> T) -> Result { + let guard = self.enter_target(t)?; + let r = f(self); + self.leave_target(guard); + Ok(r) + } + + /// The first half of [`Session::with_target`], for callers whose closure can't borrow the + /// session alone (the UI runs commands through its app): activates the target window and + /// returns what [`Session::leave_target`] needs to bring the previous one back. + pub fn enter_target(&mut self, t: Target) -> Result> { + if t == Target::Active { + return Ok(None); + } + let id = self.target_window(t)?; + let back = self.active_window; + self.activate_window(id); + Ok(Some(TargetGuard { id, back })) + } + + pub fn leave_target(&mut self, guard: Option) { + let Some(TargetGuard { id, back: Some(back) }) = guard else { return }; + if self.active_window == Some(id) && back != id && self.window(back).is_some() { + self.activate_window(back); + } + } + + /// [`Session::execute`] on a target window. + pub fn execute_in(&mut self, t: Target, id: &str, params: Json) -> Result { + self.with_target(t, |s| s.execute(id, params))? + } + + /// [`Session::execute_headless`] on a target window. + pub fn execute_headless_in(&mut self, t: Target, id: &str, params: Json) -> Result { + self.with_target(t, |s| s.execute_headless(id, params))? + } +} + +/// Commands that take `window`/`doc` as their own parameters rather than as a target. +pub fn is_window_command(id: &str) -> bool { + id.starts_with("window.") || matches!(id, "view.newWindow" | "view.hideWindow" | "view.unhideWindow" | "view.switchWindows") +} diff --git a/crates/engine/tests/command_sequence_fuzz.rs b/crates/engine/tests/command_sequence_fuzz.rs index f8cb603cb..1724b9b9d 100644 --- a/crates/engine/tests/command_sequence_fuzz.rs +++ b/crates/engine/tests/command_sequence_fuzz.rs @@ -59,7 +59,8 @@ const EXCLUDED: &[(&str, &str)] = &[ ("file.exportPdf", "renders a PDF: slow, and may write files"), ("file.print", "renders a PDF: slow"), ("window.activate", "switches documents"), - ("view.newWindow", "opens another document"), + ("window.close", "closes the document under test"), + ("view.newWindow", "opens another window, whose view swaps with the first"), ("insert.picture", "reads files"), ("pageLayout.background", "reads files"), ("formulas.calculationOptions", "manual and iterative calculation differ from a full recalc by design"), diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 5f96fbd25..28a5cecce 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -1,6 +1,6 @@ //! An in-process engine session that answers the engine-level control-channel methods itself. -use gridcraft_engine::Session; +use gridcraft_engine::{Session, Target}; use serde_json::{Value, json}; use crate::backend::Backend; @@ -33,9 +33,9 @@ impl Headless { Self { session } } - fn execute(&mut self, id: &str, params: Value) -> Result { + fn execute(&mut self, target: Target, id: &str, params: Value) -> Result { // Programmatic calls never open dialogs; anything a command queued for a UI is dropped. - self.session.execute_headless(id, params).map_err(|e| e.to_string()) + self.session.execute_headless_in(target, id, params).map_err(|e| e.to_string()) } fn commands(&self, params: &Value) -> Value { @@ -68,14 +68,28 @@ impl Backend for Headless { if !(p.is_object() || p.is_null()) { return Err(format!("engine.execute: `params` of `{id}` must be an object")); } - self.execute(&id, p) + let target = Target::from_json(¶ms).map_err(|e| e.to_string())?; + self.execute(target, &id, p) } "engine.commands" => Ok(self.commands(¶ms)), "engine.journal" => Ok(Value::Array(self.session.journal.iter().map(|(id, p)| json!({"command": id, "params": p})).collect())), m if m.starts_with("ui.") || m.starts_with("app.screenshot") => Err(format!("{m}: {NO_UI}")), // Read-only engine methods (`document.inspect`, `sheet.read`, `cell.get`, …) are // commands with the same id. - m if gridcraft_engine::find_command(m).is_some() => self.execute(m, params), + // Their params may name a `window` or `doc` to act in, except for the window commands, + // whose own params those are. + m if gridcraft_engine::find_command(m).is_some() => { + if gridcraft_engine::windows::is_window_command(m) { + return self.execute(Target::Active, m, params); + } + let target = Target::from_json(¶ms).map_err(|e| e.to_string())?; + let mut params = params; + if let Some(o) = params.as_object_mut() { + o.remove("window"); + o.remove("doc"); + } + self.execute(target, m, params) + } other => Err(format!("unknown method `{other}`")), } } diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index c699ccf69..5ceeea0ce 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -106,6 +106,7 @@ fn tools_list_has_schemas() { "redo", "screenshot", "list_functions", + "list_windows", ] { assert!(names.contains(&want), "missing tool {want}"); } @@ -548,3 +549,31 @@ fn invalid_sort_keys_are_invalid_params() { } ok(&mut s, "sort_range", json!({"range": "A1:A3", "keys": ["A", {"column": "A", "order": "desc"}]})); } + +#[test] +fn a_window_argument_routes_tools_to_that_window() { + let mut s = server(); + ok(&mut s, "set_cell", json!({"cell": "A1", "input": "book one"})); + ok(&mut s, "new_workbook", json!({})); + let windows = ok(&mut s, "list_windows", json!({})); + let rows = windows.as_array().unwrap(); + assert_eq!(rows.len(), 2, "{windows}"); + let (w1, book1) = (rows[0]["window"].as_u64().unwrap(), rows[0]["doc"].as_u64().unwrap()); + assert_eq!((rows[0]["title"].as_str(), rows[1]["active"].as_bool()), (Some("Book1"), Some(true))); + + // Book2 stays active; reads and writes go to Book1's window. + ok(&mut s, "set_cell", json!({"cell": "B1", "input": "=A1", "window": w1})); + let got = ok(&mut s, "get_cell", json!({"cell": "B1", "doc": book1})); + assert!(got.to_string().contains("book one"), "{got}"); + let book2 = ok(&mut s, "read_range", json!({"range": "A1:B1"})); + assert!(!book2.to_string().contains("book one"), "{book2}"); + let info = ok(&mut s, "inspect_workbook", json!({"window": w1})); + assert!(info.to_string().contains("Book1"), "{info}"); + assert!(fails(&mut s, "get_cell", json!({"cell": "A1", "window": 999})).contains("no open window")); + + // A second window on Book1 gets a numbered title. + ok(&mut s, "execute_command", json!({"command": "view.newWindow", "window": w1})); + let titles: Vec = + ok(&mut s, "list_windows", json!({})).as_array().unwrap().iter().map(|r| r["title"].as_str().unwrap().to_string()).collect(); + assert_eq!(titles, ["Book1:1", "Book2", "Book1:2"]); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index a6879b3ff..b422b5f34 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -106,8 +106,26 @@ const BORDER_PRESETS: &[&str] = &[ "diagonalUp", ]; -/// All tools, in `tools/list` order. +/// Tools that don't act on one workbook, so they take no `window`/`doc` target. +const UNTARGETED: &[&str] = &["list_commands", "list_functions", "list_windows", "new_workbook", "open_workbook", "screenshot"]; + +/// All tools, in `tools/list` order. Tools that act on a workbook take an optional `window` or +/// `doc` id (from list_windows) and default to the active window. pub fn tool_definitions() -> Vec { + let mut tools = base_tool_definitions(); + for t in &mut tools { + if UNTARGETED.iter().any(|n| t["name"] == *n) { + continue; + } + if let Some(props) = t["inputSchema"]["properties"].as_object_mut() { + props.insert("window".into(), int("Window id from list_windows (default: the active window)")); + props.insert("doc".into(), int("Workbook id from list_windows: its most recent window (default: the active window)")); + } + } + tools +} + +fn base_tool_definitions() -> Vec { let empty = || obj(json!({}), &[]); vec![ // ----- generic ----- @@ -125,6 +143,14 @@ pub fn tool_definitions() -> Vec { ), false, ), + tool( + "list_windows", + "List windows", + "List the open windows: window id, workbook (`doc`) id, title (\"Book1\", or \"Book1:2\" for a workbook's second \ + window), unsaved changes, active and hidden. Pass a `window` or `doc` id to other tools to act there.", + empty(), + true, + ), tool( "list_commands", "List commands", @@ -608,6 +634,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result exec(b, cmd, p) } "list_commands" => b.call("engine.commands", pick(a, &["search"])), + "list_windows" => exec(b, "window.list", json!({})), "inspect_workbook" => b.call("document.inspect", json!({})), "read_range" => b.call("sheet.read", pick(a, &["range", "sheet", "formulas", "formatted"])), "write_range" => exec(b, "range.setValues", pick(a, &["range", "values"])), @@ -638,6 +665,35 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result } } +/// Sends every engine call of one tool to the window its `window`/`doc` argument names. +struct Targeted<'a> { + inner: &'a mut dyn Backend, + target: Map, +} + +impl Backend for Targeted<'_> { + fn call(&mut self, method: &str, params: Value) -> Result { + let envelope = if method == "engine.execute" { + params + } else if gridcraft_engine::find_command(method).is_some() { + json!({"command": method, "params": params}) + } else { + return self.inner.call(method, params); + }; + let mut envelope = envelope; + if let Some(o) = envelope.as_object_mut() { + o.extend(self.target.clone()); + } + self.inner.call("engine.execute", envelope) + } + fn has_ui(&self) -> bool { + self.inner.has_ui() + } + fn describe(&self) -> String { + self.inner.describe() + } +} + /// Runs one tool. Failures become `isError` results with a readable message. pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { let empty = Map::new(); @@ -646,7 +702,11 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { Value::Null => &empty, _ => return ToolResult::error("tool arguments must be a JSON object"), }; - match dispatch(b, name, a) { + let target: Map = + ["window", "doc"].iter().filter_map(|k| a.get(*k).filter(|v| !v.is_null()).map(|v| (k.to_string(), v.clone()))).collect(); + let r = + if target.is_empty() || UNTARGETED.contains(&name) { dispatch(b, name, a) } else { dispatch(&mut Targeted { inner: b, target }, name, a) }; + match r { Ok(v) => ToolResult::json(&v), Err(e) => ToolResult::error(format!("{name}: {e}")), } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 11d535ac4..bf1dedac1 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -104,6 +104,38 @@ fn run(app: &mut SheetApp, id: &str, params: Json) -> Result { r } +/// [`run`] in another window (`window` or `doc` from the request), bringing back the active +/// window afterwards unless the command itself switched windows. +fn run_in(app: &mut SheetApp, target: gridcraft_engine::Target, id: &str, params: Json) -> Result { + let guard = app.session.enter_target(target).map_err(|e| e.to_string())?; + let r = run(app, id, params); + app.session.leave_target(guard); + r +} + +/// A command called by its id as the method. Its params may name a `window` or `doc` to act in, +/// except for the window commands, whose own params those are. +fn direct(app: &mut SheetApp, id: &str, p: &Json, via_app: bool) -> Result { + let mut params = p.clone(); + let target = if gridcraft_engine::windows::is_window_command(id) { + gridcraft_engine::Target::Active + } else { + let t = gridcraft_engine::Target::from_json(p).map_err(|e| e.to_string())?; + if let Some(o) = params.as_object_mut() { + o.remove("window"); + o.remove("doc"); + } + t + }; + if via_app { + return run_in(app, target, id, params); + } + let guard = app.session.enter_target(target).map_err(|e| e.to_string())?; + let r = app.session.run(id, params); + app.session.leave_target(guard); + r +} + pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json { let r = ctx.content_rect(); let v = app.view(); @@ -120,6 +152,8 @@ pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json { "mode": format!("{:?}", app.session.mode), "documents": app.session.documents().iter().map(|d| json!({"title": d.display_title(), "dirty": d.is_dirty()})).collect::>(), "activeDocument": app.session.active_index(), + "windows": app.session.window_list(), + "activeWindow": app.session.active_window(), "perf": {"frameMs": app.perf.frame_ms, "gridMs": app.perf.grid_ms, "fps": app.perf.fps}, }) } @@ -222,7 +256,11 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - "engine.execute" | "command" | "ui.menu.invoke" => { let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; let params = p.get("params").cloned().unwrap_or(json!({})); - wrap(run(app, id, if params.is_null() { json!({}) } else { params })) + let target = match gridcraft_engine::Target::from_json(p) { + Ok(t) => t, + Err(e) => return err(e), + }; + wrap(run_in(app, target, id, if params.is_null() { json!({}) } else { params })) } "engine.commands" => { let mut v: Vec = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); @@ -256,7 +294,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ok(Json::Array(filter_commands(v, s("search")))) } "engine.journal" => ok(json!(app.session.journal.iter().map(|(id, p)| json!({"command": id, "params": p})).collect::>())), - "document.inspect" | "sheet.read" | "cell.get" | "selection.stats" => wrap(app.session.run(method, p.clone())), + "document.inspect" | "sheet.read" | "cell.get" | "selection.stats" => wrap(direct(app, method, p, false)), "ui.inspect" => ok(inspect(app, ctx)), "ui.ribbon" => { app.ui.ribbon_tab = s("tab").unwrap_or("Home").to_string(); @@ -440,7 +478,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ok(Json::Null) } // Anything else that names a command runs it. - other if gridcraft_engine::find_command(other).is_some() => wrap(run(app, other, p.clone())), + other if gridcraft_engine::find_command(other).is_some() => wrap(direct(app, other, p, true)), other => err(format!("unknown method `{other}`")), }; ctx.request_repaint(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 612ca93b2..be474d37f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1327,7 +1327,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { let quitting = d.values.get("quit").and_then(Json::as_bool) == Some(true); ui.horizontal(|ui| { if ui.button("Don't Save").clicked() { - app.run_or_alert("file.close", json!({"force": true})); + app.run_or_alert("file.close", json!({"force": true, "document": true})); if quitting { app.continue_quit(ui.ctx()); } @@ -1340,7 +1340,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { if ui.button("Save").clicked() && app.commit_edit(0, 0, false, false) { app.run_or_alert("file.save", json!({})); if app.session.active().is_some_and(|x| !x.is_dirty()) { - app.run_or_alert("file.close", json!({"force": true})); + app.run_or_alert("file.close", json!({"force": true, "document": true})); if quitting { app.continue_quit(ui.ctx()); } diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index 3f23e8bed..95e74d959 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -20,6 +20,9 @@ pub const REF_COLORS: [Color32; 8] = [ pub struct EditState { /// The workbook (`DocState::uid`) the draft belongs to: it is drawn and committed only there. pub doc: u64, + /// The window it was typed in: switching to another window, even on the same workbook, + /// commits it there first (`None` until the app sets it). + pub window: Option, pub sheet: usize, /// Syntax of this edit, captured when it starts (prevents a language switch reinterpreting it). pub locale: gridcraft_engine::formula::FormulaLocale, @@ -56,6 +59,7 @@ impl EditState { let original = (!enter_mode).then(|| text.clone()); EditState { doc, + window: None, sheet, locale: Default::default(), cell, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cc478168e..d330d1fcf 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -166,10 +166,12 @@ pub struct Notice { pub action: Option<(String, String)>, } -/// One open workbook, as View › Switch Windows and the macOS Window menu list it. +/// One open window, as View › Switch Windows and the macOS Window menu list it. #[derive(Clone, Debug, PartialEq, Eq)] pub struct OpenWindow { - /// The workbook's `DocState::uid`, the `doc` param of `view.switchWindows`. + /// The window's id, the `window` param of `view.switchWindows`. + pub window: u64, + /// Its workbook's `DocState::uid`. pub doc: u64, pub title: String, pub dirty: bool, @@ -177,7 +179,8 @@ pub struct OpenWindow { } impl OpenWindow { - /// The title, with a bullet after it while the workbook has unsaved changes. + /// The title ("Book1", or "Book1:2" for a workbook's second window), with a bullet after it + /// while the workbook has unsaved changes. pub fn label(&self) -> String { if self.dirty { format!("{} •", self.title) } else { self.title.clone() } } @@ -256,6 +259,7 @@ pub struct SheetApp { pub ui: UiState, /// The window chrome to draw (see [`Chrome`]). pub chrome: Chrome, + /// Scroll position per (window id, sheet index). pub views: HashMap<(u64, usize), SheetView>, pub editor: Option, pub text_box_editor: Option, @@ -410,7 +414,7 @@ impl SheetApp { fn run_command(&mut self, id: &str, params: Json) -> Result { let switches_window = id.starts_with("window.") || matches!(id, "view.newWindow" | "view.hideWindow" | "view.unhideWindow") - || (id == "view.switchWindows" && params.get("doc").is_some_and(|d| !d.is_null())); + || (id == "view.switchWindows" && ["window", "doc"].iter().any(|k| params.get(*k).is_some_and(|d| !d.is_null()))); if id.starts_with("file.") || switches_window { self.settle_drafts()?; } @@ -567,9 +571,9 @@ impl SheetApp { if id == "insert.recommendedCharts" && params.get("type").is_none() { return self.open_dialog("recommendedCharts", json!({})); } - // Switch Windows with no workbook named (key tip, command search) opens the View tab's + // Switch Windows with no window named (key tip, command search) opens the View tab's // list to choose from; programmatic callers of `run` get the list back. - if id == "view.switchWindows" && params.get("doc").is_none_or(Json::is_null) { + if id == "view.switchWindows" && ["window", "doc"].iter().all(|k| params.get(*k).is_none_or(Json::is_null)) { self.ui.ribbon_tab = "View".into(); self.ui.ribbon_collapsed = false; self.open_switch_windows = true; @@ -914,27 +918,36 @@ impl SheetApp { } } - /// The open workbooks in order, for the Switch Windows lists. + /// The open windows in order, for the Switch Windows lists. pub fn open_windows(&self) -> Vec { - let active = self.session.active_index(); + let active = self.session.active_window(); self.session - .documents() + .windows() .iter() - .enumerate() - .map(|(i, d)| OpenWindow { doc: d.uid, title: d.display_title(), dirty: d.is_dirty(), active: i == active }) + .filter_map(|w| { + let d = self.session.documents().iter().find(|d| d.uid == w.doc_uid)?; + let title = self.session.window_title(w.id)?; + Some(OpenWindow { window: w.id, doc: d.uid, title, dirty: d.is_dirty(), active: active == Some(w.id) }) + }) .collect() } + /// The active workbook and sheet: what drafts, text boxes and picture targets belong to. pub fn view_key(&self) -> Option<(u64, usize)> { self.session.active().map(|d| (d.uid, d.wb.active_sheet)) } + /// The active window and sheet: each window scrolls on its own. + fn scroll_key(&self) -> Option<(u64, usize)> { + Some((self.session.active_window()?, self.session.active()?.wb.active_sheet)) + } + pub fn view(&self) -> SheetView { - self.view_key().and_then(|k| self.views.get(&k).copied()).unwrap_or_else(|| self.saved_view()) + self.scroll_key().and_then(|k| self.views.get(&k).copied()).unwrap_or_else(|| self.saved_view()) } pub fn view_mut(&mut self) -> Option<&mut SheetView> { - let k = self.view_key()?; + let k = self.scroll_key()?; let saved = self.saved_view(); Some(self.views.entry(k).or_insert(saved)) } @@ -956,10 +969,11 @@ impl SheetApp { /// cell to the engine so a save writes them (`sheetView topLeftCell`). Normal view only: /// Page Layout view scrolls over pages, not cells. pub fn store_view_state(&mut self) { + let window = self.session.active_window(); let Some(d) = self.session.active_mut() else { return }; let mut tops = Vec::new(); - for (&(uid, si), v) in &self.views { - let Some(sh) = d.wb.sheets.get(si).filter(|sh| uid == d.uid && sh.view == gridcraft_engine::model::SheetViewMode::Normal) else { + for (&(w, si), v) in &self.views { + let Some(sh) = d.wb.sheets.get(si).filter(|sh| Some(w) == window && sh.view == gridcraft_engine::model::SheetViewMode::Normal) else { continue; }; let (fr, fc) = sh.freeze.unwrap_or((0, 0)); @@ -1005,6 +1019,7 @@ impl SheetApp { None => (current.clone(), false), }; let mut ed = editor::EditState::new(d.uid, d.wb.active_sheet, at, text, replace, from_formula_bar); + ed.window = self.session.active_window(); ed.locale = locale; if replace && self.ui.autocomplete && !ed.is_formula() { ed.completion = editor::column_completion(sh, at, &ed.text); @@ -1015,29 +1030,38 @@ impl SheetApp { /// Commits the edit; `then` moves the selection (dr, dc) afterwards. /// - /// The draft goes into the workbook it was typed in. If another workbook is showing, that - /// workbook stays active afterwards; a draft its own workbook refuses (validation, a bad - /// formula) brings that workbook back to the front with the draft still open. + /// The draft goes into the window and workbook it was typed in. If another window is + /// showing, that window stays active afterwards; a draft its own workbook refuses + /// (validation, a bad formula) brings its window back to the front with the draft still open. pub fn commit_edit(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool { - let Some(doc) = self.editor.as_ref().map(|ed| ed.doc) else { return true }; - let shown = self.session.active().map(|d| d.uid); - if shown == Some(doc) { + let Some((doc, window)) = self.editor.as_ref().map(|ed| (ed.doc, ed.window)) else { return true }; + if !self.draft_elsewhere() { return self.commit_edit_here(dr, dc, array, fill_selection); } - let Some(owner) = self.session.document_index(doc) else { + // Its window, or (when that window closed) its workbook's most recent one. + let Some(owner) = window.filter(|w| self.session.window(*w).is_some()).or_else(|| self.session.doc_window(doc)) else { self.cancel_edit(); return true; }; - let back = self.session.active_index(); - self.session.set_active(owner); + let back = self.session.active_window(); + self.session.activate_window(owner); if !self.commit_edit_here(dr, dc, array, fill_selection) { - // Refused: its workbook stays in front with the draft open and the reason shown. + // Refused: its window stays in front with the draft open and the reason shown. return false; } - self.session.set_active(back); + if let Some(back) = back { + self.session.activate_window(back); + } true } + /// The cell draft belongs to a window or workbook other than the one showing. + fn draft_elsewhere(&self) -> bool { + self.editor.as_ref().is_some_and(|ed| { + self.session.active().map(|d| d.uid) != Some(ed.doc) || ed.window.is_some_and(|w| self.session.active_window() != Some(w)) + }) + } + /// Commits the cell and text-box drafts, as Enter would, before a command that can show /// another workbook or write this one to a file, so a draft never lands in another workbook. /// A draft its workbook refuses stays open with the reason shown, and the command doesn't run. @@ -1360,10 +1384,10 @@ impl SheetApp { } let t0 = now_ms(); text_box::before_ui(self, &ctx); - // Another workbook came forward without going through a command (which settles drafts - // first): the draft goes into its own workbook instead of floating over this one. If that - // workbook refuses it, it comes back to the front with the draft open. - if self.editor.as_ref().is_some_and(|ed| self.session.active().map(|d| d.uid) != Some(ed.doc)) { + // Another window came forward without going through a command (which settles drafts + // first): the draft goes into its own window instead of floating over this one. If its + // workbook refuses it, its window comes back to the front with the draft open. + if self.draft_elsewhere() { self.commit_edit(0, 0, false, false); } // The language the widgets translate with this frame (see `i18n::current`). diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index d90b642ea..6bd45e18f 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2403,7 +2403,7 @@ fn switch_windows(app: &mut SheetApp, ui: &mut Ui) { for w in app.open_windows() { let label = format!("{}{}", if w.active { "✓ " } else { " " }, w.label()); if ui.add(egui::Button::selectable(w.active, label).min_size(vec2(220.0, 22.0))).clicked() { - act(app, "view.switchWindows", json!({"doc": w.doc})); + act(app, "view.switchWindows", json!({"window": w.window})); ui.close(); } } diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs index 5c447cb63..fa9350f7a 100644 --- a/crates/ui-egui/tests/file_menu.rs +++ b/crates/ui-egui/tests/file_menu.rs @@ -484,3 +484,65 @@ fn save_a_copy_with_a_bare_name_asks_where_to_save_it() { assert_eq!(asked.lock().unwrap().len(), 1); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn new_window_shows_the_same_workbook_and_a_draft_settles_in_its_own_window() { + let mut h = harness(Services::default()); + h.state_mut().run("selection.set", serde_json::json!({"range": "B2"})).unwrap(); + let first = h.state().session.active_window().unwrap(); + h.state_mut().run_or_alert("view.newWindow", serde_json::json!({})); + h.run_steps(2); + let second = h.state().session.active_window().unwrap(); + let book = h.state().session.doc().unwrap().uid; + assert_ne!(first, second); + assert_eq!(h.state().session.documents().len(), 1, "one workbook, two windows"); + let titles: Vec = h.state().open_windows().iter().map(|w| w.title.clone()).collect(); + assert_eq!(titles, ["Book1:1", "Book1:2"]); + + // A draft typed in window 2 is entered in window 2's cell before window 1 comes forward. + h.state_mut().run("selection.set", serde_json::json!({"range": "D4"})).unwrap(); + h.input_mut().events.push(egui::Event::Text("typed".into())); + h.run_steps(2); + h.state_mut().run_or_alert("view.switchWindows", serde_json::json!({"window": first})); + h.run_steps(2); + assert!(h.state().editor.is_none(), "the draft doesn't follow into window 1"); + assert_eq!(h.state().session.active_window(), Some(first)); + let d = h.state().session.doc().unwrap(); + assert_eq!(d.wb.active().unwrap().value(CellRef::parse("D4").unwrap()), Value::text("typed")); + assert_eq!(d.selection.active, CellRef::parse("B2").unwrap(), "window 1 keeps its own selection"); + + // Quitting asks once about the workbook, whichever window shows it. + request_window_close(&mut h); + let sent = click_commands(&mut h, "Don't Save"); + assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); + assert!(h.state().session.document_index(book).is_none(), "both windows closed with the workbook"); +} + +#[test] +fn control_requests_route_to_a_window_and_list_windows() { + use gridcraft_ui_egui::control::{Outcome, handle}; + let mut h = harness(Services::default()); + let first = h.state().session.active_window().unwrap(); + h.state_mut().run_or_alert("file.new", serde_json::json!({})); + h.run_steps(2); + let ctx = h.ctx.clone(); + let call = |h: &mut egui_kittest::Harness<'static, SheetApp>, method: &str, p: serde_json::Value| match handle(h.state_mut(), &ctx, method, &p) { + Outcome::Done(v) => v, + Outcome::Screenshot { .. } => serde_json::Value::Null, + }; + let r = call(&mut h, "engine.execute", serde_json::json!({"command": "cell.set", "params": {"cell": "A1", "input": "one"}, "window": first})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(a1(&h, 0), Value::text("one")); + assert_eq!(a1(&h, 1), Value::Empty); + assert_eq!(h.state().session.active_index(), 1, "Book2 stays in front"); + let r = call(&mut h, "cell.get", serde_json::json!({"cell": "A1", "window": first})); + assert!(r["result"].to_string().contains("one"), "{r}"); + + let r = call(&mut h, "window.list", serde_json::json!({})); + let rows = r["result"].as_array().unwrap(); + assert_eq!(rows.len(), 2, "{r}"); + assert_eq!((rows[0]["window"].as_u64(), rows[0]["title"].as_str(), rows[0]["dirty"].as_bool()), (Some(first), Some("Book1"), Some(true))); + assert_eq!(rows[1]["active"].as_bool(), Some(true)); + let r = call(&mut h, "engine.execute", serde_json::json!({"command": "cell.get", "params": {"cell": "A1"}, "window": 999})); + assert_eq!(r["ok"], false, "{r}"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0c24e9252..00bbc8fc5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -23,13 +23,14 @@ connection; nothing sent after it runs. Blank lines are skipped. | Method | Params | What it does | |---|---|---| -| `engine.execute` | `{command, params}` | Runs any engine command (see `engine.commands` or `gridcraft-cli commands`). `edit.goTo`, `edit.goToSpecial`, `selection.set` and `selection.move` scroll the active cell into view, as Go To does in the window | +| `engine.execute` | `{command, params, window?, doc?}` | Runs any engine command (see `engine.commands` or `gridcraft-cli commands`) in the active window, or in the window named by `window` (a window id) or `doc` (a workbook id: its most recent window). The active window stays in front afterwards unless the command itself switches windows. `edit.goTo`, `edit.goToSpecial`, `selection.set` and `selection.move` scroll the active cell into view, as Go To does in the window | +| `window.list` | | The open windows: `[{window, doc, index, title, dirty, active, hidden}]`. `title` is the workbook name, or "Book1:1", "Book1:2"… when a workbook has several windows; `index` is the workbook's position among the open workbooks | | `engine.commands` | `{search?}` | Every command: id, label, ribbon path, shortcut, params doc, enabled; `search` keeps those whose id, label or ribbon path contains it (case-insensitive) | | `engine.journal` | | Commands run so far (replayable). Passwords (`password`, `…Password` params) are left out, so a replayed Protect Sheet has no password. Capped at 10,000 entries and 64 MB (serialized); past either cap the oldest entries go | | `document.inspect` | | Workbook summary: sheets, used ranges, tables, charts, names, selection, undo labels | | `sheet.read` | `{range?, sheet?, formulas?, formatted?}` | Values (or formulas / displayed text) of a range | | `cell.get` | `{cell?}` | One cell: value, formula, displayed text, style, merge, comment, link | -| `ui.inspect` | | UI state: ribbon tab, editor, open dialog, Backstage page, message, grid rect, scroll, perf | +| `ui.inspect` | | UI state: ribbon tab, editor, open dialog, Backstage page, message, grid rect, scroll, perf, `windows` (as `window.list`) and `activeWindow` | | `ui.click` / `ui.drag` / `ui.move` | `{x, y, toX?, toY?, button?, count?, shift?, cmd?, alt?}` | Real pointer input in window points; refused with an error if it would leave more than 10,000 pointer events queued | | `ui.cell.click` / `ui.cell.drag` | `{cell}` / `{from, to}` | Pointer input at a cell's centre (works in point mode while editing); same 10,000-event queue limit | | `ui.key` / `ui.text` | `{key, shift?, cmd?, alt?, ctrl?}` / `{text}` | Keyboard input (typing in the grid starts editing) | @@ -42,7 +43,20 @@ connection; nothing sent after it runs. Blank lines are skipped. | `ui.resize` / `ui.focus` | `{width, height}` | Window control | | `app.open` / `app.save` / `app.quit` | `{path, password?}` / `{path?}` | Files and lifetime. `password` opens a password-protected workbook; without it, such a workbook returns an error (never a dialog) | -Any other method name that is a command id (e.g. `home.bold`) runs that command. +Any other method name that is a command id (e.g. `home.bold`) runs that command. Its params may +include `window` or `doc` to run it in that window, as with `engine.execute`; the window commands +(`window.*`, `view.newWindow`, `view.hideWindow`, `view.unhideWindow`, `view.switchWindows`) read +them as their own parameters. + +### Windows + +Every open workbook has one or more windows; View › New Window (`view.newWindow`) adds another +on the same workbook, and saving from any of them saves that workbook. Each window has its own +selection, active sheet and scroll position; switching windows never marks the workbook as +changed. `window.activate {window}`, `view.switchWindows {window}` and `view.unhideWindow +{window}` bring a window forward; `window.close {window?, force?}` closes one (the workbook +closes with its last window, asking about unsaved changes unless `force`); `file.close` closes +the active window, or with `{document: true}` the workbook and all its windows. Use `engine.execute` with `{command: "view.theme", params: {mode: "system"}}` to choose the display theme; `mode` accepts `"system"`, `"light"`, or `"dark"`. diff --git a/docs/mcp.md b/docs/mcp.md index 1877cf228..8c45182c9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -41,6 +41,7 @@ with a file open. |---|---|---| | `execute_command` | `command`, `params?` | Runs **any** engine command (`cell.set`, `home.bold`, `data.removeDuplicates`, …). | | `list_commands` | `search?` | Command ids, labels, ribbon paths, shortcuts and parameter docs. | +| `list_windows` | — | Open windows: window id, workbook (`doc`) id, title ("Book1", or "Book1:2" for a workbook's second window), unsaved changes, active, hidden. | | `inspect_workbook` | — | Sheets, used ranges, tables, charts, names, selection, history. | | `read_range` | `range?`, `sheet?`, `formulas?`, `formatted?` | Rows of values (default: the used range). | | `write_range` | `range`, `values` (2-D) | `range.setValues`; strings starting with `=` are formulas. | @@ -57,6 +58,10 @@ with a file open. | `screenshot` | `path` | PNG of the window (Remote only). | | `list_functions` | `search?`, `category?` | Worksheet functions with signatures. | +Every tool that acts on a workbook (all but `list_commands`, `list_functions`, `list_windows`, `open_workbook`, +`new_workbook` and `screenshot`) also takes `window` or `doc`, an id from `list_windows`, and acts in that window +(for `doc`, the workbook's most recent window) without bringing it forward. Without them it acts in the active window. + Results are one text block holding pretty JSON; failures set `isError: true` with a readable message. Unknown tools, missing required arguments and wrong argument types are JSON-RPC `-32602` errors; malformed JSON is `-32700`; unknown methods `-32601`. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b70682ac3..839984959 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Switch Windows lists the open workbooks; a cell being typed stays in its own workbook through New, Open and window switches; macOS Quit asks about unsaved workbooks) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (New Window opens a second window on the same workbook, titled Name:1 / Name:2; Switch Windows lists the open windows; a cell being typed stays in its own window through New, Open and window switches; macOS Quit asks about unsaved workbooks) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -34,7 +34,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Dark mode, system appearance | yes | yes | done (10-09) | | Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | margins drag on the rulers (#792, with header and footer margins); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) | | Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) | -| Switch Windows, unsaved workbooks on quit | yes | yes (10-22): View › Switch Windows and the macOS Window menu list the open workbooks (active one checked, unsaved ones marked •) and bring the chosen one forward (`view.switchWindows {doc}`); a cell being typed is entered into its own workbook before New, Open, Close, Save, Print and window switches, and a refused entry stays open and stops the command; quitting (window close, Cmd+Q, the macOS Quit item) asks Save / Don't Save / Cancel for each unsaved workbook | one OS window for all workbooks (#162) | +| Switch Windows, New Window, unsaved workbooks on quit | yes | yes (10-22): View › New Window opens another window on the same workbook (no copy: saving from either saves it), each with its own selection, active sheet and scroll; a workbook with several windows titles them "Book1:1", "Book1:2"; File › Close closes one of them without asking and asks only for the last; View › Switch Windows and the macOS Window menu list the open windows (active one checked, unsaved ones marked •) and bring the chosen one forward (`view.switchWindows {window}`, or `{doc}` for a workbook's most recent window); a cell being typed is entered into its own window and workbook before New, Open, Close, Save, Print and window switches (also between two windows on one workbook), and a refused entry stays open and stops the command; quitting (window close, Cmd+Q, the macOS Quit item) asks Save / Don't Save / Cancel for each unsaved workbook | one OS window for all workbooks (#162) | ## Grid interaction @@ -177,6 +177,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | New Window opens a second window on the same workbook instead of a copy that saved over the original; windows keep their own selection, sheet and scroll; titles Name:1 / Name:2; Switch Windows, Hide and Unhide work by window; File › Close closes one window of several without asking | | 2026-10-22 | minor | New workbook no longer loses the first one: the cell being typed is entered into its own workbook before any File or window command (an entry the workbook refuses stays open and the command doesn't run; an untouched F2 draft changes nothing), the editor is tied to its workbook, View › Switch Windows and the macOS Window menu list the open workbooks, and the macOS Quit item asks about unsaved workbooks like the close button | | 2026-10-21 | minor | Background error checking, first part (#1994): a constant whose text would be a number if typed (signs, thousands, currency, percent, exponent, parenthesised negative) gets Excel's green corner triangle; the active cell then shows a warning button whose menu names the problem and offers Convert to Number for the selection (`formulas.convertToNumber`). Dates, times and fractions kept as text and formula results are not flagged, as in Excel | | 2026-10-21 | minor | Paste Special laid out as Excel's: Paste and Operation options each in two columns inside a frame, Skip blanks and Transpose side by side, Paste Link as a button that pastes references at once; labels translated (the generic form didn't translate them) and the window no longer runs past the screen | From 5bd5d241a0825a4af8551cc28c6c97b9b31f2d66 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:21:59 +0000 Subject: [PATCH 05/76] G3: a native window per workbook window Each engine window is drawn in its own native window through craft-window's immediate-viewport driver (desktop only; the web app keeps one window). The root viewport shows one window and adopts another when its window closes. - WindowUi: the cell draft, text-box draft, dialog, Backstage, message, toast, notice, grid state, name box, key tips, selected chart, Tab run and edit mode are per window. SheetApp's fields hold the active window's; the others are parked and swapped in when a window is drawn or activated, and after any command that switched windows. - Session::show_window makes a window current for drawing without counting as an activation. - The ribbon in each window acts on it; menu bar commands and scripts go to the window focused last. Leaving a window enters its draft as Enter would. - A window's close button runs window.close: one of several windows on a workbook closes without asking, a workbook's last asks when unsaved. SheetApp::request_quit (macOS Quit item, Dock quit) asks about each unsaved workbook in its window; Cancel stops. Save Changes answers moved to SheetApp::answer_save_changes. - The last window closed quits on Windows and Linux; macOS hides the root and keeps running until New or Open. - Fonts are bound for every open workbook, since each may be showing. craft-window is pinned to the aiongg/craft-libs window-list branch until it merges into storytold/craft-libs. --- Cargo.lock | 10 + Cargo.toml | 4 + ROADMAP.md | 4 +- apps/gridcraft/src/apple_events.rs | 3 +- apps/gridcraft/src/main.rs | 20 +- apps/gridcraft/src/native_menu.rs | 14 +- crates/engine/src/windows.rs | 20 +- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/dialogs.rs | 25 +-- crates/ui-egui/src/doc_windows.rs | 227 ++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 148 +++++++++++++-- crates/ui-egui/src/window_ui.rs | 133 +++++++++++++ crates/ui-egui/tests/doc_windows.rs | 284 ++++++++++++++++++++++++++++ docs/hardware-parity.md | 5 +- docs/ui-parity.md | 7 +- 15 files changed, 840 insertions(+), 65 deletions(-) create mode 100644 crates/ui-egui/src/doc_windows.rs create mode 100644 crates/ui-egui/src/window_ui.rs create mode 100644 crates/ui-egui/tests/doc_windows.rs diff --git a/Cargo.lock b/Cargo.lock index e041aafbc..77b3b801a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -758,6 +758,15 @@ dependencies = [ "egui", ] +[[package]] +name = "craft-window" +version = "0.1.0" +source = "git+https://github.com/aiongg/craft-libs?rev=4fbdea5fcf86ba893d9bc03685d4531cfdd4efc2#4fbdea5fcf86ba893d9bc03685d4531cfdd4efc2" +dependencies = [ + "egui", + "wayland-client", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -1636,6 +1645,7 @@ name = "gridcraft-ui-egui" version = "0.10.0" dependencies = [ "craft-ui", + "craft-window", "egui", "egui_kittest", "epaint_default_fonts", diff --git a/Cargo.toml b/Cargo.toml index e8a6dc67e..b28d7eb33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,10 @@ sys-locale = "0.3" skrifa = { version = "0.44", default-features = false, features = ["std"] } # Real font bytes for tests (Ubuntu Light, compiled into epaint). epaint_default_fonts = "0.36" +# One native window per workbook: the window list, Window menu titles and the immediate-viewport +# driver (craft-libs). Pinned to the aiongg fork's `window-list` branch; repins to +# storytold/craft-libs once that branch merges there. +craft-window = { version = "0.1.0", git = "https://github.com/aiongg/craft-libs", rev = "4fbdea5fcf86ba893d9bc03685d4531cfdd4efc2", default-features = false, features = ["egui"] } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/ROADMAP.md b/ROADMAP.md index bf25cc3a9..38c394fb0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** minor (no lost workbook after New: drafts stay in their workbook, Switch Windows, macOS Quit asks to save; New Window shares the workbook) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** minor (a native window per workbook window; no lost workbook after New: drafts stay in their workbook, Switch Windows, macOS Quit asks to save; New Window shares the workbook) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-22 | On the desktop every workbook window is a native window of its own: New, Open and New Window open one, each keeps its own cell draft, dialog and ribbon, and the macOS menu bar acts on the window focused last. Closing one of several windows on a workbook doesn't ask; closing a workbook's last window, or quitting, asks about unsaved changes in that window. The last window closed quits on Windows and Linux; macOS keeps running with the menu bar. First craft-libs `craft-window` use (window list and viewport driver) | | 2026-10-22 | View › New Window opens a second window on the same workbook (it used to open a copy whose Save overwrote the original): each window has its own selection, active sheet and scroll, titles read "Book1:1" / "Book1:2", and Switch Windows, Hide, Unhide and Close act per window. The control channel, CLI `send` and MCP tools take an optional `window` or `doc` id and default to the active window; `window.list` (MCP `list_windows`) lists the windows | | 2026-10-22 | Typing in a cell and then choosing File › New no longer strands the first workbook: the entry is committed into its own workbook before any File or window command (a refused entry stays open and stops the command), View › Switch Windows and the macOS Window menu list the open workbooks with unsaved markers, `view.switchWindows {doc}` switches by a stable workbook id, and the macOS Quit item asks about unsaved workbooks | | 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here | @@ -222,6 +223,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Progress log: a native window per workbook window | | 2026-10-22 | minor | Progress log: New Window shares the workbook; window ids for scripts and MCP | | 2026-10-22 | minor | Progress log: drafts stay in their workbook, Switch Windows, macOS Quit asks to save | | 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips | diff --git a/apps/gridcraft/src/apple_events.rs b/apps/gridcraft/src/apple_events.rs index 405ee89a0..4d596f957 100644 --- a/apps/gridcraft/src/apple_events.rs +++ b/apps/gridcraft/src/apple_events.rs @@ -43,7 +43,8 @@ pub fn poll(inbox: &Inbox, app: &mut SheetApp, ctx: &egui::Context) { app.open_path(p); } } - Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + // Quit from the Dock or the system: each unsaved workbook is asked about in its window. + Event::Quit => app.request_quit(ctx), } } } diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 8aad9aeb8..c0b50038d 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -22,24 +22,27 @@ use std::cell::RefCell; use std::rc::Rc; use gridcraft_engine::Session; +use gridcraft_ui_egui::doc_windows::DocWindows; use gridcraft_ui_egui::{Services, SheetApp}; use serde_json::json; -struct App(SheetApp, #[cfg(target_os = "macos")] Option, #[cfg(target_os = "macos")] fmv_macos_events::Inbox); +/// The app, its native window per workbook window, and (macOS) the menu bar and Finder events. +struct App(SheetApp, DocWindows, #[cfg(target_os = "macos")] Option, #[cfg(target_os = "macos")] fmv_macos_events::Inbox); impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { #[cfg(target_os = "macos")] { - if self.1.is_none() && std::env::var_os("GRIDCRAFT_NO_NATIVE_MENU").is_none() { - self.1 = Some(native_menu::NativeMenu::install(ctx)); + if self.2.is_none() && std::env::var_os("GRIDCRAFT_NO_NATIVE_MENU").is_none() { + self.2 = Some(native_menu::NativeMenu::install(ctx)); } - if let Some(m) = &mut self.1 { - m.poll(&mut self.0, ctx); + if let Some(m) = &mut self.2 { + m.poll(&mut self.0, ctx, self.1.focused_viewport()); } - apple_events::poll(&self.2, &mut self.0, ctx); + apple_events::poll(&self.3, &mut self.0, ctx); } self.0.logic(ctx); + self.1.logic(&mut self.0, ctx); self.0.poll_printing(ctx); if ctx.input(|input| input.viewport().close_requested()) && self.0.printing.busy() { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); @@ -55,7 +58,7 @@ impl eframe::App for App { } } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - self.0.ui(ui); + self.1.show(&mut self.0, ui); } fn on_exit(&mut self) { print::finish_pending_jobs(); @@ -320,6 +323,7 @@ fn main() -> eframe::Result<()> { if let Some(i) = icon() { viewport = viewport.with_icon(i); } + let window_builder = viewport.clone(); let options = eframe::NativeOptions { viewport, ..Default::default() }; // Before eframe creates the wgpu instance: Windows starts on DirectX 12, or OpenGL when // DirectX 12 has no GPU driver, and never touches Vulkan unless asked (see graphics.rs). @@ -357,8 +361,10 @@ fn main() -> eframe::Result<()> { Err(e) => log::error!("control channel on port {port}: {e}"), } } + let windows = DocWindows::new(&mut app, window_builder.clone(), DocWindows::platform_quits_with_last_window()); Ok(Box::new(App( app, + windows, #[cfg(target_os = "macos")] None, #[cfg(target_os = "macos")] diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index 826a12b31..9559c47d7 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -203,8 +203,8 @@ pub struct NativeMenu { /// Menu id of the Window menu item that brings workbook `doc` forward. const WINDOW_ITEM: &str = "window:id:"; -/// The Quit item: closing the window asks about unsaved workbooks first, which AppKit's own -/// Quit (terminate:) would skip. +/// The Quit item: asks about each unsaved workbook in its window first +/// (`SheetApp::request_quit`), which AppKit's own Quit (terminate:) would skip. const QUIT: &str = "app.quit"; fn select_theme(app: &mut SheetApp, id: &str) -> bool { @@ -311,11 +311,13 @@ impl NativeMenu { } } - pub fn poll(&mut self, app: &mut SheetApp, ctx: &egui::Context) { + /// Runs the chosen menu items. Commands go to the active window, the most recently focused; + /// `viewport` is its egui viewport, for the items that act on its input. + pub fn poll(&mut self, app: &mut SheetApp, ctx: &egui::Context, viewport: egui::ViewportId) { while let Ok(ev) = self.events.try_recv() { let id = ev.id.as_ref().to_string(); if id == QUIT { - ctx.send_viewport_cmd(egui::ViewportCommand::Close); + app.request_quit(ctx); continue; } if let Some(window) = id.strip_prefix(WINDOW_ITEM).and_then(|w| w.parse::().ok()) { @@ -331,11 +333,11 @@ impl NativeMenu { _ => None, }; if let Some(command) = clipboard { - ctx.send_viewport_cmd(command); + ctx.send_viewport_cmd_to(viewport, command); continue; } if id == "edit.undo" || id == "edit.redo" { - ctx.input_mut(|i| { + ctx.input_mut_for(viewport, |i| { i.events.push(egui::Event::Key { key: egui::Key::Z, physical_key: None, diff --git a/crates/engine/src/windows.rs b/crates/engine/src/windows.rs index 8dcb92011..4441166db 100644 --- a/crates/engine/src/windows.rs +++ b/crates/engine/src/windows.rs @@ -166,6 +166,16 @@ impl Session { /// Makes a window active, swapping its view into its workbook. Unknown ids are ignored. pub fn activate_window(&mut self, id: u64) { + self.enter_window(id, true); + } + + /// Makes a window the one commands act on while the interface draws it, without counting as + /// an activation: the most recently active window of each workbook stays the same. + pub fn show_window(&mut self, id: u64) { + self.enter_window(id, false); + } + + fn enter_window(&mut self, id: u64, recent: bool) { let Some(wi) = self.windows.iter().position(|w| w.id == id) else { return }; let Some(w) = self.windows.get(wi) else { return }; let doc = w.doc_uid; @@ -185,10 +195,12 @@ impl Session { } } } - self.activation = self.activation.saturating_add(1); - let stamp = self.activation; - if let Some(w) = self.windows.get_mut(wi) { - w.last_active = stamp; + if recent { + self.activation = self.activation.saturating_add(1); + let stamp = self.activation; + if let Some(w) = self.windows.get_mut(wi) { + w.last_active = stamp; + } } self.active_window = Some(id); if let Some(di) = self.document_index(doc) { diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 85a24dda9..626d5e7fa 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -33,6 +33,7 @@ gridcraft-xlsx = { workspace = true, features = ["test-support"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # The desktop's preferred language for the first-run interface language. sys-locale = { workspace = true } +craft-window = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] # Read the browser's preferred language for the first-run web interface language. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index be474d37f..55c0819df 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1323,29 +1323,14 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { } "saveChanges" => { ui.label(format!("Do you want to save the changes you made to {}?", d.values.get("title").and_then(Json::as_str).unwrap_or("this workbook"))); - // Asked while quitting: once answered, the next unsaved workbook is asked about. let quitting = d.values.get("quit").and_then(Json::as_bool) == Some(true); ui.horizontal(|ui| { - if ui.button("Don't Save").clicked() { - app.run_or_alert("file.close", json!({"force": true, "document": true})); - if quitting { - app.continue_quit(ui.ctx()); + for (label, answer) in + [("Don't Save", crate::SaveAnswer::DontSave), ("Cancel", crate::SaveAnswer::Cancel), ("Save", crate::SaveAnswer::Save)] + { + if ui.button(label).clicked() && app.answer_save_changes(answer, quitting, ui.ctx()) { + open = false; } - open = false; - } - if ui.button("Cancel").clicked() { - open = false; - } - // File operations must include the cell the user is still editing (as the File > Save menu item already does). - if ui.button("Save").clicked() && app.commit_edit(0, 0, false, false) { - app.run_or_alert("file.save", json!({})); - if app.session.active().is_some_and(|x| !x.is_dirty()) { - app.run_or_alert("file.close", json!({"force": true, "document": true})); - if quitting { - app.continue_quit(ui.ctx()); - } - } - open = false; } }); } diff --git a/crates/ui-egui/src/doc_windows.rs b/crates/ui-egui/src/doc_windows.rs new file mode 100644 index 000000000..88ba3967a --- /dev/null +++ b/crates/ui-egui/src/doc_windows.rs @@ -0,0 +1,227 @@ +//! Desktop: each workbook window is a native window of its own. +//! +//! The engine owns the windows ([`Session::windows`]); [`DocWindows`] mirrors the visible ones in +//! a [`WindowList`] and draws each through [`Viewports`]: the root viewport shows one window and +//! every other window is an immediate viewport. Before a window draws, it becomes the engine's +//! window and its interface state is swapped in ([`SheetApp::show_window`]); after the frame the +//! focused window is active again, so menu commands, scripts and shortcuts outside a window's own +//! input go to the window the user worked in last. +//! +//! Close and quit: +//! - A window's close button closes that window (`window.close`): one of several windows on a +//! workbook closes without asking; a workbook's last window asks Save / Don't Save / Cancel when +//! it has unsaved changes. +//! - Quit ([`SheetApp::request_quit`]) asks about each unsaved workbook in its window; Cancel stops. +//! - When the root's window closes while others are open, another window moves into the root. +//! - With no window left, the app quits, or (macOS) keeps running with its root hidden and the +//! menu bar showing until a workbook is created or opened. +//! +//! [`Session::windows`]: gridcraft_engine::Session::windows + +use craft_window::{Frame, Viewports, WindowEvent, WindowId, WindowList}; +use serde_json::json; + +use crate::SheetApp; + +/// The native windows (see the module docs). +pub struct DocWindows { + /// Engine window id per native window. + list: WindowList, + viewports: Viewports, + /// The builder new windows start from: the root window's size, icon and frame settings. + base: egui::ViewportBuilder, + /// Quit when the last window closes (Windows, Linux); macOS keeps running without a window. + quit_with_last_window: bool, + /// The root viewport is hidden because no window is open. + root_hidden: bool, +} + +impl DocWindows { + /// Takes over `app`'s windows. `base` is the root window's builder, used for new windows. + pub fn new(app: &mut SheetApp, base: egui::ViewportBuilder, quit_with_last_window: bool) -> DocWindows { + app.native_windows = true; + // The root window keeps the desktop's frame, so every window does. + DocWindows { list: WindowList::new(), viewports: Viewports::new(Frame::Desktop), base, quit_with_last_window, root_hidden: false } + } + + /// The platform default for the last window closing: macOS keeps running, others quit. + pub fn platform_quits_with_last_window() -> bool { + !cfg!(target_os = "macos") + } + + /// The native window showing engine window `window`. + fn find(&self, window: u64) -> Option { + self.list.find(|w| *w == window) + } + + /// The engine window drawn in the root viewport. + pub fn root_window(&self) -> Option { + self.viewports.root().and_then(|w| self.list.get(w).copied()) + } + + /// The engine windows that have a native window, in opening order. + pub fn shown_windows(&self) -> Vec { + self.list.iter().map(|(_, w)| *w).collect() + } + + /// The egui viewport showing engine window `window`. + pub fn viewport_of(&self, window: u64) -> Option { + self.find(window).map(|id| self.viewports.viewport_id(id)) + } + + /// The egui viewport of the focused window, where menu commands that act on input (copy, + /// paste, undo in a text box) go. + pub fn focused_viewport(&self) -> egui::ViewportId { + self.list.active().map(|w| self.viewports.viewport_id(w)).unwrap_or(egui::ViewportId::ROOT) + } + + /// Call each frame after [`SheetApp::logic`], also while the root is hidden (eframe runs only + /// the logic then): shows windows created or opened by menu commands, files and scripts. + pub fn logic(&mut self, app: &mut SheetApp, ctx: &egui::Context) { + self.sync(app, ctx); + } + + /// Mirrors the engine's visible windows: closed or hidden windows lose their native window + /// (the root adopting another), new ones get one, and a window a command activated comes to + /// the front. With no window left, quits or hides the root. + fn sync(&mut self, app: &mut SheetApp, ctx: &egui::Context) { + let visible = visible_windows(app); + let gone: Vec = self.list.iter().filter(|(_, w)| !visible.contains(w)).map(|(id, _)| id).collect(); + let mut root_closed = false; + for id in gone { + root_closed |= self.viewports.root() == Some(id); + self.list.close(id); + } + if root_closed { + // The window the engine brought forward, else the most recently focused. + let next = app.session.active_window().and_then(|w| self.find(w)).or_else(|| self.list.active()); + match next { + Some(id) => self.viewports.adopt(ctx, &self.list, id), + None => self.viewports.set_root(None), + } + } + for w in visible { + if self.find(w).is_none() { + self.list.open(w); + } + } + if self.viewports.root().is_none() { + let first = app.session.active_window().and_then(|w| self.find(w)).or_else(|| self.list.active()); + self.viewports.set_root(first); + } + if self.list.is_empty() { + if self.quit_with_last_window { + if !app.allow_close { + app.allow_close = true; + ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Close); + } + } else if !self.root_hidden { + self.root_hidden = true; + ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Visible(false)); + } + return; + } + if self.root_hidden { + self.root_hidden = false; + ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Focus); + } + // A command or script activated another window: bring its native window forward. + if let Some(id) = app.session.active_window().and_then(|w| self.find(w)) + && self.list.active() != Some(id) + { + self.list.focus(id); + self.viewports.focus(ctx, id); + } + } + + /// Draws every window. Call from the root viewport's UI each frame, in place of + /// [`SheetApp::ui`]. + pub fn show(&mut self, app: &mut SheetApp, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + self.sync(app, &ctx); + let focused = self.list.active().and_then(|id| self.list.get(id).copied()); + app.focus_window = focused; + let titles: std::collections::HashMap = self.list.iter().map(|(_, w)| (*w, window_title(app, *w))).collect(); + let base = &self.base; + // The window a command run in some window's UI activated (File › New, Switch Windows, a close). + let mut requested: Option = None; + let events = self.viewports.show( + ui, + &mut self.list, + |_, w| base.clone().with_title(titles.get(w).cloned().unwrap_or_default()), + |ui, _, w| { + app.show_window(*w); + // Files dropped on a window open as workbooks (the root's arrive through the app's + // raw input hook; a child window's input reaches only its own pass). + let dropped: Vec = ui + .ctx() + .input(|i| i.raw.dropped_files.iter().filter_map(|f| f.path().to_str().map(str::to_string)).filter(|p| !p.is_empty()).collect()); + for path in dropped { + app.open_path(&path); + } + app.ui(ui); + if app.session.active_window() != Some(*w) { + requested = app.session.active_window(); + } + }, + ); + for event in events { + match event { + WindowEvent::Focused(_) => {} + WindowEvent::CloseRequested(id) => { + if let Some(w) = self.close_requested(app, &ctx, id) { + requested = Some(w); + } + } + } + } + let target = requested.or_else(|| self.list.active().and_then(|id| self.list.get(id).copied())); + if let Some(target) = target.filter(|w| app.session.window(*w).is_some()) { + if requested.is_none() + && let Some(previous) = focused.filter(|p| *p != target && app.session.window(*p).is_some()) + { + // The user moved to another window: the previous window's draft is entered there, + // as Enter would. One its workbook refuses stays open in that window. + app.enter_window(previous); + let _ = app.settle_drafts(); + } + app.enter_window(target); + } + self.sync(app, &ctx); + app.focus_window = self.list.active().and_then(|id| self.list.get(id).copied()); + } + + /// A window's close button. Returns the window to bring forward when the close needs an + /// answer (or moved focus); `None` when nothing changed. + fn close_requested(&mut self, app: &mut SheetApp, ctx: &egui::Context, id: WindowId) -> Option { + let is_root = self.viewports.root() == Some(id); + if app.allow_close { + // Quitting: every workbook has been answered for; the root's close ends the app. + return None; + } + if is_root { + // eframe would end the app with the root window; closing it is the app's decision. + ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::CancelClose); + } + let window = *self.list.get(id)?; + app.enter_window(window); + app.run_or_alert("window.close", json!({"window": window})); + app.session.active_window() + } +} + +/// The windows that get a native window: every window not hidden (View › Hide), or the active one +/// when all are hidden, so the app still has a window to unhide them from. +fn visible_windows(app: &SheetApp) -> Vec { + let shown: Vec = app.session.windows().iter().filter(|w| !w.hidden).map(|w| w.id).collect(); + if shown.is_empty() { app.session.active_window().into_iter().collect() } else { shown } +} + +/// A native window's title: the window's ("Book1", "Book1:2"), marked while its workbook has +/// unsaved changes. +fn window_title(app: &SheetApp, window: u64) -> String { + let title = app.session.window_title(window).unwrap_or_default(); + let dirty = app.session.window(window).and_then(|w| app.session.documents().iter().find(|d| d.uid == w.doc_uid)).is_some_and(|d| d.is_dirty()); + if dirty { format!("{title} — Edited") } else { title } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d330d1fcf..f405a3d18 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -13,6 +13,8 @@ pub mod chartview; pub mod control; pub mod credits; pub mod dialogs; +#[cfg(not(target_arch = "wasm32"))] +pub mod doc_windows; pub mod editor; mod floating_pictures; pub mod fonts; @@ -34,6 +36,9 @@ pub mod tabs; pub mod text_box; pub mod theme; pub mod widgets; +mod window_ui; + +pub use window_ui::WindowUi; use std::collections::HashMap; @@ -166,6 +171,14 @@ pub struct Notice { pub action: Option<(String, String)>, } +/// An answer to the Save Changes question. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SaveAnswer { + Save, + DontSave, + Cancel, +} + /// One open window, as View › Switch Windows and the macOS Window menu list it. #[derive(Clone, Debug, PartialEq, Eq)] pub struct OpenWindow { @@ -282,8 +295,8 @@ pub struct SheetApp { fonts_set: bool, /// Workbook fonts bound into the current font set; a change rebinds (installed fonts only). fonts_bound: Vec, - /// The (doc uid, revision) `fonts_bound` was computed for, so the style table is walked only - /// when the workbook changes. + /// The open workbooks' (uid, revision), folded, that `fonts_bound` was computed for, so the + /// style tables are walked only when a workbook changes. fonts_seen: (u64, u64), /// Set while a font-set change is pending activation: skip this frame's paint, like the /// startup `fonts_ready` gate (egui applies new definitions at the next pass). @@ -320,6 +333,17 @@ pub struct SheetApp { /// View › Switch Windows opens its list on the next frame the View tab draws (asked for by a /// key tip, the command search or a menu, possibly while another tab was showing). pub(crate) open_switch_windows: bool, + /// The window whose interface state the per-window fields above hold (see [`WindowUi`]). + ui_window: Option, + /// The other windows' interface state, by window id. + parked: HashMap, + /// Each window is a native window of its own ([`doc_windows`] on the desktop): a window's + /// close button closes that window, and quitting is asked for separately. Without it (web, + /// tests, embedding hosts) one window shows the active workbook window, and its close button + /// quits. + pub native_windows: bool, + /// The native window that has focus, which menu commands and screenshots go to. + pub(crate) focus_window: Option, } impl SheetApp { @@ -367,6 +391,10 @@ impl SheetApp { started: now_ms(), allow_close: false, open_switch_windows: false, + ui_window: None, + parked: HashMap::new(), + native_windows: false, + focus_window: None, } } @@ -385,11 +413,12 @@ impl SheetApp { theme::apply(ctx, dark); } - /// The font names the active workbook uses: every style's font plus the theme's heading/body - /// fonts. These are the families [`fonts`] binds an installed file for. + /// The font names the open workbooks use: every style's font plus the theme's heading/body + /// fonts. These are the families [`fonts`] binds an installed file for. Every workbook's, as + /// each may be showing in a window of its own. pub fn used_fonts(&self) -> Vec { let mut names: Vec = Vec::new(); - if let Some(d) = self.session.active() { + for d in self.session.documents() { names.extend(d.wb.styles.iter().map(|(_, s)| s.font.name.clone())); names.push(d.wb.theme.major_font.clone()); names.push(d.wb.theme.minor_font.clone()); @@ -441,7 +470,14 @@ impl SheetApp { if let Some(r) = self.run_ui_command(id, ¶ms) { return r; } + let before = self.session.active_window(); let r = self.session.run(id, params); + if id.starts_with("file.") && self.session.active_window() != before { + // File › New or Open from the Backstage view: the window it was in goes back to its sheet. + self.backstage = None; + } + // Requests the command made (a dialog, a draft) belong to the window it left active. + self.follow_active_window(); self.after_engine(); // The new chart is selected, as after inserting any object, so Delete removes it, not its data. if id == "insert.chart" @@ -1065,7 +1101,7 @@ impl SheetApp { /// Commits the cell and text-box drafts, as Enter would, before a command that can show /// another workbook or write this one to a file, so a draft never lands in another workbook. /// A draft its workbook refuses stays open with the reason shown, and the command doesn't run. - fn settle_drafts(&mut self) -> Result<(), String> { + pub(crate) fn settle_drafts(&mut self) -> Result<(), String> { if !self.commit_edit(0, 0, false, false) { return Err(self.message.as_ref().map(|(_, m)| m.clone()).unwrap_or_else(|| "The cell being edited can't be entered.".into())); } @@ -1216,7 +1252,7 @@ impl SheetApp { /// Holds back a window close (the close button, Alt+F4, the OS quitting the app) while a /// workbook has unsaved changes, and asks about them first. fn guard_window_close(&mut self, ctx: &egui::Context) { - if self.allow_close || !ctx.input(|i| i.viewport().close_requested()) { + if self.native_windows || self.allow_close || !ctx.input(|i| i.viewport().close_requested()) { return; } // A cell still being edited is part of the workbook, as for File › Close. @@ -1237,21 +1273,75 @@ impl SheetApp { } } - /// Asks about the next unsaved workbook before quitting, or closes the window once none is left. + /// Asks about the next unsaved workbook before quitting, in its window, or closes the app + /// once none is left. pub(crate) fn continue_quit(&mut self, ctx: &egui::Context) { - match self.session.documents().iter().position(|d| d.is_dirty()) { - Some(i) => { - self.session.set_active(i); + let dirty = self.session.documents().iter().find(|d| d.is_dirty()).map(|d| d.uid); + match dirty.and_then(|uid| self.session.doc_window(uid)) { + Some(window) => { + // Its window comes forward (the native window too: see `doc_windows`). + self.enter_window(window); let title = self.session.active().map(|d| d.display_title()).unwrap_or_default(); self.open_dialog("saveChanges", json!({"title": title, "quit": true})); } None => { self.allow_close = true; - ctx.send_viewport_cmd(egui::ViewportCommand::Close); + ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Close); } } } + /// Answers the Save Changes question about the active workbook (its buttons). While quitting, + /// the next unsaved workbook is asked about once this one is answered; Cancel stops the quit. + /// Returns whether the question is settled: Save keeps it open when the cell being edited + /// can't be entered. + pub fn answer_save_changes(&mut self, answer: SaveAnswer, quitting: bool, ctx: &egui::Context) -> bool { + match answer { + SaveAnswer::Cancel => {} + SaveAnswer::DontSave => { + self.run_or_alert("file.close", json!({"force": true, "document": true})); + if quitting { + self.continue_quit(ctx); + } + } + SaveAnswer::Save => { + // File operations include the cell still being edited, as File › Save does. + if !self.commit_edit(0, 0, false, false) { + return false; + } + self.run_or_alert("file.save", json!({})); + if self.session.active().is_some_and(|x| !x.is_dirty()) { + self.run_or_alert("file.close", json!({"force": true, "document": true})); + if quitting { + self.continue_quit(ctx); + } + } + } + } + true + } + + /// Quit (the macOS app menu, ⌘Q): commits every window's cell draft as Enter would, then asks + /// about each unsaved workbook in its window; Cancel on any of them stops the quit. A draft its + /// workbook refuses stops the quit too, with its window in front and the reason shown. + pub fn request_quit(&mut self, ctx: &egui::Context) { + while let Some(window) = self.parked_window_with_draft() { + self.enter_window(window); + if self.settle_drafts().is_err() { + return; + } + } + if self.settle_drafts().is_err() { + return; + } + if let Some(d) = self.dialog.as_mut().filter(|d| d.name() == "saveChanges") { + // Already asking about a workbook (File › Close): answering it goes on to quit. + d.values.insert("quit".into(), json!(true)); + return; + } + self.continue_quit(ctx); + } + /// Per-frame logic (control channel, screenshots). Call before `ui`. pub fn logic(&mut self, ctx: &egui::Context) { if self.session.text_width.is_none() { @@ -1288,7 +1378,11 @@ impl SheetApp { // Bind the workbook's installed fonts once the background scan has finished. Adding // fonts re-applies the font set, which egui activates at the next pass, so skip this // frame's paint (same one-frame cost as startup). Only when the workbook changed. - let seen = self.session.active().map(|d| (d.uid, d.revision)).unwrap_or((0, 0)); + let seen = self + .session + .documents() + .iter() + .fold((0u64, 0u64), |(uid, rev), d| (uid.wrapping_mul(31).wrapping_add(d.uid), rev.wrapping_mul(31).wrapping_add(d.revision))); if seen != self.fonts_seen { let used = self.used_fonts(); self.fonts_seen = seen; @@ -1322,9 +1416,10 @@ impl SheetApp { ctx.options_mut(|o| o.zoom_with_keyboard = false); } // egui closes the window on Ctrl+Q (and with Shift or Alt held too) without asking to save - // (#1443). Excel for Windows has no such key (Ctrl+Q is Quick Analysis); macOS keeps Cmd+Q, - // the app's Quit there. - if ctx.os() != egui::os::OperatingSystem::Mac && ctx.options(|o| !o.quit_shortcuts.is_empty()) { + // (#1443). Excel for Windows has no such key (Ctrl+Q is Quick Analysis). With a native + // window per workbook egui's Cmd+Q would close only the window it was typed in: the menu + // bar's Quit item has the key instead (it asks about unsaved workbooks first). + if (ctx.os() != egui::os::OperatingSystem::Mac || self.native_windows) && ctx.options(|o| !o.quit_shortcuts.is_empty()) { ctx.options_mut(|o| o.quit_shortcuts.clear()); } let zoom = self.ui.zoom_factor(); @@ -1357,9 +1452,12 @@ impl SheetApp { } self.after_engine(); } - if self.session.active().is_none() { + // One window always shows a workbook, unless each workbook has a native window of its own + // (`doc_windows` decides what having none left means). + if self.session.active().is_none() && !self.native_windows { self.session.new_workbook(); } + self.follow_active_window(); } pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { @@ -1383,6 +1481,10 @@ impl SheetApp { return; } let t0 = now_ms(); + // A script may have switched windows since the last frame. + self.follow_active_window(); + // Native windows: menu commands and screenshots go to the focused one. + let focused = !self.native_windows || self.focus_window == self.ui_window; text_box::before_ui(self, &ctx); // Another window came forward without going through a command (which settles drafts // first): the draft goes into its own window instead of floating over this one. If its @@ -1428,8 +1530,10 @@ impl SheetApp { dialogs::show(self, &ctx); widgets::message_box(self, &ctx); widgets::toast(self, &ctx); - control::issue_screenshots(self, &ctx); - control::collect_screenshots(self, &ctx); + if focused { + control::issue_screenshots(self, &ctx); + control::collect_screenshots(self, &ctx); + } if !ctx.input(|i| i.focused) { // Nothing else to do. } @@ -1453,8 +1557,10 @@ impl SheetApp { self.store_view_state(); let _ = self.session.run("file.save", json!({})); } - // Window title. - if let Some(d) = self.session.active() { + // Window title (native windows get theirs from `doc_windows`). + if !self.native_windows + && let Some(d) = self.session.active() + { let title = format!("{}{}", d.display_title(), if d.is_dirty() { " — Edited" } else { "" }); if self.grid.last_title.as_deref() != Some(title.as_str()) { ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs new file mode 100644 index 000000000..96aa83c79 --- /dev/null +++ b/crates/ui-egui/src/window_ui.rs @@ -0,0 +1,133 @@ +//! The interface state each window keeps for itself. +//! +//! [`SheetApp`]'s per-window fields (the cell draft, dialog, Backstage view, grid gestures…) hold +//! the state of one window: the engine's active window. Every other window's state is parked in a +//! [`WindowUi`] and swapped in when that window is activated or drawn, the way the engine swaps a +//! window's selection and sheet into its workbook. So a draft, a dialog or a message stays in the +//! window it belongs to. + +use gridcraft_engine::core::CellRef; + +use crate::{Notice, SheetApp, backstage, dialogs, editor, grid, keytips, text_box}; + +/// One window's parked interface state (see the module docs). +#[derive(Default)] +pub struct WindowUi { + editor: Option, + text_box_editor: Option, + dialog: Option, + backstage: Option, + message: Option<(String, String)>, + toast: Option<(String, f64)>, + notice: Option, + grid: grid::GridState, + name_box: Option, + keytips: keytips::KeyTips, + selected_chart: Option, + tab_run: Option<(u64, usize, u32, CellRef)>, + open_switch_windows: bool, + /// The status bar's Ready/Enter/Edit, which follows the window's cell draft. + mode: gridcraft_engine::Mode, +} + +impl WindowUi { + /// A new window's state. The last border, fill and font colour picked carry over, as they are + /// the user's recent choices rather than the window's. + fn fresh(like: &grid::GridState) -> WindowUi { + WindowUi { + grid: grid::GridState { + last_border: like.last_border.clone(), + last_fill: like.last_fill.clone(), + last_font_color: like.last_font_color.clone(), + ..Default::default() + }, + ..Default::default() + } + } +} + +impl SheetApp { + /// Exchanges the per-window fields with `w`. Destructured in full, so a field added to + /// [`WindowUi`] can't be left out of the swap. + fn swap_window_ui(&mut self, w: &mut WindowUi) { + let WindowUi { + editor, + text_box_editor, + dialog, + backstage, + message, + toast, + notice, + grid, + name_box, + keytips, + selected_chart, + tab_run, + open_switch_windows, + mode, + } = w; + std::mem::swap(&mut self.editor, editor); + std::mem::swap(&mut self.text_box_editor, text_box_editor); + std::mem::swap(&mut self.dialog, dialog); + std::mem::swap(&mut self.backstage, backstage); + std::mem::swap(&mut self.message, message); + std::mem::swap(&mut self.toast, toast); + std::mem::swap(&mut self.notice, notice); + std::mem::swap(&mut self.grid, grid); + std::mem::swap(&mut self.name_box, name_box); + std::mem::swap(&mut self.keytips, keytips); + std::mem::swap(&mut self.selected_chart, selected_chart); + std::mem::swap(&mut self.tab_run, tab_run); + std::mem::swap(&mut self.open_switch_windows, open_switch_windows); + std::mem::swap(&mut self.session.mode, mode); + } + + /// The window whose interface state is in the per-window fields. + pub fn ui_window(&self) -> Option { + self.ui_window + } + + /// Brings the per-window fields in line with the engine's active window, after a command (or + /// a script) switched windows: the previous window's state is parked, the active one's swapped + /// in (a window never seen before starts fresh). A closed window's state is dropped. + pub fn follow_active_window(&mut self) { + let active = self.session.active_window(); + if active == self.ui_window { + return; + } + let Some(previous) = self.ui_window else { + // The first window: the fields are already its state. + self.ui_window = active; + return; + }; + let mut outgoing = WindowUi::fresh(&self.grid); + self.swap_window_ui(&mut outgoing); + if self.session.window(previous).is_some() { + self.parked.insert(previous, outgoing); + } + if let Some(mut incoming) = active.and_then(|w| self.parked.remove(&w)) { + self.swap_window_ui(&mut incoming); + } + self.ui_window = active; + let session = &self.session; + self.parked.retain(|w, _| session.window(*w).is_some()); + } + + /// Activates window `id` (it becomes the most recently active) with its interface state. + pub fn enter_window(&mut self, id: u64) { + self.session.activate_window(id); + self.follow_active_window(); + } + + /// Makes window `id` and its interface state current while it is drawn, without changing + /// which window counts as most recently active. + pub fn show_window(&mut self, id: u64) { + self.session.show_window(id); + self.follow_active_window(); + } + + /// Whether a window other than the current one has a cell or text-box draft open. + pub fn parked_window_with_draft(&self) -> Option { + self.parked.iter().filter(|(_, w)| w.editor.is_some() || w.text_box_editor.is_some()).map(|(id, _)| *id).min() + } +} diff --git a/crates/ui-egui/tests/doc_windows.rs b/crates/ui-egui/tests/doc_windows.rs new file mode 100644 index 000000000..ff8e2d631 --- /dev/null +++ b/crates/ui-egui/tests/doc_windows.rs @@ -0,0 +1,284 @@ +//! One native window per workbook window: each window keeps its own interface state, and closing +//! or quitting asks about unsaved workbooks only when a workbook would close. +//! +//! The windows are drawn through a test backend that, like eframe, runs each immediate viewport +//! as a nested pass with its own input, so a close button can be pressed on one window. +#![cfg(not(target_arch = "wasm32"))] + +use std::cell::RefCell; + +use egui::{Context, RawInput, ViewportCommand, ViewportEvent, ViewportId, ViewportInfo}; +use gridcraft_engine::{Session, core::CellRef, core::Value}; +use gridcraft_ui_egui::doc_windows::DocWindows; +use gridcraft_ui_egui::{SaveAnswer, SheetApp}; +use serde_json::json; + +thread_local! { + /// Each viewport's input for the next frame (focus, close button). + static INFOS: RefCell> = RefCell::new(egui::ViewportIdMap::default()); + /// The viewport commands the app sent this frame. + static SENT: RefCell> = const { RefCell::new(Vec::new()) }; +} + +fn record(mut out: egui::FullOutput) { + out.textures_delta.clear(); // not painted + SENT.with(|s| { + let mut s = s.borrow_mut(); + for (id, v) in &out.viewport_output { + s.extend(v.commands.iter().map(|c| (*id, c.clone()))); + } + }); +} + +struct Desk { + ctx: Context, + app: SheetApp, + windows: DocWindows, +} + +impl Desk { + fn new(quit_with_last_window: bool) -> Desk { + INFOS.with(|i| i.borrow_mut().clear()); + let ctx = Context::default(); + ctx.set_embed_viewports(false); + Context::set_immediate_viewport_renderer(|ctx, viewport| { + let id = viewport.ids.this; + let mut input = RawInput { viewport_id: id, ..RawInput::default() }; + input.viewports = INFOS.with(|i| i.borrow().clone()); + input.viewports.entry(id).or_default().parent = Some(viewport.ids.parent); + let mut cb = viewport.viewport_ui_cb; + record(ctx.run_ui(input, |ui| cb(ui))); + }); + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + let windows = DocWindows::new(&mut app, egui::ViewportBuilder::default(), quit_with_last_window); + let mut desk = Desk { ctx, app, windows }; + desk.frames(3); + desk + } + + /// One frame as eframe runs it; `act` runs first, inside the frame. Returns the viewport + /// commands sent. + fn frame_with(&mut self, act: impl FnOnce(&mut SheetApp, &Context)) -> Vec<(ViewportId, ViewportCommand)> { + SENT.with(|s| s.borrow_mut().clear()); + let mut input = RawInput { viewport_id: ViewportId::ROOT, ..RawInput::default() }; + input.viewports = INFOS.with(|i| i.borrow().clone()); + input.viewports.entry(ViewportId::ROOT).or_default(); + let Desk { ctx, app, windows } = self; + let mut act = Some(act); + let out = ctx.clone().run_ui(input, |ui| { + let ctx = ui.ctx().clone(); + if let Some(act) = act.take() { + act(app, &ctx); + } + app.logic(&ctx); + windows.logic(app, &ctx); + windows.show(app, ui); + }); + record(out); + // Close buttons are one-shot events. + INFOS.with(|i| i.borrow_mut().values_mut().for_each(|v| v.events.clear())); + SENT.with(|s| s.borrow().clone()) + } + + fn frames(&mut self, n: usize) -> Vec<(ViewportId, ViewportCommand)> { + (0..n).flat_map(|_| self.frame_with(|_, _| {})).collect() + } + + fn viewport(&self, window: u64) -> ViewportId { + self.windows.viewport_of(window).unwrap_or_else(|| panic!("window {window} has no native window")) + } + + /// The user presses window `window`'s close button. + fn close(&mut self, window: u64) -> Vec<(ViewportId, ViewportCommand)> { + let vp = self.viewport(window); + INFOS.with(|i| i.borrow_mut().entry(vp).or_default().events.push(ViewportEvent::Close)); + let mut sent = self.frame_with(|_, _| {}); + sent.extend(self.frames(2)); + sent + } + + /// Answers the Save Changes question showing in the active window, as its button does. + fn answer(&mut self, answer: SaveAnswer) -> Vec<(ViewportId, ViewportCommand)> { + let mut sent = self.frame_with(|app, ctx| { + let d = app.dialog.take().unwrap_or_else(|| panic!("no question is showing")); + assert_eq!(d.name(), "saveChanges"); + let quitting = d.values.get("quit").and_then(serde_json::Value::as_bool) == Some(true); + app.answer_save_changes(answer, quitting, ctx); + }); + sent.extend(self.frames(2)); + sent + } + + fn window_of(&self, doc: usize) -> u64 { + let uid = self.app.session.documents()[doc].uid; + self.app.session.doc_window(uid).unwrap_or_default() + } +} + +fn sent_to(sent: &[(ViewportId, ViewportCommand)], vp: ViewportId, cmd: &ViewportCommand) -> bool { + sent.iter().any(|(id, c)| *id == vp && c == cmd) +} + +#[test] +fn each_window_keeps_its_own_selection_and_draft() { + let mut desk = Desk::new(true); + desk.app.run("file.new", json!({})).unwrap(); + desk.frames(3); + let (w1, w2) = (desk.window_of(0), desk.window_of(1)); + assert_eq!(desk.windows.shown_windows(), [w1, w2], "a native window per workbook"); + assert_ne!(desk.viewport(w1), desk.viewport(w2)); + + desk.app.enter_window(w1); + desk.app.run("selection.set", json!({"range": "B2"})).unwrap(); + desk.app.begin_edit(Some("one".into()), false); + desk.frames(2); + desk.app.enter_window(w2); + desk.app.run("selection.set", json!({"range": "D4"})).unwrap(); + desk.app.begin_edit(Some("two".into()), false); + desk.frames(3); + + for (w, cell, text) in [(w1, "B2", "one"), (w2, "D4", "two")] { + desk.app.enter_window(w); + assert_eq!(desk.app.editor.as_ref().map(|ed| ed.text.as_str()), Some(text), "window {w} keeps its draft"); + assert!(desk.app.grid.rect.is_some(), "window {w} drew its own grid"); + assert_eq!(desk.app.session.doc().unwrap().selection.active, CellRef::parse(cell).unwrap(), "window {w} keeps its selection"); + } + assert!(desk.app.session.documents().iter().all(|d| !d.is_dirty()), "drawing the other window commits no draft"); +} + +#[test] +fn closing_one_of_two_windows_on_a_workbook_does_not_ask() { + let mut desk = Desk::new(true); + desk.app.run("cell.set", json!({"cell": "A1", "input": "42"})).unwrap(); + desk.app.run("view.newWindow", json!({})).unwrap(); + desk.frames(3); + let windows = desk.windows.shown_windows(); + assert_eq!(windows.len(), 2, "New Window opens a native window"); + let child = windows.iter().copied().find(|w| Some(*w) != desk.windows.root_window()).unwrap(); + + let sent = desk.close(child); + assert!(desk.app.dialog.is_none(), "the workbook stays open in its other window: nothing to ask"); + assert_eq!(desk.app.session.documents().len(), 1); + assert_eq!(desk.app.session.windows().len(), 1); + assert!(desk.app.session.documents()[0].is_dirty(), "its changes are kept"); + assert_eq!(desk.windows.shown_windows().len(), 1); + assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "{sent:?}"); +} + +#[test] +fn closing_the_last_window_of_an_unsaved_workbook_asks_and_the_app_quits_after() { + let mut desk = Desk::new(true); + desk.app.run("cell.set", json!({"cell": "A1", "input": "42"})).unwrap(); + desk.frames(2); + let w = desk.window_of(0); + let sent = desk.close(w); + assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::CancelClose), "the window stays open to ask: {sent:?}"); + assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("saveChanges")); + assert_eq!(desk.app.session.documents().len(), 1); + + let sent = desk.answer(SaveAnswer::Cancel); + assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "Cancel keeps it open"); + assert_eq!(desk.app.session.documents().len(), 1); + + desk.close(w); + let sent = desk.answer(SaveAnswer::DontSave); + assert!(desk.app.session.documents().is_empty()); + assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "the last window closed: the app quits: {sent:?}"); + assert!(desk.app.allow_close); +} + +#[test] +fn without_quitting_on_the_last_window_the_app_hides_and_new_shows_it_again() { + let mut desk = Desk::new(false); + let w = desk.window_of(0); + let sent = desk.close(w); + assert!(desk.app.session.documents().is_empty(), "a clean workbook closes without asking"); + assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Visible(false)), "{sent:?}"); + assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "the app keeps running: {sent:?}"); + assert!(desk.app.session.documents().is_empty(), "and doesn't make a workbook of its own"); + + let sent = desk.frame_with(|app, _| app.run_or_alert("file.new", json!({}))); + assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Visible(true)), "{sent:?}"); + assert_eq!(desk.windows.root_window(), Some(desk.window_of(0))); +} + +#[test] +fn quit_asks_about_each_unsaved_workbook_in_its_window_and_cancel_stops() { + let mut desk = Desk::new(true); + desk.app.run("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); + desk.app.run("file.new", json!({})).unwrap(); + desk.app.run("cell.set", json!({"cell": "A1", "input": "2"})).unwrap(); + desk.frames(3); + let (w1, w2) = (desk.window_of(0), desk.window_of(1)); + assert_eq!(desk.app.session.active_window(), Some(w2)); + + let sent = desk.frame_with(|app, ctx| app.request_quit(ctx)); + assert_eq!(desk.app.session.active_window(), Some(w1), "Book1's window comes forward to ask"); + assert!(sent_to(&sent, desk.viewport(w1), &ViewportCommand::Focus), "{sent:?}"); + assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("saveChanges")); + let sent = desk.answer(SaveAnswer::Cancel); + assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "Cancel stops the quit: {sent:?}"); + assert_eq!(desk.app.session.documents().len(), 2); + assert!(desk.app.dialog.is_none()); + + desk.frame_with(|app, ctx| app.request_quit(ctx)); + let sent = desk.answer(SaveAnswer::DontSave); + assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "Book2 is still unsaved: {sent:?}"); + assert_eq!(desk.app.session.active_window(), Some(w2), "then Book2's window asks"); + assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("saveChanges")); + let sent = desk.answer(SaveAnswer::DontSave); + assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "both answered: the app quits: {sent:?}"); +} + +#[test] +fn the_root_adopts_the_remaining_window_with_its_workbook_and_state() { + let mut desk = Desk::new(true); + desk.app.run("file.new", json!({})).unwrap(); + desk.frames(3); + let (w1, w2) = (desk.window_of(0), desk.window_of(1)); + assert_eq!(desk.windows.root_window(), Some(w1)); + let book2 = desk.app.session.documents()[1].uid; + desk.app.enter_window(w2); + desk.app.run("selection.set", json!({"range": "C3"})).unwrap(); + desk.app.begin_edit(Some("kept".into()), false); + desk.frames(2); + + let sent = desk.close(w1); + assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::CancelClose), "closing the root's window doesn't end the app: {sent:?}"); + assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "{sent:?}"); + assert_eq!(desk.windows.root_window(), Some(w2), "Book2's window moves into the root"); + assert_eq!(desk.windows.shown_windows(), [w2]); + assert_eq!(desk.app.session.documents().len(), 1); + assert_eq!(desk.app.session.doc().unwrap().uid, book2); + assert_eq!(desk.app.editor.as_ref().map(|ed| ed.text.as_str()), Some("kept"), "its draft is still open"); + assert_eq!(desk.app.session.doc().unwrap().selection.active, CellRef::parse("C3").unwrap()); + assert_eq!(desk.app.session.doc().unwrap().wb.active().unwrap().value(CellRef::parse("C3").unwrap()), Value::Empty); +} + +#[test] +fn menu_commands_follow_the_focused_window_and_leaving_a_window_enters_its_draft() { + let mut desk = Desk::new(true); + desk.app.run("file.new", json!({})).unwrap(); + desk.frames(3); + let (w1, w2) = (desk.window_of(0), desk.window_of(1)); + desk.app.begin_edit(Some("typed".into()), false); + desk.frames(2); + assert_eq!(desk.app.session.active_window(), Some(w2)); + + // The user clicks into Book1's window. + let (v1, v2) = (desk.viewport(w1), desk.viewport(w2)); + INFOS.with(|i| { + let mut i = i.borrow_mut(); + i.entry(v1).or_default().focused = Some(true); + i.entry(v2).or_default().focused = Some(false); + }); + desk.frames(2); + assert_eq!(desk.app.session.active_window(), Some(w1), "commands from the menu bar go to the window focused last"); + assert!(desk.app.editor.is_none(), "Book1's window has no draft"); + let book2 = &desk.app.session.documents()[1]; + assert_eq!(book2.wb.active().unwrap().value(CellRef::new(0, 0)), Value::text("typed"), "the draft was entered in its own window"); + desk.app.run("cell.set", json!({"cell": "B1", "input": "menu"})).unwrap(); + assert_eq!(desk.app.session.documents()[0].wb.active().unwrap().value(CellRef::new(0, 1)), Value::text("menu")); +} diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 3f9135c49..f19b4a49d 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (a native window per workbook window) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 The hardware Excel uses, per platform, against GridCraft. Part of [target-app-parity.md](target-app-parity.md). **Hardware: ~40% ready, 25–40 h** (estimated). @@ -11,7 +11,7 @@ The hardware Excel uses, per platform, against GridCraft. Part of | GPU rendering of the grid and charts | DirectX / Metal accelerated drawing | wgpu: Metal · DirectX 12 (default since 10-08; OpenGL when DirectX 12 has no GPU driver or fails to start; Vulkan only on request, so the Intel UHD `igvk64.dll` crash (#120) can't happen; shaders compiled with FXC, so no DXC runtime is needed, #339) · Vulkan/GL · Vulkan/GL · WebGPU or WebGL2 | At parity in kind. Driver problems: screen flashing on Windows 11 (#76): 2–4 h | | Pen and touch (Draw tab) | Pen pressure and tilt, eraser end, touch mode, Ink to Shape, ink replay | Pen, highlighter, eraser, Ink to Shape with mouse/pen input; **no pressure or tilt**, no touch mode | Pressure-sensitive strokes where winit reports force; touch-friendly ribbon spacing: 4–6 h | | Trackpad and mouse | Smooth scrolling, pinch zoom, horizontal scroll, Magic Mouse | Smooth scrolling and zoom via egui | Verify pinch zoom on each platform: 1–2 h | -| Multiple windows and monitors | One window per workbook, New Window for the same workbook, per-monitor DPI | One window; multiple workbooks switch inside it (#162; PR #171) | Native window per workbook, New Window, Arrange All across monitors: 6–8 h | +| Multiple windows and monitors | One window per workbook, New Window for the same workbook, per-monitor DPI | A native window per workbook window on the desktop, New Window for the same workbook; new windows cascade from the last one on its monitor (web: one window) | Arrange All across monitors; verify per-monitor DPI with windows on two monitors: 2–3 h | | High-DPI and display scaling | yes | yes (display scale matched to desktop density; View › Interface size 75–200 % on top, #230) | — | | Accessibility hardware (screen readers, braille displays, switch control) | Narrator, JAWS, NVDA, VoiceOver | AccessKit enabled in eframe; grid cells and ribbon not yet exposed as an accessible tree | Counted under UI ([ui-parity.md](ui-parity.md)) | | Printers | System print with driver options | Desktop submits through craft-print with printer choice, copies, duplex and grayscale; web browser printing | Counted under print | @@ -20,6 +20,7 @@ The hardware Excel uses, per platform, against GridCraft. Part of | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Multiple windows: each workbook window is a native window of its own (desktop). | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | trivial | Windows: DirectX 12 shaders compiled with FXC, no DXC runtime needed (#339, #354) | | 2026-10-11 | trivial | Windows: OpenGL fallback when DirectX 12 has no GPU or fails to start; Vulkan never by default (#120) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 839984959..817b5b915 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (New Window opens a second window on the same workbook, titled Name:1 / Name:2; Switch Windows lists the open windows; a cell being typed stays in its own window through New, Open and window switches; macOS Quit asks about unsaved workbooks) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (every workbook window is a native window of its own on the desktop, with its own draft, dialog and ribbon; closing one of several windows on a workbook doesn't ask; Quit asks about each unsaved workbook in its window; the last window closed quits on Windows and Linux, macOS keeps running) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -33,8 +33,8 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse; the recent list tells when each workbook was last changed, 10-12), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | | Dark mode, system appearance | yes | yes | done (10-09) | | Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | margins drag on the rulers (#792, with header and footer margins); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) | -| Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) | -| Switch Windows, New Window, unsaved workbooks on quit | yes | yes (10-22): View › New Window opens another window on the same workbook (no copy: saving from either saves it), each with its own selection, active sheet and scroll; a workbook with several windows titles them "Book1:1", "Book1:2"; File › Close closes one of them without asking and asks only for the last; View › Switch Windows and the macOS Window menu list the open windows (active one checked, unsaved ones marked •) and bring the chosen one forward (`view.switchWindows {window}`, or `{doc}` for a workbook's most recent window); a cell being typed is entered into its own window and workbook before New, Open, Close, Save, Print and window switches (also between two windows on one workbook), and a refused entry stays open and stops the command; quitting (window close, Cmd+Q, the macOS Quit item) asks Save / Don't Save / Cancel for each unsaved workbook | one OS window for all workbooks (#162) | +| Multiple windows per workbook | yes | yes (10-22, desktop): each workbook window is a native window of its own (New, Open and New Window open one; titles "Book1", or "Book1:1" / "Book1:2" for a workbook's second window, with " — Edited" while unsaved). Each window keeps its own cell draft, dialog, Backstage view, message and grid state, and its ribbon acts on it; on macOS the menu bar acts on the window focused last. Leaving a window enters its draft as Enter would. A window's close button closes that window: one of several on a workbook closes without asking, a workbook's last asks Save / Don't Save / Cancel when unsaved. Quit asks about each unsaved workbook in its window, and Cancel stops it. The last window closed quits on Windows and Linux; macOS keeps running with the menu bar until New or Open. Web: one window | no Arrange All; a hidden window (View › Hide) has no native window; every window repaints when one does; see [hardware-parity.md](hardware-parity.md) | +| Switch Windows, New Window, unsaved workbooks on quit | yes | yes (10-22): View › New Window opens another window on the same workbook (no copy: saving from either saves it), each with its own selection, active sheet and scroll; a workbook with several windows titles them "Book1:1", "Book1:2"; File › Close closes one of them without asking and asks only for the last; View › Switch Windows and the macOS Window menu list the open windows (active one checked, unsaved ones marked •) and bring the chosen one forward (`view.switchWindows {window}`, or `{doc}` for a workbook's most recent window); a cell being typed is entered into its own window and workbook before New, Open, Close, Save, Print and window switches (also between two windows on one workbook), and a refused entry stays open and stops the command; quitting (Cmd+Q, the macOS Quit item, the Dock) asks Save / Don't Save / Cancel for each unsaved workbook | | ## Grid interaction @@ -177,6 +177,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | One native window per workbook window on the desktop: per-window drafts, dialogs and ribbons; menu bar commands go to the focused window; closing one of several windows on a workbook doesn't ask; Quit asks in each unsaved workbook's window; the last window closed quits (Windows, Linux) or leaves macOS running | | 2026-10-22 | minor | New Window opens a second window on the same workbook instead of a copy that saved over the original; windows keep their own selection, sheet and scroll; titles Name:1 / Name:2; Switch Windows, Hide and Unhide work by window; File › Close closes one window of several without asking | | 2026-10-22 | minor | New workbook no longer loses the first one: the cell being typed is entered into its own workbook before any File or window command (an entry the workbook refuses stays open and the command doesn't run; an untouched F2 draft changes nothing), the editor is tied to its workbook, View › Switch Windows and the macOS Window menu list the open workbooks, and the macOS Quit item asks about unsaved workbooks like the close button | | 2026-10-21 | minor | Background error checking, first part (#1994): a constant whose text would be a number if typed (signs, thousands, currency, percent, exponent, parenthesised negative) gets Excel's green corner triangle; the active cell then shows a warning button whose menu names the problem and offers Convert to Number for the selection (`formulas.convertToNumber`). Dates, times and fractions kept as text and formula results are not flagged, as in Excel | From d4db58cde467f7a412293e55861d390a7f77b4ad Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:29:24 +0000 Subject: [PATCH 06/76] G3: feedback from a command that switched windows shows in the window it left active after_engine, show_open_warnings and alert bring the per-window state in line with the engine's active window first, so a notice, message or toast from opening a workbook (also by a script on the session) lands in the opened workbook's window instead of being parked with the previous one. --- crates/ui-egui/src/lib.rs | 7 +++++++ crates/ui-egui/tests/doc_windows.rs | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f405a3d18..19f69a90f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -648,6 +648,8 @@ impl SheetApp { /// Shows a command error: a toast for an unavailable command, else a message box. fn alert(&mut self, e: String) { + // Shown in the window the user ends up in, also after a command that switched windows. + self.follow_active_window(); if e.contains("is not available right now") { self.toast = Some((e, now_ms())); } else { @@ -799,6 +801,8 @@ impl SheetApp { /// Picks up requests commands made (dialogs, edit mode…). pub fn after_engine(&mut self) { + // The requests belong to the window the command left active. + self.follow_active_window(); for req in self.session.take_ui_requests() { match req { UiRequest::Dialog(name, params) => self.open_dialog(&name, params), @@ -924,6 +928,9 @@ impl SheetApp { /// most important one (a preserved VBA project outranks, say, a skipped optional part). /// Kept deliberately generic: any open-time warning can reach the user this way. pub fn show_open_warnings(&mut self, r: &Json) { + // The opened workbook's window shows them, however it was opened (a script may have run + // `file.open` on the session directly). + self.follow_active_window(); let Some(list) = r.get("warnings").and_then(Json::as_array) else { self.notice = None; return; diff --git a/crates/ui-egui/tests/doc_windows.rs b/crates/ui-egui/tests/doc_windows.rs index ff8e2d631..b6d518ce2 100644 --- a/crates/ui-egui/tests/doc_windows.rs +++ b/crates/ui-egui/tests/doc_windows.rs @@ -282,3 +282,28 @@ fn menu_commands_follow_the_focused_window_and_leaving_a_window_enters_its_draft desk.app.run("cell.set", json!({"cell": "B1", "input": "menu"})).unwrap(); assert_eq!(desk.app.session.documents()[0].wb.active().unwrap().value(CellRef::new(0, 1)), Value::text("menu")); } + +#[test] +fn feedback_from_opening_a_workbook_shows_in_its_new_window() { + let mut desk = Desk::new(true); + desk.app.run("cell.set", json!({"cell": "A1", "input": "kept"})).unwrap(); + desk.frames(2); + let book1 = desk.window_of(0); + let mut wb = gridcraft_engine::model::Workbook::new(); + wb.vba_project = Some(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1 fake OLE2 vbaProject".to_vec().into()); + let b64 = gridcraft_engine::io::base64_encode(&gridcraft_engine::io::save_bytes(&wb, "macros.xlsm").unwrap()); + desk.frame_with(|app, _| { + // A script opening the file on the session, then the interface reporting on it. + let r = app.session.run("file.open", json!({"name": "macros.xlsm", "base64": b64})).unwrap(); + app.show_open_warnings(&r); + app.run_or_alert("no.such.command", json!({})); + }); + desk.frames(2); + let opened = desk.window_of(1); + assert_eq!(desk.windows.shown_windows(), [book1, opened]); + assert_eq!(desk.app.session.active_window(), Some(opened)); + assert_eq!(desk.app.notice.as_ref().map(|n| n.kind), Some(gridcraft_ui_egui::NoticeKind::Warning), "the macro notice shows in the opened window"); + assert!(desk.app.message.is_some(), "and so does the alert"); + desk.app.enter_window(book1); + assert!(desk.app.notice.is_none() && desk.app.message.is_none(), "Book1's window shows neither"); +} From 2ed3df2bd64d2022d28ce588e6275babd6d654ce Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:50:10 +0000 Subject: [PATCH 07/76] G4: two windows on one workbook without copying it; Arrange All - The workbook's active sheet is an atomic set in place (Workbook::active_sheet / set_active_sheet), so a window swap or sheet switch never clones the Arc or marks it dirty. Selections already live in DocState and the window views. Save records the sheet of the most recently active window. - A dialog taken out of its window while it runs goes back to that window when a command run from it switched windows (dialogs::show, ui.dialog.confirm). - view.arrangeAll {arrange?: tiled|horizontal|vertical|cascade, activeWorkbook?} returns the visible windows, active first; the desktop moves and sizes the native windows over the main window's monitor. New Window and Arrange All buttons on the View ribbon; Arrange Windows dialog. - Docs: ui-parity, hardware-parity, gaps #20, target-app-parity, ROADMAP. --- ROADMAP.md | 4 +- crates/engine/src/cmd/analysis.rs | 2 +- crates/engine/src/cmd/data.rs | 18 +-- crates/engine/src/cmd/draw.rs | 4 +- crates/engine/src/cmd/edit.rs | 27 ++--- crates/engine/src/cmd/extra.rs | 51 ++++++-- crates/engine/src/cmd/file.rs | 4 +- crates/engine/src/cmd/forecast.rs | 10 +- crates/engine/src/cmd/format.rs | 2 +- crates/engine/src/cmd/formulas.rs | 16 +-- crates/engine/src/cmd/insert.rs | 22 ++-- crates/engine/src/cmd/inspect.rs | 2 +- crates/engine/src/cmd/mod.rs | 6 +- crates/engine/src/cmd/pivot.rs | 16 +-- crates/engine/src/cmd/print.rs | 6 +- crates/engine/src/cmd/review.rs | 6 +- crates/engine/src/cmd/sheet.rs | 20 ++-- crates/engine/src/cmd/slicer.rs | 6 +- crates/engine/src/cmd/solver.rs | 12 +- crates/engine/src/cmd/view.rs | 4 +- crates/engine/src/cmd/whatif.rs | 14 +-- crates/engine/src/io.rs | 2 +- crates/engine/src/lib.rs | 4 +- crates/engine/src/sample.rs | 2 +- crates/engine/src/tests.rs | 75 +++++++++++- crates/engine/src/windows.rs | 30 ++--- crates/engine/tests/command_sequence_fuzz.rs | 6 +- .../engine/tests/hidden_sheet_navigation.rs | 12 +- crates/model/src/workbook.rs | 68 ++++++++++- crates/ui-egui/examples/snapshot.rs | 2 +- crates/ui-egui/src/control.rs | 3 +- crates/ui-egui/src/dialogs.rs | 30 ++++- crates/ui-egui/src/doc_windows.rs | 74 +++++++++++- crates/ui-egui/src/formula_bar.rs | 6 +- crates/ui-egui/src/grid.rs | 33 ++--- crates/ui-egui/src/lib.rs | 27 +++-- crates/ui-egui/src/panes.rs | 2 +- crates/ui-egui/src/paper_view.rs | 8 +- crates/ui-egui/src/ribbon.rs | 12 +- crates/ui-egui/src/tabs.rs | 10 +- crates/ui-egui/src/text_box.rs | 2 +- crates/ui-egui/src/window_ui.rs | 13 ++ crates/ui-egui/tests/commit_after_pointing.rs | 4 +- crates/ui-egui/tests/doc_windows.rs | 113 ++++++++++++++++++ crates/ui-egui/tests/popup_dismissal.rs | 2 +- crates/ui-egui/tests/sheet_navigator.rs | 4 +- crates/ui-egui/tests/sheet_tabs.rs | 6 +- crates/ui-egui/tests/trace_navigation.rs | 4 +- crates/xlsx/src/read.rs | 2 +- crates/xlsx/src/write.rs | 4 +- crates/xlsx/src/xls/mod.rs | 2 +- crates/xlsx/src/xlsb.rs | 2 +- docs/gaps.md | 7 +- docs/hardware-parity.md | 5 +- docs/target-app-parity.md | 9 +- docs/ui-parity.md | 7 +- 56 files changed, 620 insertions(+), 224 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 38c394fb0..782c00e3a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** minor (a native window per workbook window; no lost workbook after New: drafts stay in their workbook, Switch Windows, macOS Quit asks to save; New Window shares the workbook) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** minor (Arrange All; a native window per workbook window; no lost workbook after New: drafts stay in their workbook, Switch Windows, macOS Quit asks to save; New Window shares the workbook) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-22 | View › New Window and Arrange All on the ribbon. Arrange All tiles, stacks side by side or top to bottom, or cascades the native windows over the main window's monitor, the active one first. Two windows on one workbook show different sheets without copying the workbook or marking it changed; Save records the sheet of the window used last. A dialog stays in the window it was opened in | | 2026-10-22 | On the desktop every workbook window is a native window of its own: New, Open and New Window open one, each keeps its own cell draft, dialog and ribbon, and the macOS menu bar acts on the window focused last. Closing one of several windows on a workbook doesn't ask; closing a workbook's last window, or quitting, asks about unsaved changes in that window. The last window closed quits on Windows and Linux; macOS keeps running with the menu bar. First craft-libs `craft-window` use (window list and viewport driver) | | 2026-10-22 | View › New Window opens a second window on the same workbook (it used to open a copy whose Save overwrote the original): each window has its own selection, active sheet and scroll, titles read "Book1:1" / "Book1:2", and Switch Windows, Hide, Unhide and Close act per window. The control channel, CLI `send` and MCP tools take an optional `window` or `doc` id and default to the active window; `window.list` (MCP `list_windows`) lists the windows | | 2026-10-22 | Typing in a cell and then choosing File › New no longer strands the first workbook: the entry is committed into its own workbook before any File or window command (a refused entry stays open and stops the command), View › Switch Windows and the macOS Window menu list the open workbooks with unsaved markers, `view.switchWindows {doc}` switches by a stable workbook id, and the macOS Quit item asks about unsaved workbooks | @@ -223,6 +224,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Progress log: Arrange All; two windows on one workbook without copying it | | 2026-10-22 | minor | Progress log: a native window per workbook window | | 2026-10-22 | minor | Progress log: New Window shares the workbook; window ids for scripts and MCP | | 2026-10-22 | minor | Progress log: drafts stay in their workbook, Switch Windows, macOS Quit asks to save | diff --git a/crates/engine/src/cmd/analysis.rs b/crates/engine/src/cmd/analysis.rs index 8ff7e3582..6225b4934 100644 --- a/crates/engine/src/cmd/analysis.rs +++ b/crates/engine/src/cmd/analysis.rs @@ -1182,7 +1182,7 @@ mod tests { fn v(s: &Session, a: &str) -> Value { let d = s.doc().unwrap(); let (sh, body) = crate::cmd::split_sheet(a); - let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet); + let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet()); d.wb.sheet(si).unwrap().value(CellRef::parse(body).unwrap()) } fn n(s: &Session, a: &str) -> f64 { diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 52a716cff..8ffab2b51 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -158,7 +158,7 @@ fn data_target(s: &Session, p: &Json, cmd: &str) -> Result<(usize, RangeRef)> { { return Err(bad(cmd, format!("`{t}` names a sheet other than `sheet`"))); } - if sheet != d.wb.active_sheet && p.get("range").is_none() { + if sheet != d.wb.active_sheet() && p.get("range").is_none() { return Err(bad(cmd, "missing `range`: the selection is on the active sheet")); } Ok((sheet, data_range(s, p)?)) @@ -210,7 +210,7 @@ fn sort_target(s: &Session, p: &Json) -> Result<(usize, RangeRef)> { { return Err(bad("data.sort", format!("`{t}` names a sheet other than `sheet`"))); } - if sheet != d.wb.active_sheet && p.get("range").is_none() { + if sheet != d.wb.active_sheet() && p.get("range").is_none() { return Err(bad("data.sort", "missing `range`: the selection is on the active sheet")); } Ok((sheet, data_range(s, p)?)) @@ -502,7 +502,7 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result { let (sheet, r) = data_target(s, p, "data.filter")?; super::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?; // The active cell belongs to the active sheet: on another sheet the table is the one at `range`. - let at = if sheet == s.doc()?.wb.active_sheet { s.doc()?.selection.active } else { r.start }; + let at = if sheet == s.doc()?.wb.active_sheet() { s.doc()?.selection.active } else { r.start }; let has = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.autofilter.is_some()); let in_table = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(at).map(|t| t.name.clone())); if let Some(name) = in_table { @@ -554,7 +554,7 @@ fn install_filter(s: &mut Session, p: &Json) -> Result { } fn filter_by(s: &mut Session, p: &Json) -> Result { - let sheet = s.doc()?.wb.active_sheet; + let sheet = s.doc()?.wb.active_sheet(); super::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?; let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; let af = match &sh.autofilter { @@ -741,7 +741,7 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { } fn clear_filter(s: &mut Session, _: &Json) -> Result { - let sheet = s.doc()?.wb.active_sheet; + let sheet = s.doc()?.wb.active_sheet(); edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; if let Some(a) = sh.autofilter.as_mut() { @@ -754,7 +754,7 @@ fn clear_filter(s: &mut Session, _: &Json) -> Result { } fn reapply(s: &mut Session, _: &Json) -> Result { - let sheet = s.doc()?.wb.active_sheet; + let sheet = s.doc()?.wb.active_sheet(); edit(s, |cx| { apply_filter(&mut cx.wb, sheet); cx.structural = true; @@ -1258,7 +1258,7 @@ fn validate_cmd(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); let input = str_param(p, "input").unwrap_or(""); - match check_validation(&d.wb, d.wb.active_sheet, at, input) { + match check_validation(&d.wb, d.wb.active_sheet(), at, input) { None => Ok(json!({"ok": true})), Some((dv, msg)) => Ok(json!({"ok": false, "message": msg, "title": dv.error_title, "style": format!("{:?}", dv.error_style)})), } @@ -1266,7 +1266,7 @@ fn validate_cmd(s: &mut Session, p: &Json) -> Result { fn circle_invalid(s: &mut Session, _: &Json) -> Result { let d = s.doc()?; - let sheet = d.wb.active_sheet; + let sheet = d.wb.active_sheet(); let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; let mut bad_cells = Vec::new(); for dv in &sh.validations { @@ -1305,7 +1305,7 @@ fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> { fn group(s: &mut Session, p: &Json, on: bool) -> Result { let si = target_sheet(s, p)?; // The selection belongs to the active sheet, so another sheet needs its lines. - if si != s.doc()?.wb.active_sheet && p.get("rows").is_none() && p.get("cols").is_none() { + if si != s.doc()?.wb.active_sheet() && p.get("rows").is_none() && p.get("cols").is_none() { return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet")); } let (rows, a, b) = outline_lines(s, p)?; diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index af3e65e28..a86be3099 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -72,7 +72,7 @@ fn stroke(s: &mut Session, p: &Json) -> Result { str_param(p, "color").map(str::to_string).unwrap_or_else(|| if s.draw_color.is_empty() { "#1F5FC9".into() } else { s.draw_color.clone() }); let width = f64_param(p, "width").map(|w| w as f32).unwrap_or(if s.draw_width <= 0.0 { 2.0 } else { s.draw_width }).clamp(0.5, 40.0); let d = s.doc()?; - let si = d.wb.active_sheet; + let si = d.wb.active_sheet(); let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; let cell = CellRef::new(sh.row_at(miny), sh.col_at(minx)); let dx = (minx - sh.col_left(cell.col)) as f32; @@ -115,7 +115,7 @@ pub fn ink_points(sh: &Shape) -> Vec<[f32; 2]> { } fn ink_to_shape(s: &mut Session, p: &Json) -> Result { - let si = s.doc()?.wb.active_sheet; + let si = s.doc()?.wb.active_sheet(); let id = u32_param(p, "id"); edit(s, |cx| { let sh = cx.sheet_mut(si)?; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e53740712..77c2fb8a0 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -237,7 +237,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { { return Err(EngineError::Other(m)); } - let active_sheet = s.doc()?.wb.active_sheet; + let active_sheet = s.doc()?.wb.active_sheet(); edit(s, |cx| { if array { // Legacy Ctrl+Shift+Enter array formula over the selection, when the selection holds @@ -414,23 +414,18 @@ fn selection_set(s: &mut Session, p: &Json) -> Result { impl crate::DocState { /// Switches the active sheet, remembering each sheet's selection. pub fn wb_switch_sheet(&mut self, i: usize) { - if i >= self.wb.sheets.len() || i == self.wb.active_sheet { + if i >= self.wb.sheets.len() || i == self.wb.active_sheet() { return; } - let cur = self.wb.active_sheet; + let cur = self.wb.active_sheet(); if self.sheet_selections.len() < self.wb.sheets.len() { self.sheet_selections.resize(self.wb.sheets.len(), crate::Selection::default()); } if let Some(slot) = self.sheet_selections.get_mut(cur) { *slot = self.selection.clone(); } - // The active sheet is view state: change it without making the workbook dirty. - let dirty = self.is_dirty(); - let wb = std::sync::Arc::make_mut(&mut self.wb); - wb.active_sheet = i; - if !dirty { - self.saved = self.wb.clone(); - } + // View state: set in place, so the workbook is neither copied nor made dirty. + self.wb.set_active_sheet(i); self.selection = self.sheet_selections.get(i).cloned().unwrap_or_default(); } } @@ -558,7 +553,7 @@ pub(super) fn go_to(s: &mut Session, p: &Json) -> Result { // A defined name or table? let d = s.doc()?; let resolved = if RangeRef::parse(split_sheet(reference).1).is_none() { - let sheet = d.wb.active_sheet; + let sheet = d.wb.active_sheet(); if let Some(n) = d.wb.name(reference, sheet) { n.formula.clone() } else if let Some((si, ti)) = d.wb.table(reference) { @@ -1535,7 +1530,7 @@ fn find(s: &mut Session, p: &Json) -> Result { let workbook = str_param(p, "within") == Some("workbook"); s.last_find = Some(p.clone()); let d = s.doc()?; - let sheets: Vec = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet] }; + let sheets: Vec = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet()] }; let mut hits: Vec<(usize, CellRef, String)> = Vec::new(); for si in sheets { let Some(sh) = d.wb.sheet(si) else { continue }; @@ -1565,7 +1560,7 @@ fn find(s: &mut Session, p: &Json) -> Result { return Ok(json!({"count": list.len(), "results": list})); } // Next hit after the active cell. - let cur = (d.wb.active_sheet, d.selection.active); + let cur = (d.wb.active_sheet(), d.selection.active); let key = |si: usize, c: CellRef| if by_cols { (si, c.col, c.row) } else { (si, c.row, c.col) }; let next = hits.iter().find(|(si, c, _)| key(*si, *c) > key(cur.0, cur.1)).or_else(|| hits.first()).cloned(); let Some((si, c, t)) = next else { return Err(EngineError::Other("not found".into())) }; @@ -1587,7 +1582,7 @@ fn replace(s: &mut Session, p: &Json) -> Result { let workbook = str_param(p, "within") == Some("workbook"); let active = s.doc()?.selection.active; let n = edit(s, |cx| { - let sheets: Vec = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet] }; + let sheets: Vec = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet()] }; let mut n = 0; for si in sheets { let cells: Vec<(CellRef, String)> = @@ -1659,7 +1654,7 @@ fn replace_ci(text: &str, what: &str, with: &str) -> String { fn format_painter(s: &mut Session, p: &Json) -> Result { if let Some(dest) = str_param(p, "apply").and_then(RangeRef::parse) { let Some((wb, sheet, src, sticky)) = s.format_painter.clone() else { return Err(EngineError::Other("Pick up a format first.".into())) }; - let dest_sheet = s.doc()?.wb.active_sheet; + let dest_sheet = s.doc()?.wb.active_sheet(); let same = std::sync::Arc::ptr_eq(&wb, &s.doc()?.wb) || s.doc()?.wb.styles == wb.styles; edit(s, |cx| { let Some(src_sheet) = wb.sheet(sheet) else { return Ok(()) }; @@ -1739,7 +1734,7 @@ fn format_painter(s: &mut Session, p: &Json) -> Result { } let sticky = bool_param(p, "sticky").unwrap_or(false); let d = s.doc()?; - s.format_painter = Some((d.wb.clone(), d.wb.active_sheet, d.selection.current(), sticky)); + s.format_painter = Some((d.wb.clone(), d.wb.active_sheet(), d.selection.current(), sticky)); ok() } diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 395345543..506f56443 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -142,7 +142,15 @@ pub fn specs() -> Vec { cmd!(noundo "view.customViews", "Custom Views…", ["View", "Workbook Views"], None, "{save?: name, show?: name, delete?: name} → list", has_doc, custom_views), cmd!(noundo "view.split", "Split", ["View", "Window"], None, "{} (toggles a split at the active cell; shown as frozen panes in this version)", has_doc, split), cmd!(noundo "view.newWindow", "New Window", ["View", "Window"], None, "{} → {window, doc, title}: opens another window on the active workbook (the same workbook: saving from either window saves it)", has_doc, new_window), - cmd!(noundo "view.arrangeAll", "Arrange All", ["View", "Window"], None, "{}", has_doc, |_, _| ok()), + cmd!( + noundo "view.arrangeAll", + "Arrange All", + ["View", "Window"], + None, + "{arrange?: tiled|horizontal|vertical|cascade, activeWorkbook?: bool} → {arrange, windows}: lays out the visible windows (tiled by default; `activeWorkbook` only the active workbook's), the active window first", + has_doc, + arrange_all + ), cmd!(noundo "view.hideWindow", "Hide", ["View", "Window"], None, "{window?, doc?} (the active window by default)", has_doc, hide_window), cmd!(noundo "view.unhideWindow", "Unhide…", ["View", "Window"], None, "{window?, doc?, index?} (the most recently hidden window by default)", has_doc, unhide_window), cmd!( @@ -276,7 +284,7 @@ pub fn specs() -> Vec { fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> { let d = s.doc()?; - let si = d.wb.active_sheet; + let si = d.wb.active_sheet(); let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; let idx = match u32_param(p, "chart") { Some(id) => sh.charts.iter().position(|c| c.id == id), @@ -442,7 +450,7 @@ fn select_data(s: &mut Session, p: &Json) -> Result { let (sheet_name, body) = split_sheet(range); let src_si = match sheet_name { Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?, - None => d.wb.active_sheet, + None => d.wb.active_sheet(), }; let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?; if r.count() > 1_000_000 { @@ -464,7 +472,7 @@ fn select_data(s: &mut Session, p: &Json) -> Result { let (sheet_name, body) = split_sheet(t); let si = match sheet_name { Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?, - None => d.wb.active_sheet, + None => d.wb.active_sheet(), }; let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "`categories` is not a range"))?; let name = d.wb.sheet(si).map(|sh| sh.name.clone()).ok_or(EngineError::NoDocument)?; @@ -546,7 +554,7 @@ fn shape_outline(s: &mut Session, p: &Json) -> Result { } fn with_shape(s: &mut Session, p: &Json, f: impl FnOnce(&mut Shape)) -> Result { - let si = s.doc()?.wb.active_sheet; + let si = s.doc()?.wb.active_sheet(); let id = u32_param(p, "id"); edit(s, |cx| { let shapes = &mut cx.sheet_mut(si)?.shapes; @@ -564,7 +572,7 @@ fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result { let id = u32_param(p, "id").ok_or_else(|| bad("arrange", "missing `id`"))?; let kind = str_param(p, "kind").unwrap_or("shape").to_string(); let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false); - let si = s.doc()?.wb.active_sheet; + let si = s.doc()?.wb.active_sheet(); fn reorder(v: &mut Vec, i: usize, forward: bool, extreme: bool) { if i >= v.len() { return; @@ -630,7 +638,7 @@ fn align(s: &mut Session, p: &Json) -> Result { let ids: Vec = p.get("ids").and_then(Json::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as u32)).collect()).unwrap_or_default(); let how = str_param(p, "align").unwrap_or("left").to_string(); - let si = s.doc()?.wb.active_sheet; + let si = s.doc()?.wb.active_sheet(); edit(s, |cx| { let sheet = cx.sheet_mut(si)?; let geo: Vec<(u32, f64, f64, f32, f32)> = { @@ -743,7 +751,7 @@ fn custom_views(s: &mut Session, p: &Json) -> Result { let mut views = stash_map(&d.wb, VIEWS_NAME); if let Some(name) = str_param(p, "save") { let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - views[name] = json!({"sheet": d.wb.active_sheet, "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze}); + views[name] = json!({"sheet": d.wb.active_sheet(), "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze}); let v = views.clone(); edit(s, |cx| { stash_set(&mut cx.wb, VIEWS_NAME, &v); @@ -802,6 +810,25 @@ pub(crate) fn window_param(s: &Session, cmd: &str, p: &Json) -> Result { s.target_window(t).map_err(|e| bad(cmd, e.to_string())) } +/// View › Arrange All. The engine has no window geometry: it picks the windows and the layout, +/// and the interface moves its native windows ([`Session::window_list`] order, active first). +fn arrange_all(s: &mut Session, p: &Json) -> Result { + let arrange = match str_param(p, "arrange").unwrap_or("tiled") { + a @ ("tiled" | "horizontal" | "vertical" | "cascade") => a, + other => return Err(bad("view.arrangeAll", format!("`arrange` must be tiled, horizontal, vertical or cascade, not `{other}`"))), + }; + let active = s.active_window(); + let doc = active.and_then(|w| s.window(w)).map(|w| w.doc_uid); + let only_active = p.get("activeWorkbook").and_then(Json::as_bool).unwrap_or(false); + let mut windows: Vec = s.windows().iter().filter(|w| !w.hidden && (!only_active || Some(w.doc_uid) == doc)).map(|w| w.id).collect(); + if let Some(at) = windows.iter().position(|w| Some(*w) == active) + && let Some(first) = windows.get_mut(..=at) + { + first.rotate_right(1); + } + Ok(json!({"arrange": arrange, "windows": windows})) +} + fn hide_window(s: &mut Session, p: &Json) -> Result { let id = window_param(s, "view.hideWindow", p)?; s.hide_window(id); @@ -835,7 +862,7 @@ fn switch_windows(s: &mut Session, p: &Json) -> Result { fn table_resize(s: &mut Session, p: &Json) -> Result { let r = str_param(p, "range").and_then(RangeRef::parse).ok_or_else(|| bad("table.resize", "missing `range`"))?; let d = s.doc()?; - let si = d.wb.active_sheet; + let si = d.wb.active_sheet(); let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; let ti = match str_param(p, "table") { Some(n) => sh.tables.iter().position(|t| t.name.eq_ignore_ascii_case(n)), @@ -944,7 +971,7 @@ fn from_table_range(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let sh = d.wb.active().ok_or(EngineError::NoDocument)?; let r = if r.is_single() { crate::selection::current_region(sh, r.start) } else { r }; - let text = crate::display::range_text(&d.wb, d.wb.active_sheet, r); + let text = crate::display::range_text(&d.wb, d.wb.active_sheet(), r); from_csv(s, &json!({"text": text, "delimiter": "\t", "sheet": format!("{} (2)", sh.name).chars().take(31).collect::()})) } @@ -1117,7 +1144,7 @@ fn theme_fonts(s: &mut Session, p: &Json) -> Result { fn background(s: &mut Session, p: &Json) -> Result { if bool_param(p, "clear").unwrap_or(false) { - let si = s.doc()?.wb.active_sheet; + let si = s.doc()?.wb.active_sheet(); return edit(s, |cx| { cx.sheet_mut(si)?.images.retain(|i| i.alt != "Sheet background"); Ok(Json::Null) @@ -1210,7 +1237,7 @@ fn watch_window(s: &mut Session, p: &Json) -> Result { .iter() .map(|w| { let (sheet, body) = split_sheet(w); - let si = sheet.and_then(|n| d.wb.sheet_index(&n)).unwrap_or(d.wb.active_sheet); + let si = sheet.and_then(|n| d.wb.sheet_index(&n)).unwrap_or(d.wb.active_sheet()); let c = CellRef::parse(body).unwrap_or_default(); let sh = d.wb.sheet(si); json!({"cell": w, "value": sh.map(|s| s.value(c).display()), "formula": sh.and_then(|s| s.cell(c)).and_then(|x| x.formula.as_ref()).map(|f| format!("={}", f.text))}) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 1f50b908b..f704714cc 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -185,9 +185,9 @@ fn close(s: &mut Session, p: &Json) -> Result { fn export(s: &mut Session, p: &Json, ext: &str) -> Result { let path = str_param(p, "path").ok_or_else(|| bad("file.export", "missing `path`"))?.to_string(); let d = s.doc()?; - let mut wb = (*d.wb).clone(); + let wb = (*d.wb).clone(); if p.get("sheet").is_some() { - wb.active_sheet = target_sheet(s, p)?; + wb.set_active_sheet(target_sheet(s, p)?); } let target = if path.ends_with(&format!(".{ext}")) { path.clone() } else { format!("{path}.{ext}") }; let bytes = crate::io::save_bytes(&wb, &target)?; diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs index 139d6fc46..b7472fd22 100644 --- a/crates/engine/src/cmd/forecast.rs +++ b/crates/engine/src/cmd/forecast.rs @@ -68,7 +68,7 @@ fn range_on(s: &Session, t: &str) -> Result<(usize, RangeRef)> { let (sh, body) = split_sheet(t); let si = match sh { Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad(CMD, format!("no sheet named `{n}`")))?, - None => d.wb.active_sheet, + None => d.wb.active_sheet(), }; let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(CMD, format!("not a range: `{t}`")))?; let r = if r.is_full_cols() || r.is_full_rows() { @@ -101,7 +101,7 @@ fn source(s: &Session, p: &Json) -> Result<(usize, Vec, Vec)> None => { let sh = d.wb.active().ok_or(EngineError::NoDocument)?; let sel = d.selection.current(); - (d.wb.active_sheet, if sel.is_single() { current_region(sh, d.selection.active) } else { sel }) + (d.wb.active_sheet(), if sel.is_single() { current_region(sh, d.selection.active) } else { sel }) } }; if r.width() < 2 { @@ -307,7 +307,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { let stats_col = width + 1; let chart_col = if opts.statistics { stats_col + 3 } else { width + 1 }; - let at = s.doc()?.wb.active_sheet; + let at = s.doc()?.wb.active_sheet(); let index = edit(s, |cx| { let at = at.min(cx.wb.sheets.len()); let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true); @@ -440,7 +440,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { *sc += 1; } } - cx.wb.active_sheet = at; + cx.wb.set_active_sheet(at); *cx.sel = crate::Selection::at(CellRef::new(0, 0)); cx.structural = true; Ok(at) @@ -502,7 +502,7 @@ mod tests { assert_eq!(r["periods"], 4); let wb = &s.doc().unwrap().wb; // Inserted before the data sheet and activated. - assert_eq!(wb.active_sheet, 0); + assert_eq!(wb.active_sheet(), 0); let sh = wb.sheet(0).unwrap(); assert_eq!(sh.name, r["sheet"].as_str().unwrap()); assert_eq!(value(&s, 0, "C1"), Value::text("Forecast(Sales)")); diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index cd28cfbfa..84bc2f3cc 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -1011,7 +1011,7 @@ fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u return Ok((sheet, lines)); } let d = s.doc()?; - if sheet != d.wb.active_sheet { + if sheet != d.wb.active_sheet() { return Err(bad(key, format!("missing `{key}`: the selection is on the active sheet"))); } Ok((sheet, d.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect())) diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 5334784fa..22db2abd0 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -102,7 +102,7 @@ fn auto_sum(s: &mut Session, p: &Json) -> Result { (CellRef::new(out_row, c), format!("={func}({})", RangeRef::new(CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c)).a1())) }) .collect(); - let sheet = d.wb.active_sheet; + let sheet = d.wb.active_sheet(); edit(s, |cx| { for (c, f) in &writes { let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb)?.unwrap_or_default(); @@ -400,7 +400,7 @@ fn name_manager(s: &mut Session, _: &Json) -> Result { .iter() .filter(|n| !n.hidden) .map(|n| { - let sheet = n.scope.unwrap_or(d.wb.active_sheet); + let sheet = n.scope.unwrap_or(d.wb.active_sheet()); // parse_name, not evaluate's parse: a name's top-level commas are unions. let v = gridcraft_formula::parse_name(&n.formula) .map_or(Value::Error(CellError::Name), |e| gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, CellRef::default(), &e)); @@ -477,7 +477,7 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result { fn trace_precedents(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); - let areas = d.calc.graph.precedents_of((d.wb.active_sheet, at)); + let areas = d.calc.graph.precedents_of((d.wb.active_sheet(), at)); let v: Vec = areas.iter().map(|a| json!({"sheet": d.wb.sheet(a.sheet).map(|s| s.name.clone()), "range": a.range.a1()})).collect(); Ok(Json::Array(v)) } @@ -486,7 +486,7 @@ fn trace_dependents(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); let mut deps = Vec::new(); - d.calc.graph.dependents(d.wb.active_sheet, at, &mut deps); + d.calc.graph.dependents(d.wb.active_sheet(), at, &mut deps); deps.sort(); deps.dedup(); let v: Vec = deps.iter().map(|(si, c)| json!({"sheet": d.wb.sheet(*si).map(|s| s.name.clone()), "cell": c.a1()})).collect(); @@ -499,7 +499,7 @@ fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result = BTreeSet::new(); @@ -570,7 +570,7 @@ fn select_dependents(s: &mut Session, p: &Json) -> Result { } fn show_formulas(s: &mut Session, p: &Json) -> Result { - let sheet = s.doc()?.wb.active_sheet; + let sheet = s.doc()?.wb.active_sheet(); let cur = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.show_formulas); let on = bool_param(p, "on").unwrap_or(!cur); edit(s, |cx| { @@ -636,7 +636,7 @@ fn error_checking(s: &mut Session, _: &Json) -> Result { fn evaluate_formula(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); - let sheet = d.wb.active_sheet; + let sheet = d.wb.active_sheet(); let text = match str_param(p, "formula") { Some(f) => f.trim_start_matches('=').to_string(), None => { @@ -675,7 +675,7 @@ fn evaluate(s: &mut Session, p: &Json) -> Result { let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); let expr = crate::cmd::edit::parse_typed_formula(f.strip_prefix('=').unwrap_or(f))?; crate::cmd::edit::check_arg_counts(&expr)?; - let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, d.wb.active_sheet, at, &expr); + let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, d.wb.active_sheet(), at, &expr); Ok(crate::cmd::inspect::value_json(&v)) } diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index aae5a9faa..08369a773 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -356,7 +356,7 @@ fn find_table(s: &Session, p: &Json) -> Result<(usize, usize)> { if let Some(n) = str_param(p, "table") { return d.wb.table(n).ok_or_else(|| bad("table", format!("no table named `{n}`"))); } - let sheet = d.wb.active_sheet; + let sheet = d.wb.active_sheet(); let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; let ti = sh .tables @@ -819,7 +819,7 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result { fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> { let d = s.doc()?; - let sheet = d.wb.active_sheet; + let sheet = d.wb.active_sheet(); let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; let idx = match u32_param(p, "chart") { Some(id) => sh.charts.iter().position(|c| c.id == id), @@ -968,7 +968,7 @@ fn insert_sparkline(s: &mut Session, p: &Json) -> Result { _ => SparklineKind::Line, }; let markers = bool_param(p, "markers").unwrap_or(false); - let sheet = s.doc()?.wb.active_sheet; + let sheet = s.doc()?.wb.active_sheet(); edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; sh.sparklines.retain(|x| x.cell != loc); @@ -993,7 +993,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result { if placement == "cell" { let mime = validate_cell_picture(&data)?; let d = s.doc()?; - let sheet = d.wb.active_sheet; + let sheet = d.wb.active_sheet(); let sh = d.wb.active().ok_or(EngineError::NoDocument)?; let at = match str_param(p, "at") { Some(a) => CellRef::parse(a).ok_or_else(|| bad("insert.picture", "invalid cell address"))?, @@ -1037,7 +1037,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result { mime, alt: str_param(p, "alt").unwrap_or("").into(), }; - let sheet = d.wb.active_sheet; + let sheet = d.wb.active_sheet(); edit(s, |cx| { cx.sheet_mut(sheet)?.images.push(img); Ok(json!({"image": id})) @@ -1107,7 +1107,7 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result { text: str_param(p, "text").unwrap_or("").into(), format: Default::default(), }; - let sheet = d.wb.active_sheet; + let sheet = d.wb.active_sheet(); edit(s, |cx| { cx.sheet_mut(sheet)?.shapes.push(shape); Ok(json!({"shape": id})) @@ -1125,7 +1125,7 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result { return Err(bad("shape.setText", "`text` is longer than 32,767 characters")); } let d = s.doc()?; - let sheet = d.wb.active_sheet; + let sheet = d.wb.active_sheet(); let sh = d.wb.active().ok_or_else(|| bad("shape.setText", "no such text box"))?; let shape = sh.shapes.iter().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?; if shape.kind != ShapeKind::TextBox { @@ -1148,7 +1148,7 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result { fn delete_object(s: &mut Session, p: &Json) -> Result { let id = u32_param(p, "id").ok_or_else(|| bad("object.delete", "missing `id`"))?; let kind = str_param(p, "kind").unwrap_or("").to_string(); - let sheet = s.doc()?.wb.active_sheet; + let sheet = s.doc()?.wb.active_sheet(); edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; match kind.as_str() { @@ -1164,7 +1164,7 @@ fn delete_object(s: &mut Session, p: &Json) -> Result { fn move_object(s: &mut Session, p: &Json) -> Result { let id = u32_param(p, "id").ok_or_else(|| bad("object.move", "missing `id`"))?; let kind = str_param(p, "kind").unwrap_or("").to_string(); - let sheet = s.doc()?.wb.active_sheet; + let sheet = s.doc()?.wb.active_sheet(); let at = cell_param(s, p, "at")?; edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; @@ -1205,7 +1205,7 @@ fn set_anchor_mode(s: &mut Session, p: &Json) -> Result { return Err(bad("object.setAnchorMode", "kind must be chart, image or shape")); } let mode = anchor_mode_param(p).ok_or_else(|| bad("object.setAnchorMode", "mode must be moveAndSize, moveOnly or absolute"))?; - let sheet = s.doc()?.wb.active_sheet; + let sheet = s.doc()?.wb.active_sheet(); edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; let anchor = match kind.as_str() { @@ -1703,7 +1703,7 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result { text: name, format: Default::default(), }; - let sheet = d.wb.active_sheet; + let sheet = d.wb.active_sheet(); edit(s, |cx| { cx.sheet_mut(sheet)?.shapes.push(shape); Ok(json!({"shape": id})) diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index f373de489..c322f4388 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -63,7 +63,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result { "title": d.display_title(), "path": d.path, "dirty": d.is_dirty(), - "activeSheet": wb.active_sheet, + "activeSheet": wb.active_sheet(), "sheets": sheets, "selection": d.selection.a1(), "activeCell": d.selection.active.a1(), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 1f15ddfb8..3d1b404d0 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -271,7 +271,7 @@ pub(crate) fn target_sheet(s: &Session, p: &Json) -> Result { { return d.wb.sheet_index(&sh).ok_or_else(|| bad("range", format!("no sheet named `{sh}`"))); } - Ok(d.wb.active_sheet) + Ok(d.wb.active_sheet()) } pub(crate) use gridcraft_core::split_sheet; @@ -280,7 +280,7 @@ pub(crate) use gridcraft_core::split_sheet; /// a cell reference, or that names a sheet other than the active one, is an error rather than a /// silent fallback to some other cell. pub(crate) fn cell_param(s: &Session, p: &Json, key: &str) -> Result> { - cell_param_on(s, p, key, s.doc()?.wb.active_sheet) + cell_param_on(s, p, key, s.doc()?.wb.active_sheet()) } /// Like [`cell_param`], for a command that operates on `sheet` (a `Sheet!` prefix must name it). @@ -562,7 +562,7 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) { impl Ctx<'_> { pub fn sheet_index(&self) -> usize { - self.wb.active_sheet + self.wb.active_sheet() } pub fn sheet_mut(&mut self, i: usize) -> Result<&mut gridcraft_model::Sheet> { self.wb.sheet_mut(i).ok_or_else(|| EngineError::Other("no such sheet".into())) diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 43c3aa4b5..3d3c28b44 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -206,7 +206,7 @@ fn other(msg: impl Into) -> EngineError { /// (sheet, index) of the pivot a call targets. fn find_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> { let d = s.doc()?; - let active = d.wb.active_sheet; + let active = d.wb.active_sheet(); if let Some(v) = p.get("pivot").filter(|v| !v.is_null()) { let mut order: Vec = vec![active]; order.extend((0..d.wb.sheets.len()).filter(|i| *i != active)); @@ -616,7 +616,7 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result { return Err(other("The workbook is protected and cannot be changed.")); } let source = source_param(s, p)?; - let active = s.doc()?.wb.active_sheet; + let active = s.doc()?.wb.active_sheet(); // Validate the source now (header names, size). let src = pv::read_source(&s.doc()?.wb, &source, active).map_err(other)?; let name = match str_param(p, "name") { @@ -654,7 +654,7 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result { let (si, anchor) = match target { Some(t) => t, None => { - let at = cx.wb.active_sheet.min(cx.wb.sheets.len()); + let at = cx.wb.active_sheet().min(cx.wb.sheets.len()); let nm = cx.wb.next_sheet_name(); cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm))); for n in cx.wb.names.iter_mut() { @@ -704,7 +704,7 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result { sh.pivots.push(pt); let pi = sh.pivots.len() - 1; let area = pv::refresh(&mut cx.wb, si, pi, replace).map_err(other)?; - cx.wb.active_sheet = si; + cx.wb.set_active_sheet(si); *cx.sel = crate::Selection::at(anchor); cx.structural = true; let sheet_name = cx.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default(); @@ -1395,7 +1395,7 @@ fn chart_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> { let sh = gridcraft_core::unquote_sheet(sh); (d.wb.sheet_index(&sh), a) } - None => (Some(d.wb.active_sheet), t), + None => (Some(d.wb.active_sheet()), t), }; if let (Some(si), Some(c)) = (sheet, CellRef::parse(addr.trim().replace('$', "").as_str())) { let hit = d.wb.sheet(si).and_then(|sh| sh.pivots.iter().position(|pt| pt.last_range.is_some_and(|r| r.contains(c)) || pt.anchor == c)); @@ -1529,7 +1529,7 @@ fn suggestions(wb: &Workbook, src: &pv::Source) -> Vec { fn recommended(s: &mut Session, p: &Json) -> Result { let source = source_param(s, p)?; - let active = s.doc()?.wb.active_sheet; + let active = s.doc()?.wb.active_sheet(); let src = pv::read_source(&s.doc()?.wb, &source, active).map_err(other)?; let list = suggestions(&s.doc()?.wb, &src); let Some(apply) = p.get("apply").and_then(Json::as_u64) else { @@ -1612,7 +1612,7 @@ mod tests { assert_eq!(r["fields"], json!(["Region", "Product", "Date", "Units", "Sales"])); let d = s.doc().unwrap(); assert_eq!(d.wb.sheets.len(), 2); - assert_eq!(d.wb.active_sheet, 0, "new sheet goes before the current one"); + assert_eq!(d.wb.active_sheet(), 0, "new sheet goes before the current one"); assert_eq!(d.wb.active().unwrap().name, "Sheet2"); assert!(text(&s, "A3").starts_with("PivotTable1")); assert_eq!(pt(&s).source, "Sheet1!$A$1:$E$13"); @@ -1993,7 +1993,7 @@ mod tests { } fn plotted(s: &Session) -> Vec>> { let d = s.doc().unwrap(); - let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, &pivot_chart(s)); + let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet(), &pivot_chart(s)); data.series.into_iter().map(|x| x.values).collect() } diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 4c80f2fa9..35225cbe9 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -370,13 +370,13 @@ pub fn layouts(wb: &Workbook, opts: &PrintOptions) -> Vec { let sheets: Vec = if opts.all_sheets { (0..wb.sheets.len()).filter(|i| wb.sheet(*i).is_some_and(|s| s.visibility == Visibility::Visible)).collect() } else { - vec![wb.active_sheet] + vec![wb.active_sheet()] }; for si in sheets { if pages >= MAX_PAGES { break; } - let ovr = if si == wb.active_sheet { opts.range } else { None }; + let ovr = if si == wb.active_sheet() { opts.range } else { None }; if opts.all_sheets && ovr.is_none() && wb.sheet(si).is_some_and(|s| print_range(s).is_none()) { continue; } @@ -387,7 +387,7 @@ pub fn layouts(wb: &Workbook, opts: &PrintOptions) -> Vec { } } if out.is_empty() && opts.all_sheets { - out.extend(layout_sheet(wb, wb.active_sheet, opts, None)); + out.extend(layout_sheet(wb, wb.active_sheet(), opts, None)); } out } diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 5464abba5..9811fde9e 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -90,7 +90,7 @@ fn has_comments(s: &Session) -> std::result::Result<(), String> { fn step_comment(s: &mut Session, forward: bool) -> Result { let list = comment_cells(s); let d = s.doc()?; - let here = (d.wb.active_sheet, d.selection.active); + let here = (d.wb.active_sheet(), d.selection.active); let target = if forward { list.iter().find(|c| **c > here).or_else(|| list.first()) } else { @@ -374,7 +374,7 @@ mod tests { fn at(s: &Session) -> (usize, String) { let d = s.doc().unwrap(); - (d.wb.active_sheet, d.selection.active.a1()) + (d.wb.active_sheet(), d.selection.active.a1()) } #[test] @@ -443,7 +443,7 @@ mod tests { ); let prot: SheetProtection = serde_json::from_str(&old).unwrap(); assert_eq!((prot.legacy_password_hash, prot.password.is_none()), (Some(password_hash("old")), true)); - let i = s.doc().unwrap().wb.active_sheet; + let i = s.doc().unwrap().wb.active_sheet(); let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb); std::sync::Arc::make_mut(&mut wb.sheets[i]).protection = Some(prot); assert!(s.execute("review.unprotectSheet", json!({"password": "new"})).is_err()); diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 555c2fb79..e68bf6070 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -687,7 +687,7 @@ fn insert_sheet(s: &mut Session, p: &Json) -> Result { let before = p.get("before").and_then(Json::as_u64).map(|v| v as usize); let idx = edit_doc(d, |cx| { // Excel inserts before the active sheet; the "+" button appends after it. - let at = before.unwrap_or(cx.wb.active_sheet + 1).min(cx.wb.sheets.len()); + let at = before.unwrap_or(cx.wb.active_sheet() + 1).min(cx.wb.sheets.len()); cx.wb.sheets.insert(at, Arc::new(Sheet::new(name.clone()))); // Names scoped to later sheets shift. for n in cx.wb.names.iter_mut() { @@ -697,7 +697,7 @@ fn insert_sheet(s: &mut Session, p: &Json) -> Result { *sc += 1; } } - cx.wb.active_sheet = at; + cx.wb.set_active_sheet(at); *cx.sel = crate::Selection::default(); cx.structural = true; Ok(at) @@ -785,8 +785,8 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result { } } } - if cx.wb.active_sheet >= cx.wb.sheets.len() || cx.wb.active_sheet > i { - cx.wb.active_sheet = cx.wb.active_sheet.saturating_sub(1).min(cx.wb.sheets.len().saturating_sub(1)); + if cx.wb.active_sheet() >= cx.wb.sheets.len() || cx.wb.active_sheet() > i { + cx.wb.set_active_sheet(cx.wb.active_sheet().saturating_sub(1).min(cx.wb.sheets.len().saturating_sub(1))); } *cx.sel = crate::Selection::default(); cx.structural = true; @@ -900,12 +900,12 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result { } cx.wb.names.extend(copies); cx.wb.sheets.insert(at, Arc::new(sh)); - cx.wb.active_sheet = at; + cx.wb.set_active_sheet(at); } else { let sh = cx.wb.sheets.remove(i); let at = to.min(n - 1); cx.wb.sheets.insert(at, sh); - cx.wb.active_sheet = at; + cx.wb.set_active_sheet(at); // Sheet-level names follow their sheet. for nm in cx.wb.names.iter_mut() { if let Some(sc) = nm.scope.as_mut() { @@ -942,8 +942,8 @@ fn hide_sheet(s: &mut Session, p: &Json) -> Result { } edit(s, |cx| { cx.sheet_mut(i)?.visibility = Visibility::Hidden; - if cx.wb.active_sheet == i { - cx.wb.active_sheet = cx.wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0); + if cx.wb.active_sheet() == i { + cx.wb.set_active_sheet(cx.wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0)); } Ok(Json::Null) }) @@ -964,7 +964,7 @@ fn unhide_sheet(s: &mut Session, p: &Json) -> Result { let i = target_sheet(s, p)?; edit(s, |cx| { cx.sheet_mut(i)?.visibility = Visibility::Visible; - cx.wb.active_sheet = i; + cx.wb.set_active_sheet(i); Ok(Json::Null) }) } @@ -978,7 +978,7 @@ fn activate(s: &mut Session, p: &Json) -> Result { fn step_sheet(s: &mut Session, dir: isize) -> Result { let d = s.doc()?; - let mut i = d.wb.active_sheet; + let mut i = d.wb.active_sheet(); while let Some(next) = i.checked_add_signed(dir) { let Some(sheet) = d.wb.sheet(next) else { break }; if sheet.visibility == Visibility::Visible { diff --git a/crates/engine/src/cmd/slicer.rs b/crates/engine/src/cmd/slicer.rs index cb72ded71..10e3cf13c 100644 --- a/crates/engine/src/cmd/slicer.rs +++ b/crates/engine/src/cmd/slicer.rs @@ -165,7 +165,7 @@ fn slicer_toggle(s: &mut Session, p: &Json) -> Result { let (idx, id) = find_slicer(s, p)?; let value = str_param(p, "value").ok_or_else(|| bad("slicer.toggle", "missing `value`"))?.to_string(); let multi = bool_param(p, "multi").unwrap_or(false); - let sheet = s.doc()?.wb.active_sheet; + let sheet = s.doc()?.wb.active_sheet(); let (table, column, current) = { let d = s.doc()?; let sl = d.wb.active().and_then(|sh| sh.slicers.get(idx)).ok_or_else(|| bad("slicer.toggle", "no slicer"))?; @@ -204,7 +204,7 @@ fn slicer_toggle(s: &mut Session, p: &Json) -> Result { fn slicer_select_all(s: &mut Session, p: &Json) -> Result { let (idx, id) = find_slicer(s, p)?; - let sheet = s.doc()?.wb.active_sheet; + let sheet = s.doc()?.wb.active_sheet(); edit(s, |cx| { if let Some(sl) = cx.sheet_mut(sheet)?.slicers.get_mut(idx) { sl.selected = None; @@ -217,7 +217,7 @@ fn slicer_select_all(s: &mut Session, p: &Json) -> Result { fn slicer_delete(s: &mut Session, p: &Json) -> Result { let (idx, id) = find_slicer(s, p)?; - let sheet = s.doc()?.wb.active_sheet; + let sheet = s.doc()?.wb.active_sheet(); // Absolute column of the slicer, so its filter criterion can be removed too. let abs_col = s.doc().ok().and_then(|d| { let sh = d.wb.active()?; diff --git a/crates/engine/src/cmd/solver.rs b/crates/engine/src/cmd/solver.rs index ffd426f5f..bf78b4d4c 100644 --- a/crates/engine/src/cmd/solver.rs +++ b/crates/engine/src/cmd/solver.rs @@ -299,7 +299,7 @@ fn parse_model(s: &Session, p: &Json) -> Result { let d = s.doc()?; let wb = &d.wb; let obj_text = str_param(p, "objective").or_else(|| str_param(p, "setObjective")).or_else(|| str_param(p, "set")).unwrap_or(""); - let (sheet, obj) = cells_of(wb, obj_text, wb.active_sheet, 1).map_err(|_| EngineError::Other("The objective must be a single cell.".into()))?; + let (sheet, obj) = cells_of(wb, obj_text, wb.active_sheet(), 1).map_err(|_| EngineError::Other("The objective must be a single cell.".into()))?; let objective = *obj.first().ok_or_else(|| EngineError::Other("The objective must be a single cell.".into()))?; let sh = wb.sheet(sheet).ok_or(EngineError::NoDocument)?; if sh.cell(objective).is_none_or(|c| c.formula.is_none()) { @@ -1913,8 +1913,8 @@ fn write_reports(cx: &mut Ctx, at: usize, built: Vec<(String, Lines)>) -> Vec= at { - cx.wb.active_sheet += count; + if cx.wb.active_sheet() >= at { + cx.wb.set_active_sheet(cx.wb.active_sheet().saturating_add(count)); } cx.structural = true; names @@ -1939,7 +1939,7 @@ fn report(s: &mut Session, p: &Json) -> Result { let params = match p.get("model") { Some(m @ Json::Object(_)) => m.clone(), Some(Json::Null) | None => { - let mut params = saved_model(&d.wb, d.wb.active_sheet).unwrap_or_else(|| json!({})); + let mut params = saved_model(&d.wb, d.wb.active_sheet()).unwrap_or_else(|| json!({})); if let (Some(base), Some(over)) = (params.as_object_mut(), p.as_object()) { for (k, v) in over { base.insert(k.clone(), v.clone()); @@ -1988,7 +1988,7 @@ fn report(s: &mut Session, p: &Json) -> Result { fn run(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; - let active = d.wb.active_sheet; + let active = d.wb.active_sheet(); let mut params = p.clone(); if bool_param(p, "saved").unwrap_or(false) { let saved = saved_model(&d.wb, active).ok_or_else(|| EngineError::Other("This sheet has no saved Solver model.".into()))?; @@ -2145,7 +2145,7 @@ mod tests { /// The first saved-model name scoped to the active sheet. fn saved(s: &Session) -> String { let d = s.doc().unwrap(); - d.wb.names.iter().find(|n| n.scope == Some(d.wb.active_sheet)).map(|n| n.name.clone()).unwrap_or_default() + d.wb.names.iter().find(|n| n.scope == Some(d.wb.active_sheet())).map(|n| n.name.clone()).unwrap_or_default() } fn set(s: &mut Session, cells: &[(&str, &str)]) { for (c, v) in cells { diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index f0b2f77d3..e79d79630 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -139,7 +139,7 @@ fn freeze_panes(s: &mut Session, p: &Json) -> Result { } let at = match cell_param_on(s, p, "cell", si)? { Some(c) => c, - None if si == d.wb.active_sheet => d.selection.active, + None if si == d.wb.active_sheet() => d.selection.active, // The selection belongs to the active sheet. None => return Err(bad("view.freezePanes", "missing `cell`: the selection is on the active sheet")), }; @@ -190,7 +190,7 @@ fn zoom(s: &mut Session, p: &Json) -> Result { let d = s.doc_mut()?; let dirty = d.is_dirty(); let wb = std::sync::Arc::make_mut(&mut d.wb); - let si = wb.active_sheet; + let si = wb.active_sheet(); if let Some(sh) = wb.sheet_mut(si) { sh.zoom = z; } diff --git a/crates/engine/src/cmd/whatif.rs b/crates/engine/src/cmd/whatif.rs index cec845b7a..53b0dc3cc 100644 --- a/crates/engine/src/cmd/whatif.rs +++ b/crates/engine/src/cmd/whatif.rs @@ -101,7 +101,7 @@ fn cell_arg(s: &Session, p: &Json, key: &str, cmd: &str) -> Result<(usize, CellR let d = s.doc()?; let t = str_param(p, key).ok_or_else(|| bad(cmd, format!("missing `{key}`")))?; let (sh, body) = split_sheet(t); - let si = sheet_named(&d.wb, sh, d.wb.active_sheet, cmd)?; + let si = sheet_named(&d.wb, sh, d.wb.active_sheet(), cmd)?; let c = CellRef::parse(body.trim()).ok_or_else(|| bad(cmd, format!("`{key}` is not a cell: `{t}`")))?; Ok((si, c)) } @@ -110,7 +110,7 @@ fn cell_arg(s: &Session, p: &Json, key: &str, cmd: &str) -> Result<(usize, CellR /// are trimmed to the used range. pub(crate) fn parse_range(wb: &Workbook, t: &str, cmd: &str) -> Result<(usize, RangeRef)> { let (sh, body) = split_sheet(t); - let si = sheet_named(wb, sh, wb.active_sheet, cmd)?; + let si = sheet_named(wb, sh, wb.active_sheet(), cmd)?; let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(cmd, format!("not a range: `{t}`")))?; Ok((si, trim_to_used(wb, si, r))) } @@ -152,7 +152,7 @@ fn cell_list(wb: &Workbook, t: &str, cmd: &str, cap: usize) -> Result<(usize, Ve return Err(bad(cmd, format!("too many cells (at most {cap})"))); } } - Ok((sheet.unwrap_or(wb.active_sheet), out)) + Ok((sheet.unwrap_or(wb.active_sheet()), out)) } /// What a JSON value typed into a cell is, as a constant. @@ -525,7 +525,7 @@ fn scenario_delete(s: &mut Session, p: &Json) -> Result { fn scenario_summary(s: &mut Session, p: &Json) -> Result { const CMD: &str = "data.scenarioSummary"; let d = s.doc()?; - let si = d.wb.active_sheet; + let si = d.wb.active_sheet(); let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; let scenarios: Vec = load_scenarios(&d.wb).into_iter().filter(|x| x.sheet.eq_ignore_ascii_case(&sh.name)).collect(); if scenarios.is_empty() { @@ -799,7 +799,7 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result { Some(t) => parse_range(&d.wb, t, CMD)?, None => { let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - (d.wb.active_sheet, crate::selection::current_region(sh, d.selection.active)) + (d.wb.active_sheet(), crate::selection::current_region(sh, d.selection.active)) } }; let criteria = str_param(p, "criteria").filter(|t| !t.trim().is_empty()).map(|t| parse_range(&d.wb, t, CMD)).transpose()?; @@ -1024,7 +1024,7 @@ fn consolidate(s: &mut Session, p: &Json) -> Result { let (si, r) = parse_range(&d.wb, t, CMD)?; (si, r.start) } - None => (d.wb.active_sheet, d.selection.active), + None => (d.wb.active_sheet(), d.selection.active), }; let mut row_keys: Vec = Vec::new(); let mut col_keys: Vec = Vec::new(); @@ -1146,7 +1146,7 @@ mod tests { fn v(s: &Session, a: &str) -> Value { let d = s.doc().unwrap(); let (sh, body) = super::split_sheet(a); - let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet); + let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet()); d.wb.sheet(si).unwrap().value(CellRef::parse(body).unwrap()) } fn n(s: &Session, a: &str) -> f64 { diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 5c0bf800b..ef1cc7bb5 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -234,7 +234,7 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { /// Encodes a workbook as `kind`, whatever `path`'s extension says. pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result> { - let sheet = wb.active_sheet; + let sheet = wb.active_sheet(); match kind { // The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry // the VBA project, so any other name saves without it, as Excel does. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 74924d542..f55fba080 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -194,14 +194,14 @@ impl DocState { !Arc::ptr_eq(&self.wb, &self.saved) } pub fn sheet_index(&self) -> usize { - self.wb.active_sheet + self.wb.active_sheet() } /// Records the view state that is saved with the file (`sheetView topLeftCell`, the active /// cell): the first visible cell of the scrolling area per sheet index in `tops`, and the /// active sheet's active cell. View state never makes the workbook dirty. pub fn store_view(&mut self, tops: &[(usize, CellRef)]) { let active = self.selection.active; - let si = self.wb.active_sheet; + let si = self.wb.active_sheet(); let changed = tops.iter().any(|(i, c)| self.wb.sheets.get(*i).is_some_and(|sh| sh.view_top_left != *c)) || self.wb.sheets.get(si).is_some_and(|sh| sh.view_active != active); if !changed { diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 129fe2171..d5f5b81d0 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -37,7 +37,7 @@ pub fn build(name: &str) -> Option { return None; } let mut wb = (*s.doc().ok()?.wb).clone(); - wb.active_sheet = 0; + wb.set_active_sheet(0); if let Some(sh) = wb.sheet_mut(0) { sh.view_active = gridcraft_core::CellRef::default(); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6f7f7f4bc..73ea96743 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -188,7 +188,7 @@ fn chart_category_axis_from_blank_corner_and_select_data() { assert_eq!(series(&s), [(Some("Sheet1!$D$2:$D$4".to_string()), "Sheet1!$E$2:$E$4".to_string())]); let d = s.doc().unwrap(); let sh = d.wb.active().unwrap(); - let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, sh.charts.last().unwrap()); + let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet(), sh.charts.last().unwrap()); assert_eq!(data.categories, ["2020", "2021", "2022"]); // The last series can't be removed; "" clears the labels. s.execute("chart.selectData", json!({"categories": "", "removeSeries": [0]})).unwrap(); @@ -1608,7 +1608,7 @@ fn slicer_finds_a_named_table_on_another_sheet() { s.execute("insert.table", json!({"range": "A1:B3", "name": "Sales"})).unwrap(); s.execute("home.insertSheet", json!({"name": "Report"})).unwrap(); let report = s.doc().unwrap().wb.sheet_index("Report").unwrap(); - assert_eq!(s.doc().unwrap().wb.active_sheet, report); + assert_eq!(s.doc().unwrap().wb.active_sheet(), report); // With another sheet active, `table` still names the table, and the slicer goes on the table's sheet. let r = s.execute("insert.slicer", json!({"table": "Sales", "column": "Region", "at": "D2"})).unwrap(); @@ -3843,7 +3843,7 @@ fn line_sizes_and_freeze_panes_take_a_sheet() { assert!(data.cols.get(&4).is_some_and(|c| c.hidden)); assert_eq!(data.default_col_width, 80.0); assert_eq!(sheet(&s, 0), before, "the active sheet is untouched"); - assert_eq!(s.doc().unwrap().wb.active_sheet, 0); + assert_eq!(s.doc().unwrap().wb.active_sheet(), 0); s.execute("view.freezePanes", json!({"cell": "Data!B3"})).unwrap(); assert_eq!(sheet(&s, 1).freeze, Some((2, 1))); @@ -4550,11 +4550,11 @@ fn switching_windows_swaps_their_views_without_dirtying_the_workbook() { s.execute("window.activate", json!({"window": first})).unwrap(); let d = s.doc().unwrap(); - assert_eq!((d.wb.active_sheet, d.selection.a1()), (0, "B2:C3".to_string())); + assert_eq!((d.wb.active_sheet(), d.selection.a1()), (0, "B2:C3".to_string())); assert!(!d.is_dirty(), "a window's sheet and selection are not document changes"); s.execute("view.switchWindows", json!({"window": second})).unwrap(); let d = s.doc().unwrap(); - assert_eq!((d.wb.active_sheet, d.selection.a1()), (1, "D4".to_string())); + assert_eq!((d.wb.active_sheet(), d.selection.a1()), (1, "D4".to_string())); assert!(!d.is_dirty()); // A routed command acts in its window and leaves the active one showing. @@ -4564,6 +4564,71 @@ fn switching_windows_swaps_their_views_without_dirtying_the_workbook() { assert_eq!(s.doc().unwrap().selection.a1(), "D4"); } +#[test] +fn window_swaps_share_the_workbook_and_save_records_the_latest_window_sheet() { + let mut s = s(); + s.execute("home.insertSheet", json!({})).unwrap(); + let path = std::env::temp_dir().join(format!("gridcraft-window-arc-{}.xlsx", std::process::id())); + s.execute("file.save", json!({"path": path})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + s.execute("selection.set", json!({"range": "B2"})).unwrap(); + let first = s.active_window().unwrap(); + let second = s.execute("view.newWindow", json!({})).unwrap()["window"].as_u64().unwrap(); + s.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + s.execute("selection.set", json!({"range": "C3:D4"})).unwrap(); + + // Swapping windows, as the interface does before drawing each one, never copies the workbook. + let before = s.doc().unwrap().wb.clone(); + for _ in 0..3 { + s.show_window(first); + assert_eq!((s.doc().unwrap().wb.active_sheet(), s.doc().unwrap().selection.a1()), (0, "B2".to_string())); + s.activate_window(second); + assert_eq!((s.doc().unwrap().wb.active_sheet(), s.doc().unwrap().selection.a1()), (1, "C3:D4".to_string())); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + } + let d = s.doc().unwrap(); + assert!(std::sync::Arc::ptr_eq(&before, &d.wb), "a window swap or sheet switch copied the workbook"); + assert!(!d.is_dirty()); + + // A command run in window 2 edits window 2's sheet at its selection. + s.execute("cell.set", json!({"input": "two"})).unwrap(); + let d = s.doc().unwrap(); + assert_eq!(d.wb.sheets[1].value(CellRef::parse("C3").unwrap()), Value::text("two")); + assert_eq!(d.wb.sheets[0].value(CellRef::parse("B2").unwrap()), Value::Empty); + s.show_window(first); + assert_eq!(s.doc().unwrap().wb.active_sheet(), 0); + + // The most recently active window is window 2: the file records its sheet. + s.execute_in(crate::Target::Window(second), "file.save", json!({"path": path})).unwrap(); + let mut t = Session::new(); + t.execute("file.open", json!({"path": path})).unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(t.doc().unwrap().wb.active_sheet(), 1); + s.activate_window(first); + s.execute("file.save", json!({"path": path})).unwrap(); + let mut t = Session::new(); + t.execute("file.open", json!({"path": path})).unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(t.doc().unwrap().wb.active_sheet(), 0); +} + +#[test] +fn arrange_all_picks_the_visible_windows_active_first() { + let mut s = s(); + let book1 = s.active_window().unwrap(); + let book1b = s.execute("view.newWindow", json!({})).unwrap()["window"].as_u64().unwrap(); + s.execute("file.new", json!({})).unwrap(); + let book2 = s.active_window().unwrap(); + let r = s.execute("view.arrangeAll", json!({})).unwrap(); + assert_eq!(r, json!({"arrange": "tiled", "windows": [book2, book1, book1b]})); + s.execute("window.activate", json!({"window": book1b})).unwrap(); + s.execute("view.hideWindow", json!({"window": book2})).unwrap(); + let r = s.execute("view.arrangeAll", json!({"arrange": "cascade", "activeWorkbook": true})).unwrap(); + assert_eq!(r, json!({"arrange": "cascade", "windows": [book1b, book1]})); + assert!(s.execute("view.arrangeAll", json!({"arrange": "diagonal"})).is_err()); +} + #[test] fn hide_unhide_and_switch_by_window_id() { let mut s = s(); diff --git a/crates/engine/src/windows.rs b/crates/engine/src/windows.rs index 4441166db..df43b7d32 100644 --- a/crates/engine/src/windows.rs +++ b/crates/engine/src/windows.rs @@ -2,10 +2,11 @@ //! //! Every open workbook has one or more windows (View › New Window adds another on the same //! workbook). A window has its own selection, active sheet and per-sheet selections. The active -//! window's view lives in its [`DocState`] (`selection`, `wb.active_sheet`, `sheet_selections`), -//! where every command reads it; each other window keeps its view in [`WindowState`] until it is -//! activated. Swapping a view in or out never makes the workbook dirty: per-window view state is -//! not a document change. +//! window's view lives in its [`DocState`] (`selection`, `sheet_selections`) and in the workbook's +//! active sheet ([`gridcraft_model::workbook::Workbook::active_sheet`]), where every command reads +//! it; each other window keeps its view in [`WindowState`] until it is activated. Swapping a view +//! in or out never copies the workbook or makes it dirty: the active sheet is set in place through +//! the shared `Arc`, and selections live outside the workbook. use serde_json::{Value as Json, json}; @@ -15,7 +16,7 @@ use crate::{DocState, EngineError, Result, Selection, Session}; #[derive(Clone, Debug, Default, PartialEq)] pub struct WindowView { pub selection: Selection, - pub active_sheet: usize, + pub sheet: usize, /// The active sheet's name, so a sheet inserted, deleted or moved meanwhile in another /// window still finds it. pub sheet_name: String, @@ -42,27 +43,20 @@ impl WindowView { fn capture(d: &DocState) -> WindowView { WindowView { selection: d.selection.clone(), - active_sheet: d.wb.active_sheet, - sheet_name: d.wb.sheets.get(d.wb.active_sheet).map(|s| s.name.clone()).unwrap_or_default(), + sheet: d.wb.active_sheet(), + sheet_name: d.wb.sheets.get(d.wb.active_sheet()).map(|s| s.name.clone()).unwrap_or_default(), sheet_selections: d.sheet_selections.clone(), } } fn apply(self, d: &mut DocState) { let sheets = &d.wb.sheets; - let at = if sheets.get(self.active_sheet).is_some_and(|s| s.name == self.sheet_name) { - self.active_sheet + let at = if sheets.get(self.sheet).is_some_and(|s| s.name == self.sheet_name) { + self.sheet } else { - sheets.iter().position(|s| s.name == self.sheet_name).unwrap_or(self.active_sheet) + sheets.iter().position(|s| s.name == self.sheet_name).unwrap_or(self.sheet) }; - let at = at.min(sheets.len().saturating_sub(1)); - if d.wb.active_sheet != at { - let dirty = d.is_dirty(); - std::sync::Arc::make_mut(&mut d.wb).active_sheet = at; - if !dirty { - d.saved = d.wb.clone(); - } - } + d.wb.set_active_sheet(at.min(sheets.len().saturating_sub(1))); d.selection = self.selection; d.sheet_selections = self.sheet_selections; } diff --git a/crates/engine/tests/command_sequence_fuzz.rs b/crates/engine/tests/command_sequence_fuzz.rs index 1724b9b9d..fd06c70b3 100644 --- a/crates/engine/tests/command_sequence_fuzz.rs +++ b/crates/engine/tests/command_sequence_fuzz.rs @@ -373,8 +373,8 @@ fn structure_problem(wb: &Workbook, sel: &gridcraft_engine::Selection) -> Option if wb.sheets.is_empty() { return Some("no sheets left".into()); } - if wb.active_sheet >= wb.sheets.len() { - return Some(format!("active sheet {} of {}", wb.active_sheet, wb.sheets.len())); + if wb.active_sheet() >= wb.sheets.len() { + return Some(format!("active sheet {} of {}", wb.active_sheet(), wb.sheets.len())); } let mut tables = BTreeSet::new(); for (i, sh) in wb.sheets.iter().enumerate() { @@ -490,7 +490,7 @@ fn watch_list(wb: &Workbook) -> Option<&str> { /// reset: undo restores view state as it was, but switching sheets isn't an undoable edit. fn canonical(wb: &Workbook) -> Workbook { let mut wb = wb.clone(); - wb.active_sheet = 0; + wb.set_active_sheet(0); wb.names.retain(|n| n.name != WATCH_NAME); for sh in wb.sheets.iter_mut() { let sh = Arc::make_mut(sh); diff --git a/crates/engine/tests/hidden_sheet_navigation.rs b/crates/engine/tests/hidden_sheet_navigation.rs index f457f4bdb..8869d52ba 100644 --- a/crates/engine/tests/hidden_sheet_navigation.rs +++ b/crates/engine/tests/hidden_sheet_navigation.rs @@ -25,7 +25,7 @@ fn next_stays_on_the_last_visible_sheet() { for _ in 0..2 { session.execute("sheet.next", json!({})).unwrap(); let doc = session.active().unwrap(); - assert_eq!(doc.wb.active_sheet, 1); + assert_eq!(doc.wb.active_sheet(), 1); assert_eq!(doc.selection.active.a1(), "C7"); } } @@ -36,7 +36,7 @@ fn previous_stays_on_the_first_visible_sheet() { for _ in 0..2 { session.execute("sheet.previous", json!({})).unwrap(); let doc = session.active().unwrap(); - assert_eq!(doc.wb.active_sheet, 2); + assert_eq!(doc.wb.active_sheet(), 2); assert_eq!(doc.selection.active.a1(), "C7"); } } @@ -45,10 +45,10 @@ fn previous_stays_on_the_first_visible_sheet() { fn navigation_skips_hidden_sheets_in_both_directions() { let mut session = workbook(&[1, 2], 0); session.execute("sheet.next", json!({})).unwrap(); - assert_eq!(session.active().unwrap().wb.active_sheet, 3); + assert_eq!(session.active().unwrap().wb.active_sheet(), 3); session.execute("sheet.previous", json!({})).unwrap(); let doc = session.active().unwrap(); - assert_eq!(doc.wb.active_sheet, 0); + assert_eq!(doc.wb.active_sheet(), 0); assert_eq!(doc.selection.active.a1(), "C7"); } @@ -58,7 +58,7 @@ fn navigation_with_one_visible_sheet_is_a_noop() { for command in ["sheet.previous", "sheet.next"] { session.execute(command, json!({})).unwrap(); let doc = session.active().unwrap(); - assert_eq!(doc.wb.active_sheet, 1); + assert_eq!(doc.wb.active_sheet(), 1); assert_eq!(doc.selection.active.a1(), "C7"); } } @@ -73,6 +73,6 @@ fn very_hidden_sheets_are_skipped_at_interior_and_boundary_positions() { } for (command, expected) in [("sheet.next", 2), ("sheet.next", 2), ("sheet.previous", 0)] { session.execute(command, json!({})).unwrap(); - assert_eq!(session.active().unwrap().wb.active_sheet, expected); + assert_eq!(session.active().unwrap().wb.active_sheet(), expected); } } diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 3b943d295..ac6b3131b 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -1,6 +1,7 @@ //! Workbooks. use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use gridcraft_core::DateSystem; use gridcraft_formula::Expr; @@ -147,7 +148,8 @@ pub struct Workbook { pub calc: CalcSettings, #[serde(default, skip_serializing_if = "is_all")] pub show_objects: ShowObjects, - pub active_sheet: usize, + /// Read and set with [`Workbook::active_sheet`] and [`Workbook::set_active_sheet`]. + pub active_sheet: ActiveSheet, pub props: DocProps, /// Workbook structure protection. pub protected_structure: bool, @@ -205,6 +207,57 @@ impl Default for Workbook { } } +/// The active sheet index of a [`Workbook`]. +/// +/// Every other part of a workbook value is immutable once shared (the engine keeps versions as +/// `Arc` and tells unsaved changes by pointer). The active sheet is the exception: it +/// is the view state of the window showing the workbook, and several windows on one workbook +/// swap their own sheet into it whenever one of them becomes the one commands act on. Keeping it +/// in an atomic lets that swap happen through the shared `Arc` without cloning the workbook. +/// Copies (undo versions, a cloned workbook) take the value at the time of the copy. +#[derive(Default, Serialize, Deserialize)] +#[serde(from = "usize", into = "usize")] +pub struct ActiveSheet(AtomicUsize); + +impl ActiveSheet { + pub fn get(&self) -> usize { + self.0.load(Ordering::Relaxed) + } + pub fn set(&self, i: usize) { + self.0.store(i, Ordering::Relaxed); + } +} + +impl From for ActiveSheet { + fn from(i: usize) -> ActiveSheet { + ActiveSheet(AtomicUsize::new(i)) + } +} + +impl From for usize { + fn from(a: ActiveSheet) -> usize { + a.get() + } +} + +impl Clone for ActiveSheet { + fn clone(&self) -> ActiveSheet { + ActiveSheet::from(self.get()) + } +} + +impl PartialEq for ActiveSheet { + fn eq(&self, other: &ActiveSheet) -> bool { + self.get() == other.get() + } +} + +impl std::fmt::Debug for ActiveSheet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.get().fmt(f) + } +} + impl Workbook { /// A new workbook with one sheet, "Sheet1". pub fn new() -> Workbook { @@ -216,7 +269,7 @@ impl Workbook { date_system: DateSystem::D1900, calc: CalcSettings::default(), show_objects: ShowObjects::All, - active_sheet: 0, + active_sheet: ActiveSheet::default(), props: DocProps::default(), protected_structure: false, structure_password: None, @@ -239,8 +292,17 @@ impl Workbook { pub fn sheet_index(&self, name: &str) -> Option { self.sheets.iter().position(|s| s.name.eq_ignore_ascii_case(name)) } + /// The index of the sheet the workbook's window shows (the file's active tab). + pub fn active_sheet(&self) -> usize { + self.active_sheet.get() + } + /// Changes the active sheet in place, also through a shared `Arc`: it is view state, + /// so switching sheets or windows neither copies the workbook nor makes it a new version. + pub fn set_active_sheet(&self, i: usize) { + self.active_sheet.set(i); + } pub fn active(&self) -> Option<&Sheet> { - self.sheet(self.active_sheet) + self.sheet(self.active_sheet()) } /// A fresh sheet name `SheetN` not in use. pub fn next_sheet_name(&self) -> String { diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index dcee71173..3fe935732 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -119,7 +119,7 @@ fn main() { app.open_dialog(name, p); } // Page Layout view: edit the first page's centre header (or footer) section. - let active = app.session.active().and_then(|d| d.wb.active().cloned().map(|s| (d.wb.active_sheet, s))); + let active = app.session.active().and_then(|d| d.wb.active().cloned().map(|s| (d.wb.active_sheet(), s))); if let (Some(part), Some((si, sh))) = (&hf_edit, active) { let zone = gridcraft_engine::paper::HfZone { footer: part == "footer", section: 1 }; gridcraft_ui_egui::paper_view::begin(&mut app, si, &sh, (0, 0), zone); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index bf1dedac1..ec04f327b 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -343,9 +343,10 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - Some(d) => match d.command { Some(cmd) => { let params = crate::dialogs::build_params(&d); + let home = app.ui_window(); let r = app.run(cmd, params); if r.is_err() { - app.dialog = Some(d); + app.return_dialog(home, d); } wrap(r) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 55c0819df..d463d8670 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -134,6 +134,20 @@ impl Dialog { }], json!({"shift": "down"}), ), + "arrangeAll" => Dialog::form( + "arrangeAll", + "Arrange Windows", + "view.arrangeAll", + vec![ + Choice { + key: "arrange", + label: "Arrange", + options: vec![("tiled", "Tiled"), ("horizontal", "Horizontal"), ("vertical", "Vertical"), ("cascade", "Cascade")], + }, + Check { key: "activeWorkbook", label: "Windows of active workbook" }, + ], + json!({"arrange": "tiled", "activeWorkbook": false}), + ), "insertPaste" => Dialog::form( "insertPaste", "Insert Paste", @@ -540,7 +554,7 @@ impl Dialog { } "solver" => { // Starts from the sheet's saved model, else the active cell as objective. - let saved = app.session.active().and_then(|doc| gridcraft_engine::cmd::solver::saved_model(&doc.wb, doc.wb.active_sheet)); + let saved = app.session.active().and_then(|doc| gridcraft_engine::cmd::solver::saved_model(&doc.wb, doc.wb.active_sheet())); let mut values = json!({"objective": active, "goal": "max", "value": "0", "variables": "", "constraints": [], "nonNegative": true, "method": "grg"}); if let (Some(m), Some(v)) = (saved.as_ref().and_then(Json::as_object), values.as_object_mut()) { for (k, x) in m { @@ -776,7 +790,7 @@ impl Dialog { // Chart Design › Select Data: data range, series to keep, category axis labels. let id = p("chart").as_u64(); let found = app.session.active().and_then(|doc| { - let si = doc.wb.active_sheet; + let si = doc.wb.active_sheet(); let sh = doc.wb.sheet(si)?; let ch = sh.charts.iter().find(|c| Some(u64::from(c.id)) == id)?; let data = gridcraft_chart::resolve(&doc.wb, si, ch); @@ -1139,6 +1153,7 @@ pub(crate) fn build_params(d: &Dialog) -> Json { pub fn show(app: &mut SheetApp, ctx: &egui::Context) { let Some(mut d) = app.dialog.take() else { return }; + let home = app.ui_window(); if d.name == "pasteSpecial" { paste_special_shortcuts(&mut d, ctx); } @@ -1415,6 +1430,15 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { } else if confirm { open = false; } + if app.ui_window() != home { + // A command run from the dialog switched windows: whatever it opened shows in the new + // window, and this dialog (or the one it returns to) stays in its own. + let keep = if open { Some(d) } else { d.back.take().map(|b| *b) }; + if let Some(keep) = keep { + app.return_dialog(home, keep); + } + return; + } if let Some(child) = app.dialog.as_mut() { // The body opened another dialog (Name Manager › New…). It takes over; this one waits // behind it and comes back when it closes, as Excel returns to the Name Manager. Go To @@ -2032,7 +2056,7 @@ fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confir let mut open = true; ok_cancel(ui, confirm, &mut open); if *confirm && let Some(n) = hits.get(d.list_index).and_then(|f| f["name"].as_str()) { - let n = app.session.active().map(|doc| crate::formula_locale::completion_name(n, locale, &doc.wb, doc.wb.active_sheet)).unwrap_or(n); + let n = app.session.active().map(|doc| crate::formula_locale::completion_name(n, locale, &doc.wb, doc.wb.active_sheet())).unwrap_or(n); let cur = app.editor.as_ref().map(|e| e.text.clone()); let text = match cur { Some(t) if t.starts_with('=') => format!("{t}{n}("), diff --git a/crates/ui-egui/src/doc_windows.rs b/crates/ui-egui/src/doc_windows.rs index 88ba3967a..f4dcc2107 100644 --- a/crates/ui-egui/src/doc_windows.rs +++ b/crates/ui-egui/src/doc_windows.rs @@ -18,7 +18,7 @@ //! //! [`Session::windows`]: gridcraft_engine::Session::windows -use craft_window::{Frame, Viewports, WindowEvent, WindowId, WindowList}; +use craft_window::{CASCADE_STEP, Frame, Rect, Viewports, WindowEvent, WindowId, WindowList, cascade}; use serde_json::json; use crate::SheetApp; @@ -79,6 +79,33 @@ impl DocWindows { /// the logic then): shows windows created or opened by menu commands, files and scripts. pub fn logic(&mut self, app: &mut SheetApp, ctx: &egui::Context) { self.sync(app, ctx); + if let Some((arrange, windows)) = app.arrange_windows.take() { + self.arrange(ctx, &arrange, &windows); + } + } + + /// View › Arrange All: moves and sizes the native windows of `windows` (engine ids, the first + /// on top or at the top left) over the root window's monitor. + fn arrange(&self, ctx: &egui::Context, arrange: &str, windows: &[u64]) { + let ids: Vec = windows.iter().filter_map(|w| self.find(*w)).collect(); + let (monitor, infos) = ctx.input(|i| (i.viewport().monitor_size, i.raw.viewports.clone())); + // Platforms that don't report the monitor (Wayland, tests) get a common laptop screen. + let size = monitor.filter(|m| m.x > 0.0 && m.y > 0.0).unwrap_or(egui::vec2(1280.0, 800.0)); + let mut rects = arrange_rects(arrange, ids.len(), Rect::new(0.0, 0.0, size.x, size.y)); + if arrange == "cascade" { + // The active window goes in front: the last step of the cascade. + rects.reverse(); + } + for (id, r) in ids.iter().zip(rects) { + let vp = self.viewports.viewport_id(*id); + // Inner size is what can be set: take off the frame the window has now. + let frame = infos.get(&vp).and_then(|v| Some(v.outer_rect?.size() - v.inner_rect?.size())).unwrap_or(egui::Vec2::ZERO); + ctx.send_viewport_cmd_to(vp, egui::ViewportCommand::OuterPosition(egui::pos2(r.x, r.y))); + ctx.send_viewport_cmd_to(vp, egui::ViewportCommand::InnerSize((egui::vec2(r.width, r.height) - frame).max(egui::vec2(240.0, 160.0)))); + } + if let Some(first) = ids.first() { + self.viewports.focus(ctx, *first); + } } /// Mirrors the engine's visible windows: closed or hidden windows lose their native window @@ -225,3 +252,48 @@ fn window_title(app: &SheetApp, window: u64) -> String { let dirty = app.session.window(window).and_then(|w| app.session.documents().iter().find(|d| d.uid == w.doc_uid)).is_some_and(|d| d.is_dirty()); if dirty { format!("{title} — Edited") } else { title } } + +/// Outer rectangles for `n` windows arranged within `bounds` as View › Arrange All's `arrange` +/// says: `tiled` (a grid, the last row's windows widened to fill it), `horizontal` (stacked top +/// to bottom), `vertical` (side by side) or `cascade` (each one title bar below and right of the +/// previous, wrapping at the edges). +pub fn arrange_rects(arrange: &str, n: usize, bounds: Rect) -> Vec { + if n == 0 { + return vec![]; + } + let count = n as f32; + match arrange { + "horizontal" => { + let h = bounds.height / count; + (0..n).map(|i| Rect::new(bounds.x, bounds.y + h * i as f32, bounds.width, h)).collect() + } + "vertical" => { + let w = bounds.width / count; + (0..n).map(|i| Rect::new(bounds.x + w * i as f32, bounds.y, w, bounds.height)).collect() + } + "cascade" => { + let shift = CASCADE_STEP * (count - 1.0); + let size = [(bounds.width - shift).max(bounds.width * 0.5), (bounds.height - shift).max(bounds.height * 0.5)]; + let mut previous = Rect::new(bounds.x - CASCADE_STEP, bounds.y - CASCADE_STEP, size[0], size[1]); + (0..n) + .map(|_| { + previous = cascade(previous, size, bounds, CASCADE_STEP); + previous + }) + .collect() + } + _ => { + let cols = (count.sqrt().ceil() as usize).max(1); + let rows = n.div_ceil(cols); + let h = bounds.height / rows as f32; + (0..n) + .map(|i| { + let (row, col) = (i / cols, i % cols); + let in_row = if row + 1 == rows { n - row * cols } else { cols }; + let w = bounds.width / in_row as f32; + Rect::new(bounds.x + w * col as f32, bounds.y + h * row as f32, w, h) + }) + .collect() + } + } +} diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index b88699f57..5ca1edd12 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -83,7 +83,7 @@ fn active_input(app: &SheetApp) -> String { } return sh .cell(anchor) - .map(|c| crate::formula_locale::display(&c.input_text(), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet)) + .map(|c| crate::formula_locale::display(&c.input_text(), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet())) .unwrap_or_default(); } if let Some(c) = sh.cell(a) { @@ -91,7 +91,7 @@ fn active_input(app: &SheetApp) -> String { return String::new(); } return if let Some(f) = &c.formula { - let shown = crate::formula_locale::display(&c.input_text(), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet); + let shown = crate::formula_locale::display(&c.input_text(), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet()); // A legacy array formula shows in braces (`{=A1:A2*2}`), as `Cell::bar_text` does. if f.array.is_some() { format!("{{{shown}}}") } else { shown } } else { @@ -126,7 +126,7 @@ fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) { if !target.is_empty() && target != shown { // A new name for the selection, or a place to go. let is_ref = gridcraft_engine::core::RangeRef::parse(target.split('!').next_back().unwrap_or("")).is_some(); - let known = app.session.active().is_some_and(|d| d.wb.name(&target, d.wb.active_sheet).is_some() || d.wb.table(&target).is_some()); + let known = app.session.active().is_some_and(|d| d.wb.name(&target, d.wb.active_sheet()).is_some() || d.wb.table(&target).is_some()); let r = if is_ref || known { app.run("edit.goTo", json!({"reference": target})) } else { diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 145b9f583..37d12759d 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -563,7 +563,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { app.grid.rect = Some(rect); let Some(d) = app.session.active() else { return }; let wb: Arc = d.wb.clone(); - let si = wb.active_sheet; + let si = wb.active_sheet(); let Some(sh) = wb.sheet(si) else { return }; let sel = d.selection.clone(); let place = (d.uid, si); @@ -1803,7 +1803,7 @@ pub(crate) fn paint_selection( } // Marching ants around the copied range. if let Some(clip) = &app.session.clipboard - && app.session.active().is_some_and(|d| d.uid == clip.doc_uid && d.wb.active_sheet == clip.sheet) + && app.session.active().is_some_and(|d| d.uid == clip.doc_uid && d.wb.active_sheet() == clip.sheet) && range_in_pane(clip.range, rows, cols) { // A multi-area copy marks each area as selected. @@ -2109,7 +2109,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & if primary_clicked && !resp.drag_started() && let Some(p) = pos - && let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet, p) + && let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet(), p) { app.selected_chart = Some(id); match act { @@ -2307,13 +2307,13 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & && ed.can_point() { let anchor = if mods.shift && ed.point_at_caret() { - ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet).map(|(_, cell)| cell).unwrap_or(c) + ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet()).map(|(_, cell)| cell).unwrap_or(c) } else { c }; let range = RangeRef::new(anchor, c); let text = if range.is_single() { c.a1() } else { range.a1() }; - let sheet_name = (ed.sheet != wb.active_sheet).then(|| sh.name.clone()); + let sheet_name = (ed.sheet != wb.active_sheet()).then(|| sh.name.clone()); let r = match sheet_name { Some(n) => format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&n)), None => text, @@ -2325,7 +2325,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } ed.request_focus = true; ed.point_cell = Some(c); - ed.point_anchor = Some((wb.active_sheet, anchor)); + ed.point_anchor = Some((wb.active_sheet(), anchor)); app.grid.drag = Drag::Select; app.grid.drag_select = true; if resp.drag_started() @@ -2614,7 +2614,7 @@ fn format_painter_apply(app: &mut SheetApp) { fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef, extend: bool, add: bool) -> bool { let Some(ed) = app.editor.as_mut().filter(|ed| ed.can_point()) else { return false }; let edit_sheet = ed.sheet; - let active_sheet = wb.active_sheet; + let active_sheet = wb.active_sheet(); let range = if extend { ed.point .and_then(|point| crate::editor::formula_refs(&ed.text).into_iter().find(|(start, end, ..)| (*start, *end) == point)) @@ -2636,7 +2636,8 @@ fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef, range }; let reference = range.a1(); - let reference = if ed.sheet != wb.active_sheet { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference }; + let reference = + if ed.sheet != wb.active_sheet() { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference }; if add { ed.append_ref(&reference); } else { @@ -2656,13 +2657,13 @@ fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p if let Some(ed) = app.editor.as_mut() && ed.point.is_some() { - if let Some(start) = ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet).map(|(_, cell)| cell).or(ed.point_cell) { + if let Some(start) = ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet()).map(|(_, cell)| cell).or(ed.point_cell) { let range = RangeRef::new(start, cell); let text = if range.is_single() { range.start.a1() } else { range.a1() }; - let text = if ed.sheet != wb.active_sheet { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { text }; + let text = if ed.sheet != wb.active_sheet() { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { text }; ed.insert_ref(&text); ed.point_cell = Some(cell); - ed.point_anchor = Some((wb.active_sheet, start)); + ed.point_anchor = Some((wb.active_sheet(), start)); } } else if let Some(d) = app.session.active() { // Read the current anchor: starting a drag may have just replaced the selection. @@ -3117,7 +3118,7 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) let Some((col, row, at)) = app.grid.filter_menu else { return }; let Some(d) = app.session.active() else { return }; let wb = d.wb.clone(); - let si = wb.active_sheet; + let si = wb.active_sheet(); let Some(sh) = wb.sheet(si) else { return }; let _ = geo; // Tables get an autofilter on demand. @@ -3460,12 +3461,12 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte ) }) .unwrap_or(from); - ed.point_anchor.filter(|(sheet, _)| *sheet == d.wb.active_sheet).map(|(_, cell)| cell).unwrap_or(inferred_anchor) + ed.point_anchor.filter(|(sheet, _)| *sheet == d.wb.active_sheet()).map(|(_, cell)| cell).unwrap_or(inferred_anchor) } else { to }; let text = if extend { RangeRef::new(anchor, to).a1() } else { to.a1() }; - let sheet = d.wb.active_sheet; + let sheet = d.wb.active_sheet(); ed.insert_ref(&text); ed.point_cell = Some(to); ed.point_anchor = Some((sheet, anchor)); @@ -3606,7 +3607,7 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) let Some(c) = app.grid.list_picker else { return }; let Some(d) = app.session.active() else { return }; let wb = d.wb.clone(); - let si = wb.active_sheet; + let si = wb.active_sheet(); let Some(sh) = wb.sheet(si) else { return }; let Some(dv) = sh.validations.iter().find(|x| x.ranges.iter().any(|r| r.contains(c))).cloned() else { app.grid.list_picker = None; @@ -3743,7 +3744,7 @@ fn is_checkbox(sh: &Sheet, app: &SheetApp) -> bool { fn paint_pages(app: &mut SheetApp, p: &Painter, geo: &Geo, sh: &Sheet) { let Some(d) = app.session.active() else { return }; - let key = (d.uid, d.revision, d.wb.active_sheet); + let key = (d.uid, d.revision, d.wb.active_sheet()); if app.grid.pages.as_ref().is_none_or(|(k, _)| *k != key) { let pages: Vec<(RangeRef, u32)> = app .session diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 19f69a90f..973b12de5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -342,6 +342,8 @@ pub struct SheetApp { /// tests, embedding hosts) one window shows the active workbook window, and its close button /// quits. pub native_windows: bool, + /// View › Arrange All's layout and windows, waiting for the native windows to move. + pub arrange_windows: Option<(String, Vec)>, /// The native window that has focus, which menu commands and screenshots go to. pub(crate) focus_window: Option, } @@ -394,6 +396,7 @@ impl SheetApp { ui_window: None, parked: HashMap::new(), native_windows: false, + arrange_windows: None, focus_window: None, } } @@ -479,6 +482,14 @@ impl SheetApp { // Requests the command made (a dialog, a draft) belong to the window it left active. self.follow_active_window(); self.after_engine(); + if id == "view.arrangeAll" + && self.native_windows + && let Ok(r) = &r + { + let arrange = r.get("arrange").and_then(Json::as_str).unwrap_or("tiled").to_string(); + let windows = r.get("windows").and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_u64).collect()).unwrap_or_default(); + self.arrange_windows = Some((arrange, windows)); + } // The new chart is selected, as after inserting any object, so Delete removes it, not its data. if id == "insert.chart" && let Ok(r) = &r @@ -811,7 +822,7 @@ impl SheetApp { let text = text.map(|text| { self.session .active() - .map(|d| formula_locale::display_template(&text, self.ui.language.formula_locale(), &d.wb, d.wb.active_sheet)) + .map(|d| formula_locale::display_template(&text, self.ui.language.formula_locale(), &d.wb, d.wb.active_sheet())) .unwrap_or(text) }); self.begin_edit(text, false); @@ -977,12 +988,12 @@ impl SheetApp { /// The active workbook and sheet: what drafts, text boxes and picture targets belong to. pub fn view_key(&self) -> Option<(u64, usize)> { - self.session.active().map(|d| (d.uid, d.wb.active_sheet)) + self.session.active().map(|d| (d.uid, d.wb.active_sheet())) } /// The active window and sheet: each window scrolls on its own. fn scroll_key(&self) -> Option<(u64, usize)> { - Some((self.session.active_window()?, self.session.active()?.wb.active_sheet)) + Some((self.session.active_window()?, self.session.active()?.wb.active_sheet())) } pub fn view(&self) -> SheetView { @@ -1053,7 +1064,7 @@ impl SheetApp { String::new() } else { match sh.cell(at) { - Some(c) if c.formula.is_some() => formula_locale::display(&c.input_text(), locale, &d.wb, d.wb.active_sheet), + Some(c) if c.formula.is_some() => formula_locale::display(&c.input_text(), locale, &d.wb, d.wb.active_sheet()), _ => gridcraft_engine::display::edit_text(&d.wb, sh, at), } }; @@ -1061,7 +1072,7 @@ impl SheetApp { Some(t) => (t, true), None => (current.clone(), false), }; - let mut ed = editor::EditState::new(d.uid, d.wb.active_sheet, at, text, replace, from_formula_bar); + let mut ed = editor::EditState::new(d.uid, d.wb.active_sheet(), at, text, replace, from_formula_bar); ed.window = self.session.active_window(); ed.locale = locale; if replace && self.ui.autocomplete && !ed.is_formula() { @@ -1153,7 +1164,7 @@ impl SheetApp { self.toast = Some((msg, now_ms())); } // Point mode can leave another sheet active; the edit belongs to the sheet it started on. - if self.session.active().is_some_and(|d| d.wb.active_sheet != ed.sheet) + if self.session.active().is_some_and(|d| d.wb.active_sheet() != ed.sheet) && let Err(e) = self.session.run("sheet.activate", json!({"sheet": ed.sheet})) { self.message = Some(("GridCraft".into(), clean_error(&e))); @@ -1214,7 +1225,7 @@ impl SheetApp { let multi = self.session.active().is_some_and(|d| !d.selection.is_single_cell()); // Any other move (arrows, clicks, another sheet) leaves the selection off the cell the // last Tab landed on, which ends the run. - let here = self.session.active().map(|d| (d.uid, d.wb.active_sheet, d.selection.active)); + let here = self.session.active().map(|d| (d.uid, d.wb.active_sheet(), d.selection.active)); let run = self.tab_run.take().filter(|&(uid, sheet, _, at)| here == Some((uid, sheet, at))); if multi { let fwd = dr > 0 || dc > 0; @@ -1227,7 +1238,7 @@ impl SheetApp { if dr == 0 && dc != 0 { let start = run.map(|r| r.2); self.tab_run = self.session.active().and_then(|d| { - let (uid, sheet) = (d.uid, d.wb.active_sheet); + let (uid, sheet) = (d.uid, d.wb.active_sheet()); let col = start.or(here.map(|h| h.2.col))?; Some((uid, sheet, col, d.selection.active)) }); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index a8a7e35f4..623803e2a 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -230,7 +230,7 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { }); }); ui.collapsing("Series", |ui| { - let data = app.session.active().map(|d| gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, &chart)); + let data = app.session.active().map(|d| gridcraft_chart::resolve(&d.wb, d.wb.active_sheet(), &chart)); for (i, s) in chart.series.iter().enumerate() { let name = data.as_ref().and_then(|d| d.series.get(i)).map(|x| x.name.clone()).unwrap_or_else(|| format!("Series {}", i + 1)); let col = diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index 621a97903..c3bc3312d 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -279,14 +279,14 @@ pub(crate) fn paint_headers_footers(app: &SheetApp, ui: &egui::Ui, p: &Painter, pub(crate) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) { // Leaving the sheet or the view commits. if let Some(e) = &app.grid.hf_edit - && (e.sheet != wb.active_sheet || sh.view != SheetViewMode::PageLayout || geo.paper.is_none()) + && (e.sheet != wb.active_sheet() || sh.view != SheetViewMode::PageLayout || geo.paper.is_none()) { commit(app); } let Some(pg) = geo.paper.clone() else { return }; - ruler_margins(app, ui, geo, &pg, sh, wb.active_sheet); + ruler_margins(app, ui, geo, &pg, sh, wb.active_sheet()); if std::mem::take(&mut app.grid.hf_start) { - begin(app, wb.active_sheet, sh, (pg.data_rows.0, pg.data_cols.0), HfZone { footer: false, section: 1 }); + begin(app, wb.active_sheet(), sh, (pg.data_rows.0, pg.data_cols.0), HfZone { footer: false, section: 1 }); } if resp.clicked_by(egui::PointerButton::Primary) && let Some(p) = ui.input(|i| i.pointer.interact_pos()) @@ -294,7 +294,7 @@ pub(crate) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo let (x, y) = (geo.scroll.x as f64 + ((p.x - geo.cells.left()) / geo.z) as f64, geo.scroll.y as f64 + ((p.y - geo.cells.top()) / geo.z) as f64); match pg.hf_zone_at(x, y) { - Some((page, zone)) if geo.cells.contains(p) => begin(app, wb.active_sheet, sh, page, zone), + Some((page, zone)) if geo.cells.contains(p) => begin(app, wb.active_sheet(), sh, page, zone), _ => { // A click anywhere else ends the edit; the grid then handles the click. if app.grid.hf_edit.is_some() { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 6bd45e18f..bea9e7191 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1930,7 +1930,7 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { { let locale = app.ui.language.formula_locale(); let name = - app.session.active().map(|d| crate::formula_locale::completion_name(n, locale, &d.wb, d.wb.active_sheet)).unwrap_or(n); + app.session.active().map(|d| crate::formula_locale::completion_name(n, locale, &d.wb, d.wb.active_sheet())).unwrap_or(n); let desc = crate::formula_locale::description(n, locale, app.ui.language).unwrap_or_default(); if ui.button(name).on_hover_text(desc).clicked() { app.run_or_alert("formulas.insertFunction", json!({"name": n})); @@ -2318,6 +2318,16 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { act(app, "view.zoomToSelection", json!({"viewWidth": r.width(), "viewHeight": r.height()})); } sep(ui); + let __k = big_button(ui, Icon::NewFile, "New\nWindow", "New Window", false); + control_keytip(app, ui, &__k, "view.newWindow"); + if __k.clicked() { + act(app, "view.newWindow", json!({})); + } + let __k = big_button(ui, Icon::Table, "Arrange\nAll", "Arrange All", false); + control_keytip(app, ui, &__k, "view.arrangeAll"); + if __k.clicked() { + app.open_dialog("arrangeAll", json!({})); + } let fp = big_button(ui, Icon::Freeze, "Freeze\nPanes", "Freeze Panes", true); for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["view.freezePanes", "view.freezeTopRow", "view.freezeFirstColumn"]) diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index bc6fcc1d8..14d2dfc6a 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -72,7 +72,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let all = crate::widgets::icon_button(ui, Icon::SheetList, t.text_dim, app.ui.language.tr("All sheets"), vec2(24.0, 24.0)); all.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, app.ui.language.tr("All sheets"))); let items = app.session.active().map(|d| navigator_items(&d.wb.sheets)).unwrap_or_default(); - let active = app.session.active().map(|d| d.wb.active_sheet.to_string()).unwrap_or_default(); + let active = app.session.active().map(|d| d.wb.active_sheet().to_string()).unwrap_or_default(); let rows: Vec<(&str, String)> = items.iter().map(|(i, name)| (i.as_str(), name.clone())).collect(); if let crate::ribbon::ComboResult::Picked(i) = crate::ribbon::editable_combo(ui, &all, "sheet_navigator", &active, &rows, false) && let Ok(i) = i.parse::() @@ -86,7 +86,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { .iter() .enumerate() .filter(|(_, s)| s.visibility == Visibility::Visible) - .map(|(i, s)| (i, s.name.clone(), s.tab_color.and_then(|c| c.resolve(&d.wb.theme)), i == d.wb.active_sheet)) + .map(|(i, s)| (i, s.name.clone(), s.tab_color.and_then(|c| c.resolve(&d.wb.theme)), i == d.wb.active_sheet())) .collect(); // Keep worksheet tabs in their own horizontally scrollable viewport. As in Excel, the // tabs get up to 60% of the bar and the grid's horizontal scrollbar the rest; a strip @@ -94,7 +94,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let tabs_width = (ui.available_width() * 0.6).max(100.0); // Reveal the selected sheet after navigation or layout changes, but // allow browsing other tabs without snapping back on every frame. - let reveal_key = egui::Id::new((d.uid, d.wb.active_sheet, &sheets, tabs_width.to_bits())); + let reveal_key = egui::Id::new((d.uid, d.wb.active_sheet(), &sheets, tabs_width.to_bits())); let reveal_id = ui.id().with("worksheet_tabs_reveal"); let reveal_active = ui.ctx().data_mut(|data| { let changed = data.get_temp::(reveal_id) != Some(reveal_key); @@ -451,11 +451,11 @@ mod tests { let mut app = crate::SheetApp::new(session, Default::default()); app.begin_edit(Some("99".into()), false); activate_sheet(&mut app, 1); - assert_eq!(app.session.active().unwrap().wb.active_sheet, 0, "a value the validation stops must not leave its sheet"); + assert_eq!(app.session.active().unwrap().wb.active_sheet(), 0, "a value the validation stops must not leave its sheet"); assert!(app.editor.is_some(), "the edit stays open to be corrected"); app.editor.as_mut().unwrap().text = "7".into(); activate_sheet(&mut app, 1); - assert_eq!(app.session.active().unwrap().wb.active_sheet, 1, "the corrected value commits and the sheet changes"); + assert_eq!(app.session.active().unwrap().wb.active_sheet(), 1, "the corrected value commits and the sheet changes"); let a1 = gridcraft_engine::core::CellRef::parse("A1").unwrap(); assert!(app.session.active().unwrap().wb.sheet(0).unwrap().cell(a1).is_some(), "the value lands on the first sheet"); } diff --git a/crates/ui-egui/src/text_box.rs b/crates/ui-egui/src/text_box.rs index a0b30c025..229f4ea5f 100644 --- a/crates/ui-egui/src/text_box.rs +++ b/crates/ui-egui/src/text_box.rs @@ -44,7 +44,7 @@ impl SheetApp { id, text: shape.text.clone(), uid: d.uid, - sheet: d.wb.active_sheet, + sheet: d.wb.active_sheet(), original: shape.text.clone(), request_focus: true, rect: None, diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index 96aa83c79..bc6f31a3d 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -126,6 +126,19 @@ impl SheetApp { self.follow_active_window(); } + /// Puts back a dialog that was taken out of window `home`'s fields while it ran. A command + /// run from the dialog may have switched windows meanwhile; the dialog stays in the window it + /// was opened in (dropped if that window closed). + pub(crate) fn return_dialog(&mut self, home: Option, d: dialogs::Dialog) { + if home == self.ui_window { + self.dialog = Some(d); + } else if let Some(w) = home.and_then(|h| self.parked.get_mut(&h)) + && w.dialog.is_none() + { + w.dialog = Some(d); + } + } + /// Whether a window other than the current one has a cell or text-box draft open. pub fn parked_window_with_draft(&self) -> Option { self.parked.iter().filter(|(_, w)| w.editor.is_some() || w.text_box_editor.is_some()).map(|(id, _)| *id).min() diff --git a/crates/ui-egui/tests/commit_after_pointing.rs b/crates/ui-egui/tests/commit_after_pointing.rs index 89e1ccd29..3b29eb7a6 100644 --- a/crates/ui-egui/tests/commit_after_pointing.rs +++ b/crates/ui-egui/tests/commit_after_pointing.rs @@ -34,7 +34,7 @@ fn commit_after_pointing_on_another_sheet_writes_to_the_origin_sheet() { assert!(h.state_mut().commit_edit(0, 0, false, false)); let at = CellRef::parse("F8").unwrap(); let wb = &h.state().session.active().unwrap().wb; - assert_eq!(wb.active_sheet, 0, "the origin sheet is shown again"); + assert_eq!(wb.active_sheet(), 0, "the origin sheet is shown again"); assert!(wb.sheet(0).unwrap().cell(at).is_some_and(|c| c.formula.is_some()), "formula written on the origin sheet"); assert!(wb.sheet(1).unwrap().cell(at).is_none(), "the pointed-at sheet is untouched"); } @@ -44,7 +44,7 @@ fn ctrl_enter_after_pointing_on_another_sheet_fills_the_origin_selection() { let mut h = pointing_on_second_sheet("B2:C3"); assert!(h.state_mut().commit_edit(0, 0, false, true)); let wb = &h.state().session.active().unwrap().wb; - assert_eq!(wb.active_sheet, 0, "the origin sheet is shown again"); + assert_eq!(wb.active_sheet(), 0, "the origin sheet is shown again"); for a1 in ["B2", "C2", "B3", "C3"] { let at = CellRef::parse(a1).unwrap(); assert!(wb.sheet(0).unwrap().cell(at).is_some_and(|c| c.formula.is_some()), "{a1} filled on the origin sheet"); diff --git a/crates/ui-egui/tests/doc_windows.rs b/crates/ui-egui/tests/doc_windows.rs index b6d518ce2..46427f3bf 100644 --- a/crates/ui-egui/tests/doc_windows.rs +++ b/crates/ui-egui/tests/doc_windows.rs @@ -307,3 +307,116 @@ fn feedback_from_opening_a_workbook_shows_in_its_new_window() { desk.app.enter_window(book1); assert!(desk.app.notice.is_none() && desk.app.message.is_none(), "Book1's window shows neither"); } + +/// Book1 with two sheets, saved, shown in two windows: window 1 on Sheet1 at B2, window 2 on +/// Sheet2 at D4. Returns the window ids. +fn two_windows_on_one_workbook(desk: &mut Desk) -> (u64, u64) { + desk.app.run("home.insertSheet", json!({})).unwrap(); + let path = std::env::temp_dir().join(format!("gridcraft-two-windows-{}-{:?}.xlsx", std::process::id(), std::thread::current().id())); + desk.app.run("file.save", json!({"path": path})).unwrap(); + let _ = std::fs::remove_file(&path); + desk.app.run("sheet.activate", json!({"sheet": 0})).unwrap(); + desk.app.run("selection.set", json!({"range": "B2"})).unwrap(); + desk.frames(2); + let w1 = desk.app.session.active_window().unwrap(); + desk.app.run("view.newWindow", json!({})).unwrap(); + desk.frames(3); + let w2 = desk.app.session.active_window().unwrap(); + desk.app.run("sheet.activate", json!({"sheet": 1})).unwrap(); + desk.app.run("selection.set", json!({"range": "D4"})).unwrap(); + desk.frames(3); + (w1, w2) +} + +#[test] +fn two_windows_on_one_workbook_show_their_own_sheet_and_each_others_edits() { + let mut desk = Desk::new(true); + let (w1, w2) = two_windows_on_one_workbook(&mut desk); + assert_eq!(desk.windows.shown_windows(), [w1, w2], "New Window opens a native window"); + assert_ne!(desk.viewport(w1), desk.viewport(w2)); + let titles: Vec = [w1, w2].iter().map(|w| desk.app.session.window_title(*w).unwrap()).collect(); + assert!(titles[0].ends_with(".xlsx:1") && titles[1].ends_with(".xlsx:2"), "{titles:?}"); + + // Drawing both windows every frame swaps their views without copying the workbook. + let wb = desk.app.session.doc().unwrap().wb.clone(); + desk.frames(3); + assert!(std::sync::Arc::ptr_eq(&wb, &desk.app.session.doc().unwrap().wb), "a window swap copied the workbook"); + assert!(!desk.app.session.doc().unwrap().is_dirty()); + for (w, sheet, cell) in [(w1, 0, "B2"), (w2, 1, "D4")] { + desk.app.enter_window(w); + let d = desk.app.session.doc().unwrap(); + assert_eq!((d.wb.active_sheet(), d.selection.active), (sheet, CellRef::parse(cell).unwrap()), "window {w}'s sheet and selection"); + assert!(desk.app.grid.rect.is_some(), "window {w} drew its grid"); + } + + // Window 1 refers to Sheet2!D4; window 2 types there; window 1 shows it on the next frame. + desk.app.enter_window(w1); + desk.app.run("cell.set", json!({"cell": "B2", "input": "=Sheet2!D4*2"})).unwrap(); + desk.frames(1); + desk.app.enter_window(w2); + desk.app.run("cell.set", json!({"input": "21"})).unwrap(); + desk.frames(1); + desk.app.enter_window(w1); + let d = desk.app.session.doc().unwrap(); + assert_eq!(d.wb.active_sheet(), 0); + assert_eq!(d.wb.active().unwrap().value(CellRef::parse("B2").unwrap()), Value::Number(42.0)); +} + +#[test] +fn a_dialog_stays_with_its_window_across_a_switch() { + let mut desk = Desk::new(true); + let (w1, w2) = two_windows_on_one_workbook(&mut desk); + desk.app.enter_window(w1); + desk.app.open_dialog("zoom", json!({})); + desk.frames(2); + assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("zoom")); + + // From the menu bar. + desk.app.run("view.switchWindows", json!({"window": w2})).unwrap(); + desk.frames(3); + assert_eq!(desk.app.session.active_window(), Some(w2)); + assert!(desk.app.dialog.is_none(), "window 2 shows no dialog"); + + // The user clicks into window 1, then back into window 2. + for (w, other) in [(w1, w2), (w2, w1)] { + let (v, o) = (desk.viewport(w), desk.viewport(other)); + INFOS.with(|i| { + let mut i = i.borrow_mut(); + i.entry(v).or_default().focused = Some(true); + i.entry(o).or_default().focused = Some(false); + }); + desk.frames(2); + assert_eq!(desk.app.session.active_window(), Some(w)); + let expected = if w == w1 { Some("zoom") } else { None }; + assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), expected, "window {w}'s dialog"); + } + + // A script switching windows while the dialog is open. + desk.frame_with(|app, ctx| { + let _ = gridcraft_ui_egui::control::handle(app, ctx, "engine.execute", &json!({"command": "window.activate", "params": {"window": w1}})); + }); + desk.frames(2); + assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("zoom")); + desk.app.enter_window(w2); + assert!(desk.app.dialog.is_none()); +} + +#[test] +fn arrange_all_lays_out_the_native_windows_active_first() { + let mut desk = Desk::new(true); + let (w1, w2) = two_windows_on_one_workbook(&mut desk); + INFOS.with(|i| i.borrow_mut().entry(ViewportId::ROOT).or_default().monitor_size = Some(egui::vec2(1000.0, 800.0))); + desk.app.run("view.arrangeAll", json!({"arrange": "vertical"})).unwrap(); + let sent = desk.frames(1); + let at = |x: f32| ViewportCommand::OuterPosition(egui::pos2(x, 0.0)); + assert!(sent_to(&sent, desk.viewport(w2), &at(0.0)), "the active window goes left: {sent:?}"); + assert!(sent_to(&sent, desk.viewport(w1), &at(500.0))); + assert!(sent_to(&sent, desk.viewport(w1), &ViewportCommand::InnerSize(egui::vec2(500.0, 800.0)))); + + // Without parameters, as a script calls it: tiled. + let r = desk.app.run("view.arrangeAll", json!({})).unwrap(); + assert_eq!(r["arrange"], "tiled"); + let tiled = gridcraft_ui_egui::doc_windows::arrange_rects("tiled", 3, craft_window::Rect::new(0.0, 0.0, 1000.0, 800.0)); + let r = |x, y, w, h| craft_window::Rect::new(x, y, w, h); + assert_eq!(tiled, [r(0.0, 0.0, 500.0, 400.0), r(500.0, 0.0, 500.0, 400.0), r(0.0, 400.0, 1000.0, 400.0)]); +} diff --git a/crates/ui-egui/tests/popup_dismissal.rs b/crates/ui-egui/tests/popup_dismissal.rs index 24d9fafbc..8ea1ec9b1 100644 --- a/crates/ui-egui/tests/popup_dismissal.rs +++ b/crates/ui-egui/tests/popup_dismissal.rs @@ -106,7 +106,7 @@ fn filter_closes_when_clicking_another_table_or_sheet() { click(&mut h, outside, PointerButton::Primary); assert!(!is_open(&h, Menu::Filter), "filter stayed open after switching table/sheet"); if sheet { - assert_eq!(h.state().session.active().unwrap().wb.active_sheet, 1); + assert_eq!(h.state().session.active().unwrap().wb.active_sheet(), 1); } else { assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "H2"); } diff --git a/crates/ui-egui/tests/sheet_navigator.rs b/crates/ui-egui/tests/sheet_navigator.rs index a16e306ed..d8ee2eae1 100644 --- a/crates/ui-egui/tests/sheet_navigator.rs +++ b/crates/ui-egui/tests/sheet_navigator.rs @@ -32,7 +32,7 @@ fn harness() -> egui_kittest::Harness<'static, SheetApp> { #[test] fn typing_a_name_and_enter_activates_that_sheet() { let mut h = harness(); - assert_eq!(h.state().session.active().unwrap().wb.active_sheet, 0); + assert_eq!(h.state().session.active().unwrap().wb.active_sheet(), 0); h.get_by_label("All sheets").click(); h.run_steps(4); h.input_mut().events.push(Event::Text("summ".into())); @@ -40,5 +40,5 @@ fn typing_a_name_and_enter_activates_that_sheet() { h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); h.run_steps(4); - assert_eq!(h.state().session.active().unwrap().wb.active_sheet, 3, "Enter on the filtered row must activate Summary"); + assert_eq!(h.state().session.active().unwrap().wb.active_sheet(), 3, "Enter on the filtered row must activate Summary"); } diff --git a/crates/ui-egui/tests/sheet_tabs.rs b/crates/ui-egui/tests/sheet_tabs.rs index c3e5d13fb..8b419ecc7 100644 --- a/crates/ui-egui/tests/sheet_tabs.rs +++ b/crates/ui-egui/tests/sheet_tabs.rs @@ -80,7 +80,7 @@ fn navigation_arrows_reveal_the_selected_tab() { for _ in 0..29 { click(&mut h, next); } - assert_eq!(h.state().session.doc().unwrap().wb.active_sheet, 29); + assert_eq!(h.state().session.doc().unwrap().wb.active_sheet(), 29); assert_visible(&h, "Tab29"); } @@ -141,7 +141,7 @@ fn hidden_sheets_long_names_and_window_resize_keep_active_tab_visible() { h.set_size(vec2(1000.0, 120.0)); h.run_steps(20); assert_visible(&h, name); - assert_eq!(h.state().session.doc().unwrap().wb.active_sheet, 29); + assert_eq!(h.state().session.doc().unwrap().wb.active_sheet(), 29); } #[test] @@ -185,7 +185,7 @@ fn right_clicking_a_tab_activates_its_sheet_so_the_menu_acts_on_it() { h.step(); h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); h.run_steps(20); - assert_eq!(h.state().session.doc().unwrap().wb.active_sheet, 3, "right-clicking Tab03 should activate it"); + assert_eq!(h.state().session.doc().unwrap().wb.active_sheet(), 3, "right-clicking Tab03 should activate it"); // The menu now offers Unprotect for the protected sheet that was clicked, not Protect for the one that was active. let texts: Vec = h .output() diff --git a/crates/ui-egui/tests/trace_navigation.rs b/crates/ui-egui/tests/trace_navigation.rs index 11f79fcff..8a74ec87e 100644 --- a/crates/ui-egui/tests/trace_navigation.rs +++ b/crates/ui-egui/tests/trace_navigation.rs @@ -107,11 +107,11 @@ fn cross_sheet_precedents_jump_to_the_other_sheet() { s.execute("cell.set", json!({"cell": "A1", "input": "=Src!B2*2"})).unwrap(); // Back on the first sheet, Ctrl+[ from A1 jumps to Src!B2. let d = s.active().unwrap(); - let first = d.wb.active_sheet; + let first = d.wb.active_sheet(); s.execute("selection.set", json!({"range": "A1"})).unwrap(); let mut h = harness(s); key(&mut h, Key::OpenBracket, Modifiers::CTRL); let d = h.state().session.active().unwrap(); - assert_ne!(d.wb.active_sheet, first, "selection jumped to the precedent's sheet"); + assert_ne!(d.wb.active_sheet(), first, "selection jumped to the precedent's sheet"); assert_eq!(d.selection.active, CellRef { row: 1, col: 1 }, "B2 on the Src sheet"); } diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 774567bf8..9381e010e 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -297,7 +297,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { cx.warn("the workbook has no worksheets; added an empty sheet"); wb.sheets.push(Arc::new(Sheet::new("Sheet1"))); } - wb.active_sheet = file_to_model.get(active_tab).copied().flatten().unwrap_or(0); + wb.set_active_sheet(file_to_model.get(active_tab).copied().flatten().unwrap_or(0)); // Defined names. Chartex parts refer to their data through hidden `_xlchart.*` names. let mut chart_names: Vec<(String, String)> = vec![]; diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index cf208d6cd..d26dddea4 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -244,8 +244,8 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) // Which sheet is active (must be visible), and at least one visible sheet. let any_visible = wb.sheets.iter().any(|s| s.visibility == Visibility::Visible); - let active = if wb.sheets.get(wb.active_sheet).is_some_and(|s| s.visibility == Visibility::Visible) { - wb.active_sheet + let active = if wb.sheets.get(wb.active_sheet()).is_some_and(|s| s.visibility == Visibility::Visible) { + wb.active_sheet() } else { wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0) }; diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index ceaf3e26d..d46b058cb 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -198,7 +198,7 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo } wb.calc.precision_as_displayed = g.precision_as_displayed; defined_names(&g, &model_index, &mut wb, &mut report); - wb.active_sheet = wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0); + wb.set_active_sheet(wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0)); // The overview comes first. report.warnings.insert( 0, diff --git a/crates/xlsx/src/xlsb.rs b/crates/xlsx/src/xlsb.rs index 3c8454749..39d9fd9f6 100644 --- a/crates/xlsx/src/xlsb.rs +++ b/crates/xlsx/src/xlsb.rs @@ -120,7 +120,7 @@ pub fn read_xlsb(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { if wb.sheets.is_empty() { return Err(invalid("no supported worksheets")); } - wb.active_sheet = wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0); + wb.set_active_sheet(wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0)); Ok((wb, report)) } diff --git a/docs/gaps.md b/docs/gaps.md index 7e2e40dc0..da4703326 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-20 · **Change:** minor (the Japanese catalog covers the dialogs, messages and menus) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** minor (§20: a native window per workbook window, New Window and Arrange All) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -156,7 +156,9 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- **Estimate:** 15–23 h. ### 20. Multiple windows; pen pressure (H) -- One window (#162, PR #171); no pressure or tilt in ink. **Estimate:** 10–14 h. +- Desktop windows done: a native window per workbook window, New Window on the same workbook, + Switch Windows, Arrange All. Left: Arrange All across monitors, one window on the web, no + pressure or tilt in ink. **Estimate:** 5–8 h. [hardware-parity.md](hardware-parity.md) ### 21. Automation and add-ins (F) @@ -178,6 +180,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | §20: a native window per workbook window, New Window on the same workbook and Arrange All on the desktop; left: Arrange All across monitors, the web's one window, pen pressure (estimate 10–14 h → 5–8 h) | | 2026-10-20 | minor | §11: the Japanese catalog covers the dialogs, messages and menus — 943 keys (~43%), matching the pt-BR coverage; a test keeps the two catalogs in step | | 2026-10-17 | minor | §6: the five reports it tracked are closed and their PRs merged (#57, #133, #139, #140, #134; PRs #148, #155, #156, #157); the entry stays open for §15's oracle suite | | 2026-10-15 | minor | §13: Solver prepares reports before committing the solution, model and report sheets together; report refusal preserves the workbook and Undo/Redo (#1050) | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index f19b4a49d..71cf001d5 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (a native window per workbook window) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Arrange All) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 The hardware Excel uses, per platform, against GridCraft. Part of [target-app-parity.md](target-app-parity.md). **Hardware: ~40% ready, 25–40 h** (estimated). @@ -11,7 +11,7 @@ The hardware Excel uses, per platform, against GridCraft. Part of | GPU rendering of the grid and charts | DirectX / Metal accelerated drawing | wgpu: Metal · DirectX 12 (default since 10-08; OpenGL when DirectX 12 has no GPU driver or fails to start; Vulkan only on request, so the Intel UHD `igvk64.dll` crash (#120) can't happen; shaders compiled with FXC, so no DXC runtime is needed, #339) · Vulkan/GL · Vulkan/GL · WebGPU or WebGL2 | At parity in kind. Driver problems: screen flashing on Windows 11 (#76): 2–4 h | | Pen and touch (Draw tab) | Pen pressure and tilt, eraser end, touch mode, Ink to Shape, ink replay | Pen, highlighter, eraser, Ink to Shape with mouse/pen input; **no pressure or tilt**, no touch mode | Pressure-sensitive strokes where winit reports force; touch-friendly ribbon spacing: 4–6 h | | Trackpad and mouse | Smooth scrolling, pinch zoom, horizontal scroll, Magic Mouse | Smooth scrolling and zoom via egui | Verify pinch zoom on each platform: 1–2 h | -| Multiple windows and monitors | One window per workbook, New Window for the same workbook, per-monitor DPI | A native window per workbook window on the desktop, New Window for the same workbook; new windows cascade from the last one on its monitor (web: one window) | Arrange All across monitors; verify per-monitor DPI with windows on two monitors: 2–3 h | +| Multiple windows and monitors | One window per workbook, New Window for the same workbook, per-monitor DPI | A native window per workbook window on the desktop, New Window for the same workbook; new windows cascade from the last one on its monitor; View › Arrange All tiles, stacks or cascades them over the main window's monitor (web: one window) | Arrange All across monitors; verify per-monitor DPI with windows on two monitors: 2–3 h | | High-DPI and display scaling | yes | yes (display scale matched to desktop density; View › Interface size 75–200 % on top, #230) | — | | Accessibility hardware (screen readers, braille displays, switch control) | Narrator, JAWS, NVDA, VoiceOver | AccessKit enabled in eframe; grid cells and ribbon not yet exposed as an accessible tree | Counted under UI ([ui-parity.md](ui-parity.md)) | | Printers | System print with driver options | Desktop submits through craft-print with printer choice, copies, duplex and grayscale; web browser printing | Counted under print | @@ -20,6 +20,7 @@ The hardware Excel uses, per platform, against GridCraft. Part of | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | View › Arrange All over the main window's monitor. | | 2026-10-22 | minor | Multiple windows: each workbook window is a native window of its own (desktop). | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | trivial | Windows: DirectX 12 shaders compiled with FXC, no DXC runtime needed (#339, #354) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 08a9124bd..f15a13f42 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (Page Setup paper sizes match Japanese Excel: B4/B5/B6 are the JIS sizes, with ISO B4/B5/B6 by name and the Japanese postcards, envelopes and A6 added; no score change) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** minor (several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -102,11 +102,11 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 |---|---|---|---|---| | Features | 45 | ~59% | 300–485 | Table above | | File formats | 15 | ~50% | 80–120 | XLSX read/write is broad but unproven on a real corpus and in Excel; XLSB and ODS are values-only import; no XLS, XML Spreadsheet 2003, SYLK, DIF, PRN; VBA dropped; 100 MB XLSX fails (#175). [file-format-parity.md](file-format-parity.md) | -| UI/UX fidelity | 12 | ~55% | 50–80 | Excel-style ribbon, galleries, dialogs, formula bar, status bar; 59 commands with shortcuts vs Excel's 200+; key tips on part of the Home tab only; short context menu; single window. [ui-parity.md](ui-parity.md) | +| UI/UX fidelity | 12 | ~55% | 50–80 | Excel-style ribbon, galleries, dialogs, formula bar, status bar; 59 commands with shortcuts vs Excel's 200+; key tips on part of the Home tab only; short context menu; a native window per workbook window on the desktop (one on the web). [ui-parity.md](ui-parity.md) | | Performance | 8 | ~35% | 35–55 | [Performance](#performance) | | Stability | 8 | ~60% | 15–25 | Never-crash rules enforced by lint, every command fuzzed with hostile params, random command sequences fuzzed against undo/redo, XLSX round-trip and full-recalc invariants, last-resort guard in `Session::execute`. Field reports: Windows Intel GPU crash (#120), error on close (#117), freeze opening from Finder (#166), screen flashing (#76) | | Localization | 4 | ~10% | 100–165 | Ribbon-only catalog in five languages (ja, zh, ko, pt-BR, ru; ~12% of strings each), CJK/Thai/Hebrew font fallback, no shaping or RTL. [localization-parity.md](localization-parity.md) | -| Hardware | 2 | ~40% | 25–40 | GPU UI via wgpu; single-threaded calc; no pen pressure; one window. [hardware-parity.md](hardware-parity.md) | +| Hardware | 2 | ~40% | 25–40 | GPU UI via wgpu; single-threaded calc; no pen pressure; a native window per workbook window, Arrange All on one monitor. [hardware-parity.md](hardware-parity.md) | | Platforms | 2 | ~80% | 30–60 | [Platforms](#platforms) | | Ecosystem / add-ins | 2 | ~10% | 25–40 | [Ecosystem and AI](#ecosystem-and-ai) | | AI features | 2 | ~15% | 30–50 | [Ecosystem and AI](#ecosystem-and-ai) | @@ -144,7 +144,7 @@ Discounts for what still stops real work: | Discount | Factor | Evidence | |---|---|---| -| Interaction fidelity | × 0.90 | 59 command shortcuts vs Excel's 200+; key tips only on part of the Home tab (#43); rectangular selection bug (#28); charts can't be resized (#190); one window (#162); missing horizontal scrollbar report (#176) | +| Interaction fidelity | × 0.90 | 59 command shortcuts vs Excel's 200+; key tips only on part of the Home tab (#43); rectangular selection bug (#28); charts can't be resized (#190); missing horizontal scrollbar report (#176) | | Stability on real machines | × 0.92 | Never-crash lints, fuzzed commands and the `Session::execute` guard; but field reports of a startup crash on Intel UHD (#120), an error on close (#117), a freeze opening from Finder (#166) and screen flashing (#76) | | File exchange with Excel users | × 0.88 | Excel opening our output is unverified across features; unmodelled parts dropped on save; advanced chart kinds lose their type; 100 MB files fail (#175); `.xls` and encrypted files are refused with a clear message (#17) rather than opened. Harsher than a drawing app's discount because spreadsheets are exchanged constantly | @@ -334,6 +334,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Several windows on the desktop: a native window per workbook window, New Window on the same workbook, Switch Windows and Arrange All; "one window" removed from UI/UX fidelity, Hardware and the interaction-fidelity discount; no score change | | 2026-10-21 | minor | Page Setup paper sizes: `B4`/`B5`/`B6` are the JIS sizes Japanese Excel uses (ISO B4/B5/B6 by name), and Japanese Postcard, Japanese Double Postcard, the chou/kaku envelopes and A6 are added; PDF MediaBox and `file.print` follow, and the Size menu lists them | | 2026-10-18 | trivial | Stale chart notes: all chart kinds survive XLSX round-trip (PRs #158, #161) and trendlines landed | | 2026-10-16 | minor | Advanced Filter copy-to clears only its actual output and rejects overflow without changing cells (#1031) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 817b5b915..d34b8c464 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (every workbook window is a native window of its own on the desktop, with its own draft, dialog and ribbon; closing one of several windows on a workbook doesn't ask; Quit asks about each unsaved workbook in its window; the last window closed quits on Windows and Linux, macOS keeps running) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (View › Arrange All lays out the native windows; two windows on one workbook show different sheets without copying it; a dialog stays in its window) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -33,8 +33,8 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse; the recent list tells when each workbook was last changed, 10-12), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | | Dark mode, system appearance | yes | yes | done (10-09) | | Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | margins drag on the rulers (#792, with header and footer margins); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) | -| Multiple windows per workbook | yes | yes (10-22, desktop): each workbook window is a native window of its own (New, Open and New Window open one; titles "Book1", or "Book1:1" / "Book1:2" for a workbook's second window, with " — Edited" while unsaved). Each window keeps its own cell draft, dialog, Backstage view, message and grid state, and its ribbon acts on it; on macOS the menu bar acts on the window focused last. Leaving a window enters its draft as Enter would. A window's close button closes that window: one of several on a workbook closes without asking, a workbook's last asks Save / Don't Save / Cancel when unsaved. Quit asks about each unsaved workbook in its window, and Cancel stops it. The last window closed quits on Windows and Linux; macOS keeps running with the menu bar until New or Open. Web: one window | no Arrange All; a hidden window (View › Hide) has no native window; every window repaints when one does; see [hardware-parity.md](hardware-parity.md) | -| Switch Windows, New Window, unsaved workbooks on quit | yes | yes (10-22): View › New Window opens another window on the same workbook (no copy: saving from either saves it), each with its own selection, active sheet and scroll; a workbook with several windows titles them "Book1:1", "Book1:2"; File › Close closes one of them without asking and asks only for the last; View › Switch Windows and the macOS Window menu list the open windows (active one checked, unsaved ones marked •) and bring the chosen one forward (`view.switchWindows {window}`, or `{doc}` for a workbook's most recent window); a cell being typed is entered into its own window and workbook before New, Open, Close, Save, Print and window switches (also between two windows on one workbook), and a refused entry stays open and stops the command; quitting (Cmd+Q, the macOS Quit item, the Dock) asks Save / Don't Save / Cancel for each unsaved workbook | | +| Multiple windows per workbook | yes | yes (10-22, desktop): each workbook window is a native window of its own (New, Open and New Window open one; titles "Book1", or "Book1:1" / "Book1:2" for a workbook's second window, with " — Edited" while unsaved). Each window keeps its own cell draft, dialog, Backstage view, message and grid state, and its ribbon acts on it; on macOS the menu bar acts on the window focused last. Leaving a window enters its draft as Enter would. A window's close button closes that window: one of several on a workbook closes without asking, a workbook's last asks Save / Don't Save / Cancel when unsaved. Quit asks about each unsaved workbook in its window, and Cancel stops it. The last window closed quits on Windows and Linux; macOS keeps running with the menu bar until New or Open. View › Arrange All (dialog: Tiled, Horizontal, Vertical, Cascade, Windows of active workbook; `view.arrangeAll` tiles by default) lays the visible windows out over the main window's monitor, the active one first. Web: one window | Arrange All uses one monitor; a hidden window (View › Hide) has no native window; every window repaints when one does; see [hardware-parity.md](hardware-parity.md) | +| Switch Windows, New Window, unsaved workbooks on quit | yes | yes (10-22): View › New Window opens another window on the same workbook (no copy: saving from either saves it), each with its own selection, active sheet and scroll (showing different sheets copies nothing and leaves the workbook unchanged; Save records the sheet of the window used last); a workbook with several windows titles them "Book1:1", "Book1:2"; File › Close closes one of them without asking and asks only for the last; View › Switch Windows and the macOS Window menu list the open windows (active one checked, unsaved ones marked •) and bring the chosen one forward (`view.switchWindows {window}`, or `{doc}` for a workbook's most recent window); a cell being typed is entered into its own window and workbook before New, Open, Close, Save, Print and window switches (also between two windows on one workbook), and a refused entry stays open and stops the command; quitting (Cmd+Q, the macOS Quit item, the Dock) asks Save / Don't Save / Cancel for each unsaved workbook | | ## Grid interaction @@ -177,6 +177,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | View › New Window and Arrange All on the View ribbon; Arrange All tiles, stacks or cascades the native windows; two windows on one workbook keep their own sheet without copying the workbook; a dialog opened in one window stays there when a command run from it switches windows | | 2026-10-22 | minor | One native window per workbook window on the desktop: per-window drafts, dialogs and ribbons; menu bar commands go to the focused window; closing one of several windows on a workbook doesn't ask; Quit asks in each unsaved workbook's window; the last window closed quits (Windows, Linux) or leaves macOS running | | 2026-10-22 | minor | New Window opens a second window on the same workbook instead of a copy that saved over the original; windows keep their own selection, sheet and scroll; titles Name:1 / Name:2; Switch Windows, Hide and Unhide work by window; File › Close closes one window of several without asking | | 2026-10-22 | minor | New workbook no longer loses the first one: the cell being typed is entered into its own workbook before any File or window command (an entry the workbook refuses stays open and the command doesn't run; an untouched F2 draft changes nothing), the editor is tied to its workbook, View › Switch Windows and the macOS Window menu list the open workbooks, and the macOS Quit item asks about unsaved workbooks like the close button | From 14dd23db6ceff8cb3a51508c00edffb8adaac905 Mon Sep 17 00:00:00 2001 From: skylar Date: Sun, 25 Oct 2026 16:36:12 -0600 Subject: [PATCH 08/76] chore(deps): refresh serde_json, arboard, muda, winresource, sha2 --- Cargo.lock | 136 ++++++++++++----------------------------------------- 1 file changed, 31 insertions(+), 105 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 63d2260e7..1edb8da90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -206,9 +206,9 @@ dependencies = [ [[package]] name = "arboard" -version = "3.6.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" dependencies = [ "clipboard-win", "image", @@ -220,7 +220,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.52.0", "wl-clipboard-rs", "x11rb", ] @@ -1395,7 +1395,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1989,7 +1989,7 @@ dependencies = [ "gridcraft-formula", "serde", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "thiserror 2.0.21", ] @@ -2054,7 +2054,7 @@ dependencies = [ "gridcraft-model", "quick-xml 0.41.0", "sha1 0.11.0", - "sha2 0.11.0", + "sha2 0.11.1", "thiserror 2.0.21", "zip 3.0.0", ] @@ -2763,9 +2763,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.21.1" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7b000cca0783af26837fe2ebd9ddf1c42279463c085b97622ee1d2f444b89e" +checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03" dependencies = [ "crossbeam-channel", "dpi", @@ -3287,7 +3287,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3689,7 +3689,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3996,7 +3996,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4055,7 +4055,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4201,9 +4201,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -4290,9 +4290,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures 0.3.1", @@ -4783,10 +4783,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5738,7 +5738,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5859,7 +5859,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -5868,16 +5868,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -5895,31 +5886,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -5937,96 +5911,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" @@ -6119,9 +6045,9 @@ dependencies = [ [[package]] name = "winresource" -version = "0.1.31" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "toml", "version_check", From 6f0e43c21c5fd71b8ac6c30ac86d447f7671240f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 19:37:08 -0400 Subject: [PATCH 09/76] Linux: default to XWayland for file drag-and-drop on Wayland desktops (#165) * Linux: default to XWayland for file drag-and-drop on Wayland desktops * Ignore an empty DISPLAY before forcing X11 --- Cargo.lock | 1 + apps/gridcraft/Cargo.toml | 3 +++ apps/gridcraft/src/main.rs | 12 ++++++++++++ 3 files changed, 16 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 1edb8da90..4e7cf4a26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1885,6 +1885,7 @@ dependencies = [ "pollster 1.0.1", "rfd", "serde_json", + "winit", "winresource", ] diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index d7be2fb15..52b577cc5 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -24,6 +24,9 @@ chrono = { workspace = true } # Icon + VERSIONINFO for gridcraft.exe; build.rs only uses it when the target is Windows. winresource = "0.1" +[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] +winit = { version = "0.30", default-features = false, features = ["x11"] } + [target.'cfg(not(target_arch = "wasm32"))'.dependencies] craft-print = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" } craft-update = { workspace = true } diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 2b0c9113b..14375944c 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -336,6 +336,18 @@ fn main() -> eframe::Result<()> { viewport = viewport.with_icon(i); } let options = eframe::NativeOptions { viewport, ..Default::default() }; + #[cfg(all(unix, not(target_os = "macos")))] + let mut options = options; + // winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file + // manager showed a "no" cursor. Run through XWayland when it's there; GRIDCRAFT_WAYLAND=1 + // keeps the native Wayland backend. + #[cfg(all(unix, not(target_os = "macos")))] + if std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()) && std::env::var_os("GRIDCRAFT_WAYLAND").is_none() { + options.event_loop_builder = Some(Box::new(|b| { + use winit::platform::x11::EventLoopBuilderExtX11; + b.with_x11(); + })); + } // Before eframe creates the wgpu instance: Windows starts on DirectX 12, or OpenGL when // DirectX 12 has no GPU driver, and never touches Vulkan unless asked (see graphics.rs). #[cfg(target_os = "windows")] From 0b6d243946db1352a1be7de9570b8e2d47c0ee01 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 16:47:19 -0700 Subject: [PATCH 10/76] Format Chart Area: Chart Type offers the stacked and 100% stacked types and marks the current one The Chart Type section of the Format Chart Area pane listed nine types, with stacked column as the only stacked one, so a chart could not be switched there to stacked bar, 100% stacked column or bar, stacked line, or stacked or 100% stacked area. It now lists them after their family's entries, and "Stacked" is renamed "Stacked Column". Each entry is drawn selected when it is the chart's current type, as the Legend entries are, using the same type and subtype mapping that chart.set applies. --- crates/ui-egui/src/panes.rs | 11 ++- crates/ui-egui/tests/it/format_chart_type.rs | 91 ++++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + 3 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/it/format_chart_type.rs diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 65d169c87..4cf5bbbc1 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -185,16 +185,23 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { ui.horizontal_wrapped(|ui| { for (l, k, s) in [ ("Column", "column", ""), - ("Stacked", "column", "stacked"), + ("Stacked Column", "column", "stacked"), + ("100% Stacked Column", "column", "stacked100"), ("Bar", "bar", ""), + ("Stacked Bar", "bar", "stacked"), + ("100% Stacked Bar", "bar", "stacked100"), ("Line", "line", "markers"), + ("Stacked Line", "line", "stacked"), ("Area", "area", ""), + ("Stacked Area", "area", "stacked"), + ("100% Stacked Area", "area", "stacked100"), ("Pie", "pie", ""), ("Doughnut", "doughnut", ""), ("Scatter", "scatter", ""), ("Radar", "radar", ""), ] { - if ui.button(l).clicked() { + let cur = chart.kind == gridcraft_engine::cmd::insert::chart_kind(k, s); + if ui.selectable_label(cur, l).clicked() { app.run_or_alert("chart.set", json!({"chart": id, "type": k, "subtype": s})); } } diff --git a/crates/ui-egui/tests/it/format_chart_type.rs b/crates/ui-egui/tests/it/format_chart_type.rs new file mode 100644 index 000000000..ae19e1091 --- /dev/null +++ b/crates/ui-egui/tests/it/format_chart_type.rs @@ -0,0 +1,91 @@ +//! The Format Chart Area pane's Chart Type section changes the chart's type and marks the current one. + +use egui::accesskit::{Role, Toggled}; +use egui_kittest::kittest::{NodeT, Queryable}; +use gridcraft_engine::Session; +use gridcraft_engine::model::ChartKind; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +/// A two-series chart made with `insert.chart`'s `type` `t` and `subtype` `sub`, with Format Chart Area open on its Chart Type section. +fn open_chart_type(t: &str, sub: &str) -> (Harness, u32) { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "North", "South"], ["Q1", 10, 30], ["Q2", 30, 10]]})).unwrap(); + session.execute("selection.set", json!({"range": "A1:C3"})).unwrap(); + let chart = session.execute("insert.chart", json!({"type": t, "subtype": sub, "at": "E3"})).unwrap(); + let id = chart["chart"].as_u64().unwrap() as u32; + let mut h = crate::support::harness(session, 4); + h.state_mut().selected_chart = Some(id); + h.state_mut().grid.pane = Some("formatChart".into()); + h.run_steps(2); + h.get_by_role_and_label(Role::DisclosureTriangle, "Chart Type").click(); + h.run_steps(2); + (h, id) +} + +fn kind(h: &Harness, id: u32) -> ChartKind { + h.state().session.active().unwrap().wb.active().unwrap().charts.iter().find(|c| c.id == id).unwrap().kind +} + +/// Whether the pane's `label` button is drawn as the chart's current type. The pane sits on the +/// right, past the Chart Type header's left edge; the ribbon's buttons are elsewhere. +fn marked(h: &Harness, label: &str) -> bool { + let left = h.get_by_role_and_label(Role::DisclosureTriangle, "Chart Type").rect().left(); + let button = h.get_all_by_role_and_label(Role::Button, label).find(|n| n.rect().left() >= left - 1.0).unwrap(); + button.accesskit_node().toggled() == Some(Toggled::True) +} + +fn click(h: &mut Harness, label: &str) { + let left = h.get_by_role_and_label(Role::DisclosureTriangle, "Chart Type").rect().left(); + h.get_all_by_role_and_label(Role::Button, label).find(|n| n.rect().left() >= left - 1.0).unwrap().click(); + h.run_steps(2); +} + +#[test] +fn chart_type_offers_the_stacked_and_hundred_percent_stacked_types() { + let (mut h, id) = open_chart_type("column", ""); + for (label, want) in [ + ("Stacked Column", ChartKind::ColumnStacked), + ("100% Stacked Column", ChartKind::ColumnStacked100), + ("Stacked Bar", ChartKind::BarStacked), + ("100% Stacked Bar", ChartKind::BarStacked100), + ("Stacked Line", ChartKind::LineStacked), + ("Stacked Area", ChartKind::AreaStacked), + ("100% Stacked Area", ChartKind::AreaStacked100), + ("Column", ChartKind::ColumnClustered), + ] { + click(&mut h, label); + assert_eq!(kind(&h, id), want, "{label} changes the chart's type"); + } +} + +#[test] +fn chart_type_marks_the_chart_s_current_type() { + for (t, sub, label) in + [("bar", "stacked100", "100% Stacked Bar"), ("area", "stacked", "Stacked Area"), ("column", "", "Column"), ("line", "markers", "Line")] + { + let (h, _) = open_chart_type(t, sub); + for other in [ + "Column", + "Stacked Column", + "100% Stacked Column", + "Bar", + "Stacked Bar", + "100% Stacked Bar", + "Line", + "Stacked Line", + "Area", + "Stacked Area", + "100% Stacked Area", + "Pie", + "Doughnut", + "Scatter", + "Radar", + ] { + assert_eq!(marked(&h, other), other == label, "{other} on a {t} {sub} chart"); + } + } +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 53625015e..5eeb4c411 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -37,6 +37,7 @@ mod filter_menu_enter; mod find_dialog_focus; mod floating_pictures; mod font_size_box; +mod format_chart_type; mod format_painter; mod formula_bar; mod formula_bar_expand; From 52cf825395b8a64c1d93affac2ce7f9a8202b504 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 21:37:09 -0400 Subject: [PATCH 11/76] refactor: split complex functions for Sonar S3776 (#2609) Extract helpers from auto_sum (selection and single-cell guess), update_cursor (drag, hover, freeze, and header-edge cursors) and read_styles (dxf reader). No behaviour change. --- crates/engine/src/cmd/formulas.rs | 162 +++++++++++++++++------------- crates/ui-egui/src/grid.rs | 84 ++++++++++------ crates/xlsx/src/styles.rs | 96 +++++++++--------- 3 files changed, 191 insertions(+), 151 deletions(-) diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index beb80f497..dc523d333 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -96,75 +96,73 @@ pub fn specs() -> Vec { ] } -fn auto_sum(s: &mut Session, p: &Json) -> Result { - let func = str_param(p, "function").unwrap_or("SUM").to_ascii_uppercase(); - let d = s.doc()?; - let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - let sel = match str_param(p, "range") { - Some(_) => target_range(s, p)?, - None => d.selection.current(), - }; - if !sel.is_single() { - // A selection: totals go in its empty last row or column, as Excel's do, and otherwise - // past it: to the right of a single row, below each column of anything taller. Columns - // past the sheet's data get no total (a whole-row selection doesn't write 16,384 - // formulas), nor do rows past it, and all totals are one edit (one undo step). - let Some(used) = sh.cells.used_range() else { return ok() }; - let empty = |row: u32, col: u32| sh.value(CellRef::new(row, col)).is_empty(); - let sum = |from: CellRef, to: CellRef| format!("={func}({})", RangeRef::new(from, to).a1()); - let last_col = used.end.col.min(sel.end.col); - let mut writes: Vec<(CellRef, String)> = Vec::new(); - if sel.start.row == sel.end.row { - // One row: its total goes in its last cell when that is empty, else just after it. - let row = sel.start.row; - let end_col = if sel.is_full_rows() { last_col } else { sel.end.col }; - let (data_end, out) = if end_col > sel.start.col && empty(row, end_col) { (end_col - 1, end_col) } else { (end_col, end_col + 1) }; - if out >= gridcraft_core::MAX_COLS { - return Err(EngineError::Other("There's no room to the right of the selection for the total.".into())); - } - writes.push((CellRef::new(row, out), sum(CellRef::new(row, sel.start.col), CellRef::new(row, data_end)))); - } else { - // An empty last column takes each row's total; rows past the data get none. - let rows_end = sel.end.row.min(used.end.row); - let right_blank = !sel.is_full_rows() && sel.end.col > sel.start.col && (sel.start.row..=rows_end).all(|r| empty(r, sel.end.col)); - let data_end_col = if right_blank { sel.end.col - 1 } else { last_col }; - let last_row_blank = (sel.start.col..=last_col).all(|c| empty(sel.end.row, c)); - let (data_end_row, out_row) = if last_row_blank { (sel.end.row.saturating_sub(1), sel.end.row) } else { (sel.end.row, sel.end.row + 1) }; - // Each column's total, below it: unless only the last column is there to take totals. - if last_row_blank || !right_blank { - if out_row >= gridcraft_core::MAX_ROWS { - return Err(EngineError::Other("There's no room below the selection for the totals.".into())); - } - for c in sel.start.col..=data_end_col.min(last_col) { - writes.push((CellRef::new(out_row, c), sum(CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c)))); - } - } - if right_blank { - for r in sel.start.row..=data_end_row.min(rows_end) { - writes.push((CellRef::new(r, sel.end.col), sum(CellRef::new(r, sel.start.col), CellRef::new(r, data_end_col)))); - } - // Both empty: the corner takes the total of the whole block. - if last_row_blank { - writes.push(( - CellRef::new(sel.end.row, sel.end.col), - sum(CellRef::new(sel.start.row, sel.start.col), CellRef::new(data_end_row, data_end_col)), - )); - } - } - } - let sheet = d.wb.active_sheet; - edit(s, |cx| { - for (c, f) in &writes { - let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb)?.unwrap_or_default(); - cx.sheet_mut(sheet)?.set_cell(*c, cell); - cx.touch(sheet, *c); - } - Ok(()) - })?; - return ok(); +/// Whether a cell on the active sheet is empty. +fn blank(sh: &gridcraft_model::sheet::Sheet, row: u32, col: u32) -> bool { + sh.value(CellRef::new(row, col)).is_empty() +} + +fn sum_formula(func: &str, from: CellRef, to: CellRef) -> String { + format!("={func}({})", RangeRef::new(from, to).a1()) +} + +/// Totals for a multi-cell selection as `(cell, formula)` writes. `None` when the sheet has no data. +fn selection_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, func: &str) -> Result>> { + // A selection: totals go in its empty last row or column, as Excel's do, and otherwise + // past it: to the right of a single row, below each column of anything taller. Columns + // past the sheet's data get no total (a whole-row selection doesn't write 16,384 + // formulas), nor do rows past it, and all totals are one edit (one undo step). + let Some(used) = sh.cells.used_range() else { return Ok(None) }; + if sel.start.row == sel.end.row { single_row_totals(sh, sel, used, func).map(Some) } else { block_totals(sh, sel, used, func).map(Some) } +} + +/// One row: its total goes in its last cell when that is empty, else just after it. +fn single_row_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, used: RangeRef, func: &str) -> Result> { + let row = sel.start.row; + let last_col = used.end.col.min(sel.end.col); + let end_col = if sel.is_full_rows() { last_col } else { sel.end.col }; + let (data_end, out) = if end_col > sel.start.col && blank(sh, row, end_col) { (end_col - 1, end_col) } else { (end_col, end_col + 1) }; + if out >= gridcraft_core::MAX_COLS { + return Err(EngineError::Other("There's no room to the right of the selection for the total.".into())); } - // Single cell: guess the range above, else to the left. - let at = sel.start; + Ok(vec![(CellRef::new(row, out), sum_formula(func, CellRef::new(row, sel.start.col), CellRef::new(row, data_end)))]) +} + +/// A block of rows: each column's total below it, and each row's total in an empty last column. +fn block_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, used: RangeRef, func: &str) -> Result> { + let mut writes: Vec<(CellRef, String)> = Vec::new(); + let last_col = used.end.col.min(sel.end.col); + // An empty last column takes each row's total; rows past the data get none. + let rows_end = sel.end.row.min(used.end.row); + let right_blank = !sel.is_full_rows() && sel.end.col > sel.start.col && (sel.start.row..=rows_end).all(|r| blank(sh, r, sel.end.col)); + let data_end_col = if right_blank { sel.end.col - 1 } else { last_col }; + let last_row_blank = (sel.start.col..=last_col).all(|c| blank(sh, sel.end.row, c)); + let (data_end_row, out_row) = if last_row_blank { (sel.end.row.saturating_sub(1), sel.end.row) } else { (sel.end.row, sel.end.row + 1) }; + // Each column's total, below it: unless only the last column is there to take totals. + if last_row_blank || !right_blank { + if out_row >= gridcraft_core::MAX_ROWS { + return Err(EngineError::Other("There's no room below the selection for the totals.".into())); + } + for c in sel.start.col..=data_end_col.min(last_col) { + writes.push((CellRef::new(out_row, c), sum_formula(func, CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c)))); + } + } + if right_blank { + for r in sel.start.row..=data_end_row.min(rows_end) { + writes.push((CellRef::new(r, sel.end.col), sum_formula(func, CellRef::new(r, sel.start.col), CellRef::new(r, data_end_col)))); + } + // Both empty: the corner takes the total of the whole block. + if last_row_blank { + writes.push(( + CellRef::new(sel.end.row, sel.end.col), + sum_formula(func, CellRef::new(sel.start.row, sel.start.col), CellRef::new(data_end_row, data_end_col)), + )); + } + } + Ok(writes) +} + +/// The range a single cell's AutoSum guesses: the numbers above it, else the numbers to its left. +fn guessed_range(sh: &gridcraft_model::sheet::Sheet, at: CellRef) -> Option { let numeric = |c: CellRef| sh.value(c).is_number(); let mut range = None; if at.row > 0 && (numeric(CellRef::new(at.row - 1, at.col)) || at.col == 0 || !numeric(CellRef::new(at.row, at.col - 1))) { @@ -186,7 +184,33 @@ fn auto_sum(s: &mut Session, p: &Json) -> Result { } range = Some(RangeRef::new(CellRef::new(at.row, left), CellRef::new(at.row, at.col - 1))); } - let text = match range { + range +} + +fn auto_sum(s: &mut Session, p: &Json) -> Result { + let func = str_param(p, "function").unwrap_or("SUM").to_ascii_uppercase(); + let d = s.doc()?; + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let sel = match str_param(p, "range") { + Some(_) => target_range(s, p)?, + None => d.selection.current(), + }; + if !sel.is_single() { + let Some(writes) = selection_totals(sh, sel, &func)? else { return ok() }; + let sheet = d.wb.active_sheet; + edit(s, |cx| { + for (c, f) in &writes { + let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb)?.unwrap_or_default(); + cx.sheet_mut(sheet)?.set_cell(*c, cell); + cx.touch(sheet, *c); + } + Ok(()) + })?; + return ok(); + } + // Single cell: guess the range above, else to the left. + let at = sel.start; + let text = match guessed_range(sh, at) { Some(r) => format!("={func}({})", r.a1()), None => { s.ui_requests.push(crate::UiRequest::EditCell(Some(format!("={func}()")))); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index d13b6e4df..1c5704ee3 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2046,38 +2046,46 @@ fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Response, geo: } // A held drag owns the cursor even outside the grid; a press below egui's threshold still // has Drag::None and uses hover. A click can briefly leave a drag state after release. - if resp.dragged() && pointer.primary_down() { - let cursor = match app.grid.drag { - Drag::None => None, - Drag::Select => Some(CursorIcon::Cell), - Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default), - Drag::Fill { .. } => Some(CursorIcon::Crosshair), - Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { resize: false, .. } => Some(CursorIcon::Move), - Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn), - Drag::ResizeRow { .. } => Some(CursorIcon::ResizeRow), - Drag::Freeze { cols, .. } => Some(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }), - Drag::Object { resize: true, .. } => Some(CursorIcon::ResizeNwSe), - }; - if let Some(cursor) = cursor { - ctx.set_cursor_icon(cursor); - return; - } + if resp.dragged() + && pointer.primary_down() + && let Some(cursor) = drag_cursor(&app.grid.drag) + { + ctx.set_cursor_icon(cursor); + return; } if !resp.hovered() { return; } let Some(p) = pos else { return }; + hover_cursor(app, ctx, resp, &pointer, geo, sh, p); +} + +/// The cursor a held drag shows, or `None` when the drag leaves it to hover. +fn drag_cursor(drag: &Drag) -> Option { + match drag { + Drag::None => None, + Drag::Select => Some(CursorIcon::Cell), + Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default), + Drag::Fill { .. } => Some(CursorIcon::Crosshair), + Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { resize: false, .. } => Some(CursorIcon::Move), + Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn), + Drag::ResizeRow { .. } => Some(CursorIcon::ResizeRow), + Drag::Freeze { cols, .. } => Some(if *cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }), + Drag::Object { resize: true, .. } => Some(CursorIcon::ResizeNwSe), + } +} + +/// The cursor over the grid when no drag owns it: freeze bars, header edges, the fill handle, cells. +fn hover_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, pointer: &egui::PointerState, geo: &Geo, sh: &Sheet, p: Pos2) { + let (freeze_cols, freeze_rows) = freeze_handles(geo); if freeze_cols.contains(p) || freeze_rows.contains(p) { - let cols = freeze_cols.contains(p); - ctx.set_cursor_icon(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }); - if app.grid.drag == Drag::None && !pointer.any_down() { - let tip = if cols { "Drag to freeze columns" } else { "Drag to freeze rows" }; - resp.clone().on_hover_text_at_pointer(crate::i18n::current(ctx).tr(tip)); - } + freeze_cursor(app, ctx, resp, pointer, freeze_cols.contains(p)); return; } + let in_cells = geo.in_cells(p); let in_col_header = p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left(); let in_row_header = p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top(); + let near_col_edge = in_col_header && col_edge(geo, sh, p.x).is_some(); let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default(); let cur_rect = geo.range_rect(sh, sel.current()); let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0)); @@ -2089,16 +2097,8 @@ fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Response, geo: let near_corner = selected_object.is_some_and(|(_, _, rect)| (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS); if app.grid.drag == Drag::None && near_corner { ctx.set_cursor_icon(CursorIcon::ResizeNwSe); - } else if (in_col_header && col_edge(geo, sh, p.x).is_some()) || (in_row_header && row_edge(geo, sh, p.y).is_some()) { - let near_col_edge = in_col_header && col_edge(geo, sh, p.x).is_some(); - // Just past a hidden column or row, Excel shows a split cursor: dragging or - // double-clicking there unhides it. - let hidden = app.grid.drag == Drag::None - && if near_col_edge { hidden_col_edge(geo, sh, p.x).is_some() } else { hidden_row_edge(geo, sh, p.y).is_some() }; - match pointer.hover_pos() { - Some(at) if hidden => paint_split_cursor(ctx, at, near_col_edge), - _ => ctx.set_cursor_icon(if near_col_edge { CursorIcon::ResizeColumn } else { CursorIcon::ResizeRow }), - } + } else if near_col_edge || (in_row_header && row_edge(geo, sh, p.y).is_some()) { + header_edge_cursor(app, ctx, pointer, geo, sh, p, near_col_edge); } else if on_handle && app.editor.is_none() { ctx.set_cursor_icon(CursorIcon::Crosshair); if app.grid.drag == Drag::None && !pointer.any_down() { @@ -2115,6 +2115,26 @@ fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Response, geo: } } +/// The resize cursor for dragging a freeze bar. +fn freeze_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, pointer: &egui::PointerState, cols: bool) { + ctx.set_cursor_icon(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }); + if app.grid.drag == Drag::None && !pointer.any_down() { + let tip = if cols { "Drag to freeze columns" } else { "Drag to freeze rows" }; + resp.clone().on_hover_text_at_pointer(crate::i18n::current(ctx).tr(tip)); + } +} + +/// The resize cursor over a column or row header edge. Just past a hidden column or row, Excel +/// shows a split cursor: dragging or double-clicking there unhides it. +fn header_edge_cursor(app: &SheetApp, ctx: &egui::Context, pointer: &egui::PointerState, geo: &Geo, sh: &Sheet, p: Pos2, near_col_edge: bool) { + let hidden = + app.grid.drag == Drag::None && if near_col_edge { hidden_col_edge(geo, sh, p.x).is_some() } else { hidden_row_edge(geo, sh, p.y).is_some() }; + match pointer.hover_pos() { + Some(at) if hidden => paint_split_cursor(ctx, at, near_col_edge), + _ => ctx.set_cursor_icon(if near_col_edge { CursorIcon::ResizeColumn } else { CursorIcon::ResizeRow }), + } +} + fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) { // Text selection and editing must not also move the object or edit worksheet cells. if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() { diff --git a/crates/xlsx/src/styles.rs b/crates/xlsx/src/styles.rs index 0baff5c0f..c1f93aca9 100644 --- a/crates/xlsx/src/styles.rs +++ b/crates/xlsx/src/styles.rs @@ -359,59 +359,55 @@ pub fn read_styles(root: &El, checkboxes: &[bool], minor_font: &str) -> StylesIn } } } - let dxfs = root - .child("dxfs") - .map(|d| { - d.kids("dxf") - .map(|x| { - let mut s = Style::default(); - if let Some(e) = x.child("font") { - let f = read_font(e, &palette); - // Differential fonts rarely name a face; unless stated, keep the default face and size. - let has_size = e.child("sz").and_then(|n| n.attr_f64("val")).is_some(); - let has_name = e.child("name").and_then(|n| n.attr("val")).is_some(); - let name = if has_name { f.name.clone() } else { s.font.name.clone() }; - let size = if has_size { f.size } else { s.font.size }; - let def = Font::default(); - let st = &mut s.dxf_stated; - st.bold = e.child("b").is_some() && !f.bold; - st.italic = e.child("i").is_some() && !f.italic; - st.strike = e.child("strike").is_some() && !f.strike; - st.underline = e.child("u").is_some() && f.underline == Underline::None; - st.size = has_size && size == def.size; - st.name = has_name && name == def.name; - st.scheme = e.child_val("scheme").map(str::to_string); - // A differential font's scheme is in `dxf_stated`. - s.font = Font { name, size, minor: s.font.minor, ..f }; - } - if let Some(n) = x.child("numFmt") { - if let Some(code) = n.attr("formatCode") { - s.num_fmt = NumFmt::new(code); - } else if let Some(id) = n.attr_u32("numFmtId") { - s.num_fmt = num_fmt_code(id, &fmts); - } - } - if let Some(f) = x.child("fill") { - s.fill = read_fill(f, &palette, true); - } - if let Some(b) = x.child("border") { - s.borders = read_border(b, &palette); - } - if let Some(a) = x.child("alignment") { - s.align = read_alignment(a); - } - if let Some(p) = x.child("protection") { - s.protection = read_protection(p); - s.dxf_stated.protection = s.protection == Protection::default(); - } - s - }) - .collect() - }) - .unwrap_or_default(); + let dxfs = root.child("dxfs").map(|d| d.kids("dxf").map(|x| read_dxf(x, &palette, &fmts)).collect()).unwrap_or_default(); StylesIn { xfs, dxfs, palette, named, num_fmts: fmts } } +/// One differential format (`dxf`): the style it sets over a cell's own. +fn read_dxf(x: &El, palette: &[u32], fmts: &HashMap) -> Style { + let mut s = Style::default(); + if let Some(e) = x.child("font") { + let f = read_font(e, palette); + // Differential fonts rarely name a face; unless stated, keep the default face and size. + let has_size = e.child("sz").and_then(|n| n.attr_f64("val")).is_some(); + let has_name = e.child("name").and_then(|n| n.attr("val")).is_some(); + let name = if has_name { f.name.clone() } else { s.font.name.clone() }; + let size = if has_size { f.size } else { s.font.size }; + let def = Font::default(); + let st = &mut s.dxf_stated; + st.bold = e.child("b").is_some() && !f.bold; + st.italic = e.child("i").is_some() && !f.italic; + st.strike = e.child("strike").is_some() && !f.strike; + st.underline = e.child("u").is_some() && f.underline == Underline::None; + st.size = has_size && size == def.size; + st.name = has_name && name == def.name; + st.scheme = e.child_val("scheme").map(str::to_string); + // A differential font's scheme is in `dxf_stated`. + s.font = Font { name, size, minor: s.font.minor, ..f }; + } + if let Some(n) = x.child("numFmt") { + if let Some(code) = n.attr("formatCode") { + s.num_fmt = NumFmt::new(code); + } else if let Some(id) = n.attr_u32("numFmtId") { + s.num_fmt = num_fmt_code(id, fmts); + } + } + if let Some(f) = x.child("fill") { + s.fill = read_fill(f, palette, true); + } + if let Some(b) = x.child("border") { + s.borders = read_border(b, palette); + } + if let Some(a) = x.child("alignment") { + s.align = read_alignment(a); + } + if let Some(p) = x.child("protection") { + s.protection = read_protection(p); + s.dxf_stated.protection = s.protection == Protection::default(); + } + s +} + // ---------------------------------------------------------------- writing pub fn color_attrs(c: &Color) -> String { From 42bbf6baa26a57f094411f17d1ab039cc91905a0 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 21:39:17 -0400 Subject: [PATCH 12/76] refactor: split complex functions for Sonar S3776 (#2612) Extract group_bracket from outline layout and move_marks from data outline_settings. No behaviour change. --- crates/engine/src/cmd/data.rs | 54 +++++++++++++++++++---------------- crates/ui-egui/src/outline.rs | 54 +++++++++++++++++++---------------- 2 files changed, 59 insertions(+), 49 deletions(-) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 721357fec..4828edeba 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -1454,6 +1454,34 @@ fn show_levels(s: &mut Session, p: &Json) -> Result { /// Data ▸ Outline ▸ Settings: whether summary rows sit below their detail and summary columns /// right of it. A changed direction moves each collapsed group's mark (+) to its new summary line. +/// Moves the collapsed marks of a line axis to the summary line on its new side (`on` is the new +/// setting, `was_before` the old one). A line that nested groups share keeps its mark on the outermost +/// group whose detail is all hidden, or the outermost when none is. +fn move_marks(map: &mut std::collections::BTreeMap, was_before: bool, on: bool) { + let mut marked: Vec = + outline::groups_with(map, was_before).into_iter().filter(|g| g.has_summary() && map.get(&g.summary()).is_some_and(|e| e.collapsed)).collect(); + // A mark on a line that nested groups share moves with the outermost of them whose + // detail is all hidden, or with the outermost when none is. + let mut owner: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for g in &marked { + let key = (!g.collapsed, g.level); + let best = owner.entry(g.summary()).or_insert(key); + *best = (*best).min(key); + } + marked.retain(|g| owner.get(&g.summary()) == Some(&(!g.collapsed, g.level))); + for g in &marked { + if let Some(e) = map.get_mut(&g.summary()) { + e.collapsed = false; + } + } + for g in marked { + let g = outline::OutlineGroup { summary_before: !on, ..g }; + if g.has_summary() { + map.entry(g.summary()).or_default().collapsed = true; + } + } +} + fn outline_settings(s: &mut Session, p: &Json) -> Result { let si = target_sheet(s, p)?; edit(s, |cx| { @@ -1469,31 +1497,7 @@ fn outline_settings(s: &mut Session, p: &Json) -> Result { if was_before != on { continue; } - let map = if rows { &mut sh.rows } else { &mut sh.cols }; - let mut marked: Vec = outline::groups_with(map, was_before) - .into_iter() - .filter(|g| g.has_summary() && map.get(&g.summary()).is_some_and(|e| e.collapsed)) - .collect(); - // A mark on a line that nested groups share moves with the outermost of them whose - // detail is all hidden, or with the outermost when none is. - let mut owner: std::collections::BTreeMap = std::collections::BTreeMap::new(); - for g in &marked { - let key = (!g.collapsed, g.level); - let best = owner.entry(g.summary()).or_insert(key); - *best = (*best).min(key); - } - marked.retain(|g| owner.get(&g.summary()) == Some(&(!g.collapsed, g.level))); - for g in &marked { - if let Some(e) = map.get_mut(&g.summary()) { - e.collapsed = false; - } - } - for g in marked { - let g = outline::OutlineGroup { summary_before: !on, ..g }; - if g.has_summary() { - map.entry(g.summary()).or_default().collapsed = true; - } - } + move_marks(if rows { &mut sh.rows } else { &mut sh.cols }, was_before, on); } Ok(json!({"summaryBelow": sh.summary_below, "summaryRight": sh.summary_right})) }) diff --git a/crates/ui-egui/src/outline.rs b/crates/ui-egui/src/outline.rs index 9096d1611..d192cee22 100644 --- a/crates/ui-egui/src/outline.rs +++ b/crates/ui-egui/src/outline.rs @@ -165,6 +165,35 @@ impl Dir<'_> { } /// The band's buttons and brackets for this frame. +/// The bracket for one group's detail, and its tick, on the lane `across`; `button` is the group's +/// summary button when it is on screen. +fn group_bracket(out: &mut Layout, dir: &Dir, grp: &OutlineGroup, button: Option, across: f32, side: f32) { + let first = dir.first_visible(grp.start, grp.end); + let Some((first_line, (a0, _))) = first else { return }; + if grp.summary_before { + // From the button down (or across) to the last detail line, with the tick there. + let a0 = button.map_or(a0, |mid| mid + side / 2.0); + let end = dir.start(grp.end) + dir.size(grp.end); + let a1 = end.min(dir.hi); + if a1 <= a0 { + return; + } + let tick = (end <= dir.hi).then(|| [dir.point(a1 - 0.5, across), dir.point(a1 - 0.5, across + side / 2.0)]); + out.brackets.push(Bracket { line: [dir.point(a0, across), dir.point(a1 - 0.5, across)], tick }); + return; + } + let a1 = match button { + Some(mid) => mid - side / 2.0, + None => dir.start(grp.summary()).min(dir.hi), + }; + if a1 <= a0 { + return; + } + let start_shown = first_line == grp.start && (a0 - dir.start(grp.start)).abs() < 0.5; + let tick = start_shown.then(|| [dir.point(a0 + 0.5, across), dir.point(a0 + 0.5, across + side / 2.0)]); + out.brackets.push(Bracket { line: [dir.point(a0 + 0.5, across), dir.point(a1, across)], tick }); +} + pub fn layout(geo: &Geo, sh: &Sheet) -> Layout { let mut out = Layout::default(); let zoom = (geo.z / DISPLAY_SCALE).max(0.1); @@ -212,30 +241,7 @@ pub fn layout(geo: &Geo, sh: &Sheet) -> Layout { continue; } // The bracket, over the part of the detail that is on screen. - let first = dir.first_visible(grp.start, grp.end); - let Some((first_line, (a0, _))) = first else { continue }; - if grp.summary_before { - // From the button down (or across) to the last detail line, with the tick there. - let a0 = button.map_or(a0, |mid| mid + side / 2.0); - let end = dir.start(grp.end) + dir.size(grp.end); - let a1 = end.min(dir.hi); - if a1 <= a0 { - continue; - } - let tick = (end <= dir.hi).then(|| [dir.point(a1 - 0.5, across), dir.point(a1 - 0.5, across + side / 2.0)]); - out.brackets.push(Bracket { line: [dir.point(a0, across), dir.point(a1 - 0.5, across)], tick }); - continue; - } - let a1 = match button { - Some(mid) => mid - side / 2.0, - None => dir.start(grp.summary()).min(dir.hi), - }; - if a1 <= a0 { - continue; - } - let start_shown = first_line == grp.start && (a0 - dir.start(grp.start)).abs() < 0.5; - let tick = start_shown.then(|| [dir.point(a0 + 0.5, across), dir.point(a0 + 0.5, across + side / 2.0)]); - out.brackets.push(Bracket { line: [dir.point(a0 + 0.5, across), dir.point(a1, across)], tick }); + group_bracket(&mut out, dir, grp, button, across, side); } } out From 22aec7e667216de3f29e3e938993edb101555381 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 02:24:45 +0000 Subject: [PATCH 13/76] G1: build on current main Scripts can't run the View tab's window commands (Switch Windows, New Window, Hide, Unhide): they change the active workbook, as window.* steps do. The Switch Windows button names its accessibility role with egui::Role (egui 0.37). --- crates/engine/src/cmd/extra.rs | 2 +- crates/engine/src/tests.rs | 3 +++ crates/ui-egui/src/ribbon.rs | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 891273b43..954583a99 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -1058,7 +1058,7 @@ fn denied_script_step(id: &str, params: &Json) -> Option { } // Scripts are scoped to their workbook: a step that switches the active window would // point every later step at another open document, so those are refused too. - if id.starts_with("window.") { + if id.starts_with("window.") || matches!(id, "view.switchWindows" | "view.newWindow" | "view.hideWindow" | "view.unhideWindow") { return Some(format!("scripts can't run `{id}`: it reaches outside the workbook")); } if params.get("path").is_some() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6648b9b7e..6ca6db43a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4907,6 +4907,9 @@ fn a_script_cannot_switch_windows() { s.execute("automate.trustScripts", json!({"trusted": true})).unwrap(); let err = s.execute("automate.runScript", json!({"name": "t"})).expect_err("script switching windows"); assert!(err.to_string().contains("window.activate"), "{err}"); + s.execute("automate.newScript", json!({"name": "sw", "commands": [{"command": "view.switchWindows", "params": {"doc": 1}}]})).unwrap(); + let err = s.execute("automate.runScript", json!({"name": "sw"})).expect_err("script switching windows from the View tab"); + assert!(err.to_string().contains("view.switchWindows"), "{err}"); // A benign script runs. s.execute("automate.newScript", json!({"name": "ok", "commands": [{"command": "cell.set", "params": {"cell": "A1", "input": "=1+1"}}]})).unwrap(); s.execute("automate.runScript", json!({"name": "ok"})).unwrap(); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index af7114c19..a3c3b69f3 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2600,7 +2600,7 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { /// choosing one brings it forward. fn switch_windows(app: &mut SheetApp, ui: &mut Ui) { let anchor = big_button(ui, Icon::Book, "Switch\nWindows", "Switch Windows", true); - anchor.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Switch Windows")); + anchor.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Switch Windows")); control_keytip(app, ui, &anchor, "view.switchWindows"); let forced = std::mem::take(&mut app.open_switch_windows); egui::Popup::menu(&anchor).open_memory(popup_command(forced, anchor.clicked())).show(|ui| { From 94fcbf8b4c50196e2c8b0c8eb0792c5042ba087b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 02:37:42 +0000 Subject: [PATCH 14/76] G3: craft-libs at e8b4dde5 (craft-libs#340) craft-update, craft-window, craft-ui and craft-print all come from one craft-libs revision, the head of storytold/craft-libs#340 on the aiongg fork, until #340 merges. --- Cargo.lock | 249 ++++++++++++++++++++++++++++++++++---- Cargo.toml | 10 +- apps/gridcraft/Cargo.toml | 4 +- crates/ui-egui/Cargo.toml | 7 +- 4 files changed, 241 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4e7cf4a26..21b2f04dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -220,7 +220,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb", ] @@ -742,6 +742,15 @@ dependencies = [ "x509-cert", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -877,13 +886,41 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +source = "git+https://github.com/aiongg/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" + +[[package]] +name = "craft-i18n" +version = "0.1.0" +source = "git+https://github.com/aiongg/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" +dependencies = [ + "fixed_decimal", + "icu_decimal", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_plurals", + "icu_properties", + "icu_provider", + "icu_provider_adapters", + "icu_provider_blob", + "log", + "objc2-foundation 0.3.2", + "sys-locale", + "unicode-bidi", + "windows-registry", + "writeable", +] + [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f" +source = "git+https://github.com/aiongg/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" dependencies = [ "craft-winprint-gdi", - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "roxmltree", "tempfile", "winprint", @@ -893,15 +930,27 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f" +source = "git+https://github.com/aiongg/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" dependencies = [ + "craft-fonts-build", + "craft-i18n", "egui", + "epaint", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "skrifa", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/aiongg/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" dependencies = [ "chrono", "dunce", @@ -923,6 +972,17 @@ dependencies = [ "zip 9.1.0", ] +[[package]] +name = "craft-window" +version = "0.1.0" +source = "git+https://github.com/aiongg/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" +dependencies = [ + "egui", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "wayland-client", +] + [[package]] name = "craft-winprint-gdi" version = "0.1.0" @@ -1395,7 +1455,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1476,6 +1536,17 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixed_decimal" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + [[package]] name = "fixedbitset" version = "0.5.7" @@ -2011,6 +2082,7 @@ version = "0.12.1" dependencies = [ "craft-ui", "craft-update", + "craft-window", "egui", "egui_kittest", "epaint_default_fonts", @@ -2307,6 +2379,23 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_decimal" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_locale_core", + "icu_pattern", + "icu_plurals", + "icu_provider", + "serde", + "writeable", + "zerovec", +] + [[package]] name = "icu_locale_core" version = "2.3.0" @@ -2315,11 +2404,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + [[package]] name = "icu_normalizer" version = "2.3.0" @@ -2331,6 +2441,9 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] @@ -2340,6 +2453,32 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +[[package]] +name = "icu_pattern" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc43ce0e76ee8cb0482dc6e2dbb9b76278e8038f45daabf2a2877577e2ceb18f" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_provider", + "serde", + "zerovec", +] + [[package]] name = "icu_properties" version = "2.3.0" @@ -2369,6 +2508,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2376,6 +2518,30 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_provider_adapters" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" +dependencies = [ + "icu_locale_fallback", + "icu_provider", +] + +[[package]] +name = "icu_provider_blob" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + [[package]] name = "idna" version = "1.1.0" @@ -2663,6 +2829,9 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] [[package]] name = "litrs" @@ -3288,7 +3457,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3537,12 +3706,24 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -3690,7 +3871,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3949,9 +4130,12 @@ dependencies = [ [[package]] name = "roxmltree" -version = "0.20.0" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907" +dependencies = [ + "memchr", +] [[package]] name = "rustc-hash" @@ -3997,7 +4181,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4056,7 +4240,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4742,7 +4926,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" dependencies = [ + "js-sys", "libc", + "wasm-bindgen", + "web-sys", ] [[package]] @@ -4784,10 +4971,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4890,6 +5077,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -5224,6 +5412,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8-zero" version = "0.8.1" @@ -5739,7 +5933,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6084,11 +6278,20 @@ dependencies = [ "wayland-protocols-wlr", ] +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +dependencies = [ + "either", +] [[package]] name = "x11-dl" @@ -6204,9 +6407,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -6399,16 +6602,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -6416,9 +6623,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 5ceb7e9f0..451106ae8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,10 @@ gridcraft-engine = { path = "crates/engine" } gridcraft-ui-egui = { path = "crates/ui-egui" } gridcraft-mcp = { path = "crates/mcp" } gridcraft-pdf = { path = "crates/pdf" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } +# Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in +# the app) comes from one craft-libs revision: the head of storytold/craft-libs#340 on the aiongg +# fork. They repin to storytold/craft-libs main once #340 merges. +craft-update = { git = "https://github.com/aiongg/craft-libs", rev = "e8b4dde5137fe41823326b55018c31302218d3a4", version = "=0.1.0", features = ["egui"] } # foundation thiserror = "2" @@ -58,9 +61,8 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] } # Real font bytes for tests (Ubuntu Light, compiled into epaint). epaint_default_fonts = "0.37" # One native window per workbook: the window list, Window menu titles and the immediate-viewport -# driver (craft-libs). Pinned to the aiongg fork's `window-list` branch; repins to -# storytold/craft-libs once that branch merges there. -craft-window = { version = "0.1.0", git = "https://github.com/aiongg/craft-libs", rev = "4fbdea5fcf86ba893d9bc03685d4531cfdd4efc2", default-features = false, features = ["egui"] } +# driver. Same craft-libs revision as craft-update above. +craft-window = { version = "0.1.0", git = "https://github.com/aiongg/craft-libs", rev = "e8b4dde5137fe41823326b55018c31302218d3a4", default-features = false, features = ["egui"] } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index 52b577cc5..61d197d7d 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -28,7 +28,9 @@ winresource = "0.1" winit = { version = "0.30", default-features = false, features = ["x11"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" } +# Same craft-libs revision as the workspace's craft-update and craft-window (see the root +# Cargo.toml): the head of storytold/craft-libs#340 on the aiongg fork. +craft-print = { git = "https://github.com/aiongg/craft-libs", rev = "e8b4dde5137fe41823326b55018c31302218d3a4" } craft-update = { workspace = true } rfd = { workspace = true } arboard = { workspace = true } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 1c3531db3..33c967b84 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,9 +9,10 @@ description = "GridCraft egui frontend (Excel-style UI); swappable — nothing b workspace = true [dependencies] -# The shared capture slot (`craft_ui::capture`) comes from the same reviewed immutable -# craft-libs revision as the desktop's craft-print dependency. -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" } +# The shared capture slot (`craft_ui::capture`) comes from the one craft-libs revision every +# craft-libs dependency uses (see the root Cargo.toml): the head of storytold/craft-libs#340 on +# the aiongg fork, repinned to storytold/craft-libs main once #340 merges. +craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "e8b4dde5137fe41823326b55018c31302218d3a4" } gridcraft-engine = { workspace = true } gridcraft-chart = { workspace = true } # `monochrome_emoji_fonts` restores the Noto Emoji and emoji-icon faces that 0.37 moved behind From 7085aa1c472ab287c7da43f84e4053ca14888cc0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 02:45:24 +0000 Subject: [PATCH 15/76] G3: build on current main Screenshots are admitted in logic for the root viewport and issued from its pass, so nothing reads the focused native window any more; the field goes. --- crates/ui-egui/src/doc_windows.rs | 2 -- crates/ui-egui/src/lib.rs | 3 --- 2 files changed, 5 deletions(-) diff --git a/crates/ui-egui/src/doc_windows.rs b/crates/ui-egui/src/doc_windows.rs index 88ba3967a..bc4ad064e 100644 --- a/crates/ui-egui/src/doc_windows.rs +++ b/crates/ui-egui/src/doc_windows.rs @@ -141,7 +141,6 @@ impl DocWindows { let ctx = ui.ctx().clone(); self.sync(app, &ctx); let focused = self.list.active().and_then(|id| self.list.get(id).copied()); - app.focus_window = focused; let titles: std::collections::HashMap = self.list.iter().map(|(_, w)| (*w, window_title(app, *w))).collect(); let base = &self.base; // The window a command run in some window's UI activated (File › New, Switch Windows, a close). @@ -189,7 +188,6 @@ impl DocWindows { app.enter_window(target); } self.sync(app, &ctx); - app.focus_window = self.list.active().and_then(|id| self.list.get(id).copied()); } /// A window's close button. Returns the window to bring forward when the close needs an diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 53bb7a95d..9f163d052 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -374,8 +374,6 @@ pub struct SheetApp { /// tests, embedding hosts) one window shows the active workbook window, and its close button /// quits. pub native_windows: bool, - /// The native window that has focus, which menu commands and screenshots go to. - pub(crate) focus_window: Option, /// The update check, the startup prompt and a running self-update (desktop only). #[cfg(not(target_arch = "wasm32"))] updates_rt: updates_ui::Runtime, @@ -436,7 +434,6 @@ impl SheetApp { ui_window: None, parked: HashMap::new(), native_windows: false, - focus_window: None, #[cfg(not(target_arch = "wasm32"))] updates_rt: Default::default(), } From 5900247075248464cb1a9439bc0408353de9b162 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 02:56:59 +0000 Subject: [PATCH 16/76] =?UTF-8?q?G1:=20Switch=20Windows=20draws=20its=20ch?= =?UTF-8?q?eck=20with=20the=20chrome=20fallback=20when=20no=20face=20has?= =?UTF-8?q?=20=E2=9C=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/ribbon.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index a3c3b69f3..cb1573c1f 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2605,7 +2605,7 @@ fn switch_windows(app: &mut SheetApp, ui: &mut Ui) { let forced = std::mem::take(&mut app.open_switch_windows); egui::Popup::menu(&anchor).open_memory(popup_command(forced, anchor.clicked())).show(|ui| { for w in app.open_windows() { - let label = format!("{}{}", if w.active { "✓ " } else { " " }, w.label()); + let label = theme::available_text(ui, &format!("{}{}", if w.active { "✓ " } else { " " }, w.label())); if ui.add(egui::Button::selectable(w.active, label).min_size(vec2(220.0, 22.0))).clicked() { act(app, "view.switchWindows", json!({"doc": w.doc})); ui.close(); From 320bcfcf54565d6be797f87a3762bff0ac96030c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 03:16:42 +0000 Subject: [PATCH 17/76] G1: regenerate the key tip tests --- crates/ui-egui/tests/it/keytips_generated.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index 60368239a..498c1a5d8 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -2647,7 +2647,7 @@ fn keytip_wvr_() { fn keytip_ww_() { let mut h = harness(blank()); sequence(&mut h, "WW", false); - assert!(h.state().message.is_none() && h.state().toast.is_none(), "WW must run view.switchWindows cleanly"); + assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.switchWindows"), "WW must journal view.switchWindows"); } #[test] From c7911c836a650c60afa5af6a2b5f7275195fb9a2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 03:28:46 +0000 Subject: [PATCH 18/76] G3: craft-libs at storytold main b926b5c0 --- Cargo.lock | 12 ++++++------ Cargo.toml | 7 +++---- apps/gridcraft/Cargo.toml | 4 ++-- crates/ui-egui/Cargo.toml | 5 ++--- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 21b2f04dc..53b2326da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -889,12 +889,12 @@ dependencies = [ [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" +source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" +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/aiongg/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" +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/aiongg/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" +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/aiongg/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" +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/aiongg/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" +source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" dependencies = [ "egui", "objc2 0.6.5", diff --git a/Cargo.toml b/Cargo.toml index 451106ae8..e23c106e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,9 +25,8 @@ gridcraft-ui-egui = { path = "crates/ui-egui" } 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 craft-libs revision: the head of storytold/craft-libs#340 on the aiongg -# fork. They repin to storytold/craft-libs main once #340 merges. -craft-update = { git = "https://github.com/aiongg/craft-libs", rev = "e8b4dde5137fe41823326b55018c31302218d3a4", version = "=0.1.0", features = ["egui"] } +# the app) comes from one storytold/craft-libs revision. +craft-update = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.1.0", features = ["egui"] } # foundation thiserror = "2" @@ -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/aiongg/craft-libs", rev = "e8b4dde5137fe41823326b55018c31302218d3a4", 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" diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index 61d197d7d..8429070b7 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -29,8 +29,8 @@ 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): the head of storytold/craft-libs#340 on the aiongg fork. -craft-print = { git = "https://github.com/aiongg/craft-libs", rev = "e8b4dde5137fe41823326b55018c31302218d3a4" } +# Cargo.toml). +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" } craft-update = { workspace = true } rfd = { workspace = true } arboard = { workspace = true } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 33c967b84..ac1a67c33 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -10,9 +10,8 @@ workspace = true [dependencies] # The shared capture slot (`craft_ui::capture`) comes from the one craft-libs revision every -# craft-libs dependency uses (see the root Cargo.toml): the head of storytold/craft-libs#340 on -# the aiongg fork, repinned to storytold/craft-libs main once #340 merges. -craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "e8b4dde5137fe41823326b55018c31302218d3a4" } +# craft-libs dependency uses (see the root Cargo.toml). +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" } gridcraft-engine = { workspace = true } gridcraft-chart = { workspace = true } # `monochrome_emoji_fonts` restores the Noto Emoji and emoji-icon faces that 0.37 moved behind From 380be36ac30bafb0d76fb3350e6c863d26f3320b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 03:33:34 +0000 Subject: [PATCH 19/76] G1: the W, W key tip opens Switch Windows through its control --- crates/ui-egui/src/keytips/keytip_table/generated.rs | 2 +- crates/ui-egui/src/keytips/keytips.json | 2 +- crates/ui-egui/src/ribbon.rs | 2 +- crates/ui-egui/tests/it/keytips_generated.rs | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index c35a3bbad..ef0011861 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -356,7 +356,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("WVH", Entry::Finish(Action::Command("view.headings"))), // Excel: Toggle Headings; containment(Headings) ("WVN", Entry::Finish(Action::Pending("view.navigation"))), // pending; Excel: Navigation; Navigation pane; reserved until the command lands ("WVR", Entry::Finish(Action::Command("view.ruler"))), // Excel: Ruler - ("WW", Entry::Finish(Action::Command("view.switchWindows"))), // Excel: Switch Windows + ("WW", Entry::Finish(Action::Control("view.switchWindows"))), // Excel: Switch Windows ("JAM", Entry::Finish(Action::Command("chart.formatSelection"))), // Excel: Chart format: Format Selection pane; containment(Format Selection) ("JCA", Entry::Finish(Action::Command("chart.addElement"))), // Excel: Chart: Add Chart Element; containment(Add Chart Element) ("JCC", Entry::Finish(Action::Command("chart.changeType"))), // Excel: Chart: Change Chart Type; containment(Change Chart Type) diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 5be64f867..f0e2c8f99 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -2471,7 +2471,7 @@ }, { "seq": "WW", - "kind": "command", + "kind": "control", "target": "view.switchWindows", "excel": "Switch Windows", "note": "" diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index cb1573c1f..673cb6b3b 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2602,7 +2602,7 @@ fn switch_windows(app: &mut SheetApp, ui: &mut Ui) { let anchor = big_button(ui, Icon::Book, "Switch\nWindows", "Switch Windows", true); anchor.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Switch Windows")); control_keytip(app, ui, &anchor, "view.switchWindows"); - let forced = std::mem::take(&mut app.open_switch_windows); + let forced = std::mem::take(&mut app.open_switch_windows) | invoke(app, "view.switchWindows"); egui::Popup::menu(&anchor).open_memory(popup_command(forced, anchor.clicked())).show(|ui| { for w in app.open_windows() { let label = theme::available_text(ui, &format!("{}{}", if w.active { "✓ " } else { " " }, w.label())); diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index 498c1a5d8..a22fabc98 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -2647,7 +2647,8 @@ fn keytip_wvr_() { fn keytip_ww_() { let mut h = harness(blank()); sequence(&mut h, "WW", false); - assert!(h.state().session.journal.iter().any(|(id, _)| id == "view.switchWindows"), "WW must journal view.switchWindows"); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "WW must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); } #[test] From d8c5f5885fc5320f8f1023d5fdc9f484a185ba86 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 26 Oct 2026 09:48:03 -0400 Subject: [PATCH 20/76] refactor: reduce cognitive complexity in options, python highlighter, i18n scanner, loop search (#2611) * refactor: split complex functions for Sonar S3776 Extract helpers from backstage options (general, calculation and iteration sections), code_editor python (string scanning), xtask i18n literals (escape and string readers) and calc recalc loops_among (SCC search). No behaviour change. * Restore the loop count that loops_among's refactor dropped The S3776 split moved the loop-detection body into loops_among without the 'let n = circ.len()' it reads, so calc failed to compile and the Sonar build broke. Restore the binding from main. * Extract the SCC pop step from components() to reduce cognitive complexity (S3776) Generated with Codebuff Co-Authored-By: Codebuff --------- Co-authored-by: Codebuff --- crates/calc/src/recalc.rs | 120 +++++++++++++++++------------- crates/ui-egui/src/backstage.rs | 65 +++++++++------- crates/ui-egui/src/code_editor.rs | 49 ++++++------ xtask/src/i18n_report.rs | 88 ++++++++++++---------- 4 files changed, 184 insertions(+), 138 deletions(-) diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 4cbbcc8b4..17a0e6649 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -927,6 +927,70 @@ impl Host for PassHost<'_> { } } +/// Pops one finished component off Tarjan's stack, labelling its nodes with `comp_index`. The +/// component is everything up to and including `root`. +fn pop_component(stack: &mut Vec, on_stack: &mut [bool], comp: &mut [usize], comp_index: usize, root: usize) { + while let Some(w) = stack.pop() { + on_stack[w] = false; + comp[w] = comp_index; + if w == root { + break; + } + } +} + +/// Strongly connected components of a graph given as adjacency lists (Tarjan, iterative): each +/// node's component index, and the number of nodes in each component. +fn components(edges: &[Vec]) -> (Vec, Vec) { + let n = edges.len(); + let (mut low, mut num) = (vec![0usize; n], vec![usize::MAX; n]); + let (mut on_stack, mut stack, mut comp) = (vec![false; n], Vec::new(), vec![usize::MAX; n]); + let (mut next, mut comps) = (0usize, 0usize); + for root in 0..n { + if num[root] != usize::MAX { + continue; + } + let mut call: Vec<(usize, usize)> = vec![(root, 0)]; + num[root] = next; + low[root] = next; + next += 1; + stack.push(root); + on_stack[root] = true; + while let Some(top) = call.last_mut() { + let v = top.0; + if let Some(&w) = edges.get(v).and_then(|e| e.get(top.1)) { + top.1 += 1; + if num[w] == usize::MAX { + num[w] = next; + low[w] = next; + next += 1; + stack.push(w); + on_stack[w] = true; + call.push((w, 0)); + } else if on_stack[w] { + low[v] = low[v].min(num[w]); + } + continue; + } + call.pop(); + if let Some(&(u, _)) = call.last() { + low[u] = low[u].min(low[v]); + } + if low[v] == num[v] { + pop_component(&mut stack, &mut on_stack, &mut comp, comps, v); + comps += 1; + } + } + } + let mut size = vec![0usize; comps]; + for &c in &comp { + if let Some(s) = size.get_mut(c) { + *s += 1; + } + } + (comp, size) +} + impl Calc { pub fn new() -> Self { Self::default() @@ -967,6 +1031,7 @@ impl Calc { if circ.is_empty() { return Vec::new(); } + let n = circ.len(); let index: HashMap = circ.iter().enumerate().map(|(i, k)| (*k, i)).collect(); // Edges from a cell to the `#CIRC!` formulas that read it; loops are the same either way. let mut deps = Vec::new(); @@ -981,59 +1046,8 @@ impl Calc { e }) .collect(); - // Tarjan's strongly connected components, iterative. - let n = circ.len(); - let (mut low, mut num) = (vec![0usize; n], vec![usize::MAX; n]); - let (mut on_stack, mut stack, mut comp) = (vec![false; n], Vec::new(), vec![usize::MAX; n]); - let (mut next, mut comps) = (0usize, 0usize); - for root in 0..n { - if num[root] != usize::MAX { - continue; - } - let mut call: Vec<(usize, usize)> = vec![(root, 0)]; - num[root] = next; - low[root] = next; - next += 1; - stack.push(root); - on_stack[root] = true; - while let Some(top) = call.last_mut() { - let v = top.0; - if let Some(&w) = edges.get(v).and_then(|e| e.get(top.1)) { - top.1 += 1; - if num[w] == usize::MAX { - num[w] = next; - low[w] = next; - next += 1; - stack.push(w); - on_stack[w] = true; - call.push((w, 0)); - } else if on_stack[w] { - low[v] = low[v].min(num[w]); - } - continue; - } - call.pop(); - if let Some(&(u, _)) = call.last() { - low[u] = low[u].min(low[v]); - } - if low[v] == num[v] { - while let Some(w) = stack.pop() { - on_stack[w] = false; - comp[w] = comps; - if w == v { - break; - } - } - comps += 1; - } - } - } - let mut size = vec![0usize; comps]; - for &c in &comp { - if let Some(s) = size.get_mut(c) { - *s += 1; - } - } + // Loop members: nodes in a component of more than one, or with an edge to themselves. + let (comp, size) = components(&edges); let on_loop = |i: usize| comp.get(i).and_then(|c| size.get(*c)).is_some_and(|s| *s > 1) || edges.get(i).is_some_and(|e| e.contains(&i)); // A formula with dynamic references (INDIRECT, OFFSET) may close a loop the graph can't see. let mut out: Vec = diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index ec097c201..f17d476ef 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -957,6 +957,35 @@ pub fn export_as(app: &mut SheetApp, fmt: &str) -> bool { fn options(app: &mut SheetApp, ui: &mut Ui) { let lang = app.ui.language; section(ui, lang, "Options|General"); + general_options(app, ui, lang); + ui.add_space(6.0); + let mut formula_bar = app.session.prefs.formula_bar; + if ui.checkbox(&mut formula_bar, lang.tr("Show formula bar")).changed() { + app.run_or_alert("view.formulaBar", json!({"on": formula_bar})); + } + ui.add_space(18.0); + section(ui, lang, "Options|Editing"); + let mut autocomplete = app.ui.autocomplete; + if ui.checkbox(&mut autocomplete, lang.tr("Enable AutoComplete for cell values")).changed() { + app.run_or_alert("options.autoComplete", json!({"on": autocomplete})); + } + ui.add_space(18.0); + section(ui, lang, "Options|Save"); + let mut autosave = app.ui.autosave; + if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() { + app.toggle_autosave(); + } + let Some(calc) = app.session.active().map(|d| d.wb.calc.clone()) else { return }; + ui.add_space(18.0); + section(ui, lang, "Formulas"); + ui.label(lang.tr("Workbook calculation")); + calc_options(app, ui, lang, &calc); + ui.add_space(6.0); + dim(ui, lang.tr("Calculation settings are saved with the workbook.")); +} + +/// The display theme and interface language controls of the General section. +fn general_options(app: &mut SheetApp, ui: &mut Ui, lang: Language) { egui::Grid::new("backstage_options_general").num_columns(2).spacing(vec2(18.0, 10.0)).show(ui, |ui| { let label = ui.label(lang.tr("Display theme")); let mode = app.ui.theme_mode(); @@ -993,27 +1022,10 @@ fn options(app: &mut SheetApp, ui: &mut Ui) { .labelled_by(label.id); ui.end_row(); }); - ui.add_space(6.0); - let mut formula_bar = app.session.prefs.formula_bar; - if ui.checkbox(&mut formula_bar, lang.tr("Show formula bar")).changed() { - app.run_or_alert("view.formulaBar", json!({"on": formula_bar})); - } - ui.add_space(18.0); - section(ui, lang, "Options|Editing"); - let mut autocomplete = app.ui.autocomplete; - if ui.checkbox(&mut autocomplete, lang.tr("Enable AutoComplete for cell values")).changed() { - app.run_or_alert("options.autoComplete", json!({"on": autocomplete})); - } - ui.add_space(18.0); - section(ui, lang, "Options|Save"); - let mut autosave = app.ui.autosave; - if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() { - app.toggle_autosave(); - } - let Some(calc) = app.session.active().map(|d| d.wb.calc.clone()) else { return }; - ui.add_space(18.0); - section(ui, lang, "Formulas"); - ui.label(lang.tr("Workbook calculation")); +} + +/// The calculation mode, and iterative calculation, of the active workbook. +fn calc_options(app: &mut SheetApp, ui: &mut Ui, lang: Language, calc: &gridcraft_engine::model::CalcSettings) { use gridcraft_engine::model::CalcMode; for (mode, id, label) in [ (CalcMode::Automatic, "automatic", "Automatic"), @@ -1030,6 +1042,13 @@ fn options(app: &mut SheetApp, ui: &mut Ui) { app.run_or_alert("formulas.calculationOptions", json!({"iterative": iterative})); } // Maximum Iterations and Maximum Change apply only while iteration is on, as in Excel. + iteration_options(app, ui, lang, calc, iterative); + // TODO: add "Set precision as displayed" here once `formulas.calculationOptions` takes + // `precisionAsDisplayed` (PR #1366); the model already stores it. +} + +/// Maximum Iterations and Maximum Change, live only while iterative calculation is on. +fn iteration_options(app: &mut SheetApp, ui: &mut Ui, lang: Language, calc: &gridcraft_engine::model::CalcSettings, iterative: bool) { ui.add_enabled_ui(iterative, |ui| { ui.horizontal(|ui| { ui.add_space(24.0); @@ -1070,10 +1089,6 @@ fn options(app: &mut SheetApp, ui: &mut Ui) { }); }); }); - // TODO: add "Set precision as displayed" here once `formulas.calculationOptions` takes - // `precisionAsDisplayed` (PR #1366); the model already stores it. - ui.add_space(6.0); - dim(ui, lang.tr("Calculation settings are saved with the workbook.")); } /// Refuses a typed setting with a message, leaving the workbook as it was. The Enter that diff --git a/crates/ui-egui/src/code_editor.rs b/crates/ui-egui/src/code_editor.rs index 6f02b4044..e462e46ef 100644 --- a/crates/ui-egui/src/code_editor.rs +++ b/crates/ui-egui/src/code_editor.rs @@ -78,6 +78,32 @@ pub fn highlight(text: &str, lang: Lang) -> Vec<(Range, Token)> { } } +/// End (exclusive, in chars) of the Python string literal opening with quote `c` at char `i`. +fn python_string_end(chars: &[(usize, char)], i: usize, c: char) -> usize { + let triple = chars.get(i + 1).is_some_and(|(_, d)| *d == c) && chars.get(i + 2).is_some_and(|(_, d)| *d == c); + let mut j = if triple { i + 3 } else { i + 1 }; + loop { + match chars.get(j) { + None => break, + Some((_, '\\')) => j += 2, + Some((_, '\n')) if !triple => break, + Some((_, d)) if *d == c => { + if !triple { + j += 1; + break; + } + if chars.get(j + 1).is_some_and(|(_, d)| *d == c) && chars.get(j + 2).is_some_and(|(_, d)| *d == c) { + j += 3; + break; + } + j += 1; + } + Some(_) => j += 1, + } + } + j.min(chars.len()) +} + fn python(text: &str) -> Vec<(Range, Token)> { let chars: Vec<(usize, char)> = text.char_indices().collect(); let end_of = |i: usize| chars.get(i).map(|(b, _)| *b).unwrap_or(text.len()); @@ -92,28 +118,7 @@ fn python(text: &str) -> Vec<(Range, Token)> { out.push((start..end_of(j), Token::Comment)); i = j; } else if c == '"' || c == '\'' { - let triple = chars.get(i + 1).is_some_and(|(_, d)| *d == c) && chars.get(i + 2).is_some_and(|(_, d)| *d == c); - let mut j = if triple { i + 3 } else { i + 1 }; - loop { - match chars.get(j) { - None => break, - Some((_, '\\')) => j += 2, - Some((_, '\n')) if !triple => break, - Some((_, d)) if *d == c => { - if !triple { - j += 1; - break; - } - if chars.get(j + 1).is_some_and(|(_, d)| *d == c) && chars.get(j + 2).is_some_and(|(_, d)| *d == c) { - j += 3; - break; - } - j += 1; - } - Some(_) => j += 1, - } - } - let j = j.min(chars.len()); + let j = python_string_end(&chars, i, c); out.push((start..end_of(j), Token::String)); i = j; } else if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(|(_, d)| d.is_ascii_digit())) { diff --git a/xtask/src/i18n_report.rs b/xtask/src/i18n_report.rs index 344ed7569..99cbaa9ee 100644 --- a/xtask/src/i18n_report.rs +++ b/xtask/src/i18n_report.rs @@ -116,6 +116,55 @@ pub fn catalog_keys(source: &str) -> BTreeSet { keys } +/// The character a `\u{...}` escape stands for, if it names a valid code point. +fn unicode_escape(chars: &mut std::iter::Peekable>) -> Option { + let mut hex = String::new(); + for (_, c) in chars.by_ref() { + match c { + '}' => break, + '{' => {} + c => hex.push(c), + } + } + u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) +} + +/// The value of a string literal whose opening quote was just read, and the byte offset past +/// its closing quote (`len`, the source's length, when it is never closed). +fn string_literal(chars: &mut std::iter::Peekable>, len: usize) -> (String, usize) { + let mut value = String::new(); + let mut end = len; + while let Some((j, c)) = chars.next() { + match c { + '"' => { + end = j + 1; + break; + } + '\\' => match chars.next().map(|(_, c)| c) { + Some('n') => value.push('\n'), + Some('t') => value.push('\t'), + Some('r') => value.push('\r'), + Some('0') => value.push('\0'), + Some(c @ ('\\' | '"' | '\'')) => value.push(c), + Some('u') => { + if let Some(c) = unicode_escape(chars) { + value.push(c); + } + } + // A line continuation skips the newline and the next line's indentation. + Some('\n') => { + while chars.peek().is_some_and(|(_, c)| c.is_whitespace()) { + chars.next(); + } + } + _ => {} + }, + c => value.push(c), + } + } + (value, end) +} + /// Every string literal outside `//` comments: its decoded value, and the byte range from its /// opening quote to just past its closing one. fn literals(source: &str) -> Vec<(String, usize, usize)> { @@ -133,44 +182,7 @@ fn literals(source: &str) -> Vec<(String, usize, usize)> { if c != '"' { continue; } - let mut value = String::new(); - let mut end = source.len(); - while let Some((j, c)) = chars.next() { - match c { - '"' => { - end = j + 1; - break; - } - '\\' => match chars.next().map(|(_, c)| c) { - Some('n') => value.push('\n'), - Some('t') => value.push('\t'), - Some('r') => value.push('\r'), - Some('0') => value.push('\0'), - Some(c @ ('\\' | '"' | '\'')) => value.push(c), - Some('u') => { - let mut hex = String::new(); - for (_, c) in chars.by_ref() { - match c { - '}' => break, - '{' => {} - c => hex.push(c), - } - } - if let Some(c) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) { - value.push(c); - } - } - // A line continuation skips the newline and the next line's indentation. - Some('\n') => { - while chars.peek().is_some_and(|(_, c)| c.is_whitespace()) { - chars.next(); - } - } - _ => {} - }, - c => value.push(c), - } - } + let (value, end) = string_literal(&mut chars, source.len()); out.push((value, i, end)); } out From e95c29b1fe89e5cf56a5fdce6b1c908ab46ff261 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:25 +0000 Subject: [PATCH 21/76] refactor(engine): move crates/engine/src/cmd/mod.rs to crates/engine/src/cmd.rs --- crates/engine/src/{cmd/mod.rs => cmd.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/engine/src/{cmd/mod.rs => cmd.rs} (100%) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd.rs similarity index 100% rename from crates/engine/src/cmd/mod.rs rename to crates/engine/src/cmd.rs From 24a18620d29806e5ec2b8417c1b40862ee7d533e Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:26 +0000 Subject: [PATCH 22/76] refactor(xlsx): move crates/xlsx/src/xls/mod.rs to crates/xlsx/src/xls.rs --- crates/xlsx/src/{xls/mod.rs => xls.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/xlsx/src/{xls/mod.rs => xls.rs} (100%) diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls.rs similarity index 100% rename from crates/xlsx/src/xls/mod.rs rename to crates/xlsx/src/xls.rs From b3e2a6a2be001e033296cb08dcac0b151efdcffe Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:26 +0000 Subject: [PATCH 23/76] refactor(xlsx): move crates/xlsx/src/tests/mod.rs to crates/xlsx/src/tests.rs --- crates/xlsx/src/{tests/mod.rs => tests.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/xlsx/src/{tests/mod.rs => tests.rs} (100%) diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests.rs similarity index 100% rename from crates/xlsx/src/tests/mod.rs rename to crates/xlsx/src/tests.rs From 58e33dfef69cefa1a75e4e1523914ce92a3dff3a Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:32 +0000 Subject: [PATCH 24/76] refactor(gridcraft): move logging tests to logging/tests.rs --- apps/gridcraft/src/logging.rs | 301 +--------------------------- apps/gridcraft/src/logging/tests.rs | 298 +++++++++++++++++++++++++++ 2 files changed, 299 insertions(+), 300 deletions(-) create mode 100644 apps/gridcraft/src/logging/tests.rs diff --git a/apps/gridcraft/src/logging.rs b/apps/gridcraft/src/logging.rs index a0f62d164..413797378 100644 --- a/apps/gridcraft/src/logging.rs +++ b/apps/gridcraft/src/logging.rs @@ -295,303 +295,4 @@ pub fn install() -> Option<&'static AppLogger> { } #[cfg(test)] -mod tests { - use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::time::{Duration, UNIX_EPOCH}; - - fn temp_dir(tag: &str) -> PathBuf { - static N: AtomicUsize = AtomicUsize::new(0); - let d = std::env::temp_dir().join(format!("gridcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); - let _ = std::fs::remove_dir_all(&d); - d - } - - fn read(path: &Path) -> String { - std::fs::read_to_string(path).expect("read log") - } - - fn record(logger: &AppLogger, level: log::Level, target: &str, msg: &str) { - use log::Log; - logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build()); - } - - #[test] - fn the_default_filter_shows_gridcraft_info_and_other_crates_warnings() { - let f = Filter::parse(DEFAULT_FILTER); - assert_eq!(f.level_for("gridcraft"), LevelFilter::Info); - assert_eq!(f.level_for("gridcraft::control_server"), LevelFilter::Info); - assert_eq!(f.level_for("gridcraft_engine::sample"), LevelFilter::Info); - assert_eq!(f.level_for("gridcraft_ui_egui::control"), LevelFilter::Info); - assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn); - assert_eq!(f.level_for("naga"), LevelFilter::Warn); - assert_eq!(f.max(), LevelFilter::Info); - } - - #[test] - fn directives_follow_env_logger_and_the_most_specific_one_wins() { - let f = Filter::parse("info,wgpu_core=error,gridcraft_calc=trace,gridcraft_calc::recalc=off"); - assert_eq!(f.level_for("eframe"), LevelFilter::Info); - assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error); - assert_eq!(f.level_for("gridcraft_calc::graph"), LevelFilter::Trace); - assert_eq!(f.level_for("gridcraft_calc::recalc"), LevelFilter::Off); - assert_eq!(f.max(), LevelFilter::Trace); - // A module name is matched at `::` boundaries, not as a bare prefix. - assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info); - // A trailing `*` is a prefix. - assert_eq!(Filter::parse("gridcraft*=debug").level_for("gridcraft_xlsx::read"), LevelFilter::Debug); - assert_eq!(Filter::parse("gridcraft*=debug").level_for("eframe"), LevelFilter::Error); - // A bare target name sets that target to the most verbose level, as env_logger does. - assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace); - assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error); - // Levels are case-insensitive and surrounding blanks are ignored. - assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("gridcraft"), LevelFilter::Debug); - assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("eframe"), LevelFilter::Warn); - } - - #[test] - fn hostile_specs_never_panic_and_fall_back_sensibly() { - let long = "x".repeat(100_000); - let many = "a=info,".repeat(10_000); - for spec in [ - "", - ",,,", - "=", - "==", - "=debug", - "gridcraft=", - "nonsense=loud", - "🦀=info", - "*", - "*=", - "=*", - "a=b=c", - "warn,,gridcraft=DEBUG", - "\0", - "é::ü=trace", - &long, - &many, - ] { - let f = Filter::parse(spec); - for target in ["", "gridcraft", "🦀", "a::b", "*"] { - let _ = f.level_for(target); - } - let _ = f.max(); - } - assert_eq!(Filter::parse("warn,,gridcraft=DEBUG").level_for("gridcraft"), LevelFilter::Debug); - // An unknown level is skipped: that target keeps the default. - assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error); - assert_eq!(Filter::parse("gridcraft=").level_for("gridcraft"), LevelFilter::Error); - assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error); - assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info); - } - - #[test] - fn timestamps_are_utc_with_milliseconds() { - assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z"); - // Leap day, and the day after it. - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z"); - // 2000 is a leap year (divisible by 400), 2100 is not. - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); - // Before the epoch (a clock set wrong) clamps instead of panicking. - assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); - // A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than - // collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built - // with a checked add and only asserted where it exists. - if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { - assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); - } - if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { - assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); - } - // Years past 9999 keep the plain zero-padded year (no `+`). - assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); - } - - #[test] - fn a_line_carries_time_level_thread_target_and_message() { - assert_eq!( - format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "gridcraft", "book.xlsx: file not found"), - "2026-10-08T07:59:17.728Z WARN [main] gridcraft: book.xlsx: file not found\n" - ); - // A multi-line message keeps its lines; the record still ends in one newline. - assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n")); - } - - #[test] - fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { - let dir = temp_dir("rotate"); - for run in 1..=4 { - let (path, _file) = rotate(&dir).expect("rotate"); - assert_eq!(path, dir.join(LOG_FILE)); - assert_eq!(read(&path), ""); - std::fs::write(&path, format!("run {run}")).expect("write"); - } - assert_eq!(read(&dir.join(LOG_FILE)), "run 4"); - assert_eq!(read(&dir.join("gridcraft.1.log")), "run 3"); - assert_eq!(read(&dir.join("gridcraft.2.log")), "run 2"); - assert!(!dir.join("gridcraft.3.log").exists(), "only {KEEP} old logs are kept"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { - let dir = temp_dir("blocked"); - std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); - std::fs::write(&dir, "a file where the directory should be").expect("block"); - assert!(rotate(&dir).is_err()); - let _ = std::fs::remove_file(&dir); - } - - #[test] - fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { - let dir = temp_dir("pending"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(MAX_FILE_BYTES); - sink.write("early 1\n"); - sink.write("early 2\n"); - sink.attach(file); - sink.write("late\n"); - assert_eq!(read(&path), "early 1\nearly 2\nlate\n"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { - let dir = temp_dir("bounded"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(MAX_FILE_BYTES); - for i in 0..MAX_PENDING + 5 { - sink.write(&format!("line {i}\n")); - } - sink.attach(file); - let text = read(&path); - assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING); - assert!(text.contains("5 earlier log lines were dropped"), "{text}"); - assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n"))); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn exactly_a_full_pending_buffer_drops_nothing() { - let dir = temp_dir("full"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(MAX_FILE_BYTES); - for i in 0..MAX_PENDING { - sink.write(&format!("line {i}\n")); - } - sink.attach(file); - let text = read(&path); - assert_eq!(text.lines().count(), MAX_PENDING); - assert!(!text.contains("dropped"), "{text}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn without_a_file_nothing_is_kept_in_memory() { - let mut sink = Sink::new(MAX_FILE_BYTES); - sink.write("early\n"); - sink.no_file(); - sink.write("late\n"); - assert!(sink.pending.is_empty() && sink.dropped == 0); - } - - #[test] - fn the_file_stops_at_its_size_cap_with_one_note() { - let dir = temp_dir("cap"); - let (path, file) = rotate(&dir).expect("rotate"); - let mut sink = Sink::new(100); - sink.attach(file); - for i in 0..50 { - sink.write(&format!("line {i:02} with some padding\n")); - } - let text = read(&path); - assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}"); - assert_eq!(text.matches("log file reached").count(), 1, "{text}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_logger_filters_records_and_writes_them_to_the_attached_file() { - use log::Log; - let dir = temp_dir("logger"); - let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); - record(&logger, log::Level::Info, "gridcraft", "before the settings directory"); - record(&logger, log::Level::Info, "wgpu_core::device", "too chatty"); - let path = logger.attach_dir(&dir).expect("attach"); - assert_eq!(path, dir.join(LOG_FILE)); - record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning"); - record(&logger, log::Level::Error, "gridcraft", "internal error: boom"); - record(&logger, log::Level::Debug, "gridcraft", "below info"); - let text = read(&path); - let lines: Vec<&str> = text.lines().collect(); - assert_eq!(lines.len(), 3, "{text}"); - assert!(lines[0].contains(" INFO [") && lines[0].ends_with("gridcraft: before the settings directory"), "{text}"); - assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}"); - assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("gridcraft: internal error: boom"), "{text}"); - assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("gridcraft_ui_egui").build())); - assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build())); - // Attaching again rotates: the first log becomes `.1`. - logger.attach_dir(&dir).expect("attach again"); - assert_eq!(read(&dir.join("gridcraft.1.log")), text); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn records_from_many_threads_are_whole_lines() { - let dir = temp_dir("threads"); - let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); - let path = logger.attach_dir(&dir).expect("attach"); - let workers: Vec<_> = (0..8) - .map(|t| { - let l = std::sync::Arc::clone(&logger); - std::thread::spawn(move || { - for i in 0..100 { - record(&l, log::Level::Info, "gridcraft", &format!("thread {t} record {i}")); - } - }) - }) - .collect(); - for w in workers { - w.join().expect("worker"); - } - let text = read(&path); - assert_eq!(text.lines().count(), 800); - assert!(text.lines().all(|l| l.contains(" gridcraft: thread ")), "{text}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() { - let dir = temp_dir("unusable"); - std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); - std::fs::write(&dir, "not a directory").expect("block"); - let logger = AppLogger::new(Filter::parse("info"), false); - assert!(logger.attach_dir(&dir).is_err()); - record(&logger, log::Level::Error, "x", "still fine"); - // Nothing waits in memory for a file that won't come. - assert!(logger.sink.lock().expect("sink").pending.is_empty()); - let _ = std::fs::remove_file(&dir); - } - - #[test] - fn a_poisoned_sink_still_logs() { - let dir = temp_dir("poison"); - let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); - let l = std::sync::Arc::clone(&logger); - let _ = std::thread::spawn(move || { - let _guard = l.sink.lock().expect("lock"); - panic!("poison the sink"); - }) - .join(); - assert!(logger.sink.is_poisoned()); - let path = logger.attach_dir(&dir).expect("attach"); - record(&logger, log::Level::Warn, "gridcraft", "after the poison"); - assert!(read(&path).contains("gridcraft: after the poison")); - let _ = std::fs::remove_dir_all(&dir); - } -} +mod tests; diff --git a/apps/gridcraft/src/logging/tests.rs b/apps/gridcraft/src/logging/tests.rs new file mode 100644 index 000000000..bfc0f81a1 --- /dev/null +++ b/apps/gridcraft/src/logging/tests.rs @@ -0,0 +1,298 @@ +use super::*; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, UNIX_EPOCH}; + +fn temp_dir(tag: &str) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let d = std::env::temp_dir().join(format!("gridcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + d +} + +fn read(path: &Path) -> String { + std::fs::read_to_string(path).expect("read log") +} + +fn record(logger: &AppLogger, level: log::Level, target: &str, msg: &str) { + use log::Log; + logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build()); +} + +#[test] +fn the_default_filter_shows_gridcraft_info_and_other_crates_warnings() { + let f = Filter::parse(DEFAULT_FILTER); + assert_eq!(f.level_for("gridcraft"), LevelFilter::Info); + assert_eq!(f.level_for("gridcraft::control_server"), LevelFilter::Info); + assert_eq!(f.level_for("gridcraft_engine::sample"), LevelFilter::Info); + assert_eq!(f.level_for("gridcraft_ui_egui::control"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn); + assert_eq!(f.level_for("naga"), LevelFilter::Warn); + assert_eq!(f.max(), LevelFilter::Info); +} + +#[test] +fn directives_follow_env_logger_and_the_most_specific_one_wins() { + let f = Filter::parse("info,wgpu_core=error,gridcraft_calc=trace,gridcraft_calc::recalc=off"); + assert_eq!(f.level_for("eframe"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error); + assert_eq!(f.level_for("gridcraft_calc::graph"), LevelFilter::Trace); + assert_eq!(f.level_for("gridcraft_calc::recalc"), LevelFilter::Off); + assert_eq!(f.max(), LevelFilter::Trace); + // A module name is matched at `::` boundaries, not as a bare prefix. + assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info); + // A trailing `*` is a prefix. + assert_eq!(Filter::parse("gridcraft*=debug").level_for("gridcraft_xlsx::read"), LevelFilter::Debug); + assert_eq!(Filter::parse("gridcraft*=debug").level_for("eframe"), LevelFilter::Error); + // A bare target name sets that target to the most verbose level, as env_logger does. + assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace); + assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error); + // Levels are case-insensitive and surrounding blanks are ignored. + assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("gridcraft"), LevelFilter::Debug); + assert_eq!(Filter::parse(" WARN , gridcraft = Debug ").level_for("eframe"), LevelFilter::Warn); +} + +#[test] +fn hostile_specs_never_panic_and_fall_back_sensibly() { + let long = "x".repeat(100_000); + let many = "a=info,".repeat(10_000); + for spec in [ + "", + ",,,", + "=", + "==", + "=debug", + "gridcraft=", + "nonsense=loud", + "🦀=info", + "*", + "*=", + "=*", + "a=b=c", + "warn,,gridcraft=DEBUG", + "\0", + "é::ü=trace", + &long, + &many, + ] { + let f = Filter::parse(spec); + for target in ["", "gridcraft", "🦀", "a::b", "*"] { + let _ = f.level_for(target); + } + let _ = f.max(); + } + assert_eq!(Filter::parse("warn,,gridcraft=DEBUG").level_for("gridcraft"), LevelFilter::Debug); + // An unknown level is skipped: that target keeps the default. + assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error); + assert_eq!(Filter::parse("gridcraft=").level_for("gridcraft"), LevelFilter::Error); + assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error); + assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info); +} + +#[test] +fn timestamps_are_utc_with_milliseconds() { + assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z"); + // Leap day, and the day after it. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z"); + // 2000 is a leap year (divisible by 400), 2100 is not. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); + // Before the epoch (a clock set wrong) clamps instead of panicking. + assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than + // collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built + // with a checked add and only asserted where it exists. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); +} + +#[test] +fn a_line_carries_time_level_thread_target_and_message() { + assert_eq!( + format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "gridcraft", "book.xlsx: file not found"), + "2026-10-08T07:59:17.728Z WARN [main] gridcraft: book.xlsx: file not found\n" + ); + // A multi-line message keeps its lines; the record still ends in one newline. + assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n")); +} + +#[test] +fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { + let dir = temp_dir("rotate"); + for run in 1..=4 { + let (path, _file) = rotate(&dir).expect("rotate"); + assert_eq!(path, dir.join(LOG_FILE)); + assert_eq!(read(&path), ""); + std::fs::write(&path, format!("run {run}")).expect("write"); + } + assert_eq!(read(&dir.join(LOG_FILE)), "run 4"); + assert_eq!(read(&dir.join("gridcraft.1.log")), "run 3"); + assert_eq!(read(&dir.join("gridcraft.2.log")), "run 2"); + assert!(!dir.join("gridcraft.3.log").exists(), "only {KEEP} old logs are kept"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { + let dir = temp_dir("blocked"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "a file where the directory should be").expect("block"); + assert!(rotate(&dir).is_err()); + let _ = std::fs::remove_file(&dir); +} + +#[test] +fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { + let dir = temp_dir("pending"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early 1\n"); + sink.write("early 2\n"); + sink.attach(file); + sink.write("late\n"); + assert_eq!(read(&path), "early 1\nearly 2\nlate\n"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { + let dir = temp_dir("bounded"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING + 5 { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING); + assert!(text.contains("5 earlier log lines were dropped"), "{text}"); + assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n"))); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn exactly_a_full_pending_buffer_drops_nothing() { + let dir = temp_dir("full"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().count(), MAX_PENDING); + assert!(!text.contains("dropped"), "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn without_a_file_nothing_is_kept_in_memory() { + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early\n"); + sink.no_file(); + sink.write("late\n"); + assert!(sink.pending.is_empty() && sink.dropped == 0); +} + +#[test] +fn the_file_stops_at_its_size_cap_with_one_note() { + let dir = temp_dir("cap"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(100); + sink.attach(file); + for i in 0..50 { + sink.write(&format!("line {i:02} with some padding\n")); + } + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}"); + assert_eq!(text.matches("log file reached").count(), 1, "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_logger_filters_records_and_writes_them_to_the_attached_file() { + use log::Log; + let dir = temp_dir("logger"); + let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); + record(&logger, log::Level::Info, "gridcraft", "before the settings directory"); + record(&logger, log::Level::Info, "wgpu_core::device", "too chatty"); + let path = logger.attach_dir(&dir).expect("attach"); + assert_eq!(path, dir.join(LOG_FILE)); + record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning"); + record(&logger, log::Level::Error, "gridcraft", "internal error: boom"); + record(&logger, log::Level::Debug, "gridcraft", "below info"); + let text = read(&path); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 3, "{text}"); + assert!(lines[0].contains(" INFO [") && lines[0].ends_with("gridcraft: before the settings directory"), "{text}"); + assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}"); + assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("gridcraft: internal error: boom"), "{text}"); + assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("gridcraft_ui_egui").build())); + assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build())); + // Attaching again rotates: the first log becomes `.1`. + logger.attach_dir(&dir).expect("attach again"); + assert_eq!(read(&dir.join("gridcraft.1.log")), text); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn records_from_many_threads_are_whole_lines() { + let dir = temp_dir("threads"); + let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); + let path = logger.attach_dir(&dir).expect("attach"); + let workers: Vec<_> = (0..8) + .map(|t| { + let l = std::sync::Arc::clone(&logger); + std::thread::spawn(move || { + for i in 0..100 { + record(&l, log::Level::Info, "gridcraft", &format!("thread {t} record {i}")); + } + }) + }) + .collect(); + for w in workers { + w.join().expect("worker"); + } + let text = read(&path); + assert_eq!(text.lines().count(), 800); + assert!(text.lines().all(|l| l.contains(" gridcraft: thread ")), "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() { + let dir = temp_dir("unusable"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "not a directory").expect("block"); + let logger = AppLogger::new(Filter::parse("info"), false); + assert!(logger.attach_dir(&dir).is_err()); + record(&logger, log::Level::Error, "x", "still fine"); + // Nothing waits in memory for a file that won't come. + assert!(logger.sink.lock().expect("sink").pending.is_empty()); + let _ = std::fs::remove_file(&dir); +} + +#[test] +fn a_poisoned_sink_still_logs() { + let dir = temp_dir("poison"); + let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false)); + let l = std::sync::Arc::clone(&logger); + let _ = std::thread::spawn(move || { + let _guard = l.sink.lock().expect("lock"); + panic!("poison the sink"); + }) + .join(); + assert!(logger.sink.is_poisoned()); + let path = logger.attach_dir(&dir).expect("attach"); + record(&logger, log::Level::Warn, "gridcraft", "after the poison"); + assert!(read(&path).contains("gridcraft: after the poison")); + let _ = std::fs::remove_dir_all(&dir); +} From da40ecba3a11461493cd985ffd17feec42078a7f Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:39 +0000 Subject: [PATCH 25/76] refactor(gridcraft-cli): move check tests to src/check/tests.rs --- apps/gridcraft-cli/src/check.rs | 454 +------------------------- apps/gridcraft-cli/src/check/tests.rs | 451 +++++++++++++++++++++++++ 2 files changed, 452 insertions(+), 453 deletions(-) create mode 100644 apps/gridcraft-cli/src/check/tests.rs diff --git a/apps/gridcraft-cli/src/check.rs b/apps/gridcraft-cli/src/check.rs index bbd3103d8..318b43aad 100644 --- a/apps/gridcraft-cli/src/check.rs +++ b/apps/gridcraft-cli/src/check.rs @@ -1353,456 +1353,4 @@ fn print_human(reports: &[FileReport], opts: &Options) { } #[cfg(test)] -mod tests { - #![allow(clippy::unwrap_used)] - use std::io::Write as _; - use std::sync::Arc; - - use gridcraft_engine::model::Formula; - - use super::*; - - #[test] - fn discovery_reports_a_directory_read_error() { - let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id())); - std::fs::write(&path, "not a directory").unwrap(); - let mut discovery = Discovery::default(); - let mut seen = std::collections::HashSet::new(); - assert!(walk(&path, 0, &mut discovery, &mut seen)); - assert!(discovery.files.is_empty()); - assert_eq!(discovery.failures.len(), 1); - assert_eq!(discovery.failures[0].path, path.to_string_lossy()); - assert!(discovery.failures[0].error.as_ref().unwrap().contains("cannot read directory")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn discovery_reports_omissions_only_after_the_file_limit() { - let mut discovery = Discovery { files: vec![PathBuf::from("book.xlsx"); MAX_FILES - 1], ..Default::default() }; - assert!(discovery.add_file(PathBuf::from("last.xlsx"))); - assert!(discovery.failures.is_empty()); - assert!(!discovery.add_file(PathBuf::from("omitted.xlsx"))); - assert_eq!(discovery.files.len(), MAX_FILES); - assert_eq!(discovery.failures[0].path, "omitted.xlsx"); - assert!(discovery.failures[0].error.as_ref().unwrap().contains("file limit")); - } - - fn at(a1: &str) -> CellRef { - CellRef::parse(a1).unwrap() - } - - /// A workbook whose cells are `(A1, input, cached value)`: an input starting with `=` is a - /// formula with that cached value, anything else a constant. - fn book(cells: &[(&str, &str, Value)]) -> Workbook { - let mut wb = Workbook::new(); - let sh = wb.sheet_mut(0).unwrap(); - for (a1, input, cached) in cells { - let cell = if input.starts_with('=') { - Cell { value: cached.clone(), formula: Some(Arc::new(Formula::new(input))), ..Default::default() } - } else { - Cell::value(cached.clone()) - }; - sh.set_cell(at(a1), cell); - } - wb - } - - fn check(wb: &Workbook, opts: &Options) -> FileReport { - let bytes = gridcraft_engine::io::save_bytes(wb, "t.xlsx").unwrap(); - check_bytes("t.xlsx", &bytes, opts) - } - - fn n(x: f64) -> Value { - Value::number(x) - } - - #[test] - fn matching_cached_values_pass() { - let wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("C1", "=A1&\"x\"", Value::text("2x")), ("D1", "=A1>1", Value::Bool(true))]); - let r = check(&wb, &Options::default()); - assert_eq!(r.error, None); - assert_eq!((r.calc.formula_cells, r.calc.checked, r.calc.matched, r.calc.mismatched), (3, 3, 3, 0)); - assert!(r.is_clean(), "{r:?}"); - } - - #[test] - fn wrong_cached_value_is_a_mismatch_and_ranks_its_function() { - // Excel "said" SUM gives 7; GridCraft computes 6. C1 reads B1 and is consistent with - // Excel's 7, so it mismatches too, but only as a downstream effect. - let wb = book(&[("A1", "2", n(2.0)), ("A2", "4", n(4.0)), ("B1", "=SUM(A1:A2)", n(7.0)), ("C1", "=B1+1", n(8.0))]); - let r = check(&wb, &Options::default()); - assert_eq!((r.calc.checked, r.calc.mismatched), (2, 2)); - let first = &r.calc.mismatches[0]; - assert_eq!(first.cell, "Sheet1!B1"); - assert_eq!(first.formula, "=SUM(A1:A2)"); - assert_eq!((first.expected.clone(), first.actual.clone(), first.kind, first.primary), (n(7.0), n(6.0), "numeric", true)); - assert!(!r.calc.mismatches[1].primary); - assert_eq!(r.calc.functions["SUM"], FnStat { checked: 1, mismatched: 1, primary: 1 }); - assert_eq!(r.calc.functions["(no function)"], FnStat { checked: 1, mismatched: 1, primary: 0 }); - assert!(!r.is_clean()); - } - - #[test] - fn errors_compare_by_kind() { - let wb = book(&[("A1", "=1/0", Value::Error(CellError::Div0)), ("A2", "=1/0", Value::Error(CellError::NA)), ("A3", "=1/0", n(0.0))]); - let r = check(&wb, &Options::default()); - assert_eq!((r.calc.matched, r.calc.mismatched), (1, 2)); - assert!(r.calc.mismatches.iter().all(|m| m.kind == "error")); - assert_eq!(r.calc.signatures.get("#N/A → #DIV/0!"), Some(&1)); - assert_eq!(r.calc.signatures.get("number → #DIV/0!"), Some(&1)); - assert!(values_match(&Value::Error(CellError::NA), &Value::Error(CellError::NA), 0.0)); - assert!(!values_match(&Value::Error(CellError::NA), &Value::Error(CellError::Value), 0.0)); - } - - #[test] - fn numbers_compare_within_tolerance() { - assert!(values_match(&n(3.0000000001), &n(3.0), 1e-9)); - assert!(!values_match(&n(3.0000000001), &n(3.0), 1e-12)); - // Absolute near zero. - assert!(values_match(&n(1e-12), &n(0.0), 1e-9)); - assert!(values_match(&n(1e12 + 1.0), &n(1e12), 1e-9)); - assert!(!values_match(&n(0.0), &Value::Empty, 1.0)); - assert!(!values_match(&Value::text("1"), &n(1.0), 1.0)); - assert!(values_match(&Value::text("a"), &Value::text("a"), 0.0)); - assert!(!values_match(&Value::text("a"), &Value::text("A"), 0.0)); - assert!(!values_match(&Value::Bool(true), &n(1.0), 1.0)); - let wb = book(&[("A1", "=1+2", n(3.0000000001))]); - assert_eq!(check(&wb, &Options::default()).calc.matched, 1); - assert_eq!(check(&wb, &Options { tolerance: 1e-12, ..Default::default() }).calc.mismatched, 1); - } - - /// Business Central writes TRUE and FALSE as `TRUE()1`, without `t="b"`: - /// the cache holds the right value under a number type, so it matches and is counted apart. - #[test] - fn booleans_cached_as_numbers_match_and_are_counted_apart() { - let wb = book(&[ - ("A1", "=TRUE()", n(1.0)), - ("B1", "=FALSE()", n(0.0)), - ("C1", "=TRUE()", Value::Bool(true)), - ("D1", "=1=1", n(0.0)), - ("E1", "=TRUE()+0", n(1.0)), - ]); - let r = check(&wb, &Options::default()); - assert_eq!((r.calc.checked, r.calc.matched, r.calc.mismatched), (5, 4, 1)); - // A cached 0 for TRUE is still a mismatch, and a number result is compared as a number. - assert_eq!(r.calc.mismatches[0].cell, "Sheet1!D1"); - assert_eq!(r.calc.signatures.get("number → bool"), Some(&1)); - assert_eq!(r.calc.bool_as_number, 2); - let v = report_json(std::slice::from_ref(&r), &Options::default()); - assert_eq!((v["files"][0]["calc"]["boolAsNumber"].clone(), v["totals"]["boolAsNumber"].clone()), (json!(2), json!(2))); - assert!(bool_cached_as_number(&n(1.0), &Value::Bool(true))); - assert!(!bool_cached_as_number(&n(1.0), &Value::Bool(false))); - assert!(!bool_cached_as_number(&n(2.0), &Value::Bool(true))); - assert!(!bool_cached_as_number(&Value::Bool(true), &n(1.0))); - } - - #[test] - fn missing_cached_values_and_volatile_formulas_are_skipped() { - let wb = - book(&[("A1", "=1+1", Value::Empty), ("B1", "=NOW()", n(1.0)), ("B2", "=B1+1", n(2.0)), ("B3", "=CELL(\"filename\")", Value::text("x"))]); - let r = check(&wb, &Options::default()); - assert_eq!((r.calc.formula_cells, r.calc.checked, r.calc.no_oracle, r.calc.volatile), (4, 0, 1, 3)); - } - - #[test] - fn full_calc_on_load_means_no_oracle() { - // A formula without a cached value makes GridCraft's writer flag the file - // `fullCalcOnLoad`, like libraries that store 0 for every formula: B1's wrong "cached" - // value must not count as a mismatch then. - let wb = book(&[("A1", "=1+1", Value::Empty), ("B1", "=2+2", n(0.0))]); - let bytes = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap(); - assert!(full_calc_on_load(&bytes)); - let r = check_bytes("t.xlsx", &bytes, &Options::default()); - assert!(r.calc.stale_cache); - assert_eq!((r.calc.checked, r.calc.no_oracle, r.calc.mismatched), (0, 2, 0)); - let fresh = gridcraft_engine::io::save_bytes(&book(&[("B1", "=2+2", n(0.0))]), "t.xlsx").unwrap(); - assert!(!full_calc_on_load(&fresh)); - assert!(!full_calc_on_load(b"not a zip")); - } - - #[test] - fn volatile_names_propagate() { - let mut wb = book(&[("A1", "=Stamp+1", n(1.0))]); - wb.names.push(gridcraft_engine::model::DefinedName { - name: "Stamp".into(), - scope: None, - formula: "TODAY()".into(), - comment: String::new(), - hidden: false, - }); - assert!(volatile_names(&wb).contains("STAMP")); - assert_eq!(check(&wb, &Options::default()).calc.volatile, 1); - } - - #[test] - fn round_trip_of_a_simple_book_is_clean() { - let mut wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("A2", "x", Value::text("x"))]); - wb.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap()); - let source = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap(); - let rt = round_trip(&wb, &source, &Options::default()); - assert_eq!(rt.error, None); - assert!(rt.is_clean(), "{rt:?}"); - assert!(rt.categories.iter().any(|c| c.name == "package")); - } - - #[test] - fn diff_detects_lost_features() { - let mut a = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0))]); - a.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap()); - let mut b = a.clone(); - let sh = b.sheet_mut(0).unwrap(); - sh.merges.clear(); - sh.set_cell(at("A1"), Cell::value(n(3.0))); - sh.set_cell(at("B1"), Cell::value(n(6.0))); - b.sheets.push(Arc::new(Sheet::new("Extra"))); - let cats = diff_workbooks(&a, &b, &Options { max_report: 1, ..Default::default() }); - let get = |name: &str| cats.iter().find(|c| c.name == name).unwrap(); - assert_eq!(get("values").diffs, 1); - assert_eq!(get("values").examples, ["Sheet1!A1: 2 → 3"]); - assert_eq!(get("formulas").examples, ["Sheet1!B1: =A1*3 → "]); - assert_eq!(get("merges").examples, ["Sheet1: lost C1:D2"]); - assert_eq!(get("sheets").diffs, 1); - assert_eq!(get("tables").diffs, 0); - } - - #[test] - fn diff_detects_changed_cond_format_rule() { - use gridcraft_engine::model::{CfRule, CfValueKind, Color, CondFormat}; - let bar = |gradient: bool, priority: u32| CondFormat { - ranges: vec![gridcraft_engine::core::RangeRef::parse("A1").unwrap()], - rule: CfRule::DataBar { min: CfValueKind::Min, max: CfValueKind::Max, color: Color::Rgb(0x638EC6), gradient, show_value: true }, - priority, - stop_if_true: false, - }; - let with = |cf: CondFormat| { - let mut wb = book(&[("A1", "2", n(2.0))]); - wb.sheet_mut(0).unwrap().cond_formats.push(cf); - wb - }; - let diffs = - |a: &Workbook, b: &Workbook| diff_workbooks(a, b, &Options::default()).into_iter().find(|c| c.name == "condFormats").unwrap().diffs; - assert_eq!(diffs(&with(bar(false, 1)), &with(bar(true, 1))), 1); - assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 1))), 0); - assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 5))), 0); - } - - fn zip_of(parts: &[(&str, &str)]) -> Vec { - let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); - for (name, body) in parts { - z.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); - z.write_all(body.as_bytes()).unwrap(); - } - z.finish().unwrap().into_inner() - } - - #[test] - fn package_validation_finds_what_excel_would_repair() { - let bytes = zip_of(&[ - ( - "[Content_Types].xml", - r#""#, - ), - ("_rels/.rels", r#""#), - ("xl/workbook.xml", ""), - ("xl/styles.xml", ""), - ( - "xl/_rels/workbook.xml.rels", - r#""#, - ), - ]); - let issues = validate_package(&bytes).unwrap(); - assert_eq!( - issues, - [ - "part xl/styles.xml has no content type", - "content type override for missing part /xl/gone.xml", - "xl/_rels/workbook.xml.rels: rId2 (worksheet) targets missing part xl/worksheets/sheet1.xml", - "xl/_rels/workbook.xml.rels: duplicate relationship id rId2", - ] - ); - assert!(validate_package(b"not a zip").is_err()); - let own = gridcraft_engine::io::save_bytes(&book(&[("A1", "=1", n(1.0))]), "t.xlsx").unwrap(); - assert_eq!(validate_package(&own).unwrap(), Vec::::new()); - } - - const WB_RELS: &str = "xl/_rels/workbook.xml.rels"; - const SHEET_RELS: &str = "xl/worksheets/_rels/sheet1.xml.rels"; - const MADE_UP: &str = r#""#; - /// Not empty: an empty part isn't a loss. - const MADE_UP_PART: (&str, &str) = ("xl/madeUp.xml", ""); - - /// `bytes` with relationships added to rels parts (created when missing) and `parts` added. - fn with_rels(bytes: &[u8], rels: &[(&str, &str)], parts: &[(&str, &str)]) -> Vec { - let mut src = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); - let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); - let mut pending: Vec<(&str, &str)> = rels.to_vec(); - for i in 0..src.len() { - let mut f = src.by_index(i).unwrap(); - let name = f.name().to_string(); - let mut body = String::new(); - f.read_to_string(&mut body).unwrap(); - if let Some(at) = pending.iter().position(|(r, _)| *r == name) { - body = body.replace("", &format!("{}", pending.remove(at).1)); - } - out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); - out.write_all(body.as_bytes()).unwrap(); - } - let created = pending.iter().map(|(r, extra)| { - (*r, format!(r#"{extra}"#)) - }); - for (name, body) in created.collect::>().iter().map(|(n, b)| (*n, b.as_str())).chain(parts.iter().copied()) { - out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); - out.write_all(body.as_bytes()).unwrap(); - } - out.finish().unwrap().into_inner() - } - - /// The `package` examples of checking `source`. - fn package_of(source: &[u8]) -> Vec { - let r = check_bytes("t.xlsx", source, &Options::default()); - r.round_trip.categories.iter().find(|c| c.name == "package").unwrap().examples.clone() - } - - fn plain() -> Vec { - gridcraft_engine::io::save_bytes(&book(&[("A1", "1", n(1.0))]), "t.xlsx").unwrap() - } - - #[test] - fn package_reports_relationship_kinds_the_output_lost() { - // A kind GridCraft doesn't know is dropped on save; a calculation chain is never written - // (Excel rebuilds it), so its absence isn't a loss. - let calc_chain = r#""#; - let source = with_rels(&plain(), &[(WB_RELS, &format!("{MADE_UP}{calc_chain}"))], &[MADE_UP_PART, ("xl/calcChain.xml", "")]); - assert_eq!(package_of(&source), ["lost relationship madeUp"]); - assert!(check_bytes("t.xlsx", &plain(), &Options::default()).round_trip.is_clean()); - } - - // In the three tests below the made-up kind stays lost, so the comparison is known to run. - - #[test] - fn an_internal_link_written_as_a_relationship_is_not_a_loss() { - // openpyxl writes `#Sheet1!B2` as an external relationship; GridCraft writes `location`. - let link = r##""##; - let source = with_rels(&plain(), &[(WB_RELS, MADE_UP), (SHEET_RELS, link)], &[MADE_UP_PART]); - assert_eq!(package_of(&source), ["lost relationship madeUp"]); - } - - #[test] - fn comments_without_a_comment_are_not_a_loss() { - let rels = r#""#; - let comments = r#"A"#; - let vml = r#""#; - let source = with_rels( - &plain(), - &[(WB_RELS, MADE_UP), (SHEET_RELS, rels)], - &[MADE_UP_PART, ("xl/comments1.xml", comments), ("xl/drawings/vmlDrawing1.vml", vml)], - ); - assert_eq!(package_of(&source), ["lost relationship madeUp"]); - } - - #[test] - fn empty_drawings_and_person_lists_are_not_a_loss() { - let drawing = r#""#; - let person = - r#""#; - let source = with_rels( - &plain(), - &[(WB_RELS, &format!("{MADE_UP}{person}")), (SHEET_RELS, drawing)], - &[ - MADE_UP_PART, - ("xl/drawings/drawing1.xml", r#""#), - ("xl/persons/person.xml", r#""#), - ], - ); - assert_eq!(package_of(&source), ["lost relationship madeUp"]); - } - - #[test] - fn json_lists_warning_messages() { - let with = FileReport { path: "a.ods".into(), warnings: vec!["ODS data-only import".into()], ..Default::default() }; - let without = FileReport { path: "b.xlsx".into(), ..Default::default() }; - let v = report_json(&[with, without], &Options::default()); - assert_eq!(v["files"][0]["warnings"], json!(["ODS data-only import"])); - assert_eq!(v["files"][1]["warnings"], json!([])); - } - - #[test] - fn unopenable_files_are_reported() { - let r = check_bytes("broken.xlsx", b"PK\x03\x04 not really", &Options::default()); - assert!(r.error.is_some()); - assert!(!r.is_clean()); - } - - #[test] - fn json_schema_keys() { - let wb = book(&[("A1", "=1+1", n(3.0))]); - let reports = vec![check(&wb, &Options::default()), check_bytes("bad.xlsx", b"", &Options::default())]; - let v = report_json(&reports, &Options::default()); - let keys = |v: &Json| v.as_object().unwrap().keys().cloned().collect::>(); - let set = |k: &[&str]| k.iter().map(|s| s.to_string()).collect::>(); - assert_eq!(keys(&v), set(&["schema", "tolerance", "files", "totals", "functions", "signatures"])); - assert_eq!(v["schema"], SCHEMA); - let f = &v["files"][0]; - assert_eq!(keys(f), set(&["path", "opened", "error", "warnings", "calc", "roundTrip"])); - assert_eq!( - keys(&f["calc"]), - set(&[ - "formulaCells", - "checked", - "matched", - "mismatched", - "noOracle", - "staleCache", - "volatile", - "boolAsNumber", - "mismatches", - "functions", - "signatures" - ]) - ); - assert_eq!(f["calc"]["signatures"], json!({"number → number": 1})); - assert_eq!(keys(&f["calc"]["mismatches"][0]), set(&["cell", "formula", "expected", "actual", "kind", "functions", "primary"])); - assert_eq!(f["calc"]["mismatches"][0]["expected"], json!(3.0)); - assert_eq!(keys(&f["roundTrip"]), set(&["error", "differing", "categories"])); - assert_eq!( - keys(&f["roundTrip"]["categories"]), - set(&[ - "sheets", - "values", - "formulas", - "numFmts", - "merges", - "names", - "tables", - "condFormats", - "validations", - "charts", - "comments", - "hyperlinks", - "pivots", - "package" - ]) - ); - assert_eq!(keys(&f["roundTrip"]["categories"]["values"]), set(&["equal", "diffs", "examples"])); - assert_eq!(v["files"][1]["opened"], json!(false)); - assert_eq!( - keys(&v["totals"]), - set(&[ - "files", - "opened", - "failedToOpen", - "formulaCells", - "checked", - "matched", - "mismatched", - "noOracle", - "volatile", - "boolAsNumber", - "roundTripFailed", - "roundTripDiffering", - "categoriesDiffering" - ]) - ); - assert_eq!(keys(&v["functions"][0]), set(&["name", "checked", "mismatched", "primary"])); - } -} +mod tests; diff --git a/apps/gridcraft-cli/src/check/tests.rs b/apps/gridcraft-cli/src/check/tests.rs new file mode 100644 index 000000000..f1df04670 --- /dev/null +++ b/apps/gridcraft-cli/src/check/tests.rs @@ -0,0 +1,451 @@ +#![allow(clippy::unwrap_used)] +use std::io::Write as _; +use std::sync::Arc; + +use gridcraft_engine::model::Formula; + +use super::*; + +#[test] +fn discovery_reports_a_directory_read_error() { + let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id())); + std::fs::write(&path, "not a directory").unwrap(); + let mut discovery = Discovery::default(); + let mut seen = std::collections::HashSet::new(); + assert!(walk(&path, 0, &mut discovery, &mut seen)); + assert!(discovery.files.is_empty()); + assert_eq!(discovery.failures.len(), 1); + assert_eq!(discovery.failures[0].path, path.to_string_lossy()); + assert!(discovery.failures[0].error.as_ref().unwrap().contains("cannot read directory")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn discovery_reports_omissions_only_after_the_file_limit() { + let mut discovery = Discovery { files: vec![PathBuf::from("book.xlsx"); MAX_FILES - 1], ..Default::default() }; + assert!(discovery.add_file(PathBuf::from("last.xlsx"))); + assert!(discovery.failures.is_empty()); + assert!(!discovery.add_file(PathBuf::from("omitted.xlsx"))); + assert_eq!(discovery.files.len(), MAX_FILES); + assert_eq!(discovery.failures[0].path, "omitted.xlsx"); + assert!(discovery.failures[0].error.as_ref().unwrap().contains("file limit")); +} + +fn at(a1: &str) -> CellRef { + CellRef::parse(a1).unwrap() +} + +/// A workbook whose cells are `(A1, input, cached value)`: an input starting with `=` is a +/// formula with that cached value, anything else a constant. +fn book(cells: &[(&str, &str, Value)]) -> Workbook { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + for (a1, input, cached) in cells { + let cell = if input.starts_with('=') { + Cell { value: cached.clone(), formula: Some(Arc::new(Formula::new(input))), ..Default::default() } + } else { + Cell::value(cached.clone()) + }; + sh.set_cell(at(a1), cell); + } + wb +} + +fn check(wb: &Workbook, opts: &Options) -> FileReport { + let bytes = gridcraft_engine::io::save_bytes(wb, "t.xlsx").unwrap(); + check_bytes("t.xlsx", &bytes, opts) +} + +fn n(x: f64) -> Value { + Value::number(x) +} + +#[test] +fn matching_cached_values_pass() { + let wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("C1", "=A1&\"x\"", Value::text("2x")), ("D1", "=A1>1", Value::Bool(true))]); + let r = check(&wb, &Options::default()); + assert_eq!(r.error, None); + assert_eq!((r.calc.formula_cells, r.calc.checked, r.calc.matched, r.calc.mismatched), (3, 3, 3, 0)); + assert!(r.is_clean(), "{r:?}"); +} + +#[test] +fn wrong_cached_value_is_a_mismatch_and_ranks_its_function() { + // Excel "said" SUM gives 7; GridCraft computes 6. C1 reads B1 and is consistent with + // Excel's 7, so it mismatches too, but only as a downstream effect. + let wb = book(&[("A1", "2", n(2.0)), ("A2", "4", n(4.0)), ("B1", "=SUM(A1:A2)", n(7.0)), ("C1", "=B1+1", n(8.0))]); + let r = check(&wb, &Options::default()); + assert_eq!((r.calc.checked, r.calc.mismatched), (2, 2)); + let first = &r.calc.mismatches[0]; + assert_eq!(first.cell, "Sheet1!B1"); + assert_eq!(first.formula, "=SUM(A1:A2)"); + assert_eq!((first.expected.clone(), first.actual.clone(), first.kind, first.primary), (n(7.0), n(6.0), "numeric", true)); + assert!(!r.calc.mismatches[1].primary); + assert_eq!(r.calc.functions["SUM"], FnStat { checked: 1, mismatched: 1, primary: 1 }); + assert_eq!(r.calc.functions["(no function)"], FnStat { checked: 1, mismatched: 1, primary: 0 }); + assert!(!r.is_clean()); +} + +#[test] +fn errors_compare_by_kind() { + let wb = book(&[("A1", "=1/0", Value::Error(CellError::Div0)), ("A2", "=1/0", Value::Error(CellError::NA)), ("A3", "=1/0", n(0.0))]); + let r = check(&wb, &Options::default()); + assert_eq!((r.calc.matched, r.calc.mismatched), (1, 2)); + assert!(r.calc.mismatches.iter().all(|m| m.kind == "error")); + assert_eq!(r.calc.signatures.get("#N/A → #DIV/0!"), Some(&1)); + assert_eq!(r.calc.signatures.get("number → #DIV/0!"), Some(&1)); + assert!(values_match(&Value::Error(CellError::NA), &Value::Error(CellError::NA), 0.0)); + assert!(!values_match(&Value::Error(CellError::NA), &Value::Error(CellError::Value), 0.0)); +} + +#[test] +fn numbers_compare_within_tolerance() { + assert!(values_match(&n(3.0000000001), &n(3.0), 1e-9)); + assert!(!values_match(&n(3.0000000001), &n(3.0), 1e-12)); + // Absolute near zero. + assert!(values_match(&n(1e-12), &n(0.0), 1e-9)); + assert!(values_match(&n(1e12 + 1.0), &n(1e12), 1e-9)); + assert!(!values_match(&n(0.0), &Value::Empty, 1.0)); + assert!(!values_match(&Value::text("1"), &n(1.0), 1.0)); + assert!(values_match(&Value::text("a"), &Value::text("a"), 0.0)); + assert!(!values_match(&Value::text("a"), &Value::text("A"), 0.0)); + assert!(!values_match(&Value::Bool(true), &n(1.0), 1.0)); + let wb = book(&[("A1", "=1+2", n(3.0000000001))]); + assert_eq!(check(&wb, &Options::default()).calc.matched, 1); + assert_eq!(check(&wb, &Options { tolerance: 1e-12, ..Default::default() }).calc.mismatched, 1); +} + +/// Business Central writes TRUE and FALSE as `TRUE()1`, without `t="b"`: +/// the cache holds the right value under a number type, so it matches and is counted apart. +#[test] +fn booleans_cached_as_numbers_match_and_are_counted_apart() { + let wb = book(&[ + ("A1", "=TRUE()", n(1.0)), + ("B1", "=FALSE()", n(0.0)), + ("C1", "=TRUE()", Value::Bool(true)), + ("D1", "=1=1", n(0.0)), + ("E1", "=TRUE()+0", n(1.0)), + ]); + let r = check(&wb, &Options::default()); + assert_eq!((r.calc.checked, r.calc.matched, r.calc.mismatched), (5, 4, 1)); + // A cached 0 for TRUE is still a mismatch, and a number result is compared as a number. + assert_eq!(r.calc.mismatches[0].cell, "Sheet1!D1"); + assert_eq!(r.calc.signatures.get("number → bool"), Some(&1)); + assert_eq!(r.calc.bool_as_number, 2); + let v = report_json(std::slice::from_ref(&r), &Options::default()); + assert_eq!((v["files"][0]["calc"]["boolAsNumber"].clone(), v["totals"]["boolAsNumber"].clone()), (json!(2), json!(2))); + assert!(bool_cached_as_number(&n(1.0), &Value::Bool(true))); + assert!(!bool_cached_as_number(&n(1.0), &Value::Bool(false))); + assert!(!bool_cached_as_number(&n(2.0), &Value::Bool(true))); + assert!(!bool_cached_as_number(&Value::Bool(true), &n(1.0))); +} + +#[test] +fn missing_cached_values_and_volatile_formulas_are_skipped() { + let wb = + book(&[("A1", "=1+1", Value::Empty), ("B1", "=NOW()", n(1.0)), ("B2", "=B1+1", n(2.0)), ("B3", "=CELL(\"filename\")", Value::text("x"))]); + let r = check(&wb, &Options::default()); + assert_eq!((r.calc.formula_cells, r.calc.checked, r.calc.no_oracle, r.calc.volatile), (4, 0, 1, 3)); +} + +#[test] +fn full_calc_on_load_means_no_oracle() { + // A formula without a cached value makes GridCraft's writer flag the file + // `fullCalcOnLoad`, like libraries that store 0 for every formula: B1's wrong "cached" + // value must not count as a mismatch then. + let wb = book(&[("A1", "=1+1", Value::Empty), ("B1", "=2+2", n(0.0))]); + let bytes = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap(); + assert!(full_calc_on_load(&bytes)); + let r = check_bytes("t.xlsx", &bytes, &Options::default()); + assert!(r.calc.stale_cache); + assert_eq!((r.calc.checked, r.calc.no_oracle, r.calc.mismatched), (0, 2, 0)); + let fresh = gridcraft_engine::io::save_bytes(&book(&[("B1", "=2+2", n(0.0))]), "t.xlsx").unwrap(); + assert!(!full_calc_on_load(&fresh)); + assert!(!full_calc_on_load(b"not a zip")); +} + +#[test] +fn volatile_names_propagate() { + let mut wb = book(&[("A1", "=Stamp+1", n(1.0))]); + wb.names.push(gridcraft_engine::model::DefinedName { + name: "Stamp".into(), + scope: None, + formula: "TODAY()".into(), + comment: String::new(), + hidden: false, + }); + assert!(volatile_names(&wb).contains("STAMP")); + assert_eq!(check(&wb, &Options::default()).calc.volatile, 1); +} + +#[test] +fn round_trip_of_a_simple_book_is_clean() { + let mut wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("A2", "x", Value::text("x"))]); + wb.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap()); + let source = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap(); + let rt = round_trip(&wb, &source, &Options::default()); + assert_eq!(rt.error, None); + assert!(rt.is_clean(), "{rt:?}"); + assert!(rt.categories.iter().any(|c| c.name == "package")); +} + +#[test] +fn diff_detects_lost_features() { + let mut a = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0))]); + a.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap()); + let mut b = a.clone(); + let sh = b.sheet_mut(0).unwrap(); + sh.merges.clear(); + sh.set_cell(at("A1"), Cell::value(n(3.0))); + sh.set_cell(at("B1"), Cell::value(n(6.0))); + b.sheets.push(Arc::new(Sheet::new("Extra"))); + let cats = diff_workbooks(&a, &b, &Options { max_report: 1, ..Default::default() }); + let get = |name: &str| cats.iter().find(|c| c.name == name).unwrap(); + assert_eq!(get("values").diffs, 1); + assert_eq!(get("values").examples, ["Sheet1!A1: 2 → 3"]); + assert_eq!(get("formulas").examples, ["Sheet1!B1: =A1*3 → "]); + assert_eq!(get("merges").examples, ["Sheet1: lost C1:D2"]); + assert_eq!(get("sheets").diffs, 1); + assert_eq!(get("tables").diffs, 0); +} + +#[test] +fn diff_detects_changed_cond_format_rule() { + use gridcraft_engine::model::{CfRule, CfValueKind, Color, CondFormat}; + let bar = |gradient: bool, priority: u32| CondFormat { + ranges: vec![gridcraft_engine::core::RangeRef::parse("A1").unwrap()], + rule: CfRule::DataBar { min: CfValueKind::Min, max: CfValueKind::Max, color: Color::Rgb(0x638EC6), gradient, show_value: true }, + priority, + stop_if_true: false, + }; + let with = |cf: CondFormat| { + let mut wb = book(&[("A1", "2", n(2.0))]); + wb.sheet_mut(0).unwrap().cond_formats.push(cf); + wb + }; + let diffs = + |a: &Workbook, b: &Workbook| diff_workbooks(a, b, &Options::default()).into_iter().find(|c| c.name == "condFormats").unwrap().diffs; + assert_eq!(diffs(&with(bar(false, 1)), &with(bar(true, 1))), 1); + assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 1))), 0); + assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 5))), 0); +} + +fn zip_of(parts: &[(&str, &str)]) -> Vec { + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + for (name, body) in parts { + z.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(body.as_bytes()).unwrap(); + } + z.finish().unwrap().into_inner() +} + +#[test] +fn package_validation_finds_what_excel_would_repair() { + let bytes = zip_of(&[ + ( + "[Content_Types].xml", + r#""#, + ), + ("_rels/.rels", r#""#), + ("xl/workbook.xml", ""), + ("xl/styles.xml", ""), + ( + "xl/_rels/workbook.xml.rels", + r#""#, + ), + ]); + let issues = validate_package(&bytes).unwrap(); + assert_eq!( + issues, + [ + "part xl/styles.xml has no content type", + "content type override for missing part /xl/gone.xml", + "xl/_rels/workbook.xml.rels: rId2 (worksheet) targets missing part xl/worksheets/sheet1.xml", + "xl/_rels/workbook.xml.rels: duplicate relationship id rId2", + ] + ); + assert!(validate_package(b"not a zip").is_err()); + let own = gridcraft_engine::io::save_bytes(&book(&[("A1", "=1", n(1.0))]), "t.xlsx").unwrap(); + assert_eq!(validate_package(&own).unwrap(), Vec::::new()); +} + +const WB_RELS: &str = "xl/_rels/workbook.xml.rels"; +const SHEET_RELS: &str = "xl/worksheets/_rels/sheet1.xml.rels"; +const MADE_UP: &str = r#""#; +/// Not empty: an empty part isn't a loss. +const MADE_UP_PART: (&str, &str) = ("xl/madeUp.xml", ""); + +/// `bytes` with relationships added to rels parts (created when missing) and `parts` added. +fn with_rels(bytes: &[u8], rels: &[(&str, &str)], parts: &[(&str, &str)]) -> Vec { + let mut src = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let mut pending: Vec<(&str, &str)> = rels.to_vec(); + for i in 0..src.len() { + let mut f = src.by_index(i).unwrap(); + let name = f.name().to_string(); + let mut body = String::new(); + f.read_to_string(&mut body).unwrap(); + if let Some(at) = pending.iter().position(|(r, _)| *r == name) { + body = body.replace("", &format!("{}", pending.remove(at).1)); + } + out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(body.as_bytes()).unwrap(); + } + let created = pending.iter().map(|(r, extra)| { + (*r, format!(r#"{extra}"#)) + }); + for (name, body) in created.collect::>().iter().map(|(n, b)| (*n, b.as_str())).chain(parts.iter().copied()) { + out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(body.as_bytes()).unwrap(); + } + out.finish().unwrap().into_inner() +} + +/// The `package` examples of checking `source`. +fn package_of(source: &[u8]) -> Vec { + let r = check_bytes("t.xlsx", source, &Options::default()); + r.round_trip.categories.iter().find(|c| c.name == "package").unwrap().examples.clone() +} + +fn plain() -> Vec { + gridcraft_engine::io::save_bytes(&book(&[("A1", "1", n(1.0))]), "t.xlsx").unwrap() +} + +#[test] +fn package_reports_relationship_kinds_the_output_lost() { + // A kind GridCraft doesn't know is dropped on save; a calculation chain is never written + // (Excel rebuilds it), so its absence isn't a loss. + let calc_chain = r#""#; + let source = with_rels(&plain(), &[(WB_RELS, &format!("{MADE_UP}{calc_chain}"))], &[MADE_UP_PART, ("xl/calcChain.xml", "")]); + assert_eq!(package_of(&source), ["lost relationship madeUp"]); + assert!(check_bytes("t.xlsx", &plain(), &Options::default()).round_trip.is_clean()); +} + +// In the three tests below the made-up kind stays lost, so the comparison is known to run. + +#[test] +fn an_internal_link_written_as_a_relationship_is_not_a_loss() { + // openpyxl writes `#Sheet1!B2` as an external relationship; GridCraft writes `location`. + let link = r##""##; + let source = with_rels(&plain(), &[(WB_RELS, MADE_UP), (SHEET_RELS, link)], &[MADE_UP_PART]); + assert_eq!(package_of(&source), ["lost relationship madeUp"]); +} + +#[test] +fn comments_without_a_comment_are_not_a_loss() { + let rels = r#""#; + let comments = r#"A"#; + let vml = r#""#; + let source = with_rels( + &plain(), + &[(WB_RELS, MADE_UP), (SHEET_RELS, rels)], + &[MADE_UP_PART, ("xl/comments1.xml", comments), ("xl/drawings/vmlDrawing1.vml", vml)], + ); + assert_eq!(package_of(&source), ["lost relationship madeUp"]); +} + +#[test] +fn empty_drawings_and_person_lists_are_not_a_loss() { + let drawing = r#""#; + let person = + r#""#; + let source = with_rels( + &plain(), + &[(WB_RELS, &format!("{MADE_UP}{person}")), (SHEET_RELS, drawing)], + &[ + MADE_UP_PART, + ("xl/drawings/drawing1.xml", r#""#), + ("xl/persons/person.xml", r#""#), + ], + ); + assert_eq!(package_of(&source), ["lost relationship madeUp"]); +} + +#[test] +fn json_lists_warning_messages() { + let with = FileReport { path: "a.ods".into(), warnings: vec!["ODS data-only import".into()], ..Default::default() }; + let without = FileReport { path: "b.xlsx".into(), ..Default::default() }; + let v = report_json(&[with, without], &Options::default()); + assert_eq!(v["files"][0]["warnings"], json!(["ODS data-only import"])); + assert_eq!(v["files"][1]["warnings"], json!([])); +} + +#[test] +fn unopenable_files_are_reported() { + let r = check_bytes("broken.xlsx", b"PK\x03\x04 not really", &Options::default()); + assert!(r.error.is_some()); + assert!(!r.is_clean()); +} + +#[test] +fn json_schema_keys() { + let wb = book(&[("A1", "=1+1", n(3.0))]); + let reports = vec![check(&wb, &Options::default()), check_bytes("bad.xlsx", b"", &Options::default())]; + let v = report_json(&reports, &Options::default()); + let keys = |v: &Json| v.as_object().unwrap().keys().cloned().collect::>(); + let set = |k: &[&str]| k.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(keys(&v), set(&["schema", "tolerance", "files", "totals", "functions", "signatures"])); + assert_eq!(v["schema"], SCHEMA); + let f = &v["files"][0]; + assert_eq!(keys(f), set(&["path", "opened", "error", "warnings", "calc", "roundTrip"])); + assert_eq!( + keys(&f["calc"]), + set(&[ + "formulaCells", + "checked", + "matched", + "mismatched", + "noOracle", + "staleCache", + "volatile", + "boolAsNumber", + "mismatches", + "functions", + "signatures" + ]) + ); + assert_eq!(f["calc"]["signatures"], json!({"number → number": 1})); + assert_eq!(keys(&f["calc"]["mismatches"][0]), set(&["cell", "formula", "expected", "actual", "kind", "functions", "primary"])); + assert_eq!(f["calc"]["mismatches"][0]["expected"], json!(3.0)); + assert_eq!(keys(&f["roundTrip"]), set(&["error", "differing", "categories"])); + assert_eq!( + keys(&f["roundTrip"]["categories"]), + set(&[ + "sheets", + "values", + "formulas", + "numFmts", + "merges", + "names", + "tables", + "condFormats", + "validations", + "charts", + "comments", + "hyperlinks", + "pivots", + "package" + ]) + ); + assert_eq!(keys(&f["roundTrip"]["categories"]["values"]), set(&["equal", "diffs", "examples"])); + assert_eq!(v["files"][1]["opened"], json!(false)); + assert_eq!( + keys(&v["totals"]), + set(&[ + "files", + "opened", + "failedToOpen", + "formulaCells", + "checked", + "matched", + "mismatched", + "noOracle", + "volatile", + "boolAsNumber", + "roundTripFailed", + "roundTripDiffering", + "categoriesDiffering" + ]) + ); + assert_eq!(keys(&v["functions"][0]), set(&["name", "checked", "mismatched", "primary"])); +} From 97c2a984e031066645b12f146420e762faa4db96 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:39 +0000 Subject: [PATCH 26/76] refactor(engine): move cmd/data tests to src/cmd/data/tests.rs --- crates/engine/src/cmd/data.rs | 513 +--------------------------- crates/engine/src/cmd/data/tests.rs | 510 +++++++++++++++++++++++++++ 2 files changed, 511 insertions(+), 512 deletions(-) create mode 100644 crates/engine/src/cmd/data/tests.rs diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index fcab9db71..646d44bd9 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -1578,515 +1578,4 @@ fn subtotal(s: &mut Session, p: &Json) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn filter_blanks_include_empty_text_and_formula_results() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [["id", "f"], ["e", null], ["ef", "=\"\""], ["sp", " "], ["z", 0]]})).unwrap(); - s.execute("data.filter", json!({"range": "A1:B5"})).unwrap(); - let r = s.execute("data.filterBy", json!({"column": "B", "values": [], "blanks": true})).unwrap(); - assert_eq!(r["hiddenRows"], json!(2)); - let vals = filter_values(&s.doc().unwrap().wb, 0, 1); - // The `=""` result joins (Blanks) instead of showing as its own empty entry. - assert!(!vals.iter().any(|(t, _)| t.is_empty()), "{vals:?}"); - let blanks: Vec<_> = vals.into_iter().filter(|(t, _)| t == "(Blanks)").collect(); - assert_eq!(blanks, vec![("(Blanks)".to_string(), true)]); - } - - #[test] - fn subtotal_honours_header_false() { - // #131: with header:false the first data row was treated as a header and left out of its - // group's subtotal and the grand total. - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [["East", 10], ["East", 20], ["West", 30], ["West", 40]]})).unwrap(); - s.execute("data.subtotal", json!({"range": "A1:B4", "groupBy": "A", "function": "sum", "columns": ["B"], "header": false})).unwrap(); - let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); - assert_eq!(get(&mut s, "A1")["value"], json!("East")); - assert_eq!(get(&mut s, "A3")["text"], json!("East Total")); - assert_eq!(get(&mut s, "B3")["value"], json!(30.0), "East's subtotal counts both East rows"); - assert_eq!(get(&mut s, "A7")["text"], json!("Grand Total")); - assert_eq!(get(&mut s, "B7")["value"], json!(100.0), "the grand total counts every data row"); - } - - #[test] - fn subtotal_puts_summary_rows_below_their_detail() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["East", 10], ["East", 20], ["West", 30]]})).unwrap(); - s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); - s.execute("data.subtotal", json!({"range": "A1:B4", "groupBy": "A", "columns": ["B"]})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap().clone(); - assert!(sh.summary_below, "the totals sit below their groups"); - // East's detail is rows 2–3 and its total row 4: collapsing level 1 keeps the total showing. - s.execute("data.showLevels", json!({"rows": 1})).unwrap(); - let hidden = |s: &Session, row: u32| s.doc().unwrap().wb.active().unwrap().is_row_hidden(row); - assert!(hidden(&s, 1) && hidden(&s, 2), "East's detail rows are hidden"); - assert!(!hidden(&s, 3), "East Total stays visible"); - // One undo takes the subtotals and the setting back. - s.execute("edit.undo", json!({})).unwrap(); - s.execute("edit.undo", json!({})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - assert!(!sh.summary_below); - assert_eq!(sh.value(CellRef::new(3, 0)).display(), "West"); - } - - #[test] - fn filter_by_huge_numeric_column_is_rejected_not_overflowed() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "B1", "values": [["Name", "Qty"], ["a", 1], ["b", 2]]})).unwrap(); - s.execute("data.filter", json!({"range": "B1:C3"})).unwrap(); - for col in [json!(2), json!(u64::from(u32::MAX)), json!(u64::MAX)] { - let err = s.execute("data.filterBy", json!({"column": col, "values": ["a"]})).unwrap_err(); - assert!(err.to_string().contains("column outside the filter range"), "{col}: {err}"); - } - let ok = s.execute("data.filterBy", json!({"column": 0, "values": ["a"]})).unwrap(); - assert_eq!(ok["hiddenRows"], json!(1)); - } - - #[test] - fn filter_by_inside_a_table_installs_the_autofilter_and_keeps_the_button() { - for sel in [json!({"cell": "A1"}), json!({"cell": "A2"}), json!({"range": "A1:A3"})] { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"]]})).unwrap(); - s.execute("insert.table", json!({"range": "A1:A3", "header": true})).unwrap(); - s.execute("selection.set", sel.clone()).unwrap(); - s.execute("data.filterBy", json!({"column": "A", "values": ["a"]})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - assert_eq!(sh.autofilter.as_ref().map(|a| a.range.a1()), Some("A1:A3".to_string()), "{sel}"); - assert!(sh.tables[0].filter_button, "{sel}"); - assert!(!sh.is_row_hidden(1), "{sel}"); - assert!(sh.is_row_hidden(2), "{sel}"); - assert_eq!(sh.value(CellRef::new(2, 0)).display(), "b", "{sel}"); - } - } - - #[test] - fn autofilter_range_grows_when_a_value_is_entered_in_the_row_below() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["d", 4], ["b", 2]]})).unwrap(); - s.execute("data.filter", json!({"range": "A1:B3"})).unwrap(); - let range = |s: &Session| s.doc().unwrap().wb.active().unwrap().autofilter.as_ref().map(|a| a.range.a1()); - s.execute("cell.set", json!({"cell": "A4", "input": "e"})).unwrap(); - assert_eq!(range(&s), Some("A1:B4".to_string())); - s.execute("range.setValues", json!({"range": "A5", "values": [["f", 6]]})).unwrap(); - assert_eq!(range(&s), Some("A1:B5".to_string())); - // A row typed after a blank row does not grow it. - s.execute("cell.set", json!({"cell": "A7", "input": "x"})).unwrap(); - assert_eq!(range(&s), Some("A1:B5".to_string())); - // A cell outside the filter's columns does not either. - s.execute("cell.set", json!({"cell": "C6", "input": "x"})).unwrap(); - assert_eq!(range(&s), Some("A1:B5".to_string())); - // Empty text is a blank entry, not a value. - s.execute("range.setValues", json!({"range": "A6", "parse": false, "values": [[""]]})).unwrap(); - assert_eq!(range(&s), Some("A1:B5".to_string())); - // A formula and Ctrl+Enter (range.fill) are entries too. - s.execute("cell.set", json!({"cell": "B6", "input": "=1+1"})).unwrap(); - assert_eq!(range(&s), Some("A1:B6".to_string())); - s.execute("range.fill", json!({"range": "A7:B7", "input": "g"})).unwrap(); - assert_eq!(range(&s), Some("A1:B7".to_string())); - } - - #[test] - fn list_validation_without_formula1_is_an_error() { - let mut s = Session::new(); - s.new_workbook(); - assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "source": "\"a,b,c\""})).is_err()); - assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list"})).is_err()); - assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty()); - s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "formula1": "\"a,b,c\""})).unwrap(); - assert_eq!(s.execute("data.validate", json!({"cell": "A1", "input": "a"})).unwrap()["ok"], json!(true)); - } - - #[test] - fn partial_validation_edits_keep_the_rest_of_the_old_rule() { - let mut s = Session::new(); - s.new_workbook(); - let rule = |range: &str, lo: &str, hi: &str| json!({"range": range, "type": "whole", "operator": "between", "formula1": lo, "formula2": hi}); - let ok = |s: &mut Session, cell: &str, input: &str| { - s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == json!(true) - }; - s.execute("data.validation", rule("B1:B3", "1", "10")).unwrap(); - s.execute("data.validation", json!({"range": "B2", "clear": true})).unwrap(); - assert!(ok(&mut s, "B2", "999")); - assert!(!ok(&mut s, "B1", "999")); - assert!(!ok(&mut s, "B3", "999")); - s.execute("data.validation", rule("B2:B4", "100", "200")).unwrap(); - for cell in ["B2", "B3", "B4"] { - assert!(ok(&mut s, cell, "150"), "{cell}"); - assert!(!ok(&mut s, cell, "5"), "{cell}"); - } - assert!(ok(&mut s, "B1", "5")); - assert!(!ok(&mut s, "B1", "150")); - } - - #[test] - fn showing_detail_gives_zero_width_columns_their_width_back() { - // #1714: templates hide grouped columns as `width="0" hidden="1"`; + must show them. - let mut s = Session::new(); - s.new_workbook(); - let hidden = LineInfo { size: Some(0.0), hidden: true, outline: 1, ..LineInfo::default() }; - let sh = Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap(); - for c in 2..=4 { - sh.cols.insert(c, hidden); - } - sh.cols.insert(5, LineInfo { collapsed: true, ..LineInfo::default() }); - s.execute("data.showDetail", json!({"cols": "C:E", "level": 1})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - for c in 2..=4 { - assert_eq!(sh.col_width(c), sh.default_col_width, "column {c}"); - } - // Hiding again hides them; the level buttons show zero-width columns too. - s.execute("data.hideDetail", json!({"cols": "C:E", "level": 1})).unwrap(); - assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(2), 0.0); - Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().cols.insert(3, hidden); - s.execute("data.showLevels", json!({"cols": 2})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - assert_eq!((sh.col_width(2), sh.col_width(3)), (sh.default_col_width, sh.default_col_width)); - } - - #[test] - fn removing_an_autofilter_keeps_manually_hidden_and_collapsed_rows() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap(); - s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap(); - s.execute("data.group", json!({"rows": "2:4"})).unwrap(); - s.execute("data.hideDetail", json!({"rows": "2:4"})).unwrap(); - s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap(); - let hidden = |s: &Session| -> Vec { - let sh = s.doc().unwrap().wb.active().unwrap(); - sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect() - }; - let before = hidden(&s); - assert_eq!(before, [1, 2, 3, 9]); - s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); - s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); - assert!(s.doc().unwrap().wb.active().unwrap().autofilter.is_none()); - assert_eq!(hidden(&s), before); - assert_eq!(row_state(&s, 4..=4), [(false, true)]); - } - - #[test] - fn table_dropdown_setup_outside_the_table_keeps_manually_hidden_rows() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap(); - s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap(); - s.execute("insert.table", json!({"range": "A1:A4", "header": true})).unwrap(); - s.execute("home.hideRows", json!({"rows": "3:3"})).unwrap(); - s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap(); - s.execute("selection.set", json!({"range": "E1"})).unwrap(); - // The table filter dropdown installs an AutoFilter on demand by toggling `data.filter` twice. - s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); - s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - assert_eq!(sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect::>(), [2, 9]); - } - - /// Rows 5:8 grouped, with 6:7 grouped again inside (0-based 4..=7 and 5..=6). - fn nested_rows() -> Session { - let mut s = Session::new(); - s.new_workbook(); - s.execute("data.group", json!({"rows": "5:8"})).unwrap(); - s.execute("data.group", json!({"rows": "6:7"})).unwrap(); - s - } - - /// (hidden, collapsed) of 0-based rows. - fn row_state(s: &Session, rows: std::ops::RangeInclusive) -> Vec<(bool, bool)> { - let sh = s.doc().unwrap().wb.active().unwrap(); - rows.map(|r| sh.rows.get(&r).map(|l| (l.hidden, l.collapsed)).unwrap_or_default()).collect() - } - - #[test] - fn hide_detail_marks_the_summary_collapsed_and_show_detail_keeps_inner_groups_collapsed() { - let mut s = nested_rows(); - s.execute("data.hideDetail", json!({"rows": "6:7", "level": 2})).unwrap(); - assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]); - s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); - assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, true), (false, true)]); - s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap(); - assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]); - s.execute("data.showDetail", json!({"rows": "6:7", "level": 2})).unwrap(); - assert_eq!(row_state(&s, 4..=8), [(false, false); 5]); - // Each step is one undoable command. - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(row_state(&s, 5..=7), [(true, false), (true, false), (false, true)]); - } - - #[test] - fn hide_detail_on_the_summary_row_collapses_the_group_above_it() { - let mut s = nested_rows(); - s.execute("data.hideDetail", json!({"rows": "9:9"})).unwrap(); - assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, false), (false, true)]); - } - - #[test] - fn show_levels_collapses_and_expands_to_a_level() { - let mut s = nested_rows(); - s.execute("data.showLevels", json!({"rows": 1})).unwrap(); - assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, true), (false, true)]); - s.execute("data.showLevels", json!({"rows": 2})).unwrap(); - assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]); - s.execute("data.showLevels", json!({"rows": 3})).unwrap(); - assert_eq!(row_state(&s, 4..=8), [(false, false); 5]); - // Columns are separate; no level at all is an error rather than a guess. - s.execute("data.group", json!({"cols": "C:E"})).unwrap(); - s.execute("data.showLevels", json!({"cols": 1})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - assert!((2..=4).all(|c| sh.cols.get(&c).is_some_and(|l| l.hidden))); - assert!(sh.cols.get(&5).is_some_and(|l| l.collapsed)); - assert_eq!(row_state(&s, 4..=4), [(false, false)]); - assert!(s.execute("data.showLevels", json!({})).is_err()); - } - - #[test] - fn group_by_columns_for_a_block_of_cells() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("selection.set", json!({"range": "B2:C5"})).unwrap(); - s.execute("data.group", json!({"by": "cols"})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - assert!((1..=2).all(|c| sh.cols.get(&c).is_some_and(|l| l.outline == 1))); - assert!(sh.rows.values().all(|l| l.outline == 0)); - } - - #[test] - fn group_shortcuts_are_the_windows_ones() { - let sc = |id: &str| specs().into_iter().find(|c| c.id == id).and_then(|c| c.shortcut); - assert_eq!(sc("data.group"), Some("Alt+Shift+Right")); - assert_eq!(sc("data.ungroup"), Some("Alt+Shift+Left")); - } - - #[test] - fn summary_rows_above_the_detail_take_the_collapsed_mark() { - let mut s = nested_rows(); - let r = s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); - assert_eq!(r, json!({"summaryBelow": false, "summaryRight": true})); - // Row 4 (index 3) is the outer group's summary, row 5 the inner one's. - s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); - assert_eq!(row_state(&s, 3..=8), [(false, true), (true, false), (true, false), (true, false), (true, false), (false, false)]); - s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap(); - assert_eq!(row_state(&s, 3..=8), [(false, false); 6]); - // Hide Detail on the summary row collapses the group below it. - s.execute("data.hideDetail", json!({"rows": "4:4"})).unwrap(); - assert_eq!(row_state(&s, 3..=4), [(false, true), (true, false)]); - s.execute("data.showLevels", json!({"rows": 1})).unwrap(); - assert_eq!(row_state(&s, 3..=8), [(false, true), (true, true), (true, false), (true, false), (true, false), (false, false)]); - // Ungrouping the outer group clears its mark on the row above. - s.execute("data.showLevels", json!({"rows": 3})).unwrap(); - s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); - s.execute("data.ungroup", json!({"rows": "5:8"})).unwrap(); - assert_eq!(row_state(&s, 3..=3), [(false, false)]); - } - - #[test] - fn changing_the_summary_direction_moves_collapsed_marks() { - let mut s = nested_rows(); - s.execute("data.group", json!({"cols": "C:E"})).unwrap(); - s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); - s.execute("data.hideDetail", json!({"cols": "C:E"})).unwrap(); - s.execute("data.outlineSettings", json!({"summaryBelow": false, "summaryRight": false})).unwrap(); - let col_marked = |s: &Session, c: u32| s.doc().unwrap().wb.active().unwrap().cols.get(&c).is_some_and(|l| l.collapsed); - assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::>(), [true, false, false, false, false, false]); - assert!(col_marked(&s, 1) && !col_marked(&s, 5)); - s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap(); - assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::>(), [false, false, false, false, false, true]); - assert!(col_marked(&s, 1), "columns keep their direction"); - // One undo step per change; no params leaves the settings as they are. - s.execute("edit.undo", json!({})).unwrap(); - assert!(row_state(&s, 3..=3)[0].1); - assert_eq!(s.execute("data.outlineSettings", json!({})).unwrap(), json!({"summaryBelow": false, "summaryRight": false})); - // Nested groups that end on one row share its mark: it moves to the outer group only. - let mut s = Session::new(); - s.new_workbook(); - s.execute("data.group", json!({"rows": "5:8"})).unwrap(); - s.execute("data.group", json!({"rows": "6:8"})).unwrap(); - s.execute("data.showLevels", json!({"rows": 1})).unwrap(); - s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); - assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::>(), [true, false, false, false, false, false]); - s.execute("data.showDetail", json!({"rows": "4:4"})).unwrap(); - assert_eq!(row_state(&s, 3..=8), [(false, false); 6]); - // With only the inner group collapsed, its mark goes to row 5, and the outer group's - // Hide and Show Detail keep it collapsed. - s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap(); - s.execute("data.hideDetail", json!({"rows": "6:8", "level": 2})).unwrap(); - s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); - assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]); - s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); - s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap(); - assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]); - // A group on the first row has no row above it to mark. - let mut s = Session::new(); - s.new_workbook(); - s.execute("data.group", json!({"rows": "1:3"})).unwrap(); - s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); - s.execute("data.hideDetail", json!({"rows": "1:3"})).unwrap(); - assert_eq!(row_state(&s, 0..=3), [(true, false), (true, false), (true, false), (false, false)]); - } - - #[test] - fn column_sort_rejects_row_key_zero_without_panicking() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap(); - for row in [0u64, 1 << 32] { - let params = json!({"range": "A1:B2", "header": false, "orientation": "columns", "keys": [{"row": row}]}); - let err = s.execute("data.sort", params).unwrap_err(); - assert!(matches!(err, EngineError::BadParams { .. }), "row {row}: {err:?}"); - } - } - - #[test] - fn single_row_and_column_parameters() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap(); - s.execute("home.insertRows", json!({"rows": "2"})).unwrap(); - s.execute("home.insertColumns", json!({"cols": "B"})).unwrap(); - let v = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone(); - assert_eq!(v(&mut s, "C3"), json!(4.0)); - s.execute("home.hideRows", json!({"rows": "1"})).unwrap(); - s.execute("data.group", json!({"cols": "C"})).unwrap(); - assert!(s.execute("home.deleteRows", json!({"rows": "0"})).is_err()); - s.execute("home.deleteRows", json!({"rows": "2"})).unwrap(); - s.execute("home.deleteColumns", json!({"cols": "B"})).unwrap(); - assert_eq!(v(&mut s, "B2"), json!(4.0)); - } - - #[test] - fn t2c_fixed_widths_are_positions_in_any_order_without_duplicates() { - let cases: [(Json, [&str; 4]); 5] = [ - (json!([6, 3]), ["abc", "def", "ghij", ""]), - (json!([3, 3]), ["abc", "defghij", "", ""]), - (json!([0]), ["abcdefghij", "", "", ""]), - (json!([20]), ["abcdefghij", "", "", ""]), - (json!([7, 0, 3, 7, 10]), ["abc", "defg", "hij", ""]), - ]; - for (widths, want) in cases { - let mut s = Session::new(); - s.new_workbook(); - s.execute("cell.set", json!({"cell": "A1", "input": "abcdefghij"})).unwrap(); - s.execute("data.textToColumns", json!({"range": "A1:A1", "fixedWidths": widths})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - let got: Vec = (0..4).map(|c| sh.value(CellRef::new(0, c)).display()).collect(); - assert_eq!(got, want, "{widths}"); - } - } - - #[test] - fn split() { - assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]); - assert_eq!(split_delimited("a,b,,c", &[','], true, '"'), ["a", "b", "c"]); - assert_eq!(split_delimited("\"x,y\",z", &[','], false, '"'), ["x,y", "z"]); - } - - #[test] - fn remove_duplicates_without_a_valid_column_is_refused() { - // `columns: []` (or only unusable names) used to compare nothing and delete every row - // but the first. - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [["a"], ["b"], ["c"]]})).unwrap(); - for columns in [json!([]), json!(["ZZZ"])] { - let err = s.execute("data.removeDuplicates", json!({"range": "A1:A3", "columns": columns})).unwrap_err(); - assert!(err.to_string().contains("columns"), "{err}"); - } - let sh = s.doc().unwrap().wb.active().unwrap(); - assert_eq!(sh.value(CellRef::new(0, 0)), Value::text("a")); - assert_eq!(sh.value(CellRef::new(2, 0)), Value::text("c")); - } - - #[test] - fn a_list_typed_without_quotes_is_saved_as_a_quoted_list() { - // In a file an inline list is a string literal (`"a,b"`); unquoted it reads as a formula. - // References, names and formulas stay unquoted, and every rule survives a save and reopen. - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "K1:K3", "values": [["Red"], ["Green"], ["Blue"]]})).unwrap(); - s.execute("formulas.defineName", json!({"name": "Colors", "refersTo": "=Sheet1!$K$1:$K$3"})).unwrap(); - let rules = [ - ("A1", "Open,In progress,Closed", r#""Open,In progress,Closed""#), - ("B1", r#""Open,In progress,Closed""#, r#""Open,In progress,Closed""#), - ("C1", " 1,2,3 ", r#""1,2,3""#), - ("D1", r#"Say "hi",Bye"#, r#""Say ""hi"",Bye""#), - ("E1", "=$K$1:$K$3", "$K$1:$K$3"), - ("F1", "$K$1:$K$3", "$K$1:$K$3"), - ("G1", "=Colors", "Colors"), - ("H1", "Sheet1!$K$1:$K$3", "Sheet1!$K$1:$K$3"), - ("I1", "=OFFSET($K$1,0,0,3,1)", "OFFSET($K$1,0,0,3,1)"), - ]; - for (cell, source, _) in rules { - s.execute("data.validation", json!({"range": cell, "type": "list", "formula1": source})).unwrap(); - } - let accepted = [ - ("A1", "In progress"), - ("B1", "Closed"), - ("C1", "2"), - ("D1", r#"Say "hi""#), - ("E1", "Green"), - ("F1", "Blue"), - ("G1", "Red"), - ("H1", "Green"), - ]; - let check = |wb: &Workbook| { - for (cell, input) in accepted { - let at = CellRef::parse(cell).unwrap(); - assert!(check_validation(wb, 0, at, input).is_none(), "{cell} refuses {input}"); - assert!(check_validation(wb, 0, at, "Pending").is_some(), "{cell} accepts Pending"); - } - }; - let wb = s.doc().unwrap().wb.clone(); - check(&wb); - - let sheet_xml = |wb: &Workbook| { - let bytes = gridcraft_xlsx::write_xlsx(wb).unwrap(); - let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); - let mut xml = String::new(); - std::io::Read::read_to_string(&mut zip.by_name("xl/worksheets/sheet1.xml").unwrap(), &mut xml).unwrap(); - xml - }; - let xml = sheet_xml(&wb); - for (cell, _, saved) in rules { - assert!(xml.contains(&format!(r#"sqref="{cell}">{saved}"#)), "{cell}: {saved} missing from {xml}"); - } - let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap(); - check(&reopened); - assert_eq!(sheet_xml(&reopened), xml, "a second save writes the same rules"); - } - - #[test] - fn a_rule_with_an_unknown_type_operator_or_error_style_is_refused() { - // A typo used to give a rule that validates nothing (`type="none"`) or the wrong check, - // with no error. - let mut s = Session::new(); - s.new_workbook(); - for params in [ - json!({"range": "A1", "type": "lst", "formula1": "\"a,b\""}), - json!({"range": "A1", "type": "whole", "operator": "greaterthan", "formula1": "1"}), - json!({"range": "A1", "type": "whole", "operator": ">", "formula1": "1"}), - json!({"range": "A1", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warn"}), - ] { - let e = s.execute("data.validation", params.clone()).unwrap_err().to_string(); - assert!(e.contains("unknown"), "{params}: {e}"); - } - assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty()); - for params in [ - json!({"range": "A1", "type": "any"}), - json!({"range": "A2", "type": "whole", "operator": "greaterThan", "formula1": "1"}), - json!({"range": "A3", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warning"}), - ] { - s.execute("data.validation", params).unwrap(); - } - let sh = s.doc().unwrap().wb.active().unwrap().clone(); - assert_eq!(sh.validations.len(), 3); - assert!(check_validation(&s.doc().unwrap().wb, 0, CellRef::parse("A2").unwrap(), "0").is_some()); - } -} +mod tests; diff --git a/crates/engine/src/cmd/data/tests.rs b/crates/engine/src/cmd/data/tests.rs new file mode 100644 index 000000000..8708fbf75 --- /dev/null +++ b/crates/engine/src/cmd/data/tests.rs @@ -0,0 +1,510 @@ +use super::*; + +#[test] +fn filter_blanks_include_empty_text_and_formula_results() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["id", "f"], ["e", null], ["ef", "=\"\""], ["sp", " "], ["z", 0]]})).unwrap(); + s.execute("data.filter", json!({"range": "A1:B5"})).unwrap(); + let r = s.execute("data.filterBy", json!({"column": "B", "values": [], "blanks": true})).unwrap(); + assert_eq!(r["hiddenRows"], json!(2)); + let vals = filter_values(&s.doc().unwrap().wb, 0, 1); + // The `=""` result joins (Blanks) instead of showing as its own empty entry. + assert!(!vals.iter().any(|(t, _)| t.is_empty()), "{vals:?}"); + let blanks: Vec<_> = vals.into_iter().filter(|(t, _)| t == "(Blanks)").collect(); + assert_eq!(blanks, vec![("(Blanks)".to_string(), true)]); +} + +#[test] +fn subtotal_honours_header_false() { + // #131: with header:false the first data row was treated as a header and left out of its + // group's subtotal and the grand total. + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["East", 10], ["East", 20], ["West", 30], ["West", 40]]})).unwrap(); + s.execute("data.subtotal", json!({"range": "A1:B4", "groupBy": "A", "function": "sum", "columns": ["B"], "header": false})).unwrap(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + assert_eq!(get(&mut s, "A1")["value"], json!("East")); + assert_eq!(get(&mut s, "A3")["text"], json!("East Total")); + assert_eq!(get(&mut s, "B3")["value"], json!(30.0), "East's subtotal counts both East rows"); + assert_eq!(get(&mut s, "A7")["text"], json!("Grand Total")); + assert_eq!(get(&mut s, "B7")["value"], json!(100.0), "the grand total counts every data row"); +} + +#[test] +fn subtotal_puts_summary_rows_below_their_detail() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["East", 10], ["East", 20], ["West", 30]]})).unwrap(); + s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); + s.execute("data.subtotal", json!({"range": "A1:B4", "groupBy": "A", "columns": ["B"]})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap().clone(); + assert!(sh.summary_below, "the totals sit below their groups"); + // East's detail is rows 2–3 and its total row 4: collapsing level 1 keeps the total showing. + s.execute("data.showLevels", json!({"rows": 1})).unwrap(); + let hidden = |s: &Session, row: u32| s.doc().unwrap().wb.active().unwrap().is_row_hidden(row); + assert!(hidden(&s, 1) && hidden(&s, 2), "East's detail rows are hidden"); + assert!(!hidden(&s, 3), "East Total stays visible"); + // One undo takes the subtotals and the setting back. + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert!(!sh.summary_below); + assert_eq!(sh.value(CellRef::new(3, 0)).display(), "West"); +} + +#[test] +fn filter_by_huge_numeric_column_is_rejected_not_overflowed() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "B1", "values": [["Name", "Qty"], ["a", 1], ["b", 2]]})).unwrap(); + s.execute("data.filter", json!({"range": "B1:C3"})).unwrap(); + for col in [json!(2), json!(u64::from(u32::MAX)), json!(u64::MAX)] { + let err = s.execute("data.filterBy", json!({"column": col, "values": ["a"]})).unwrap_err(); + assert!(err.to_string().contains("column outside the filter range"), "{col}: {err}"); + } + let ok = s.execute("data.filterBy", json!({"column": 0, "values": ["a"]})).unwrap(); + assert_eq!(ok["hiddenRows"], json!(1)); +} + +#[test] +fn filter_by_inside_a_table_installs_the_autofilter_and_keeps_the_button() { + for sel in [json!({"cell": "A1"}), json!({"cell": "A2"}), json!({"range": "A1:A3"})] { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"]]})).unwrap(); + s.execute("insert.table", json!({"range": "A1:A3", "header": true})).unwrap(); + s.execute("selection.set", sel.clone()).unwrap(); + s.execute("data.filterBy", json!({"column": "A", "values": ["a"]})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.autofilter.as_ref().map(|a| a.range.a1()), Some("A1:A3".to_string()), "{sel}"); + assert!(sh.tables[0].filter_button, "{sel}"); + assert!(!sh.is_row_hidden(1), "{sel}"); + assert!(sh.is_row_hidden(2), "{sel}"); + assert_eq!(sh.value(CellRef::new(2, 0)).display(), "b", "{sel}"); + } +} + +#[test] +fn autofilter_range_grows_when_a_value_is_entered_in_the_row_below() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["d", 4], ["b", 2]]})).unwrap(); + s.execute("data.filter", json!({"range": "A1:B3"})).unwrap(); + let range = |s: &Session| s.doc().unwrap().wb.active().unwrap().autofilter.as_ref().map(|a| a.range.a1()); + s.execute("cell.set", json!({"cell": "A4", "input": "e"})).unwrap(); + assert_eq!(range(&s), Some("A1:B4".to_string())); + s.execute("range.setValues", json!({"range": "A5", "values": [["f", 6]]})).unwrap(); + assert_eq!(range(&s), Some("A1:B5".to_string())); + // A row typed after a blank row does not grow it. + s.execute("cell.set", json!({"cell": "A7", "input": "x"})).unwrap(); + assert_eq!(range(&s), Some("A1:B5".to_string())); + // A cell outside the filter's columns does not either. + s.execute("cell.set", json!({"cell": "C6", "input": "x"})).unwrap(); + assert_eq!(range(&s), Some("A1:B5".to_string())); + // Empty text is a blank entry, not a value. + s.execute("range.setValues", json!({"range": "A6", "parse": false, "values": [[""]]})).unwrap(); + assert_eq!(range(&s), Some("A1:B5".to_string())); + // A formula and Ctrl+Enter (range.fill) are entries too. + s.execute("cell.set", json!({"cell": "B6", "input": "=1+1"})).unwrap(); + assert_eq!(range(&s), Some("A1:B6".to_string())); + s.execute("range.fill", json!({"range": "A7:B7", "input": "g"})).unwrap(); + assert_eq!(range(&s), Some("A1:B7".to_string())); +} + +#[test] +fn list_validation_without_formula1_is_an_error() { + let mut s = Session::new(); + s.new_workbook(); + assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "source": "\"a,b,c\""})).is_err()); + assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list"})).is_err()); + assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty()); + s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "formula1": "\"a,b,c\""})).unwrap(); + assert_eq!(s.execute("data.validate", json!({"cell": "A1", "input": "a"})).unwrap()["ok"], json!(true)); +} + +#[test] +fn partial_validation_edits_keep_the_rest_of_the_old_rule() { + let mut s = Session::new(); + s.new_workbook(); + let rule = |range: &str, lo: &str, hi: &str| json!({"range": range, "type": "whole", "operator": "between", "formula1": lo, "formula2": hi}); + let ok = |s: &mut Session, cell: &str, input: &str| { + s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == json!(true) + }; + s.execute("data.validation", rule("B1:B3", "1", "10")).unwrap(); + s.execute("data.validation", json!({"range": "B2", "clear": true})).unwrap(); + assert!(ok(&mut s, "B2", "999")); + assert!(!ok(&mut s, "B1", "999")); + assert!(!ok(&mut s, "B3", "999")); + s.execute("data.validation", rule("B2:B4", "100", "200")).unwrap(); + for cell in ["B2", "B3", "B4"] { + assert!(ok(&mut s, cell, "150"), "{cell}"); + assert!(!ok(&mut s, cell, "5"), "{cell}"); + } + assert!(ok(&mut s, "B1", "5")); + assert!(!ok(&mut s, "B1", "150")); +} + +#[test] +fn showing_detail_gives_zero_width_columns_their_width_back() { + // #1714: templates hide grouped columns as `width="0" hidden="1"`; + must show them. + let mut s = Session::new(); + s.new_workbook(); + let hidden = LineInfo { size: Some(0.0), hidden: true, outline: 1, ..LineInfo::default() }; + let sh = Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap(); + for c in 2..=4 { + sh.cols.insert(c, hidden); + } + sh.cols.insert(5, LineInfo { collapsed: true, ..LineInfo::default() }); + s.execute("data.showDetail", json!({"cols": "C:E", "level": 1})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + for c in 2..=4 { + assert_eq!(sh.col_width(c), sh.default_col_width, "column {c}"); + } + // Hiding again hides them; the level buttons show zero-width columns too. + s.execute("data.hideDetail", json!({"cols": "C:E", "level": 1})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(2), 0.0); + Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().cols.insert(3, hidden); + s.execute("data.showLevels", json!({"cols": 2})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!((sh.col_width(2), sh.col_width(3)), (sh.default_col_width, sh.default_col_width)); +} + +#[test] +fn removing_an_autofilter_keeps_manually_hidden_and_collapsed_rows() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap(); + s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap(); + s.execute("data.group", json!({"rows": "2:4"})).unwrap(); + s.execute("data.hideDetail", json!({"rows": "2:4"})).unwrap(); + s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap(); + let hidden = |s: &Session| -> Vec { + let sh = s.doc().unwrap().wb.active().unwrap(); + sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect() + }; + let before = hidden(&s); + assert_eq!(before, [1, 2, 3, 9]); + s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + assert!(s.doc().unwrap().wb.active().unwrap().autofilter.is_none()); + assert_eq!(hidden(&s), before); + assert_eq!(row_state(&s, 4..=4), [(false, true)]); +} + +#[test] +fn table_dropdown_setup_outside_the_table_keeps_manually_hidden_rows() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap(); + s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap(); + s.execute("insert.table", json!({"range": "A1:A4", "header": true})).unwrap(); + s.execute("home.hideRows", json!({"rows": "3:3"})).unwrap(); + s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap(); + s.execute("selection.set", json!({"range": "E1"})).unwrap(); + // The table filter dropdown installs an AutoFilter on demand by toggling `data.filter` twice. + s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect::>(), [2, 9]); +} + +/// Rows 5:8 grouped, with 6:7 grouped again inside (0-based 4..=7 and 5..=6). +fn nested_rows() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("data.group", json!({"rows": "5:8"})).unwrap(); + s.execute("data.group", json!({"rows": "6:7"})).unwrap(); + s +} + +/// (hidden, collapsed) of 0-based rows. +fn row_state(s: &Session, rows: std::ops::RangeInclusive) -> Vec<(bool, bool)> { + let sh = s.doc().unwrap().wb.active().unwrap(); + rows.map(|r| sh.rows.get(&r).map(|l| (l.hidden, l.collapsed)).unwrap_or_default()).collect() +} + +#[test] +fn hide_detail_marks_the_summary_collapsed_and_show_detail_keeps_inner_groups_collapsed() { + let mut s = nested_rows(); + s.execute("data.hideDetail", json!({"rows": "6:7", "level": 2})).unwrap(); + assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]); + s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, true), (false, true)]); + s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]); + s.execute("data.showDetail", json!({"rows": "6:7", "level": 2})).unwrap(); + assert_eq!(row_state(&s, 4..=8), [(false, false); 5]); + // Each step is one undoable command. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(row_state(&s, 5..=7), [(true, false), (true, false), (false, true)]); +} + +#[test] +fn hide_detail_on_the_summary_row_collapses_the_group_above_it() { + let mut s = nested_rows(); + s.execute("data.hideDetail", json!({"rows": "9:9"})).unwrap(); + assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, false), (false, true)]); +} + +#[test] +fn show_levels_collapses_and_expands_to_a_level() { + let mut s = nested_rows(); + s.execute("data.showLevels", json!({"rows": 1})).unwrap(); + assert_eq!(row_state(&s, 4..=8), [(true, false), (true, false), (true, false), (true, true), (false, true)]); + s.execute("data.showLevels", json!({"rows": 2})).unwrap(); + assert_eq!(row_state(&s, 4..=8), [(false, false), (true, false), (true, false), (false, true), (false, false)]); + s.execute("data.showLevels", json!({"rows": 3})).unwrap(); + assert_eq!(row_state(&s, 4..=8), [(false, false); 5]); + // Columns are separate; no level at all is an error rather than a guess. + s.execute("data.group", json!({"cols": "C:E"})).unwrap(); + s.execute("data.showLevels", json!({"cols": 1})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert!((2..=4).all(|c| sh.cols.get(&c).is_some_and(|l| l.hidden))); + assert!(sh.cols.get(&5).is_some_and(|l| l.collapsed)); + assert_eq!(row_state(&s, 4..=4), [(false, false)]); + assert!(s.execute("data.showLevels", json!({})).is_err()); +} + +#[test] +fn group_by_columns_for_a_block_of_cells() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("selection.set", json!({"range": "B2:C5"})).unwrap(); + s.execute("data.group", json!({"by": "cols"})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert!((1..=2).all(|c| sh.cols.get(&c).is_some_and(|l| l.outline == 1))); + assert!(sh.rows.values().all(|l| l.outline == 0)); +} + +#[test] +fn group_shortcuts_are_the_windows_ones() { + let sc = |id: &str| specs().into_iter().find(|c| c.id == id).and_then(|c| c.shortcut); + assert_eq!(sc("data.group"), Some("Alt+Shift+Right")); + assert_eq!(sc("data.ungroup"), Some("Alt+Shift+Left")); +} + +#[test] +fn summary_rows_above_the_detail_take_the_collapsed_mark() { + let mut s = nested_rows(); + let r = s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); + assert_eq!(r, json!({"summaryBelow": false, "summaryRight": true})); + // Row 4 (index 3) is the outer group's summary, row 5 the inner one's. + s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + assert_eq!(row_state(&s, 3..=8), [(false, true), (true, false), (true, false), (true, false), (true, false), (false, false)]); + s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + assert_eq!(row_state(&s, 3..=8), [(false, false); 6]); + // Hide Detail on the summary row collapses the group below it. + s.execute("data.hideDetail", json!({"rows": "4:4"})).unwrap(); + assert_eq!(row_state(&s, 3..=4), [(false, true), (true, false)]); + s.execute("data.showLevels", json!({"rows": 1})).unwrap(); + assert_eq!(row_state(&s, 3..=8), [(false, true), (true, true), (true, false), (true, false), (true, false), (false, false)]); + // Ungrouping the outer group clears its mark on the row above. + s.execute("data.showLevels", json!({"rows": 3})).unwrap(); + s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + s.execute("data.ungroup", json!({"rows": "5:8"})).unwrap(); + assert_eq!(row_state(&s, 3..=3), [(false, false)]); +} + +#[test] +fn changing_the_summary_direction_moves_collapsed_marks() { + let mut s = nested_rows(); + s.execute("data.group", json!({"cols": "C:E"})).unwrap(); + s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + s.execute("data.hideDetail", json!({"cols": "C:E"})).unwrap(); + s.execute("data.outlineSettings", json!({"summaryBelow": false, "summaryRight": false})).unwrap(); + let col_marked = |s: &Session, c: u32| s.doc().unwrap().wb.active().unwrap().cols.get(&c).is_some_and(|l| l.collapsed); + assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::>(), [true, false, false, false, false, false]); + assert!(col_marked(&s, 1) && !col_marked(&s, 5)); + s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap(); + assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::>(), [false, false, false, false, false, true]); + assert!(col_marked(&s, 1), "columns keep their direction"); + // One undo step per change; no params leaves the settings as they are. + s.execute("edit.undo", json!({})).unwrap(); + assert!(row_state(&s, 3..=3)[0].1); + assert_eq!(s.execute("data.outlineSettings", json!({})).unwrap(), json!({"summaryBelow": false, "summaryRight": false})); + // Nested groups that end on one row share its mark: it moves to the outer group only. + let mut s = Session::new(); + s.new_workbook(); + s.execute("data.group", json!({"rows": "5:8"})).unwrap(); + s.execute("data.group", json!({"rows": "6:8"})).unwrap(); + s.execute("data.showLevels", json!({"rows": 1})).unwrap(); + s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); + assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::>(), [true, false, false, false, false, false]); + s.execute("data.showDetail", json!({"rows": "4:4"})).unwrap(); + assert_eq!(row_state(&s, 3..=8), [(false, false); 6]); + // With only the inner group collapsed, its mark goes to row 5, and the outer group's + // Hide and Show Detail keep it collapsed. + s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap(); + s.execute("data.hideDetail", json!({"rows": "6:8", "level": 2})).unwrap(); + s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); + assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]); + s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap(); + assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]); + // A group on the first row has no row above it to mark. + let mut s = Session::new(); + s.new_workbook(); + s.execute("data.group", json!({"rows": "1:3"})).unwrap(); + s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap(); + s.execute("data.hideDetail", json!({"rows": "1:3"})).unwrap(); + assert_eq!(row_state(&s, 0..=3), [(true, false), (true, false), (true, false), (false, false)]); +} + +#[test] +fn column_sort_rejects_row_key_zero_without_panicking() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap(); + for row in [0u64, 1 << 32] { + let params = json!({"range": "A1:B2", "header": false, "orientation": "columns", "keys": [{"row": row}]}); + let err = s.execute("data.sort", params).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "row {row}: {err:?}"); + } +} + +#[test] +fn single_row_and_column_parameters() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap(); + s.execute("home.insertRows", json!({"rows": "2"})).unwrap(); + s.execute("home.insertColumns", json!({"cols": "B"})).unwrap(); + let v = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone(); + assert_eq!(v(&mut s, "C3"), json!(4.0)); + s.execute("home.hideRows", json!({"rows": "1"})).unwrap(); + s.execute("data.group", json!({"cols": "C"})).unwrap(); + assert!(s.execute("home.deleteRows", json!({"rows": "0"})).is_err()); + s.execute("home.deleteRows", json!({"rows": "2"})).unwrap(); + s.execute("home.deleteColumns", json!({"cols": "B"})).unwrap(); + assert_eq!(v(&mut s, "B2"), json!(4.0)); +} + +#[test] +fn t2c_fixed_widths_are_positions_in_any_order_without_duplicates() { + let cases: [(Json, [&str; 4]); 5] = [ + (json!([6, 3]), ["abc", "def", "ghij", ""]), + (json!([3, 3]), ["abc", "defghij", "", ""]), + (json!([0]), ["abcdefghij", "", "", ""]), + (json!([20]), ["abcdefghij", "", "", ""]), + (json!([7, 0, 3, 7, 10]), ["abc", "defg", "hij", ""]), + ]; + for (widths, want) in cases { + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "abcdefghij"})).unwrap(); + s.execute("data.textToColumns", json!({"range": "A1:A1", "fixedWidths": widths})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + let got: Vec = (0..4).map(|c| sh.value(CellRef::new(0, c)).display()).collect(); + assert_eq!(got, want, "{widths}"); + } +} + +#[test] +fn split() { + assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]); + assert_eq!(split_delimited("a,b,,c", &[','], true, '"'), ["a", "b", "c"]); + assert_eq!(split_delimited("\"x,y\",z", &[','], false, '"'), ["x,y", "z"]); +} + +#[test] +fn remove_duplicates_without_a_valid_column_is_refused() { + // `columns: []` (or only unusable names) used to compare nothing and delete every row + // but the first. + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["a"], ["b"], ["c"]]})).unwrap(); + for columns in [json!([]), json!(["ZZZ"])] { + let err = s.execute("data.removeDuplicates", json!({"range": "A1:A3", "columns": columns})).unwrap_err(); + assert!(err.to_string().contains("columns"), "{err}"); + } + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.value(CellRef::new(0, 0)), Value::text("a")); + assert_eq!(sh.value(CellRef::new(2, 0)), Value::text("c")); +} + +#[test] +fn a_list_typed_without_quotes_is_saved_as_a_quoted_list() { + // In a file an inline list is a string literal (`"a,b"`); unquoted it reads as a formula. + // References, names and formulas stay unquoted, and every rule survives a save and reopen. + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "K1:K3", "values": [["Red"], ["Green"], ["Blue"]]})).unwrap(); + s.execute("formulas.defineName", json!({"name": "Colors", "refersTo": "=Sheet1!$K$1:$K$3"})).unwrap(); + let rules = [ + ("A1", "Open,In progress,Closed", r#""Open,In progress,Closed""#), + ("B1", r#""Open,In progress,Closed""#, r#""Open,In progress,Closed""#), + ("C1", " 1,2,3 ", r#""1,2,3""#), + ("D1", r#"Say "hi",Bye"#, r#""Say ""hi"",Bye""#), + ("E1", "=$K$1:$K$3", "$K$1:$K$3"), + ("F1", "$K$1:$K$3", "$K$1:$K$3"), + ("G1", "=Colors", "Colors"), + ("H1", "Sheet1!$K$1:$K$3", "Sheet1!$K$1:$K$3"), + ("I1", "=OFFSET($K$1,0,0,3,1)", "OFFSET($K$1,0,0,3,1)"), + ]; + for (cell, source, _) in rules { + s.execute("data.validation", json!({"range": cell, "type": "list", "formula1": source})).unwrap(); + } + let accepted = [ + ("A1", "In progress"), + ("B1", "Closed"), + ("C1", "2"), + ("D1", r#"Say "hi""#), + ("E1", "Green"), + ("F1", "Blue"), + ("G1", "Red"), + ("H1", "Green"), + ]; + let check = |wb: &Workbook| { + for (cell, input) in accepted { + let at = CellRef::parse(cell).unwrap(); + assert!(check_validation(wb, 0, at, input).is_none(), "{cell} refuses {input}"); + assert!(check_validation(wb, 0, at, "Pending").is_some(), "{cell} accepts Pending"); + } + }; + let wb = s.doc().unwrap().wb.clone(); + check(&wb); + + let sheet_xml = |wb: &Workbook| { + let bytes = gridcraft_xlsx::write_xlsx(wb).unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("xl/worksheets/sheet1.xml").unwrap(), &mut xml).unwrap(); + xml + }; + let xml = sheet_xml(&wb); + for (cell, _, saved) in rules { + assert!(xml.contains(&format!(r#"sqref="{cell}">{saved}"#)), "{cell}: {saved} missing from {xml}"); + } + let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap(); + check(&reopened); + assert_eq!(sheet_xml(&reopened), xml, "a second save writes the same rules"); +} + +#[test] +fn a_rule_with_an_unknown_type_operator_or_error_style_is_refused() { + // A typo used to give a rule that validates nothing (`type="none"`) or the wrong check, + // with no error. + let mut s = Session::new(); + s.new_workbook(); + for params in [ + json!({"range": "A1", "type": "lst", "formula1": "\"a,b\""}), + json!({"range": "A1", "type": "whole", "operator": "greaterthan", "formula1": "1"}), + json!({"range": "A1", "type": "whole", "operator": ">", "formula1": "1"}), + json!({"range": "A1", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warn"}), + ] { + let e = s.execute("data.validation", params.clone()).unwrap_err().to_string(); + assert!(e.contains("unknown"), "{params}: {e}"); + } + assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty()); + for params in [ + json!({"range": "A1", "type": "any"}), + json!({"range": "A2", "type": "whole", "operator": "greaterThan", "formula1": "1"}), + json!({"range": "A3", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warning"}), + ] { + s.execute("data.validation", params).unwrap(); + } + let sh = s.doc().unwrap().wb.active().unwrap().clone(); + assert_eq!(sh.validations.len(), 3); + assert!(check_validation(&s.doc().unwrap().wb, 0, CellRef::parse("A2").unwrap(), "0").is_some()); +} From 2b808def4f72115549f724dc6c85bb98c48b0467 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:40 +0000 Subject: [PATCH 27/76] refactor(engine): move cmd/edit tests to src/cmd/edit/tests.rs --- crates/engine/src/cmd/edit.rs | 308 +--------------------------- crates/engine/src/cmd/edit/tests.rs | 305 +++++++++++++++++++++++++++ 2 files changed, 306 insertions(+), 307 deletions(-) create mode 100644 crates/engine/src/cmd/edit/tests.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 28ce338d6..32164fb24 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1910,310 +1910,4 @@ fn begin_edit(s: &mut Session, _: &Json) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - /// Sheet1 and Sheet2 with a workbook range name, a name scoped to each sheet, a constant, a - /// formula and a hidden name. - fn go_to_workbook() -> Session { - let mut s = Session::new(); - s.new_workbook(); - s.execute("home.insertSheet", json!({"name": "Sheet2"})).unwrap(); - s.execute("selection.set", json!({"sheet": 0})).unwrap(); - let define = |s: &mut Session, name: &str, refers: &str, scope: &str| { - s.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).unwrap(); - }; - define(&mut s, "Price", "=Sheet1!$B$2:$B$5", "Workbook"); - define(&mut s, "type", "=Sheet1!$D$2:$D$5", "Workbook"); - define(&mut s, "Areas", "=Sheet1!$A$1,Sheet1!$C$3:$C$4", "Workbook"); - define(&mut s, "Local", "=Sheet2!$C$3", "Sheet2"); - define(&mut s, "Own", "=Sheet1!$E$1", "Sheet1"); - define(&mut s, "Rate", "=0.07", "Workbook"); - define(&mut s, "Total", "=SUM(Sheet1!$B$2:$B$5)", "Workbook"); - let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb); - wb.names.push(gridcraft_model::DefinedName { - name: "Secret".into(), - scope: None, - formula: "Sheet1!$Z$9".into(), - comment: String::new(), - hidden: true, - }); - s - } - - /// "The Go to popup window shows named ranges on every worksheet in your workbook." Names - /// that are not ranges, and hidden names, are not listed. - #[test] - fn go_to_lists_the_named_ranges_of_every_sheet() { - let mut s = go_to_workbook(); - let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); - assert_eq!(list["names"], json!(["Areas", "Own", "Price", "Sheet2!Local", "type"]), "{list}"); - assert_eq!(list["recent"], json!([]), "{list}"); - // Seen from Sheet2, its own name is bare and Sheet1's is qualified. - s.execute("selection.set", json!({"sheet": 1})).unwrap(); - let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); - assert_eq!(list["names"], json!(["Areas", "Local", "Price", "Sheet1!Own", "type"]), "{list}"); - } - - #[test] - fn go_to_a_name_scoped_to_another_sheet_goes_to_that_sheet() { - let mut s = go_to_workbook(); - let out = s.execute("edit.goTo", json!({"reference": "Sheet2!Local"})).unwrap(); - assert_eq!(out["selection"], "C3", "{out}"); - assert_eq!(s.doc().unwrap().wb.active_sheet(), 1); - // A name of several areas selects all of them. - let out = s.execute("edit.goTo", json!({"reference": "Areas"})).unwrap(); - assert_eq!(out["selection"], "A1,C3:C4", "{out}"); - assert_eq!(s.doc().unwrap().wb.active_sheet(), 0); - } - - /// A name matches in any case and any script, as a formula matches it. - #[test] - fn go_to_matches_a_sheet_name_in_any_case() { - let mut s = go_to_workbook(); - s.execute("formulas.defineName", json!({"name": "Größe", "refersTo": "=Sheet2!$D$4", "scope": "Sheet2"})).unwrap(); - s.execute("formulas.defineName", json!({"name": "GRÖSSE", "refersTo": "=Sheet1!$F$6", "scope": "Workbook"})).unwrap(); - let out = s.execute("edit.goTo", json!({"reference": "Sheet2!GRÖSSE"})).unwrap(); - assert_eq!(out["selection"], "D4", "{out}"); - // Seen from Sheet2, its own name hides the workbook name it shares. - let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); - assert_eq!(list["names"], json!(["Areas", "Größe", "Local", "Price", "Sheet1!Own", "type"]), "{list}"); - } - - /// "You can enter multiple named ranges or cell references in the Reference box. Separating - /// each with a comma ... Excel will highlight all the ranges." - #[test] - fn go_to_selects_every_reference_and_name_in_a_list() { - let mut s = go_to_workbook(); - let out = s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30,H21:H29"})).unwrap(); - assert_eq!(out["selection"], "B14:C22,F19:G30,H21:H29", "{out}"); - let out = s.execute("edit.goTo", json!({"reference": "Price, Type"})).unwrap(); - assert_eq!(out["selection"], "B2:B5,D2:D5", "{out}"); - let out = s.execute("edit.goTo", json!({"reference": "Sheet2!$A$1,Sheet2!B2"})).unwrap(); - assert_eq!(out["selection"], "A1,B2", "{out}"); - assert_eq!(s.doc().unwrap().wb.active_sheet(), 1); - // Areas on two sheets, and a part that is not a reference, leave the selection alone. - for bad in ["A1,Sheet1!B2", "A1,Rate", "A1,", "Total"] { - assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err(), "{bad}"); - assert_eq!(s.doc().unwrap().selection.a1(), "A1,B2", "{bad}"); - } - } - - #[test] - fn go_to_reads_the_parts_of_a_long_reference_one_at_a_time() { - let mut parts = reference_parts("A1, 'Q1, Q2'!B2,SUM(C3,D4)"); - assert_eq!(parts.next(), Some("A1")); - assert_eq!(parts.next(), Some("'Q1, Q2'!B2")); - assert_eq!(parts.next(), Some("SUM(C3,D4)")); - assert_eq!(parts.next(), None); - assert_eq!(reference_parts("").collect::>(), vec![""]); - let mut s = go_to_workbook(); - let err = s.execute("edit.goTo", json!({"reference": "A1,".repeat(GO_TO_MAX_AREAS + 1)})).unwrap_err(); - assert!(err.to_string().contains("at most"), "{err}"); - let bad = format!("Total{}", ",".repeat(1_000_000)); - assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err()); - } - - /// "The Go to box keeps track of ranges as you enter them, and you can return to any of them." - #[test] - fn go_to_keeps_the_references_entered_newest_first() { - let mut s = go_to_workbook(); - s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30", "record": true})).unwrap(); - s.execute("edit.goTo", json!({"reference": "Sheet2!A5", "record": true})).unwrap(); - // Names are listed already; a call without `record` (the Name Box) is not kept. - s.execute("edit.goTo", json!({"reference": "Price", "record": true})).unwrap(); - s.execute("edit.goTo", json!({"reference": "Z9"})).unwrap(); - s.execute("edit.goTo", json!({"reference": "b14:c22,f19:g30", "record": true})).unwrap(); - let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); - assert_eq!(list["recent"], json!(["Sheet1!B14:C22,Sheet1!F19:G30", "Sheet2!A5"]), "{list}"); - // Going back from Sheet2 to a recent reference returns to its sheet. - s.execute("selection.set", json!({"sheet": 1})).unwrap(); - let out = s.execute("edit.goTo", json!({"reference": "Sheet1!B14:C22,Sheet1!F19:G30", "record": true})).unwrap(); - assert_eq!(out["selection"], "B14:C22,F19:G30", "{out}"); - assert_eq!(s.doc().unwrap().wb.active_sheet(), 0); - for i in 0..GO_TO_RECENT + 2 { - s.execute("edit.goTo", json!({"reference": format!("A{}", i + 1), "record": true})).unwrap(); - } - let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); - assert_eq!(list["recent"].as_array().map(Vec::len), Some(GO_TO_RECENT), "{list}"); - assert_eq!(list["recent"][0], format!("Sheet1!A{}", GO_TO_RECENT + 2), "{list}"); - } - - #[test] - fn cell_set_takes_numbers_for_input_and_value() { - // #697: a JSON number for `input` used to clear the cell instead of entering it. - let mut s = Session::new(); - s.new_workbook(); - s.execute("cell.set", json!({"cell": "A1", "input": 123.5})).unwrap(); - s.execute("cell.set", json!({"cell": "A2", "value": 77})).unwrap(); - s.execute("cell.set", json!({"cell": "A3", "input": true})).unwrap(); - s.execute("cell.set", json!({"cell": "A4", "input": "=A1+A2"})).unwrap(); - let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); - assert_eq!(get(&mut s, "A1")["value"], json!(123.5)); - assert_eq!(get(&mut s, "A2")["value"], json!(77.0)); - assert_eq!(get(&mut s, "A3")["value"], json!(true)); - assert_eq!(get(&mut s, "A4")["value"], json!(200.5)); - assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err()); - } - - /// Every string in `values` went through typed input, read with en-US rules, in silence: a - /// caller sending "05.04.2026" from a dd.mm.yyyy export stored 4 May, "2.500" became 2.5 and - /// "4.25 %" became a percentage (#2255). `parse: false` stores a string as the text it is. - #[test] - fn set_values_can_store_strings_as_the_text_they_are() { - let mut s = Session::new(); - s.new_workbook(); - let strings = ["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"]; - let row: Vec = strings.iter().map(|v| json!(v)).collect(); - let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); - - // The default is unchanged: the dates swap, the thousands separator is a decimal point. - s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap(); - assert_eq!(get(&mut s, "A1")["text"], "5/4/2026", "dd.mm.yyyy read as mm.dd.yyyy"); - assert_eq!(get(&mut s, "C1")["value"], json!(2.5), "\"2.500\" read as a decimal"); - assert_eq!(get(&mut s, "D1")["value"], json!(0.0425), "\"4.25 %\" read as a percentage"); - - // With `parse: false` each string is the text it was given, and a formula is still a - // formula. - s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row.clone()]})).unwrap(); - for (i, want) in strings.iter().enumerate() { - let cell = format!("{}3", (b'A' + i as u8) as char); - let got = get(&mut s, &cell); - if *want == "=1+1" { - assert_eq!(got["value"], json!(2.0), "{cell} is still a formula: {got}"); - assert_eq!(got["formula"], "=1+1", "{got}"); - } else { - assert_eq!(got["value"], json!(*want), "{cell} is the text it was given: {got}"); - assert!(got["formula"].is_null(), "{cell} is not a formula: {got}"); - } - } - // Numbers and booleans are untouched either way. - s.execute("range.setValues", json!({"range": "A5", "parse": false, "values": [[12.5, true, null]]})).unwrap(); - assert_eq!(get(&mut s, "A5")["value"], json!(12.5)); - assert_eq!(get(&mut s, "B5")["value"], json!(true)); - assert_eq!(get(&mut s, "C5")["text"], ""); - // `parse` is a flag, and anything else is a usage error rather than a quiet default. - s.execute("range.setValues", json!({"range": "A7", "parse": true, "values": [["2.500"]]})).unwrap(); - assert_eq!(get(&mut s, "A7")["value"], json!(2.5)); - assert!(s.execute("range.setValues", json!({"range": "A8", "parse": "no", "values": [["x"]]})).is_err()); - assert!(s.execute("range.setValues", json!({"range": "A8", "parse": 0, "values": [["x"]]})).is_err()); - // A line break still turns on Wrap Text. - s.execute("range.setValues", json!({"range": "A9", "parse": false, "values": [["one\ntwo"]]})).unwrap(); - assert_eq!(get(&mut s, "A9")["value"], json!("one\ntwo")); - let d = s.doc().unwrap(); - let sh = d.wb.active().unwrap(); - assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap); - } - - #[test] - fn legacy_array_entry() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "B2", "values": [[1], [2]]})).unwrap(); - s.execute("cell.set", json!({"cell": "C4", "input": "old"})).unwrap(); - s.execute("selection.set", json!({"range": "C2:C4"})).unwrap(); - s.execute("cell.set", json!({"cell": "C2", "input": "=B2:B3*2", "array": true})).unwrap(); - let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); - assert_eq!(get(&mut s, "C3")["value"], json!(4.0)); - // Past the result Excel shows #N/A; the old content of the range is replaced. - assert_eq!(get(&mut s, "C4")["text"], "#N/A"); - let bar = s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("C2").unwrap()).map(|c| c.bar_text()); - assert_eq!(bar.as_deref(), Some("{=B2:B3*2}")); - } - - #[test] - fn an_array_entered_outside_the_selection_covers_only_its_cell() { - // Found by the command-sequence fuzzer: the array took the selection's range even with - // `cell` elsewhere (or on another sheet), so its formula sat outside its own range and the - // selected cells were wiped. - let mut s = Session::new(); - s.new_workbook(); - s.execute("cell.set", json!({"cell": "K20", "input": "keep"})).unwrap(); - s.execute("selection.set", json!({"range": "K20:K22"})).unwrap(); - s.execute("cell.set", json!({"cell": "H5", "input": "=1+1", "array": true})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - let f = sh.cell(CellRef::parse("H5").unwrap()).and_then(|c| c.formula.clone()).unwrap(); - assert_eq!(f.array, Some(RangeRef::parse("H5").unwrap())); - assert_eq!(sh.value(CellRef::parse("K20").unwrap()), Value::text("keep")); - } - - #[test] - fn wildcards() { - assert!(wildcard("hello", "h*o")); - assert!(wildcard("hello", "h?llo")); - assert!(!wildcard("hello", "h?lo")); - assert!(wildcard("a*b", "a~*b")); - assert!(!wildcard("axb", "a~*b")); - assert!(wildcard("", "*")); - } - - #[test] - fn tsv() { - assert_eq!(parse_tsv("a\tb\n1\t2\n"), vec![vec!["a", "b"], vec!["1", "2"]]); - assert_eq!(parse_tsv("\"x\ty\"\tz"), vec![vec!["x\ty", "z"]]); - assert_eq!(parse_tsv("\"he said \"\"hi\"\"\""), vec![vec!["he said \"hi\""]]); - } - - #[test] - fn paste_special_operations_keep_text_errors_and_formulas() { - let mut s = Session::new(); - s.new_workbook(); - s.execute( - "range.setValues", - json!({"range": "A1", "values": [["'123", "Total", "=NA()", "=B3*2", 5], [1, null, null, null, null], [null, 4, null, null, null]]}), - ) - .unwrap(); - let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); - assert_eq!(get(&mut s, "A1")["value"], "123"); - s.execute("edit.copy", json!({"range": "A2"})).unwrap(); - s.execute("selection.set", json!({"range": "A1:E1"})).unwrap(); - s.execute("edit.pasteSpecial", json!({"what": "values", "operation": "multiply"})).unwrap(); - assert_eq!(get(&mut s, "A1")["value"], json!(123.0)); - assert_eq!(get(&mut s, "B1")["value"], "Total"); - assert_eq!(get(&mut s, "C1")["value"]["error"], "#N/A"); - assert_eq!(get(&mut s, "D1")["formula"], "=(B3*2)*1"); - assert_eq!(get(&mut s, "D1")["value"], json!(8.0)); - assert_eq!(get(&mut s, "E1")["value"], json!(5.0)); - } - - #[test] - fn cut_paste_that_overflows_the_grid_moves_nothing() { - // #997: the source was emptied first and the cells that fell off the grid were skipped. - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2]]})).unwrap(); - let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); - s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap(); - // Transposed, the 2 rows become 2 columns and the second one is past XFD. - assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "transpose": true, "at": "XFD1"})).is_err()); - assert_eq!(get(&mut s, "A1")["value"], json!(1.0)); - assert_eq!(get(&mut s, "A2")["value"], json!(2.0)); - // Not transposed, the second row would be past the last row. - assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "at": "A1048576"})).is_err()); - assert_eq!(get(&mut s, "A1")["value"], json!(1.0)); - assert_eq!(get(&mut s, "A2")["value"], json!(2.0)); - // Not transposed it still fits in column XFD. - s.execute("edit.pasteSpecial", json!({"what": "all", "at": "XFD1"})).unwrap(); - assert_eq!(get(&mut s, "XFD1")["value"], json!(1.0)); - assert_eq!(get(&mut s, "XFD2")["value"], json!(2.0)); - } - - #[test] - fn replace_case_insensitive() { - assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear"); - } - - #[test] - fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() { - // U+212A KELVIN SIGN folds to `k` (3→1 bytes) while U+0130 folds to `i` plus a - // combining dot (2→3 bytes): the folded text is the same length but its byte positions - // no longer line up with the original, and the old slicing dropped the tail. - let mut s = Session::new(); - s.new_workbook(); - let text = "\u{212A}\u{0130}\u{0130}"; - s.execute("cell.set", json!({"cell": "A1", "input": text})).unwrap(); - s.execute("edit.replace", json!({"what": "k", "with": "x"})).unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text)); - } -} +mod tests; diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs new file mode 100644 index 000000000..c9e1e7a93 --- /dev/null +++ b/crates/engine/src/cmd/edit/tests.rs @@ -0,0 +1,305 @@ +use super::*; + +/// Sheet1 and Sheet2 with a workbook range name, a name scoped to each sheet, a constant, a +/// formula and a hidden name. +fn go_to_workbook() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.insertSheet", json!({"name": "Sheet2"})).unwrap(); + s.execute("selection.set", json!({"sheet": 0})).unwrap(); + let define = |s: &mut Session, name: &str, refers: &str, scope: &str| { + s.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).unwrap(); + }; + define(&mut s, "Price", "=Sheet1!$B$2:$B$5", "Workbook"); + define(&mut s, "type", "=Sheet1!$D$2:$D$5", "Workbook"); + define(&mut s, "Areas", "=Sheet1!$A$1,Sheet1!$C$3:$C$4", "Workbook"); + define(&mut s, "Local", "=Sheet2!$C$3", "Sheet2"); + define(&mut s, "Own", "=Sheet1!$E$1", "Sheet1"); + define(&mut s, "Rate", "=0.07", "Workbook"); + define(&mut s, "Total", "=SUM(Sheet1!$B$2:$B$5)", "Workbook"); + let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb); + wb.names.push(gridcraft_model::DefinedName { + name: "Secret".into(), + scope: None, + formula: "Sheet1!$Z$9".into(), + comment: String::new(), + hidden: true, + }); + s +} + +/// "The Go to popup window shows named ranges on every worksheet in your workbook." Names +/// that are not ranges, and hidden names, are not listed. +#[test] +fn go_to_lists_the_named_ranges_of_every_sheet() { + let mut s = go_to_workbook(); + let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); + assert_eq!(list["names"], json!(["Areas", "Own", "Price", "Sheet2!Local", "type"]), "{list}"); + assert_eq!(list["recent"], json!([]), "{list}"); + // Seen from Sheet2, its own name is bare and Sheet1's is qualified. + s.execute("selection.set", json!({"sheet": 1})).unwrap(); + let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); + assert_eq!(list["names"], json!(["Areas", "Local", "Price", "Sheet1!Own", "type"]), "{list}"); +} + +#[test] +fn go_to_a_name_scoped_to_another_sheet_goes_to_that_sheet() { + let mut s = go_to_workbook(); + let out = s.execute("edit.goTo", json!({"reference": "Sheet2!Local"})).unwrap(); + assert_eq!(out["selection"], "C3", "{out}"); + assert_eq!(s.doc().unwrap().wb.active_sheet(), 1); + // A name of several areas selects all of them. + let out = s.execute("edit.goTo", json!({"reference": "Areas"})).unwrap(); + assert_eq!(out["selection"], "A1,C3:C4", "{out}"); + assert_eq!(s.doc().unwrap().wb.active_sheet(), 0); +} + +/// A name matches in any case and any script, as a formula matches it. +#[test] +fn go_to_matches_a_sheet_name_in_any_case() { + let mut s = go_to_workbook(); + s.execute("formulas.defineName", json!({"name": "Größe", "refersTo": "=Sheet2!$D$4", "scope": "Sheet2"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "GRÖSSE", "refersTo": "=Sheet1!$F$6", "scope": "Workbook"})).unwrap(); + let out = s.execute("edit.goTo", json!({"reference": "Sheet2!GRÖSSE"})).unwrap(); + assert_eq!(out["selection"], "D4", "{out}"); + // Seen from Sheet2, its own name hides the workbook name it shares. + let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); + assert_eq!(list["names"], json!(["Areas", "Größe", "Local", "Price", "Sheet1!Own", "type"]), "{list}"); +} + +/// "You can enter multiple named ranges or cell references in the Reference box. Separating +/// each with a comma ... Excel will highlight all the ranges." +#[test] +fn go_to_selects_every_reference_and_name_in_a_list() { + let mut s = go_to_workbook(); + let out = s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30,H21:H29"})).unwrap(); + assert_eq!(out["selection"], "B14:C22,F19:G30,H21:H29", "{out}"); + let out = s.execute("edit.goTo", json!({"reference": "Price, Type"})).unwrap(); + assert_eq!(out["selection"], "B2:B5,D2:D5", "{out}"); + let out = s.execute("edit.goTo", json!({"reference": "Sheet2!$A$1,Sheet2!B2"})).unwrap(); + assert_eq!(out["selection"], "A1,B2", "{out}"); + assert_eq!(s.doc().unwrap().wb.active_sheet(), 1); + // Areas on two sheets, and a part that is not a reference, leave the selection alone. + for bad in ["A1,Sheet1!B2", "A1,Rate", "A1,", "Total"] { + assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err(), "{bad}"); + assert_eq!(s.doc().unwrap().selection.a1(), "A1,B2", "{bad}"); + } +} + +#[test] +fn go_to_reads_the_parts_of_a_long_reference_one_at_a_time() { + let mut parts = reference_parts("A1, 'Q1, Q2'!B2,SUM(C3,D4)"); + assert_eq!(parts.next(), Some("A1")); + assert_eq!(parts.next(), Some("'Q1, Q2'!B2")); + assert_eq!(parts.next(), Some("SUM(C3,D4)")); + assert_eq!(parts.next(), None); + assert_eq!(reference_parts("").collect::>(), vec![""]); + let mut s = go_to_workbook(); + let err = s.execute("edit.goTo", json!({"reference": "A1,".repeat(GO_TO_MAX_AREAS + 1)})).unwrap_err(); + assert!(err.to_string().contains("at most"), "{err}"); + let bad = format!("Total{}", ",".repeat(1_000_000)); + assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err()); +} + +/// "The Go to box keeps track of ranges as you enter them, and you can return to any of them." +#[test] +fn go_to_keeps_the_references_entered_newest_first() { + let mut s = go_to_workbook(); + s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30", "record": true})).unwrap(); + s.execute("edit.goTo", json!({"reference": "Sheet2!A5", "record": true})).unwrap(); + // Names are listed already; a call without `record` (the Name Box) is not kept. + s.execute("edit.goTo", json!({"reference": "Price", "record": true})).unwrap(); + s.execute("edit.goTo", json!({"reference": "Z9"})).unwrap(); + s.execute("edit.goTo", json!({"reference": "b14:c22,f19:g30", "record": true})).unwrap(); + let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); + assert_eq!(list["recent"], json!(["Sheet1!B14:C22,Sheet1!F19:G30", "Sheet2!A5"]), "{list}"); + // Going back from Sheet2 to a recent reference returns to its sheet. + s.execute("selection.set", json!({"sheet": 1})).unwrap(); + let out = s.execute("edit.goTo", json!({"reference": "Sheet1!B14:C22,Sheet1!F19:G30", "record": true})).unwrap(); + assert_eq!(out["selection"], "B14:C22,F19:G30", "{out}"); + assert_eq!(s.doc().unwrap().wb.active_sheet(), 0); + for i in 0..GO_TO_RECENT + 2 { + s.execute("edit.goTo", json!({"reference": format!("A{}", i + 1), "record": true})).unwrap(); + } + let list = s.execute("edit.goTo", json!({"list": true})).unwrap(); + assert_eq!(list["recent"].as_array().map(Vec::len), Some(GO_TO_RECENT), "{list}"); + assert_eq!(list["recent"][0], format!("Sheet1!A{}", GO_TO_RECENT + 2), "{list}"); +} + +#[test] +fn cell_set_takes_numbers_for_input_and_value() { + // #697: a JSON number for `input` used to clear the cell instead of entering it. + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": 123.5})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "value": 77})).unwrap(); + s.execute("cell.set", json!({"cell": "A3", "input": true})).unwrap(); + s.execute("cell.set", json!({"cell": "A4", "input": "=A1+A2"})).unwrap(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + assert_eq!(get(&mut s, "A1")["value"], json!(123.5)); + assert_eq!(get(&mut s, "A2")["value"], json!(77.0)); + assert_eq!(get(&mut s, "A3")["value"], json!(true)); + assert_eq!(get(&mut s, "A4")["value"], json!(200.5)); + assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err()); +} + +/// Every string in `values` went through typed input, read with en-US rules, in silence: a +/// caller sending "05.04.2026" from a dd.mm.yyyy export stored 4 May, "2.500" became 2.5 and +/// "4.25 %" became a percentage (#2255). `parse: false` stores a string as the text it is. +#[test] +fn set_values_can_store_strings_as_the_text_they_are() { + let mut s = Session::new(); + s.new_workbook(); + let strings = ["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"]; + let row: Vec = strings.iter().map(|v| json!(v)).collect(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + + // The default is unchanged: the dates swap, the thousands separator is a decimal point. + s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap(); + assert_eq!(get(&mut s, "A1")["text"], "5/4/2026", "dd.mm.yyyy read as mm.dd.yyyy"); + assert_eq!(get(&mut s, "C1")["value"], json!(2.5), "\"2.500\" read as a decimal"); + assert_eq!(get(&mut s, "D1")["value"], json!(0.0425), "\"4.25 %\" read as a percentage"); + + // With `parse: false` each string is the text it was given, and a formula is still a + // formula. + s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row.clone()]})).unwrap(); + for (i, want) in strings.iter().enumerate() { + let cell = format!("{}3", (b'A' + i as u8) as char); + let got = get(&mut s, &cell); + if *want == "=1+1" { + assert_eq!(got["value"], json!(2.0), "{cell} is still a formula: {got}"); + assert_eq!(got["formula"], "=1+1", "{got}"); + } else { + assert_eq!(got["value"], json!(*want), "{cell} is the text it was given: {got}"); + assert!(got["formula"].is_null(), "{cell} is not a formula: {got}"); + } + } + // Numbers and booleans are untouched either way. + s.execute("range.setValues", json!({"range": "A5", "parse": false, "values": [[12.5, true, null]]})).unwrap(); + assert_eq!(get(&mut s, "A5")["value"], json!(12.5)); + assert_eq!(get(&mut s, "B5")["value"], json!(true)); + assert_eq!(get(&mut s, "C5")["text"], ""); + // `parse` is a flag, and anything else is a usage error rather than a quiet default. + s.execute("range.setValues", json!({"range": "A7", "parse": true, "values": [["2.500"]]})).unwrap(); + assert_eq!(get(&mut s, "A7")["value"], json!(2.5)); + assert!(s.execute("range.setValues", json!({"range": "A8", "parse": "no", "values": [["x"]]})).is_err()); + assert!(s.execute("range.setValues", json!({"range": "A8", "parse": 0, "values": [["x"]]})).is_err()); + // A line break still turns on Wrap Text. + s.execute("range.setValues", json!({"range": "A9", "parse": false, "values": [["one\ntwo"]]})).unwrap(); + assert_eq!(get(&mut s, "A9")["value"], json!("one\ntwo")); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap); +} + +#[test] +fn legacy_array_entry() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "B2", "values": [[1], [2]]})).unwrap(); + s.execute("cell.set", json!({"cell": "C4", "input": "old"})).unwrap(); + s.execute("selection.set", json!({"range": "C2:C4"})).unwrap(); + s.execute("cell.set", json!({"cell": "C2", "input": "=B2:B3*2", "array": true})).unwrap(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + assert_eq!(get(&mut s, "C3")["value"], json!(4.0)); + // Past the result Excel shows #N/A; the old content of the range is replaced. + assert_eq!(get(&mut s, "C4")["text"], "#N/A"); + let bar = s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("C2").unwrap()).map(|c| c.bar_text()); + assert_eq!(bar.as_deref(), Some("{=B2:B3*2}")); +} + +#[test] +fn an_array_entered_outside_the_selection_covers_only_its_cell() { + // Found by the command-sequence fuzzer: the array took the selection's range even with + // `cell` elsewhere (or on another sheet), so its formula sat outside its own range and the + // selected cells were wiped. + let mut s = Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "K20", "input": "keep"})).unwrap(); + s.execute("selection.set", json!({"range": "K20:K22"})).unwrap(); + s.execute("cell.set", json!({"cell": "H5", "input": "=1+1", "array": true})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + let f = sh.cell(CellRef::parse("H5").unwrap()).and_then(|c| c.formula.clone()).unwrap(); + assert_eq!(f.array, Some(RangeRef::parse("H5").unwrap())); + assert_eq!(sh.value(CellRef::parse("K20").unwrap()), Value::text("keep")); +} + +#[test] +fn wildcards() { + assert!(wildcard("hello", "h*o")); + assert!(wildcard("hello", "h?llo")); + assert!(!wildcard("hello", "h?lo")); + assert!(wildcard("a*b", "a~*b")); + assert!(!wildcard("axb", "a~*b")); + assert!(wildcard("", "*")); +} + +#[test] +fn tsv() { + assert_eq!(parse_tsv("a\tb\n1\t2\n"), vec![vec!["a", "b"], vec!["1", "2"]]); + assert_eq!(parse_tsv("\"x\ty\"\tz"), vec![vec!["x\ty", "z"]]); + assert_eq!(parse_tsv("\"he said \"\"hi\"\"\""), vec![vec!["he said \"hi\""]]); +} + +#[test] +fn paste_special_operations_keep_text_errors_and_formulas() { + let mut s = Session::new(); + s.new_workbook(); + s.execute( + "range.setValues", + json!({"range": "A1", "values": [["'123", "Total", "=NA()", "=B3*2", 5], [1, null, null, null, null], [null, 4, null, null, null]]}), + ) + .unwrap(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + assert_eq!(get(&mut s, "A1")["value"], "123"); + s.execute("edit.copy", json!({"range": "A2"})).unwrap(); + s.execute("selection.set", json!({"range": "A1:E1"})).unwrap(); + s.execute("edit.pasteSpecial", json!({"what": "values", "operation": "multiply"})).unwrap(); + assert_eq!(get(&mut s, "A1")["value"], json!(123.0)); + assert_eq!(get(&mut s, "B1")["value"], "Total"); + assert_eq!(get(&mut s, "C1")["value"]["error"], "#N/A"); + assert_eq!(get(&mut s, "D1")["formula"], "=(B3*2)*1"); + assert_eq!(get(&mut s, "D1")["value"], json!(8.0)); + assert_eq!(get(&mut s, "E1")["value"], json!(5.0)); +} + +#[test] +fn cut_paste_that_overflows_the_grid_moves_nothing() { + // #997: the source was emptied first and the cells that fell off the grid were skipped. + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2]]})).unwrap(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap(); + // Transposed, the 2 rows become 2 columns and the second one is past XFD. + assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "transpose": true, "at": "XFD1"})).is_err()); + assert_eq!(get(&mut s, "A1")["value"], json!(1.0)); + assert_eq!(get(&mut s, "A2")["value"], json!(2.0)); + // Not transposed, the second row would be past the last row. + assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "at": "A1048576"})).is_err()); + assert_eq!(get(&mut s, "A1")["value"], json!(1.0)); + assert_eq!(get(&mut s, "A2")["value"], json!(2.0)); + // Not transposed it still fits in column XFD. + s.execute("edit.pasteSpecial", json!({"what": "all", "at": "XFD1"})).unwrap(); + assert_eq!(get(&mut s, "XFD1")["value"], json!(1.0)); + assert_eq!(get(&mut s, "XFD2")["value"], json!(2.0)); +} + +#[test] +fn replace_case_insensitive() { + assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear"); +} + +#[test] +fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() { + // U+212A KELVIN SIGN folds to `k` (3→1 bytes) while U+0130 folds to `i` plus a + // combining dot (2→3 bytes): the folded text is the same length but its byte positions + // no longer line up with the original, and the old slicing dropped the tail. + let mut s = Session::new(); + s.new_workbook(); + let text = "\u{212A}\u{0130}\u{0130}"; + s.execute("cell.set", json!({"cell": "A1", "input": text})).unwrap(); + s.execute("edit.replace", json!({"what": "k", "with": "x"})).unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text)); +} From 2396bb4f64d64f257dbac5c1f95f9530cae90313 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:41 +0000 Subject: [PATCH 28/76] refactor(engine): move cmd/pivot tests to src/cmd/pivot/tests.rs --- crates/engine/src/cmd/pivot.rs | 997 +-------------------------- crates/engine/src/cmd/pivot/tests.rs | 994 ++++++++++++++++++++++++++ 2 files changed, 995 insertions(+), 996 deletions(-) create mode 100644 crates/engine/src/cmd/pivot/tests.rs diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 164ae4449..feb548702 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -1695,999 +1695,4 @@ fn recommended(s: &mut Session, p: &Json) -> Result { } #[cfg(test)] -mod tests { - use gridcraft_core::{CellError, CellRef, Value}; - use serde_json::{Value as Json, json}; - - use crate::Session; - - /// Region, Product, Date, Units, Sales — 12 records over 2026. - fn sales() -> Session { - let mut s = Session::new(); - s.new_workbook(); - let rows = json!([ - ["Region", "Product", "Date", "Units", "Sales"], - ["East", "Apples", "1/15/2026", 10, 100], - ["West", "Apples", "2/10/2026", 5, 50], - ["East", "Pears", "3/3/2026", 8, 120], - ["North", "Pears", "4/20/2026", 2, 30], - ["West", "Plums", "5/5/2026", 7, 70], - ["East", "Plums", "6/30/2026", 4, 60], - ["North", "Apples", "7/4/2026", 9, 90], - ["West", "Pears", "8/8/2026", 3, 45], - ["East", "Apples", "9/9/2026", 6, 60], - ["North", "Plums", "10/10/2026", 1, 15], - ["West", "Apples", "11/11/2026", 12, 120], - ["East", "Pears", "12/12/2026", 5, 75] - ]); - s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); - s.execute("selection.set", json!({"range": "A1"})).unwrap(); - s - } - const TOTAL: f64 = 835.0; - - fn val(s: &Session, a: &str) -> Value { - s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(a).unwrap()) - } - fn text(s: &Session, a: &str) -> String { - val(s, a).display() - } - fn pt(s: &Session) -> gridcraft_model::PivotTable { - s.doc().unwrap().wb.active().unwrap().pivots.last().unwrap().clone() - } - /// Finds the row (0-based) whose first report column holds `label`. - fn find_row(s: &Session, label: &str) -> Option { - let p = pt(s); - let r = p.last_range.unwrap(); - (r.start.row..=r.end.row).find(|row| val(s, &CellRef::new(*row, r.start.col).a1()).display() == label) - } - - #[test] - fn insert_creates_placeholder_on_new_sheet() { - let mut s = sales(); - let r = s.execute("insert.pivotTable", json!({})).unwrap(); - assert_eq!(r["name"], "PivotTable1"); - assert_eq!(r["anchor"], "A3"); - assert_eq!(r["fields"], json!(["Region", "Product", "Date", "Units", "Sales"])); - let d = s.doc().unwrap(); - assert_eq!(d.wb.sheets.len(), 2); - assert_eq!(d.wb.active_sheet(), 0, "new sheet goes before the current one"); - assert_eq!(d.wb.active().unwrap().name, "Sheet2"); - assert!(text(&s, "A3").starts_with("PivotTable1")); - assert_eq!(pt(&s).source, "Sheet1!$A$1:$E$13"); - // One undo step removes it. - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(s.doc().unwrap().wb.sheets.len(), 1); - } - - #[test] - fn region_by_product_sum_of_sales() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({})).unwrap(); - s.execute("pivot.addField", json!({"field": "region", "area": "rows"})).unwrap(); - s.execute("pivot.addField", json!({"field": "Product", "area": "columns"})).unwrap(); - let r = s.execute("pivot.addField", json!({"field": "Sales", "area": "values"})).unwrap(); - assert_eq!(r["range"], "A3:E8"); - assert_eq!(text(&s, "A3"), "Sum of Sales"); - assert_eq!(text(&s, "B3"), "Column Labels"); - assert_eq!(text(&s, "A4"), "Row Labels"); - assert_eq!(text(&s, "B4"), "Apples"); - assert_eq!(text(&s, "C4"), "Pears"); - assert_eq!(text(&s, "D4"), "Plums"); - assert_eq!(text(&s, "E4"), "Grand Total"); - assert_eq!(text(&s, "A5"), "East"); - assert_eq!(text(&s, "A6"), "North"); - assert_eq!(text(&s, "A7"), "West"); - assert_eq!(text(&s, "A8"), "Grand Total"); - assert_eq!(val(&s, "B5"), Value::Number(160.0)); - assert_eq!(val(&s, "C5"), Value::Number(195.0)); - assert_eq!(val(&s, "E5"), Value::Number(415.0)); - assert_eq!(val(&s, "E8"), Value::Number(TOTAL)); - // Column grand totals add up too. - let sum: f64 = ["B8", "C8", "D8"].iter().map(|a| val(&s, a).as_f64().unwrap()).sum(); - assert_eq!(sum, TOTAL); - let rows: f64 = ["E5", "E6", "E7"].iter().map(|a| val(&s, a).as_f64().unwrap()).sum(); - assert_eq!(rows, TOTAL); - // Styles: header bold, grand total bold with a top border. - let d = s.doc().unwrap(); - let sh = d.wb.active().unwrap(); - assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A4").unwrap())).font.bold); - let g = d.wb.styles.get(sh.style_id(CellRef::parse("B8").unwrap())); - assert!(g.font.bold && !g.borders.top.is_none()); - // Listing. - let l = s.execute("pivot.list", json!({})).unwrap(); - assert_eq!(l[0]["values"][0]["name"], "Sum of Sales"); - } - - #[test] - fn multiple_value_fields_make_a_values_dimension() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales", {"field": "Units", "func": "average"}]})).unwrap(); - assert_eq!(text(&s, "A3"), "Row Labels"); - assert_eq!(text(&s, "B3"), "Sum of Sales"); - assert_eq!(text(&s, "C3"), "Average of Units"); - assert_eq!(text(&s, "A7"), "Grand Total"); - assert_eq!(val(&s, "B7"), Value::Number(TOTAL)); - assert_eq!(val(&s, "C7"), Value::Number(72.0 / 12.0)); - // With a column field the values become the innermost column level. - s.execute("pivot.addField", json!({"field": "Product", "area": "columns"})).unwrap(); - assert_eq!(text(&s, "B4"), "Apples"); - assert_eq!(text(&s, "B5"), "Sum of Sales"); - assert_eq!(text(&s, "C5"), "Average of Units"); - assert_eq!(text(&s, "D4"), "Pears"); - assert_eq!(text(&s, "H4"), "Total Sum of Sales"); - assert_eq!(text(&s, "I4"), "Total Average of Units"); - let last = pt(&s).last_range.unwrap(); - assert_eq!(val(&s, &CellRef::new(last.end.row, 7).a1()), Value::Number(TOTAL)); - } - - #[test] - fn date_grouping_by_quarter_and_month() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Date"], "values": ["Sales"]})).unwrap(); - // Ungrouped: one row per date, shown with the source date format. - assert_eq!(pt(&s).last_range.unwrap().height(), 14); - s.execute("pivot.group", json!({"field": "Date", "by": "quarters"})).unwrap(); - assert_eq!(text(&s, "A4"), "Qtr1"); - assert_eq!(text(&s, "A7"), "Qtr4"); - assert_eq!(val(&s, "B4"), Value::Number(270.0)); - assert_eq!(val(&s, "B5"), Value::Number(160.0)); - assert_eq!(val(&s, "B8"), Value::Number(TOTAL)); - s.execute("pivot.group", json!({"field": "Date", "by": "months"})).unwrap(); - assert_eq!(text(&s, "A4"), "Jan"); - assert_eq!(text(&s, "A15"), "Dec"); - s.execute("pivot.group", json!({"field": "Date", "by": "years"})).unwrap(); - assert_eq!(text(&s, "A4"), "2026"); - assert!(s.execute("pivot.group", json!({"field": "Region", "by": "years"})).is_err()); - } - - #[test] - fn filters_and_report_filter_rows() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Product"], "values": ["Sales"], "filters": ["Region"]})).unwrap(); - // Report filter above the body; the body stays at A3. - assert_eq!(text(&s, "A1"), "Region"); - assert_eq!(text(&s, "B1"), "(All)"); - assert_eq!(text(&s, "A3"), "Row Labels"); - s.execute("pivot.filter", json!({"field": "Region", "selected": ["East"]})).unwrap(); - assert_eq!(text(&s, "B1"), "East"); - let gt = find_row(&s, "Grand Total").unwrap(); - assert_eq!(val(&s, &CellRef::new(gt, 1).a1()), Value::Number(415.0)); - s.execute("pivot.filter", json!({"field": "Region", "selected": ["East", "west"]})).unwrap(); - assert_eq!(text(&s, "B1"), "(Multiple Items)"); - s.execute("pivot.filter", json!({"field": "Region", "selected": null})).unwrap(); - let gt = find_row(&s, "Grand Total").unwrap(); - assert_eq!(val(&s, &CellRef::new(gt, 1).a1()), Value::Number(TOTAL)); - // Hidden items on a row field. - s.execute("pivot.filter", json!({"field": "Product", "selected": ["Apples"]})).unwrap(); - assert_eq!(text(&s, "A4"), "Apples"); - assert_eq!(text(&s, "A5"), "Grand Total"); - assert_eq!(val(&s, "B5"), Value::Number(420.0)); - assert!(s.execute("pivot.filter", json!({"field": "Product", "selected": []})).is_err()); - } - - #[test] - fn show_values_as() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); - s.execute("pivot.valueSettings", json!({"field": "Sum of Sales", "showAs": "percentOfGrandTotal"})).unwrap(); - let east = val(&s, "B4").as_f64().unwrap(); - assert!((east - 415.0 / TOTAL).abs() < 1e-12); - assert_eq!(val(&s, "B7"), Value::Number(1.0)); - assert_eq!(text(&s, "B7"), "1"); - let d = s.doc().unwrap(); - let sh = d.wb.active().unwrap(); - assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("B4").unwrap())).num_fmt.as_str(), "0.00%"); - s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "runningTotal"})).unwrap(); - assert_eq!(val(&s, "B4"), Value::Number(415.0)); - assert_eq!(val(&s, "B5"), Value::Number(415.0 + 135.0)); - assert_eq!(val(&s, "B6"), Value::Number(TOTAL)); - s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "rank"})).unwrap(); - assert_eq!(val(&s, "B4"), Value::Number(1.0)); - assert_eq!(val(&s, "B5"), Value::Number(3.0)); - assert_eq!(val(&s, "B6"), Value::Number(2.0)); - s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "normal", "func": "max", "numberFormat": "#,##0"})).unwrap(); - assert_eq!(text(&s, "B3"), "Max of Sales"); - assert_eq!(val(&s, "B4"), Value::Number(120.0)); - assert!(s.execute("pivot.valueSettings", json!({"index": 0, "name": "Sales"})).is_err()); - s.execute("pivot.valueSettings", json!({"index": 0, "name": "Biggest sale"})).unwrap(); - assert_eq!(text(&s, "B3"), "Biggest sale"); - } - - #[test] - fn compact_vs_tabular_shapes() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales"]})).unwrap(); - // Compact: one label column; parents carry subtotals at the top; children indented. - let r = pt(&s).last_range.unwrap(); - assert_eq!(r.width(), 2); - // header + 3 regions + 9 (region, product) pairs present + grand total - assert_eq!(r.height(), 1 + 3 + 9 + 1); - assert_eq!(text(&s, "A4"), "East"); - assert_eq!(val(&s, "B4"), Value::Number(415.0)); - assert_eq!(text(&s, "A5"), "Apples"); - 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!(d.wb.styles.get(sh.style_id(CellRef::parse("A4").unwrap())).font.bold); - // Outline: one column per field. - s.execute("pivot.layout", json!({"layout": "outline"})).unwrap(); - let r = pt(&s).last_range.unwrap(); - assert_eq!(r.width(), 3); - assert_eq!(text(&s, "A3"), "Region"); - assert_eq!(text(&s, "B3"), "Product"); - assert_eq!(text(&s, "A4"), "East"); - assert_eq!(text(&s, "B5"), "Apples"); - // Tabular: first child on its parent's row; "East Total" rows below each group. - s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap(); - let r = pt(&s).last_range.unwrap(); - assert_eq!(r.width(), 3); - assert_eq!(r.height(), 1 + 9 + 3 + 1); - assert_eq!(text(&s, "A4"), "East"); - assert_eq!(text(&s, "B4"), "Apples"); - assert_eq!(text(&s, "A5"), ""); - assert_eq!(text(&s, "B5"), "Pears"); - assert_eq!(text(&s, "A7"), "East Total"); - assert_eq!(val(&s, "C7"), Value::Number(415.0)); - // No subtotals, no grand totals. - s.execute("pivot.layout", json!({"subtotals": false, "grandTotalsCols": false})).unwrap(); - assert_eq!(pt(&s).last_range.unwrap().height(), 1 + 9); - // Collapse East in compact layout: its children disappear. - s.execute("pivot.layout", json!({"layout": "compact", "subtotals": "top"})).unwrap(); - s.execute("pivot.collapse", json!({"field": "Region", "items": ["East"]})).unwrap(); - assert_eq!(text(&s, "A4"), "East"); - assert_eq!(text(&s, "A5"), "North"); - s.execute("pivot.collapse", json!({"field": "Region", "collapse": false})).unwrap(); - assert_eq!(text(&s, "A5"), "Apples"); - } - - #[test] - fn values_on_rows_layouts() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales", "Units"]})).unwrap(); - 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(); - 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] - fn collapsed_item_puts_values_in_the_values_column() { - 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"})).unwrap(); - s.execute("pivot.collapse", json!({"field": "Region", "items": ["East"]})).unwrap(); - for layout in ["tabular", "outline"] { - s.execute("pivot.layout", json!({"layout": layout})).unwrap(); - assert_eq!(text(&s, "C3"), "Values", "{layout}"); - let east = (4..20).find(|r| text(&s, &format!("A{r}")) == "East").unwrap(); - let first = if layout == "tabular" { east } else { east + 1 }; - assert_eq!(text(&s, &format!("B{first}")), "", "{layout}"); - assert_eq!(text(&s, &format!("C{first}")), "Sum of Sales", "{layout}"); - assert_eq!(val(&s, &format!("D{first}")), Value::Number(415.0), "{layout}"); - assert_eq!(text(&s, &format!("C{}", first + 1)), "Sum of Units", "{layout}"); - } - } - - #[test] - fn values_stay_last_in_columns() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Product"], "columns": ["Region"], "values": ["Sales", "Units"]})).unwrap(); - s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "columns", "position": 0})).unwrap(); - let p = pt(&s); - assert!(!p.values_on_rows); - assert_eq!(p.values_position, None); - // Σ Values to Rows and back with a position: still last in Columns. - s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap(); - s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns", "position": 0})).unwrap(); - assert_eq!(pt(&s).values_position, None); - assert_eq!(text(&s, "B5"), "Sum of Sales"); - assert_eq!(text(&s, "C5"), "Sum of Units"); - } - - #[test] - fn change_source_keeps_values_ahead_of_its_field() { - 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(); - // The new source has no Region: Σ Values stays ahead of Product. - s.execute("pivot.changeSource", json!({"source": "Sheet1!B1:E13"})).unwrap(); - let p = pt(&s); - assert_eq!(p.rows.len(), 1); - assert_eq!(p.values_position, Some(0)); - assert_eq!(text(&s, "A4"), "Sum of Sales"); - assert_eq!(text(&s, "A5"), "Apples"); - } - - #[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(); - // 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] - fn refresh_after_source_edits() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); - s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); - s.execute("cell.set", json!({"cell": "E2", "input": "1100"})).unwrap(); - // Refresh All picks the change up. - let r = s.execute("data.refreshAll", json!({})).unwrap(); - assert_eq!(r["refreshed"], 1); - let d = s.doc().unwrap(); - let si = d.wb.sheet_index("Sheet2").unwrap(); - let sh = d.wb.sheet(si).unwrap(); - assert_eq!(sh.value(CellRef::parse("B4").unwrap()), Value::Number(1415.0)); - assert_eq!(sh.value(CellRef::parse("B7").unwrap()), Value::Number(TOTAL + 1000.0)); - // pivot.refresh by name; change source to fewer rows. - s.execute("pivot.changeSource", json!({"pivot": "PivotTable1", "source": "Sheet1!A1:E3"})).unwrap(); - let d = s.doc().unwrap(); - let sh = d.wb.sheet(si).unwrap(); - assert_eq!(sh.value(CellRef::parse("A6").unwrap()).display(), "Grand Total"); - assert_eq!(sh.value(CellRef::parse("B6").unwrap()), Value::Number(1150.0)); - // Old rows were cleared. - assert_eq!(sh.value(CellRef::parse("A7").unwrap()), Value::Empty); - s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap(); - } - - fn pivot_chart(s: &Session) -> gridcraft_model::Chart { - s.doc().unwrap().wb.active().unwrap().charts.last().unwrap().clone() - } - fn plotted(s: &Session) -> Vec>> { - let d = s.doc().unwrap(); - let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet(), &pivot_chart(s)); - data.series.into_iter().map(|x| x.values).collect() - } - - #[test] - fn pivot_chart_follows_the_report() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); - // Any cell of the report picks the PivotTable. - let r = s.execute("insert.pivotChart", json!({"pivot": "B5"})).unwrap(); - assert_eq!(r["series"], 1); - let ch = pivot_chart(&s); - assert_eq!(ch.pivot.as_deref(), Some("Sheet2!PivotTable1")); - assert_eq!(ch.title.as_deref(), Some("Total")); - assert_eq!(ch.series[0].values, "Sheet2!$B$4:$B$6"); - assert_eq!(ch.series[0].categories.as_deref(), Some("Sheet2!$A$4:$A$6")); - assert_eq!(plotted(&s), vec![vec![Some(415.0), Some(135.0), Some(285.0)]]); - // A column field: one series per item, rebound by the layout change. - s.execute("pivot.addField", json!({"field": "Product", "area": "columns"})).unwrap(); - let ch = pivot_chart(&s); - let names: Vec<_> = ch.series.iter().map(|x| x.name.clone().unwrap_or_default()).collect(); - assert_eq!(names, vec!["\"Apples\"", "\"Pears\"", "\"Plums\""]); - assert_eq!(plotted(&s)[0], vec![Some(160.0), Some(90.0), Some(170.0)]); - // Nested row fields: the innermost items only, as a union around the parent rows. - s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap(); - let ch = pivot_chart(&s); - assert!(ch.series[0].values.starts_with('('), "{}", ch.series[0].values); - let pts = plotted(&s); - assert_eq!(pts[0].len(), 9); - assert_eq!(pts[0].iter().flatten().sum::(), TOTAL); - // Survives an XLSX round trip as a PivotChart. - let wb = (*s.doc().unwrap().wb).clone(); - let (back, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap(); - let si = back.sheet_index("Sheet2").unwrap(); - assert_eq!(back.sheet(si).unwrap().charts.first().and_then(|c| c.pivot.clone()).as_deref(), Some("Sheet2!PivotTable1")); - // Renaming the sheet keeps the link; a refresh still rebinds. - s.execute("sheet.rename", json!({"name": "Report"})).unwrap(); - assert_eq!(pivot_chart(&s).pivot.as_deref(), Some("Report!PivotTable1")); - s.execute("pivot.moveField", json!({"field": "Region", "from": "rows", "to": "columns"})).unwrap(); - assert_eq!(pivot_chart(&s).series.len(), 3); - // Deleting the PivotTable leaves an ordinary chart. - s.execute("pivot.delete", json!({})).unwrap(); - assert_eq!(pivot_chart(&s).pivot, None); - } - - #[test] - fn pivot_chart_errors() { - let mut s = sales(); - // No PivotTable yet. - assert!(s.execute("insert.pivotChart", json!({})).is_err()); - s.execute("insert.pivotTable", json!({"rows": ["Region"]})).unwrap(); - // Not in the report; no values. - assert!(s.execute("insert.pivotChart", json!({"pivot": "Z99"})).is_err()); - assert!(s.execute("insert.pivotChart", json!({})).unwrap_err().to_string().contains("Values")); - s.execute("pivot.addField", json!({"field": "Units", "area": "values"})).unwrap(); - let r = s.execute("insert.pivotChart", json!({"pivot": "PivotTable1", "type": "line", "title": null})).unwrap(); - assert_eq!(r["series"], 1); - let ch = pivot_chart(&s); - assert_eq!(ch.kind, gridcraft_model::ChartKind::Line); - assert_eq!(ch.title, None); - // One undo step. - s.execute("edit.undo", json!({})).unwrap(); - assert!(s.doc().unwrap().wb.active().unwrap().charts.is_empty()); - } - - #[test] - fn calculated_field_on_sums() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); - let r = s.execute("pivot.calculatedField", json!({"name": "Price", "formula": "Sales / Units"})).unwrap(); - assert_eq!(r["formula"], "=Sales / Units"); - assert_eq!(text(&s, "C3"), "Sum of Price"); - assert_eq!(text(&s, "A4"), "East"); - // East: 415 sales over 33 units, from the sums. - let east = val(&s, "C4").as_f64().unwrap(); - assert!((east - 415.0 / 33.0).abs() < 1e-12, "{east}"); - // The grand total applies the formula to the totals, not the sum of the rows. - assert_eq!(text(&s, "A7"), "Grand Total"); - let total = val(&s, "C7").as_f64().unwrap(); - assert!((total - TOTAL / 72.0).abs() < 1e-12, "{total}"); - // Quoted names, functions, and another calculated field. - s.execute("pivot.calculatedField", json!({"name": "Rounded", "formula": "=ROUND('Price'*2,1)"})).unwrap(); - assert_eq!(val(&s, "D4"), Value::Number(25.2)); - // Division by zero shows #DIV/0! cells. - s.execute("pivot.calculatedField", json!({"name": "Zero", "formula": "=Sales/(Units-Units)"})).unwrap(); - assert_eq!(val(&s, "E4"), Value::Error(CellError::Div0)); - // Change and delete. - s.execute("pivot.calculatedField", json!({"name": "zero", "formula": "=Units*2"})).unwrap(); - assert_eq!(val(&s, "E4"), Value::Number(66.0)); - s.execute("pivot.calculatedField", json!({"name": "Zero", "delete": true})).unwrap(); - assert_eq!(pt(&s).values.len(), 3); - assert_eq!(pt(&s).calculated_fields.len(), 2); - // The field list shows calculated fields. - let f = s.execute("pivot.fields", json!({})).unwrap(); - assert!(f["fields"].as_array().unwrap().iter().any(|x| x["name"] == "Price" && x["calculated"] == true)); - } - - #[test] - fn calculated_field_errors() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); - let mut e = |p: Json| s.execute("pivot.calculatedField", p).unwrap_err().to_string(); - assert!(e(json!({"name": "Bad", "formula": "=Sales*Bogus"})).contains("Bogus")); - assert!(e(json!({"name": "Bad", "formula": "=A1*2"})).contains("cells")); - assert!(e(json!({"name": "Bad", "formula": "=Sales*("})).contains("error")); - assert!(e(json!({"name": "Sales", "formula": "=1"})).contains("already exists")); - assert!(e(json!({"name": "Bad"})).contains("formula")); - assert!(e(json!({"name": "Gone", "delete": true})).contains("Gone")); - // A cycle is refused. - s.execute("pivot.calculatedField", json!({"name": "X", "formula": "=Sales"})).unwrap(); - s.execute("pivot.calculatedField", json!({"name": "Y", "formula": "=X*2"})).unwrap(); - let err = s.execute("pivot.calculatedField", json!({"name": "X", "formula": "=Y"})).unwrap_err().to_string(); - assert!(err.contains("itself"), "{err}"); - // Calculated fields only go to Values; nothing changed after the failures. - assert!(s.execute("pivot.addField", json!({"field": "Y", "area": "rows"})).is_err()); - assert_eq!(pt(&s).calculated_fields.len(), 2); - // Without a name the dialog opens (menu-style call). - s.ui_requests.clear(); - s.execute("pivot.calculatedField", json!({})).unwrap(); - assert!(matches!(s.ui_requests.last(), Some(crate::UiRequest::Dialog(n, _)) if n == "pivotCalculatedField")); - } - - #[test] - fn replace_protection() { - let mut s = sales(); - s.execute("cell.set", json!({"cell": "H5", "input": "keep me"})).unwrap(); - let e = s.execute("insert.pivotTable", json!({"destination": "Sheet1!H3", "rows": ["Region"], "values": ["Sales"]})).unwrap_err().to_string(); - assert!(e.contains("There's already data in Sheet1!H5"), "{e}"); - assert_eq!(s.doc().unwrap().wb.active().unwrap().pivots.len(), 0); - // Growing into data is refused as well. - s.execute("insert.pivotTable", json!({"destination": "Sheet1!H3"})).unwrap(); - assert!(s.execute("pivot.addField", json!({"field": "Region", "area": "rows"})).is_err()); - s.execute("pivot.addField", json!({"field": "Region", "area": "rows", "replace": true})).unwrap(); - assert_eq!(text(&s, "H5"), "North"); - // A second pivot can't overlap the first. - let e = - s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "destination": "Sheet1!H6", "replace": true})).unwrap_err().to_string(); - assert!(e.contains("overlap"), "{e}"); - // Nor its own source data. - let e = - s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "destination": "Sheet1!B2", "replace": true})).unwrap_err().to_string(); - assert!(e.contains("source data"), "{e}"); - // Delete clears the report. - s.execute("pivot.delete", json!({})).unwrap(); - assert_eq!(val(&s, "H5"), Value::Empty); - assert!(s.doc().unwrap().wb.active().unwrap().pivots.is_empty()); - } - - #[test] - fn fields_move_remove_sort() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); - let f = s.execute("pivot.fields", json!({})).unwrap(); - assert_eq!(f["fields"][0]["name"], "Region"); - assert_eq!(f["fields"][0]["samples"], json!(["East", "North", "West"])); - assert_eq!(f["fields"][0]["areas"], json!(["rows"])); - assert_eq!(f["fields"][4]["numeric"], true); - assert_eq!(f["fields"][2]["date"], true); - s.execute("pivot.sort", json!({"field": "Region", "order": "desc"})).unwrap(); - assert_eq!(text(&s, "A4"), "West"); - s.execute("pivot.moveField", json!({"field": "Region", "from": "rows", "to": "columns"})).unwrap(); - assert_eq!(text(&s, "B4"), "West"); - s.execute("pivot.removeField", json!({"field": "Sum of Sales", "area": "values"})).unwrap(); - s.execute("pivot.addField", json!({"field": "Region", "area": "values"})).unwrap(); - assert_eq!(pt(&s).values[0].name, "Count of Region"); - s.execute("pivot.removeField", json!({"field": "Region"})).unwrap(); - assert!(pt(&s).columns.is_empty() && pt(&s).values.is_empty()); - assert!(text(&s, "A3").contains("choose fields")); - assert!(s.execute("pivot.addField", json!({"field": "Nope", "area": "rows"})).is_err()); - assert!(s.execute("pivot.addField", json!({"field": "Region", "area": "sideways"})).is_err()); - } - - #[test] - fn recommended_and_summarize() { - let mut s = sales(); - let r = s.execute("insert.recommendedPivotTables", json!({})).unwrap(); - let list = r["suggestions"].as_array().unwrap(); - assert!((3..=5).contains(&list.len()), "{list:?}"); - assert_eq!(list[0]["title"], "Sum of Units by Region"); - let r = s.execute("insert.recommendedPivotTables", json!({"apply": 2})).unwrap(); - assert!(r["suggestion"]["title"].as_str().unwrap().contains("quarters")); - assert_eq!(text(&s, "A4"), "Qtr1"); - // From a table. - s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); - s.execute("insert.table", json!({"range": "A1:E13"})).unwrap(); - s.execute("selection.set", json!({"range": "B2"})).unwrap(); - let r = s.execute("table.summarizePivot", json!({"rows": ["Product"], "values": ["Units"]})).unwrap(); - assert_eq!(pt(&s).source, "Table1"); - assert_eq!(r["name"], "PivotTable2"); - let gt = find_row(&s, "Grand Total").unwrap(); - assert_eq!(val(&s, &CellRef::new(gt, 1).a1()), Value::Number(72.0)); - } - - #[test] - fn repeated_headers_get_numbered_names() { - let mut s = Session::new(); - s.new_workbook(); - let mut header = vec![json!("a"), json!("A"), json!("a"), json!("a2")]; - // Thousands of equal headers (a row of check boxes, say) used to take cubic time; this would - // hang rather than fail a wall-clock check, which is flaky on a loaded machine. - header.extend((0..3000).map(|_| json!("x"))); - let data: Vec<_> = (0..header.len()).map(|i| json!(i)).collect(); - let end = CellRef::new(1, header.len() as u32 - 1).a1(); - s.execute("range.setValues", json!({"range": "A1", "values": [header, data]})).unwrap(); - let src = crate::pivot::read_source(&s.doc().unwrap().wb, &format!("Sheet1!A1:{end}"), 0).unwrap(); - assert_eq!(&src.headers[..4], ["a", "A2", "a3", "a22"]); - assert_eq!(src.headers[4], "x"); - assert_eq!(src.headers.last().map(String::as_str), Some("x3000")); - // Table column names follow the same rule. - s.execute("insert.table", json!({"range": format!("A1:{end}"), "header": true})).unwrap(); - let text = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["text"].clone(); - assert_eq!(text(&mut s, "B1"), "A2"); - let last = CellRef::new(0, 3003).a1(); - assert_eq!(text(&mut s, &last), "x3000"); - } - - #[test] - fn hostile_params_never_panic() { - let mut s = sales(); - let cmds = [ - "insert.pivotTable", - "insert.recommendedPivotTables", - "table.summarizePivot", - "pivot.fields", - "pivot.fieldList", - "pivot.list", - "pivot.addField", - "pivot.removeField", - "pivot.moveField", - "pivot.valueSettings", - "pivot.filter", - "pivot.sort", - "pivot.group", - "pivot.collapse", - "pivot.layout", - "pivot.grandTotals", - "pivot.subtotals", - "pivot.reportLayout", - "pivot.style", - "pivot.refresh", - "data.refreshAll", - "pivot.changeSource", - "pivot.delete", - "pivot.showDetails", - "pivot.options", - ]; - let hostile = [ - json!({}), - json!([1, 2]), - json!({"pivot": 99999, "field": 5, "area": null}), - json!({"source": "ZZZ999999999", "destination": "Nope!A1"}), - json!({"source": "A:XFD", "destination": "Sheet1!XFD1048576"}), - json!({"source": "''!A1", "destination": "!"}), - json!({"field": "Sales", "area": "values", "func": "bogus", "position": 4294967295u64}), - json!({"field": "Region", "selected": [null, 1, {"x": 1}], "index": 18446744073709551615u64, "showAs": "rank"}), - json!({"apply": 99, "layout": "weird", "subtotals": 7, "style": "x".repeat(500)}), - json!({"field": "Date", "by": "decades", "items": "nope", "order": "sideways"}), - ]; - for c in cmds { - for h in &hostile { - let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let spec = crate::find_command(c).unwrap(); - let _ = (spec.run)(&mut s, h); - })); - assert!(r.is_ok(), "{c} panicked on {h}"); - } - } - // And on a real pivot. - s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "rows": ["Region"], "values": ["Sales"]})).unwrap(); - for c in cmds { - for h in &hostile { - let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - let spec = crate::find_command(c).unwrap(); - let _ = (spec.run)(&mut s, h); - })); - assert!(r.is_ok(), "{c} panicked on {h}"); - } - } - } - - #[test] - fn empty_header_is_refused() { - let mut s = Session::new(); - s.new_workbook(); - s.execute("range.setValues", json!({"range": "A1", "values": [["Region", ""], ["East", 1]]})).unwrap(); - let e = s.execute("insert.pivotTable", json!({"source": "A1:B2"})).unwrap_err().to_string(); - assert!(e.contains("field name is not valid"), "{e}"); - assert!(s.execute("insert.pivotTable", json!({"source": "NoSuchTable"})).is_err()); - } - - #[test] - fn too_many_value_columns_are_refused() { - let mut s = Session::new(); - s.new_workbook(); - let mut records: Vec = vec![json!(["Cat", "Sub", "V"])]; - for r in 0..64 { - for c in 0..64 { - records.push(json!([format!("c{r}"), format!("s{c}"), 1.0])); - } - } - s.execute("range.setValues", json!({"range": "A1", "values": records})).unwrap(); - let req = |n: usize| { - let values: Vec = (0..n).map(|_| json!({"field": "V"})).collect(); - json!({"source": "A1:C4097", "rows": ["Cat"], "columns": ["Sub"], "values": values}) - }; - let e = s.execute("insert.pivotTable", req(256)).unwrap_err().to_string(); - assert!(e.contains("will not fit"), "{e}"); - assert!(s.execute("insert.pivotTable", req(128)).is_ok()); - } - - #[test] - fn collapsed_column_items_count_as_one_column_group() { - let mut s = Session::new(); - s.new_workbook(); - let mut records: Vec = vec![json!(["Grp", "Sub", "V"])]; - records.extend((0..64).map(|c| json!(["g", format!("s{c}"), 1.0]))); - s.execute("range.setValues", json!({"range": "A1", "values": records})).unwrap(); - let values: Vec = (0..256).map(|_| json!({"field": "V"})).collect(); - s.execute("insert.pivotTable", json!({"source": "A1:C65", "columns": ["Grp"], "values": values})).unwrap(); - s.execute("pivot.collapse", json!({"field": "Grp"})).unwrap(); - // 64 Sub items under the collapsed "g" show as one column group: (1 + grand total) x 256 columns fit. - s.execute("pivot.addField", json!({"field": "Sub", "area": "columns"})).unwrap(); - } - - #[test] - fn inserting_or_deleting_lines_moves_the_pivot_with_its_cells() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "destination": "Sheet1!H10", "rows": ["Region"], "values": ["Sales"]})) - .unwrap(); - assert_eq!(pt(&s).last_range.unwrap().a1(), "H10:I14"); - // Rows above the report (and below the source) move the report only. - s.execute("home.insertRows", json!({"rows": "16:17"})).unwrap(); - s.execute("home.insertRows", json!({"rows": "6:7"})).unwrap(); - assert_eq!(text(&s, "H12"), "Row Labels"); - let l = s.execute("pivot.list", json!({})).unwrap(); - assert_eq!(l[0]["anchor"], "H12"); - assert_eq!(l[0]["range"], "H12:I16"); - assert_eq!(l[0]["source"], "Sheet1!A1:E15"); - // A row above the source moves both. - s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap(); - let p = pt(&s); - assert_eq!(p.source, "Sheet1!A2:E16"); - assert_eq!(p.anchor.a1(), "H13"); - s.execute("pivot.refresh", json!({})).unwrap(); - assert_eq!(pt(&s).last_range.unwrap().a1(), "H13:I17"); - assert_eq!(text(&s, "H13"), "Row Labels"); - assert_eq!(val(&s, "I17"), Value::Number(TOTAL)); - // Deleting a column left of the report moves it left. - s.execute("home.deleteColumns", json!({"cols": "F:F"})).unwrap(); - let p = pt(&s); - assert_eq!(p.anchor.a1(), "G13"); - assert_eq!(p.last_range.unwrap().a1(), "G13:H17"); - // Deleting rows above moves it up. - s.execute("home.deleteRows", json!({"rows": "1:1"})).unwrap(); - assert_eq!(pt(&s).source, "Sheet1!A1:E15"); - assert_eq!(pt(&s).anchor.a1(), "G12"); - s.execute("pivot.refresh", json!({})).unwrap(); - assert_eq!(text(&s, "G12"), "Row Labels"); - assert_eq!(val(&s, "H16"), Value::Number(TOTAL)); - } - - #[test] - fn inserting_rows_on_the_source_sheet_moves_another_sheets_pivot_source() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "rows": ["Region"], "values": ["Sales"]})).unwrap(); - s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); - s.execute("home.insertRows", json!({"rows": "1:2"})).unwrap(); - let l = s.execute("pivot.list", json!({})).unwrap(); - assert_eq!(l[0]["source"], "Sheet1!A3:E15"); - assert_eq!(l[0]["anchor"], "A3"); - s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap(); - } - - /// The active sheet's first five columns as shown, down to the first blank row. - fn sheet_rows(s: &mut Session) -> Vec> { - let mut out = Vec::new(); - for r in 0..30u32 { - let row: Vec = (0..5) - .map(|c| s.execute("cell.get", json!({"cell": CellRef::new(r, c).a1()})).unwrap()["text"].as_str().unwrap_or("").to_string()) - .collect(); - if row.iter().all(String::is_empty) { - break; - } - out.push(row); - } - out - } - - #[test] - fn show_details_lists_a_value_cells_records_on_a_new_sheet() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); - // B5 is East × Apples (100 + 60); the active cell is the default. - s.execute("selection.set", json!({"range": "B5"})).unwrap(); - let r = s.execute("pivot.showDetails", json!({})).unwrap(); - assert_eq!(r["records"], 2); - let d = s.doc().unwrap(); - assert_eq!(d.wb.sheets.len(), 3); - assert_eq!(d.wb.active_sheet(), 0, "the new sheet goes before the PivotTable's"); - assert_eq!(d.wb.active().unwrap().name, "Sheet3"); - assert_eq!( - sheet_rows(&mut s), - vec![ - vec!["Region", "Product", "Date", "Units", "Sales"], - vec!["East", "Apples", "1/15/2026", "10", "100"], - vec!["East", "Apples", "9/9/2026", "6", "60"], - ] - ); - // The dates fit their column instead of showing ####. - let sh = s.doc().unwrap().wb.active().unwrap().clone(); - assert!(sh.col_width(2) > sh.col_width(5), "{} vs {}", sh.col_width(2), sh.col_width(5)); - // One undo step takes the sheet away. - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); - assert_eq!(val(&s, "B5"), Value::Number(160.0)); - } - - #[test] - fn show_details_on_a_total_takes_every_record_it_adds_up() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); - let records = |s: &mut Session, cell: &str| { - let n = s.execute("pivot.showDetails", json!({"cell": cell})).unwrap()["records"].as_u64().unwrap(); - s.execute("edit.undo", json!({})).unwrap(); - n - }; - assert_eq!(records(&mut s, "E5"), 5, "East's row total"); - assert_eq!(records(&mut s, "C8"), 4, "Pears' column total"); - assert_eq!(records(&mut s, "E8"), 12, "the grand total"); - // A subtotal at the top of its group, in a two-level row axis. - s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap(); - assert_eq!(text(&s, "A4"), "East"); - assert_eq!(records(&mut s, "B4"), 5); - assert_eq!(text(&s, "A6"), "Pears"); - assert_eq!(records(&mut s, "B6"), 2); - } - - #[test] - fn show_details_leaves_out_the_records_the_report_filters_out() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Product"], "values": ["Sales"], "filters": ["Region"]})).unwrap(); - s.execute("pivot.filter", json!({"field": "Region", "selected": ["East"]})).unwrap(); - s.execute("pivot.filter", json!({"field": "Product", "selected": ["Apples"]})).unwrap(); - let gt = find_row(&s, "Grand Total").unwrap(); - let r = s.execute("pivot.showDetails", json!({"cell": CellRef::new(gt, 1).a1()})).unwrap(); - assert_eq!(r["records"], 2); - let rows = sheet_rows(&mut s); - assert_eq!(rows.len(), 3); - assert!(rows.iter().skip(1).all(|row| row[0] == "East" && row[1] == "Apples"), "{rows:?}"); - } - - #[test] - fn show_details_refuses_a_report_its_source_has_moved_on_from() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); - // B5 still shows East × Apples (160), but the first East record is now Central's. - s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap(); - assert_eq!(val(&s, "B5"), Value::Number(160.0)); - let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string(); - assert!(e.contains("out of date"), "{e}"); - assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); - // After a refresh East × Apples (now B6) has the one record left. - s.execute("pivot.refresh", json!({})).unwrap(); - assert_eq!(text(&s, "A6"), "East"); - assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B6"})).unwrap()["records"], 1); - } - - #[test] - fn show_details_refuses_a_report_that_would_now_be_shorter() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); - // North goes away: the source now gives one row fewer, so row 8 is no longer the Grand Total. - s.execute("range.setValues", json!({"range": "Sheet1!A5", "values": [["East"]]})).unwrap(); - s.execute("range.setValues", json!({"range": "Sheet1!A8", "values": [["East"]]})).unwrap(); - s.execute("range.setValues", json!({"range": "Sheet1!A11", "values": [["East"]]})).unwrap(); - assert_eq!(text(&s, "A8"), "Grand Total"); - let e = s.execute("pivot.showDetails", json!({"cell": "E8"})).unwrap_err().to_string(); - assert!(e.contains("out of date"), "{e}"); - assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); - } - - #[test] - fn show_details_needs_a_value_cell() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); - for cell in ["A3", "B3", "A4", "B4", "A5", "A8", "H20"] { - assert!(s.execute("pivot.showDetails", json!({"cell": cell})).is_err(), "{cell}"); - } - assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); - } - - #[test] - fn enable_show_details_off_stops_show_details() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); - assert_eq!(s.execute("pivot.options", json!({})).unwrap()["enableShowDetails"], true); - s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap(); - assert!(!pt(&s).enable_show_details); - assert_eq!(s.execute("pivot.fields", json!({})).unwrap()["enableShowDetails"], false); - let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string(); - assert_eq!(e, "Show Details is turned off for this PivotTable."); - assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); - // A label still reports that it is not a value cell. - let e = s.execute("pivot.showDetails", json!({"cell": "A5"})).unwrap_err().to_string(); - assert!(!e.contains("turned off"), "{e}"); - // Setting the same value again adds no undo step. - let after = s.doc().unwrap().revision; - s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap(); - assert_eq!(s.doc().unwrap().revision, after); - assert!(s.execute("pivot.options", json!({"enableShowDetails": "no"})).is_err()); - // One undo turns the option back on. - s.execute("edit.undo", json!({})).unwrap(); - assert!(pt(&s).enable_show_details); - assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap()["records"], 2); - } - - #[test] - fn enable_show_details_off_is_reported_before_an_out_of_date_report() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); - s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap(); - // The source changes after the report was written, so the report is out of date. - s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap(); - let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string(); - assert!(e.contains("turned off") && !e.contains("out of date"), "{e}"); - assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); - } - - #[test] - fn detail_cells_are_the_cells_show_details_takes() { - let mut s = sales(); - s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); - for layout in 0..2 { - if layout == 1 { - // A two-level row axis, with subtotals at the top of each group. - s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap(); - } - let d = s.doc().unwrap(); - let si = d.wb.active_sheet(); - let pt = &d.wb.active().unwrap().pivots[0]; - let r = pt.last_range.unwrap(); - let cells = crate::pivot::detail_cells(&d.wb, pt, si).unwrap(); - let mut values = 0; - for row in 0..r.height() { - for col in 0..r.width() { - let one = crate::pivot::detail_records(&d.wb, pt, si, row, col).unwrap(); - assert_eq!(cells.contains(&(row, col)), one.is_some(), "layout {layout}, cell ({row}, {col})"); - values += usize::from(one.is_some()); - } - } - assert!(values > 4, "layout {layout} has value cells"); - } - // An out-of-date report answers as Show Details does. - s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap(); - let d = s.doc().unwrap(); - let pt = &d.wb.active().unwrap().pivots[0]; - assert!(crate::pivot::detail_cells(&d.wb, pt, d.wb.active_sheet()).unwrap_err().contains("out of date")); - } -} +mod tests; diff --git a/crates/engine/src/cmd/pivot/tests.rs b/crates/engine/src/cmd/pivot/tests.rs new file mode 100644 index 000000000..49aca34d8 --- /dev/null +++ b/crates/engine/src/cmd/pivot/tests.rs @@ -0,0 +1,994 @@ +use gridcraft_core::{CellError, CellRef, Value}; +use serde_json::{Value as Json, json}; + +use crate::Session; + +/// Region, Product, Date, Units, Sales — 12 records over 2026. +fn sales() -> Session { + let mut s = Session::new(); + s.new_workbook(); + let rows = json!([ + ["Region", "Product", "Date", "Units", "Sales"], + ["East", "Apples", "1/15/2026", 10, 100], + ["West", "Apples", "2/10/2026", 5, 50], + ["East", "Pears", "3/3/2026", 8, 120], + ["North", "Pears", "4/20/2026", 2, 30], + ["West", "Plums", "5/5/2026", 7, 70], + ["East", "Plums", "6/30/2026", 4, 60], + ["North", "Apples", "7/4/2026", 9, 90], + ["West", "Pears", "8/8/2026", 3, 45], + ["East", "Apples", "9/9/2026", 6, 60], + ["North", "Plums", "10/10/2026", 1, 15], + ["West", "Apples", "11/11/2026", 12, 120], + ["East", "Pears", "12/12/2026", 5, 75] + ]); + s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); + s.execute("selection.set", json!({"range": "A1"})).unwrap(); + s +} +const TOTAL: f64 = 835.0; + +fn val(s: &Session, a: &str) -> Value { + s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(a).unwrap()) +} +fn text(s: &Session, a: &str) -> String { + val(s, a).display() +} +fn pt(s: &Session) -> gridcraft_model::PivotTable { + s.doc().unwrap().wb.active().unwrap().pivots.last().unwrap().clone() +} +/// Finds the row (0-based) whose first report column holds `label`. +fn find_row(s: &Session, label: &str) -> Option { + let p = pt(s); + let r = p.last_range.unwrap(); + (r.start.row..=r.end.row).find(|row| val(s, &CellRef::new(*row, r.start.col).a1()).display() == label) +} + +#[test] +fn insert_creates_placeholder_on_new_sheet() { + let mut s = sales(); + let r = s.execute("insert.pivotTable", json!({})).unwrap(); + assert_eq!(r["name"], "PivotTable1"); + assert_eq!(r["anchor"], "A3"); + assert_eq!(r["fields"], json!(["Region", "Product", "Date", "Units", "Sales"])); + let d = s.doc().unwrap(); + assert_eq!(d.wb.sheets.len(), 2); + assert_eq!(d.wb.active_sheet(), 0, "new sheet goes before the current one"); + assert_eq!(d.wb.active().unwrap().name, "Sheet2"); + assert!(text(&s, "A3").starts_with("PivotTable1")); + assert_eq!(pt(&s).source, "Sheet1!$A$1:$E$13"); + // One undo step removes it. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 1); +} + +#[test] +fn region_by_product_sum_of_sales() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({})).unwrap(); + s.execute("pivot.addField", json!({"field": "region", "area": "rows"})).unwrap(); + s.execute("pivot.addField", json!({"field": "Product", "area": "columns"})).unwrap(); + let r = s.execute("pivot.addField", json!({"field": "Sales", "area": "values"})).unwrap(); + assert_eq!(r["range"], "A3:E8"); + assert_eq!(text(&s, "A3"), "Sum of Sales"); + assert_eq!(text(&s, "B3"), "Column Labels"); + assert_eq!(text(&s, "A4"), "Row Labels"); + assert_eq!(text(&s, "B4"), "Apples"); + assert_eq!(text(&s, "C4"), "Pears"); + assert_eq!(text(&s, "D4"), "Plums"); + assert_eq!(text(&s, "E4"), "Grand Total"); + assert_eq!(text(&s, "A5"), "East"); + assert_eq!(text(&s, "A6"), "North"); + assert_eq!(text(&s, "A7"), "West"); + assert_eq!(text(&s, "A8"), "Grand Total"); + assert_eq!(val(&s, "B5"), Value::Number(160.0)); + assert_eq!(val(&s, "C5"), Value::Number(195.0)); + assert_eq!(val(&s, "E5"), Value::Number(415.0)); + assert_eq!(val(&s, "E8"), Value::Number(TOTAL)); + // Column grand totals add up too. + let sum: f64 = ["B8", "C8", "D8"].iter().map(|a| val(&s, a).as_f64().unwrap()).sum(); + assert_eq!(sum, TOTAL); + let rows: f64 = ["E5", "E6", "E7"].iter().map(|a| val(&s, a).as_f64().unwrap()).sum(); + assert_eq!(rows, TOTAL); + // Styles: header bold, grand total bold with a top border. + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A4").unwrap())).font.bold); + let g = d.wb.styles.get(sh.style_id(CellRef::parse("B8").unwrap())); + assert!(g.font.bold && !g.borders.top.is_none()); + // Listing. + let l = s.execute("pivot.list", json!({})).unwrap(); + assert_eq!(l[0]["values"][0]["name"], "Sum of Sales"); +} + +#[test] +fn multiple_value_fields_make_a_values_dimension() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales", {"field": "Units", "func": "average"}]})).unwrap(); + assert_eq!(text(&s, "A3"), "Row Labels"); + assert_eq!(text(&s, "B3"), "Sum of Sales"); + assert_eq!(text(&s, "C3"), "Average of Units"); + assert_eq!(text(&s, "A7"), "Grand Total"); + assert_eq!(val(&s, "B7"), Value::Number(TOTAL)); + assert_eq!(val(&s, "C7"), Value::Number(72.0 / 12.0)); + // With a column field the values become the innermost column level. + s.execute("pivot.addField", json!({"field": "Product", "area": "columns"})).unwrap(); + assert_eq!(text(&s, "B4"), "Apples"); + assert_eq!(text(&s, "B5"), "Sum of Sales"); + assert_eq!(text(&s, "C5"), "Average of Units"); + assert_eq!(text(&s, "D4"), "Pears"); + assert_eq!(text(&s, "H4"), "Total Sum of Sales"); + assert_eq!(text(&s, "I4"), "Total Average of Units"); + let last = pt(&s).last_range.unwrap(); + assert_eq!(val(&s, &CellRef::new(last.end.row, 7).a1()), Value::Number(TOTAL)); +} + +#[test] +fn date_grouping_by_quarter_and_month() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Date"], "values": ["Sales"]})).unwrap(); + // Ungrouped: one row per date, shown with the source date format. + assert_eq!(pt(&s).last_range.unwrap().height(), 14); + s.execute("pivot.group", json!({"field": "Date", "by": "quarters"})).unwrap(); + assert_eq!(text(&s, "A4"), "Qtr1"); + assert_eq!(text(&s, "A7"), "Qtr4"); + assert_eq!(val(&s, "B4"), Value::Number(270.0)); + assert_eq!(val(&s, "B5"), Value::Number(160.0)); + assert_eq!(val(&s, "B8"), Value::Number(TOTAL)); + s.execute("pivot.group", json!({"field": "Date", "by": "months"})).unwrap(); + assert_eq!(text(&s, "A4"), "Jan"); + assert_eq!(text(&s, "A15"), "Dec"); + s.execute("pivot.group", json!({"field": "Date", "by": "years"})).unwrap(); + assert_eq!(text(&s, "A4"), "2026"); + assert!(s.execute("pivot.group", json!({"field": "Region", "by": "years"})).is_err()); +} + +#[test] +fn filters_and_report_filter_rows() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Product"], "values": ["Sales"], "filters": ["Region"]})).unwrap(); + // Report filter above the body; the body stays at A3. + assert_eq!(text(&s, "A1"), "Region"); + assert_eq!(text(&s, "B1"), "(All)"); + assert_eq!(text(&s, "A3"), "Row Labels"); + s.execute("pivot.filter", json!({"field": "Region", "selected": ["East"]})).unwrap(); + assert_eq!(text(&s, "B1"), "East"); + let gt = find_row(&s, "Grand Total").unwrap(); + assert_eq!(val(&s, &CellRef::new(gt, 1).a1()), Value::Number(415.0)); + s.execute("pivot.filter", json!({"field": "Region", "selected": ["East", "west"]})).unwrap(); + assert_eq!(text(&s, "B1"), "(Multiple Items)"); + s.execute("pivot.filter", json!({"field": "Region", "selected": null})).unwrap(); + let gt = find_row(&s, "Grand Total").unwrap(); + assert_eq!(val(&s, &CellRef::new(gt, 1).a1()), Value::Number(TOTAL)); + // Hidden items on a row field. + s.execute("pivot.filter", json!({"field": "Product", "selected": ["Apples"]})).unwrap(); + assert_eq!(text(&s, "A4"), "Apples"); + assert_eq!(text(&s, "A5"), "Grand Total"); + assert_eq!(val(&s, "B5"), Value::Number(420.0)); + assert!(s.execute("pivot.filter", json!({"field": "Product", "selected": []})).is_err()); +} + +#[test] +fn show_values_as() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); + s.execute("pivot.valueSettings", json!({"field": "Sum of Sales", "showAs": "percentOfGrandTotal"})).unwrap(); + let east = val(&s, "B4").as_f64().unwrap(); + assert!((east - 415.0 / TOTAL).abs() < 1e-12); + assert_eq!(val(&s, "B7"), Value::Number(1.0)); + assert_eq!(text(&s, "B7"), "1"); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("B4").unwrap())).num_fmt.as_str(), "0.00%"); + s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "runningTotal"})).unwrap(); + assert_eq!(val(&s, "B4"), Value::Number(415.0)); + assert_eq!(val(&s, "B5"), Value::Number(415.0 + 135.0)); + assert_eq!(val(&s, "B6"), Value::Number(TOTAL)); + s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "rank"})).unwrap(); + assert_eq!(val(&s, "B4"), Value::Number(1.0)); + assert_eq!(val(&s, "B5"), Value::Number(3.0)); + assert_eq!(val(&s, "B6"), Value::Number(2.0)); + s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "normal", "func": "max", "numberFormat": "#,##0"})).unwrap(); + assert_eq!(text(&s, "B3"), "Max of Sales"); + assert_eq!(val(&s, "B4"), Value::Number(120.0)); + assert!(s.execute("pivot.valueSettings", json!({"index": 0, "name": "Sales"})).is_err()); + s.execute("pivot.valueSettings", json!({"index": 0, "name": "Biggest sale"})).unwrap(); + assert_eq!(text(&s, "B3"), "Biggest sale"); +} + +#[test] +fn compact_vs_tabular_shapes() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales"]})).unwrap(); + // Compact: one label column; parents carry subtotals at the top; children indented. + let r = pt(&s).last_range.unwrap(); + assert_eq!(r.width(), 2); + // header + 3 regions + 9 (region, product) pairs present + grand total + assert_eq!(r.height(), 1 + 3 + 9 + 1); + assert_eq!(text(&s, "A4"), "East"); + assert_eq!(val(&s, "B4"), Value::Number(415.0)); + assert_eq!(text(&s, "A5"), "Apples"); + 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!(d.wb.styles.get(sh.style_id(CellRef::parse("A4").unwrap())).font.bold); + // Outline: one column per field. + s.execute("pivot.layout", json!({"layout": "outline"})).unwrap(); + let r = pt(&s).last_range.unwrap(); + assert_eq!(r.width(), 3); + assert_eq!(text(&s, "A3"), "Region"); + assert_eq!(text(&s, "B3"), "Product"); + assert_eq!(text(&s, "A4"), "East"); + assert_eq!(text(&s, "B5"), "Apples"); + // Tabular: first child on its parent's row; "East Total" rows below each group. + s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap(); + let r = pt(&s).last_range.unwrap(); + assert_eq!(r.width(), 3); + assert_eq!(r.height(), 1 + 9 + 3 + 1); + assert_eq!(text(&s, "A4"), "East"); + assert_eq!(text(&s, "B4"), "Apples"); + assert_eq!(text(&s, "A5"), ""); + assert_eq!(text(&s, "B5"), "Pears"); + assert_eq!(text(&s, "A7"), "East Total"); + assert_eq!(val(&s, "C7"), Value::Number(415.0)); + // No subtotals, no grand totals. + s.execute("pivot.layout", json!({"subtotals": false, "grandTotalsCols": false})).unwrap(); + assert_eq!(pt(&s).last_range.unwrap().height(), 1 + 9); + // Collapse East in compact layout: its children disappear. + s.execute("pivot.layout", json!({"layout": "compact", "subtotals": "top"})).unwrap(); + s.execute("pivot.collapse", json!({"field": "Region", "items": ["East"]})).unwrap(); + assert_eq!(text(&s, "A4"), "East"); + assert_eq!(text(&s, "A5"), "North"); + s.execute("pivot.collapse", json!({"field": "Region", "collapse": false})).unwrap(); + assert_eq!(text(&s, "A5"), "Apples"); +} + +#[test] +fn values_on_rows_layouts() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales", "Units"]})).unwrap(); + 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(); + 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] +fn collapsed_item_puts_values_in_the_values_column() { + 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"})).unwrap(); + s.execute("pivot.collapse", json!({"field": "Region", "items": ["East"]})).unwrap(); + for layout in ["tabular", "outline"] { + s.execute("pivot.layout", json!({"layout": layout})).unwrap(); + assert_eq!(text(&s, "C3"), "Values", "{layout}"); + let east = (4..20).find(|r| text(&s, &format!("A{r}")) == "East").unwrap(); + let first = if layout == "tabular" { east } else { east + 1 }; + assert_eq!(text(&s, &format!("B{first}")), "", "{layout}"); + assert_eq!(text(&s, &format!("C{first}")), "Sum of Sales", "{layout}"); + assert_eq!(val(&s, &format!("D{first}")), Value::Number(415.0), "{layout}"); + assert_eq!(text(&s, &format!("C{}", first + 1)), "Sum of Units", "{layout}"); + } +} + +#[test] +fn values_stay_last_in_columns() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Product"], "columns": ["Region"], "values": ["Sales", "Units"]})).unwrap(); + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "columns", "position": 0})).unwrap(); + let p = pt(&s); + assert!(!p.values_on_rows); + assert_eq!(p.values_position, None); + // Σ Values to Rows and back with a position: still last in Columns. + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap(); + s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns", "position": 0})).unwrap(); + assert_eq!(pt(&s).values_position, None); + assert_eq!(text(&s, "B5"), "Sum of Sales"); + assert_eq!(text(&s, "C5"), "Sum of Units"); +} + +#[test] +fn change_source_keeps_values_ahead_of_its_field() { + 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(); + // The new source has no Region: Σ Values stays ahead of Product. + s.execute("pivot.changeSource", json!({"source": "Sheet1!B1:E13"})).unwrap(); + let p = pt(&s); + assert_eq!(p.rows.len(), 1); + assert_eq!(p.values_position, Some(0)); + assert_eq!(text(&s, "A4"), "Sum of Sales"); + assert_eq!(text(&s, "A5"), "Apples"); +} + +#[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(); + // 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] +fn refresh_after_source_edits() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + s.execute("cell.set", json!({"cell": "E2", "input": "1100"})).unwrap(); + // Refresh All picks the change up. + let r = s.execute("data.refreshAll", json!({})).unwrap(); + assert_eq!(r["refreshed"], 1); + let d = s.doc().unwrap(); + let si = d.wb.sheet_index("Sheet2").unwrap(); + let sh = d.wb.sheet(si).unwrap(); + assert_eq!(sh.value(CellRef::parse("B4").unwrap()), Value::Number(1415.0)); + assert_eq!(sh.value(CellRef::parse("B7").unwrap()), Value::Number(TOTAL + 1000.0)); + // pivot.refresh by name; change source to fewer rows. + s.execute("pivot.changeSource", json!({"pivot": "PivotTable1", "source": "Sheet1!A1:E3"})).unwrap(); + let d = s.doc().unwrap(); + let sh = d.wb.sheet(si).unwrap(); + assert_eq!(sh.value(CellRef::parse("A6").unwrap()).display(), "Grand Total"); + assert_eq!(sh.value(CellRef::parse("B6").unwrap()), Value::Number(1150.0)); + // Old rows were cleared. + assert_eq!(sh.value(CellRef::parse("A7").unwrap()), Value::Empty); + s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap(); +} + +fn pivot_chart(s: &Session) -> gridcraft_model::Chart { + s.doc().unwrap().wb.active().unwrap().charts.last().unwrap().clone() +} +fn plotted(s: &Session) -> Vec>> { + let d = s.doc().unwrap(); + let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet(), &pivot_chart(s)); + data.series.into_iter().map(|x| x.values).collect() +} + +#[test] +fn pivot_chart_follows_the_report() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); + // Any cell of the report picks the PivotTable. + let r = s.execute("insert.pivotChart", json!({"pivot": "B5"})).unwrap(); + assert_eq!(r["series"], 1); + let ch = pivot_chart(&s); + assert_eq!(ch.pivot.as_deref(), Some("Sheet2!PivotTable1")); + assert_eq!(ch.title.as_deref(), Some("Total")); + assert_eq!(ch.series[0].values, "Sheet2!$B$4:$B$6"); + assert_eq!(ch.series[0].categories.as_deref(), Some("Sheet2!$A$4:$A$6")); + assert_eq!(plotted(&s), vec![vec![Some(415.0), Some(135.0), Some(285.0)]]); + // A column field: one series per item, rebound by the layout change. + s.execute("pivot.addField", json!({"field": "Product", "area": "columns"})).unwrap(); + let ch = pivot_chart(&s); + let names: Vec<_> = ch.series.iter().map(|x| x.name.clone().unwrap_or_default()).collect(); + assert_eq!(names, vec!["\"Apples\"", "\"Pears\"", "\"Plums\""]); + assert_eq!(plotted(&s)[0], vec![Some(160.0), Some(90.0), Some(170.0)]); + // Nested row fields: the innermost items only, as a union around the parent rows. + s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap(); + let ch = pivot_chart(&s); + assert!(ch.series[0].values.starts_with('('), "{}", ch.series[0].values); + let pts = plotted(&s); + assert_eq!(pts[0].len(), 9); + assert_eq!(pts[0].iter().flatten().sum::(), TOTAL); + // Survives an XLSX round trip as a PivotChart. + let wb = (*s.doc().unwrap().wb).clone(); + let (back, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap(); + let si = back.sheet_index("Sheet2").unwrap(); + assert_eq!(back.sheet(si).unwrap().charts.first().and_then(|c| c.pivot.clone()).as_deref(), Some("Sheet2!PivotTable1")); + // Renaming the sheet keeps the link; a refresh still rebinds. + s.execute("sheet.rename", json!({"name": "Report"})).unwrap(); + assert_eq!(pivot_chart(&s).pivot.as_deref(), Some("Report!PivotTable1")); + s.execute("pivot.moveField", json!({"field": "Region", "from": "rows", "to": "columns"})).unwrap(); + assert_eq!(pivot_chart(&s).series.len(), 3); + // Deleting the PivotTable leaves an ordinary chart. + s.execute("pivot.delete", json!({})).unwrap(); + assert_eq!(pivot_chart(&s).pivot, None); +} + +#[test] +fn pivot_chart_errors() { + let mut s = sales(); + // No PivotTable yet. + assert!(s.execute("insert.pivotChart", json!({})).is_err()); + s.execute("insert.pivotTable", json!({"rows": ["Region"]})).unwrap(); + // Not in the report; no values. + assert!(s.execute("insert.pivotChart", json!({"pivot": "Z99"})).is_err()); + assert!(s.execute("insert.pivotChart", json!({})).unwrap_err().to_string().contains("Values")); + s.execute("pivot.addField", json!({"field": "Units", "area": "values"})).unwrap(); + let r = s.execute("insert.pivotChart", json!({"pivot": "PivotTable1", "type": "line", "title": null})).unwrap(); + assert_eq!(r["series"], 1); + let ch = pivot_chart(&s); + assert_eq!(ch.kind, gridcraft_model::ChartKind::Line); + assert_eq!(ch.title, None); + // One undo step. + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.doc().unwrap().wb.active().unwrap().charts.is_empty()); +} + +#[test] +fn calculated_field_on_sums() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); + let r = s.execute("pivot.calculatedField", json!({"name": "Price", "formula": "Sales / Units"})).unwrap(); + assert_eq!(r["formula"], "=Sales / Units"); + assert_eq!(text(&s, "C3"), "Sum of Price"); + assert_eq!(text(&s, "A4"), "East"); + // East: 415 sales over 33 units, from the sums. + let east = val(&s, "C4").as_f64().unwrap(); + assert!((east - 415.0 / 33.0).abs() < 1e-12, "{east}"); + // The grand total applies the formula to the totals, not the sum of the rows. + assert_eq!(text(&s, "A7"), "Grand Total"); + let total = val(&s, "C7").as_f64().unwrap(); + assert!((total - TOTAL / 72.0).abs() < 1e-12, "{total}"); + // Quoted names, functions, and another calculated field. + s.execute("pivot.calculatedField", json!({"name": "Rounded", "formula": "=ROUND('Price'*2,1)"})).unwrap(); + assert_eq!(val(&s, "D4"), Value::Number(25.2)); + // Division by zero shows #DIV/0! cells. + s.execute("pivot.calculatedField", json!({"name": "Zero", "formula": "=Sales/(Units-Units)"})).unwrap(); + assert_eq!(val(&s, "E4"), Value::Error(CellError::Div0)); + // Change and delete. + s.execute("pivot.calculatedField", json!({"name": "zero", "formula": "=Units*2"})).unwrap(); + assert_eq!(val(&s, "E4"), Value::Number(66.0)); + s.execute("pivot.calculatedField", json!({"name": "Zero", "delete": true})).unwrap(); + assert_eq!(pt(&s).values.len(), 3); + assert_eq!(pt(&s).calculated_fields.len(), 2); + // The field list shows calculated fields. + let f = s.execute("pivot.fields", json!({})).unwrap(); + assert!(f["fields"].as_array().unwrap().iter().any(|x| x["name"] == "Price" && x["calculated"] == true)); +} + +#[test] +fn calculated_field_errors() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); + let mut e = |p: Json| s.execute("pivot.calculatedField", p).unwrap_err().to_string(); + assert!(e(json!({"name": "Bad", "formula": "=Sales*Bogus"})).contains("Bogus")); + assert!(e(json!({"name": "Bad", "formula": "=A1*2"})).contains("cells")); + assert!(e(json!({"name": "Bad", "formula": "=Sales*("})).contains("error")); + assert!(e(json!({"name": "Sales", "formula": "=1"})).contains("already exists")); + assert!(e(json!({"name": "Bad"})).contains("formula")); + assert!(e(json!({"name": "Gone", "delete": true})).contains("Gone")); + // A cycle is refused. + s.execute("pivot.calculatedField", json!({"name": "X", "formula": "=Sales"})).unwrap(); + s.execute("pivot.calculatedField", json!({"name": "Y", "formula": "=X*2"})).unwrap(); + let err = s.execute("pivot.calculatedField", json!({"name": "X", "formula": "=Y"})).unwrap_err().to_string(); + assert!(err.contains("itself"), "{err}"); + // Calculated fields only go to Values; nothing changed after the failures. + assert!(s.execute("pivot.addField", json!({"field": "Y", "area": "rows"})).is_err()); + assert_eq!(pt(&s).calculated_fields.len(), 2); + // Without a name the dialog opens (menu-style call). + s.ui_requests.clear(); + s.execute("pivot.calculatedField", json!({})).unwrap(); + assert!(matches!(s.ui_requests.last(), Some(crate::UiRequest::Dialog(n, _)) if n == "pivotCalculatedField")); +} + +#[test] +fn replace_protection() { + let mut s = sales(); + s.execute("cell.set", json!({"cell": "H5", "input": "keep me"})).unwrap(); + let e = s.execute("insert.pivotTable", json!({"destination": "Sheet1!H3", "rows": ["Region"], "values": ["Sales"]})).unwrap_err().to_string(); + assert!(e.contains("There's already data in Sheet1!H5"), "{e}"); + assert_eq!(s.doc().unwrap().wb.active().unwrap().pivots.len(), 0); + // Growing into data is refused as well. + s.execute("insert.pivotTable", json!({"destination": "Sheet1!H3"})).unwrap(); + assert!(s.execute("pivot.addField", json!({"field": "Region", "area": "rows"})).is_err()); + s.execute("pivot.addField", json!({"field": "Region", "area": "rows", "replace": true})).unwrap(); + assert_eq!(text(&s, "H5"), "North"); + // A second pivot can't overlap the first. + let e = + s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "destination": "Sheet1!H6", "replace": true})).unwrap_err().to_string(); + assert!(e.contains("overlap"), "{e}"); + // Nor its own source data. + let e = + s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "destination": "Sheet1!B2", "replace": true})).unwrap_err().to_string(); + assert!(e.contains("source data"), "{e}"); + // Delete clears the report. + s.execute("pivot.delete", json!({})).unwrap(); + assert_eq!(val(&s, "H5"), Value::Empty); + assert!(s.doc().unwrap().wb.active().unwrap().pivots.is_empty()); +} + +#[test] +fn fields_move_remove_sort() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap(); + let f = s.execute("pivot.fields", json!({})).unwrap(); + assert_eq!(f["fields"][0]["name"], "Region"); + assert_eq!(f["fields"][0]["samples"], json!(["East", "North", "West"])); + assert_eq!(f["fields"][0]["areas"], json!(["rows"])); + assert_eq!(f["fields"][4]["numeric"], true); + assert_eq!(f["fields"][2]["date"], true); + s.execute("pivot.sort", json!({"field": "Region", "order": "desc"})).unwrap(); + assert_eq!(text(&s, "A4"), "West"); + s.execute("pivot.moveField", json!({"field": "Region", "from": "rows", "to": "columns"})).unwrap(); + assert_eq!(text(&s, "B4"), "West"); + s.execute("pivot.removeField", json!({"field": "Sum of Sales", "area": "values"})).unwrap(); + s.execute("pivot.addField", json!({"field": "Region", "area": "values"})).unwrap(); + assert_eq!(pt(&s).values[0].name, "Count of Region"); + s.execute("pivot.removeField", json!({"field": "Region"})).unwrap(); + assert!(pt(&s).columns.is_empty() && pt(&s).values.is_empty()); + assert!(text(&s, "A3").contains("choose fields")); + assert!(s.execute("pivot.addField", json!({"field": "Nope", "area": "rows"})).is_err()); + assert!(s.execute("pivot.addField", json!({"field": "Region", "area": "sideways"})).is_err()); +} + +#[test] +fn recommended_and_summarize() { + let mut s = sales(); + let r = s.execute("insert.recommendedPivotTables", json!({})).unwrap(); + let list = r["suggestions"].as_array().unwrap(); + assert!((3..=5).contains(&list.len()), "{list:?}"); + assert_eq!(list[0]["title"], "Sum of Units by Region"); + let r = s.execute("insert.recommendedPivotTables", json!({"apply": 2})).unwrap(); + assert!(r["suggestion"]["title"].as_str().unwrap().contains("quarters")); + assert_eq!(text(&s, "A4"), "Qtr1"); + // From a table. + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + s.execute("insert.table", json!({"range": "A1:E13"})).unwrap(); + s.execute("selection.set", json!({"range": "B2"})).unwrap(); + let r = s.execute("table.summarizePivot", json!({"rows": ["Product"], "values": ["Units"]})).unwrap(); + assert_eq!(pt(&s).source, "Table1"); + assert_eq!(r["name"], "PivotTable2"); + let gt = find_row(&s, "Grand Total").unwrap(); + assert_eq!(val(&s, &CellRef::new(gt, 1).a1()), Value::Number(72.0)); +} + +#[test] +fn repeated_headers_get_numbered_names() { + let mut s = Session::new(); + s.new_workbook(); + let mut header = vec![json!("a"), json!("A"), json!("a"), json!("a2")]; + // Thousands of equal headers (a row of check boxes, say) used to take cubic time; this would + // hang rather than fail a wall-clock check, which is flaky on a loaded machine. + header.extend((0..3000).map(|_| json!("x"))); + let data: Vec<_> = (0..header.len()).map(|i| json!(i)).collect(); + let end = CellRef::new(1, header.len() as u32 - 1).a1(); + s.execute("range.setValues", json!({"range": "A1", "values": [header, data]})).unwrap(); + let src = crate::pivot::read_source(&s.doc().unwrap().wb, &format!("Sheet1!A1:{end}"), 0).unwrap(); + assert_eq!(&src.headers[..4], ["a", "A2", "a3", "a22"]); + assert_eq!(src.headers[4], "x"); + assert_eq!(src.headers.last().map(String::as_str), Some("x3000")); + // Table column names follow the same rule. + s.execute("insert.table", json!({"range": format!("A1:{end}"), "header": true})).unwrap(); + let text = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["text"].clone(); + assert_eq!(text(&mut s, "B1"), "A2"); + let last = CellRef::new(0, 3003).a1(); + assert_eq!(text(&mut s, &last), "x3000"); +} + +#[test] +fn hostile_params_never_panic() { + let mut s = sales(); + let cmds = [ + "insert.pivotTable", + "insert.recommendedPivotTables", + "table.summarizePivot", + "pivot.fields", + "pivot.fieldList", + "pivot.list", + "pivot.addField", + "pivot.removeField", + "pivot.moveField", + "pivot.valueSettings", + "pivot.filter", + "pivot.sort", + "pivot.group", + "pivot.collapse", + "pivot.layout", + "pivot.grandTotals", + "pivot.subtotals", + "pivot.reportLayout", + "pivot.style", + "pivot.refresh", + "data.refreshAll", + "pivot.changeSource", + "pivot.delete", + "pivot.showDetails", + "pivot.options", + ]; + let hostile = [ + json!({}), + json!([1, 2]), + json!({"pivot": 99999, "field": 5, "area": null}), + json!({"source": "ZZZ999999999", "destination": "Nope!A1"}), + json!({"source": "A:XFD", "destination": "Sheet1!XFD1048576"}), + json!({"source": "''!A1", "destination": "!"}), + json!({"field": "Sales", "area": "values", "func": "bogus", "position": 4294967295u64}), + json!({"field": "Region", "selected": [null, 1, {"x": 1}], "index": 18446744073709551615u64, "showAs": "rank"}), + json!({"apply": 99, "layout": "weird", "subtotals": 7, "style": "x".repeat(500)}), + json!({"field": "Date", "by": "decades", "items": "nope", "order": "sideways"}), + ]; + for c in cmds { + for h in &hostile { + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let spec = crate::find_command(c).unwrap(); + let _ = (spec.run)(&mut s, h); + })); + assert!(r.is_ok(), "{c} panicked on {h}"); + } + } + // And on a real pivot. + s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "rows": ["Region"], "values": ["Sales"]})).unwrap(); + for c in cmds { + for h in &hostile { + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let spec = crate::find_command(c).unwrap(); + let _ = (spec.run)(&mut s, h); + })); + assert!(r.is_ok(), "{c} panicked on {h}"); + } + } +} + +#[test] +fn empty_header_is_refused() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Region", ""], ["East", 1]]})).unwrap(); + let e = s.execute("insert.pivotTable", json!({"source": "A1:B2"})).unwrap_err().to_string(); + assert!(e.contains("field name is not valid"), "{e}"); + assert!(s.execute("insert.pivotTable", json!({"source": "NoSuchTable"})).is_err()); +} + +#[test] +fn too_many_value_columns_are_refused() { + let mut s = Session::new(); + s.new_workbook(); + let mut records: Vec = vec![json!(["Cat", "Sub", "V"])]; + for r in 0..64 { + for c in 0..64 { + records.push(json!([format!("c{r}"), format!("s{c}"), 1.0])); + } + } + s.execute("range.setValues", json!({"range": "A1", "values": records})).unwrap(); + let req = |n: usize| { + let values: Vec = (0..n).map(|_| json!({"field": "V"})).collect(); + json!({"source": "A1:C4097", "rows": ["Cat"], "columns": ["Sub"], "values": values}) + }; + let e = s.execute("insert.pivotTable", req(256)).unwrap_err().to_string(); + assert!(e.contains("will not fit"), "{e}"); + assert!(s.execute("insert.pivotTable", req(128)).is_ok()); +} + +#[test] +fn collapsed_column_items_count_as_one_column_group() { + let mut s = Session::new(); + s.new_workbook(); + let mut records: Vec = vec![json!(["Grp", "Sub", "V"])]; + records.extend((0..64).map(|c| json!(["g", format!("s{c}"), 1.0]))); + s.execute("range.setValues", json!({"range": "A1", "values": records})).unwrap(); + let values: Vec = (0..256).map(|_| json!({"field": "V"})).collect(); + s.execute("insert.pivotTable", json!({"source": "A1:C65", "columns": ["Grp"], "values": values})).unwrap(); + s.execute("pivot.collapse", json!({"field": "Grp"})).unwrap(); + // 64 Sub items under the collapsed "g" show as one column group: (1 + grand total) x 256 columns fit. + s.execute("pivot.addField", json!({"field": "Sub", "area": "columns"})).unwrap(); +} + +#[test] +fn inserting_or_deleting_lines_moves_the_pivot_with_its_cells() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "destination": "Sheet1!H10", "rows": ["Region"], "values": ["Sales"]})) + .unwrap(); + assert_eq!(pt(&s).last_range.unwrap().a1(), "H10:I14"); + // Rows above the report (and below the source) move the report only. + s.execute("home.insertRows", json!({"rows": "16:17"})).unwrap(); + s.execute("home.insertRows", json!({"rows": "6:7"})).unwrap(); + assert_eq!(text(&s, "H12"), "Row Labels"); + let l = s.execute("pivot.list", json!({})).unwrap(); + assert_eq!(l[0]["anchor"], "H12"); + assert_eq!(l[0]["range"], "H12:I16"); + assert_eq!(l[0]["source"], "Sheet1!A1:E15"); + // A row above the source moves both. + s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap(); + let p = pt(&s); + assert_eq!(p.source, "Sheet1!A2:E16"); + assert_eq!(p.anchor.a1(), "H13"); + s.execute("pivot.refresh", json!({})).unwrap(); + assert_eq!(pt(&s).last_range.unwrap().a1(), "H13:I17"); + assert_eq!(text(&s, "H13"), "Row Labels"); + assert_eq!(val(&s, "I17"), Value::Number(TOTAL)); + // Deleting a column left of the report moves it left. + s.execute("home.deleteColumns", json!({"cols": "F:F"})).unwrap(); + let p = pt(&s); + assert_eq!(p.anchor.a1(), "G13"); + assert_eq!(p.last_range.unwrap().a1(), "G13:H17"); + // Deleting rows above moves it up. + s.execute("home.deleteRows", json!({"rows": "1:1"})).unwrap(); + assert_eq!(pt(&s).source, "Sheet1!A1:E15"); + assert_eq!(pt(&s).anchor.a1(), "G12"); + s.execute("pivot.refresh", json!({})).unwrap(); + assert_eq!(text(&s, "G12"), "Row Labels"); + assert_eq!(val(&s, "H16"), Value::Number(TOTAL)); +} + +#[test] +fn inserting_rows_on_the_source_sheet_moves_another_sheets_pivot_source() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "rows": ["Region"], "values": ["Sales"]})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + s.execute("home.insertRows", json!({"rows": "1:2"})).unwrap(); + let l = s.execute("pivot.list", json!({})).unwrap(); + assert_eq!(l[0]["source"], "Sheet1!A3:E15"); + assert_eq!(l[0]["anchor"], "A3"); + s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap(); +} + +/// The active sheet's first five columns as shown, down to the first blank row. +fn sheet_rows(s: &mut Session) -> Vec> { + let mut out = Vec::new(); + for r in 0..30u32 { + let row: Vec = (0..5) + .map(|c| s.execute("cell.get", json!({"cell": CellRef::new(r, c).a1()})).unwrap()["text"].as_str().unwrap_or("").to_string()) + .collect(); + if row.iter().all(String::is_empty) { + break; + } + out.push(row); + } + out +} + +#[test] +fn show_details_lists_a_value_cells_records_on_a_new_sheet() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + // B5 is East × Apples (100 + 60); the active cell is the default. + s.execute("selection.set", json!({"range": "B5"})).unwrap(); + let r = s.execute("pivot.showDetails", json!({})).unwrap(); + assert_eq!(r["records"], 2); + let d = s.doc().unwrap(); + assert_eq!(d.wb.sheets.len(), 3); + assert_eq!(d.wb.active_sheet(), 0, "the new sheet goes before the PivotTable's"); + assert_eq!(d.wb.active().unwrap().name, "Sheet3"); + assert_eq!( + sheet_rows(&mut s), + vec![ + vec!["Region", "Product", "Date", "Units", "Sales"], + vec!["East", "Apples", "1/15/2026", "10", "100"], + vec!["East", "Apples", "9/9/2026", "6", "60"], + ] + ); + // The dates fit their column instead of showing ####. + let sh = s.doc().unwrap().wb.active().unwrap().clone(); + assert!(sh.col_width(2) > sh.col_width(5), "{} vs {}", sh.col_width(2), sh.col_width(5)); + // One undo step takes the sheet away. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + assert_eq!(val(&s, "B5"), Value::Number(160.0)); +} + +#[test] +fn show_details_on_a_total_takes_every_record_it_adds_up() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + let records = |s: &mut Session, cell: &str| { + let n = s.execute("pivot.showDetails", json!({"cell": cell})).unwrap()["records"].as_u64().unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + n + }; + assert_eq!(records(&mut s, "E5"), 5, "East's row total"); + assert_eq!(records(&mut s, "C8"), 4, "Pears' column total"); + assert_eq!(records(&mut s, "E8"), 12, "the grand total"); + // A subtotal at the top of its group, in a two-level row axis. + s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap(); + assert_eq!(text(&s, "A4"), "East"); + assert_eq!(records(&mut s, "B4"), 5); + assert_eq!(text(&s, "A6"), "Pears"); + assert_eq!(records(&mut s, "B6"), 2); +} + +#[test] +fn show_details_leaves_out_the_records_the_report_filters_out() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Product"], "values": ["Sales"], "filters": ["Region"]})).unwrap(); + s.execute("pivot.filter", json!({"field": "Region", "selected": ["East"]})).unwrap(); + s.execute("pivot.filter", json!({"field": "Product", "selected": ["Apples"]})).unwrap(); + let gt = find_row(&s, "Grand Total").unwrap(); + let r = s.execute("pivot.showDetails", json!({"cell": CellRef::new(gt, 1).a1()})).unwrap(); + assert_eq!(r["records"], 2); + let rows = sheet_rows(&mut s); + assert_eq!(rows.len(), 3); + assert!(rows.iter().skip(1).all(|row| row[0] == "East" && row[1] == "Apples"), "{rows:?}"); +} + +#[test] +fn show_details_refuses_a_report_its_source_has_moved_on_from() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + // B5 still shows East × Apples (160), but the first East record is now Central's. + s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap(); + assert_eq!(val(&s, "B5"), Value::Number(160.0)); + let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string(); + assert!(e.contains("out of date"), "{e}"); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + // After a refresh East × Apples (now B6) has the one record left. + s.execute("pivot.refresh", json!({})).unwrap(); + assert_eq!(text(&s, "A6"), "East"); + assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B6"})).unwrap()["records"], 1); +} + +#[test] +fn show_details_refuses_a_report_that_would_now_be_shorter() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + // North goes away: the source now gives one row fewer, so row 8 is no longer the Grand Total. + s.execute("range.setValues", json!({"range": "Sheet1!A5", "values": [["East"]]})).unwrap(); + s.execute("range.setValues", json!({"range": "Sheet1!A8", "values": [["East"]]})).unwrap(); + s.execute("range.setValues", json!({"range": "Sheet1!A11", "values": [["East"]]})).unwrap(); + assert_eq!(text(&s, "A8"), "Grand Total"); + let e = s.execute("pivot.showDetails", json!({"cell": "E8"})).unwrap_err().to_string(); + assert!(e.contains("out of date"), "{e}"); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); +} + +#[test] +fn show_details_needs_a_value_cell() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + for cell in ["A3", "B3", "A4", "B4", "A5", "A8", "H20"] { + assert!(s.execute("pivot.showDetails", json!({"cell": cell})).is_err(), "{cell}"); + } + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); +} + +#[test] +fn enable_show_details_off_stops_show_details() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + assert_eq!(s.execute("pivot.options", json!({})).unwrap()["enableShowDetails"], true); + s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap(); + assert!(!pt(&s).enable_show_details); + assert_eq!(s.execute("pivot.fields", json!({})).unwrap()["enableShowDetails"], false); + let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string(); + assert_eq!(e, "Show Details is turned off for this PivotTable."); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + // A label still reports that it is not a value cell. + let e = s.execute("pivot.showDetails", json!({"cell": "A5"})).unwrap_err().to_string(); + assert!(!e.contains("turned off"), "{e}"); + // Setting the same value again adds no undo step. + let after = s.doc().unwrap().revision; + s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap(); + assert_eq!(s.doc().unwrap().revision, after); + assert!(s.execute("pivot.options", json!({"enableShowDetails": "no"})).is_err()); + // One undo turns the option back on. + s.execute("edit.undo", json!({})).unwrap(); + assert!(pt(&s).enable_show_details); + assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap()["records"], 2); +} + +#[test] +fn enable_show_details_off_is_reported_before_an_out_of_date_report() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap(); + // The source changes after the report was written, so the report is out of date. + s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap(); + let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string(); + assert!(e.contains("turned off") && !e.contains("out of date"), "{e}"); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); +} + +#[test] +fn detail_cells_are_the_cells_show_details_takes() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + for layout in 0..2 { + if layout == 1 { + // A two-level row axis, with subtotals at the top of each group. + s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap(); + } + let d = s.doc().unwrap(); + let si = d.wb.active_sheet(); + let pt = &d.wb.active().unwrap().pivots[0]; + let r = pt.last_range.unwrap(); + let cells = crate::pivot::detail_cells(&d.wb, pt, si).unwrap(); + let mut values = 0; + for row in 0..r.height() { + for col in 0..r.width() { + let one = crate::pivot::detail_records(&d.wb, pt, si, row, col).unwrap(); + assert_eq!(cells.contains(&(row, col)), one.is_some(), "layout {layout}, cell ({row}, {col})"); + values += usize::from(one.is_some()); + } + } + assert!(values > 4, "layout {layout} has value cells"); + } + // An out-of-date report answers as Show Details does. + s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap(); + let d = s.doc().unwrap(); + let pt = &d.wb.active().unwrap().pivots[0]; + assert!(crate::pivot::detail_cells(&d.wb, pt, d.wb.active_sheet()).unwrap_err().contains("out of date")); +} From 1f5c4747df25c4ef70f65e8fec7705bbc46ee37c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:41 +0000 Subject: [PATCH 29/76] refactor(engine): move cmd/print tests to src/cmd/print/tests.rs --- crates/engine/src/cmd/print.rs | 573 +-------------------------- crates/engine/src/cmd/print/tests.rs | 570 ++++++++++++++++++++++++++ 2 files changed, 571 insertions(+), 572 deletions(-) create mode 100644 crates/engine/src/cmd/print/tests.rs diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index f6bfc6f17..d55841f7b 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -1864,575 +1864,4 @@ fn page_setup(s: &mut Session, p: &Json) -> Result { } #[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - fn s() -> Session { - let mut s = Session::new(); - s.new_workbook(); - s - } - fn pdf_of(s: &mut Session, p: Json) -> (Vec, u64) { - let r = s.execute("file.exportPdf", p).unwrap(); - let bytes = crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap(); - (bytes, r["pages"].as_u64().unwrap()) - } - fn contains(hay: &[u8], needle: &str) -> bool { - hay.windows(needle.len()).any(|w| w == needle.as_bytes()) - } - - #[test] - fn pdf_export_keeps_safe_links_on_their_printed_pages() { - let mut s = s(); - s.execute("insert.link", json!({"cell": "A1", "target": "https://example.org/report"})).unwrap(); - s.execute("insert.link", json!({"cell": "A2", "target": "Sheet1!A60"})).unwrap(); - s.execute("insert.link", json!({"cell": "A3", "target": "file:///private/report"})).unwrap(); - s.execute("cell.set", json!({"cell": "A60", "input": "Destination"})).unwrap(); - let (pdf, pages) = pdf_of(&mut s, json!({})); - assert_eq!(pages, 2); - let text = String::from_utf8_lossy(&pdf); - assert_eq!(text.matches("/Subtype /Link").count(), 2); - assert!(text.contains("/URI (https://example.org/report)")); - assert!(text.contains("/Dest [") && text.contains("/XYZ ")); - assert!(!text.contains("/URI (file:")); - - // A link to a cell omitted by the selected print range must not point to a nonexistent - // destination. The external link in the selected range remains clickable. - let (cropped, _) = pdf_of(&mut s, json!({"range": "A1:A3"})); - let cropped = String::from_utf8_lossy(&cropped); - assert_eq!(cropped.matches("/Subtype /Link").count(), 1); - assert!(!cropped.contains("/Dest [")); - } - - #[test] - fn header_sections_split_join_and_field_names() { - let spec = "&L&\"Arial,Bold\"Sales &&Co&CPage &P of &N&R&D"; - let parts = split_header_footer(spec); - assert_eq!(parts, ["&\"Arial,Bold\"Sales &&Co".to_string(), "Page &P of &N".into(), "&D".into()]); - assert_eq!(join_header_footer(&parts), spec); - // Text before any section code is the centre section, as when printing. - assert_eq!(split_header_footer("Report"), [String::new(), "Report".into(), String::new()]); - assert_eq!(codes_to_fields("Page &P of &N, &A (&F) &&"), "Page &[Page] of &[Pages], &[Tab] (&[File]) &&"); - assert_eq!(fields_to_codes("Page &[Page] of &[Pages], &[Tab] (&[File]) &&"), "Page &P of &N, &A (&F) &&"); - // A dangling ampersand stays literal instead of eating the next section's code. - assert_eq!(fields_to_codes("R&"), "R&&"); - let joined = join_header_footer(&[fields_to_codes("R&"), "x".into(), String::new()]); - assert_eq!(split_header_footer(&joined)[0], "R&&"); - let cx = HfContext { page: 2, pages: 5, sheet: "Data".into(), ..Default::default() }; - let out = expand_header_footer(&join_header_footer(&[String::new(), fields_to_codes("&[Page] / &[Pages]"), fields_to_codes("&[Tab]")]), &cx); - assert_eq!((out[1].text.as_str(), out[2].text.as_str()), ("2 / 5", "Data")); - // Hostile input never panics. - for t in ["&", "&\"", "&\"unterminated", "&&&", "&[Pag", "\u{1F600}&[Page]"] { - let _ = join_header_footer(&split_header_footer(t)); - let _ = fields_to_codes(&codes_to_fields(t)); - } - } - - #[test] - fn header_footer_date_follows_the_workbook_date_system() { - let mut wb = Workbook::new(); - let date_1900 = hf_date_time(&wb).0; - wb.date_system = gridcraft_core::DateSystem::D1904; - assert_eq!(hf_date_time(&wb).0, date_1900); - } - - #[test] - fn header_footer_section_edits_are_undoable_and_views_are_per_sheet() { - let mut s = s(); - s.execute("pageLayout.headerFooter", json!({"header": "&LLeft&RRight"})).unwrap(); - let r = s.execute("pageLayout.headerFooter", json!({"part": "header", "section": "center", "text": "Page &[Page] of &[Pages]"})).unwrap(); - assert_eq!(r["header"], "&LLeft&CPage &P of &N&RRight"); - s.execute("pageLayout.headerFooter", json!({"part": "footer", "section": "right", "text": "&[Date]"})).unwrap(); - assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().print.footer, "&R&D"); - s.execute("edit.undo", json!({})).unwrap(); - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().print.header, "&LLeft&RRight"); - assert!(s.execute("pageLayout.headerFooter", json!({"part": "middle", "text": "x"})).is_err()); - assert!(s.execute("pageLayout.headerFooter", json!({"section": "top"})).is_err()); - let long = "x".repeat(1000); - s.execute("pageLayout.headerFooter", json!({"part": "header", "section": "left", "text": long})).unwrap(); - assert!(s.doc().unwrap().wb.sheet(0).unwrap().print.header.chars().count() <= 255); - // The view is per sheet and doesn't dirty a clean workbook. - let mut fresh = Session::new(); - fresh.new_workbook(); - fresh.execute("home.insertSheet", json!({})).unwrap(); - fresh.doc_mut().unwrap().saved = fresh.doc().unwrap().wb.clone(); - fresh.execute("view.pageLayout", json!({"sheet": 0})).unwrap(); - let wb = &fresh.doc().unwrap().wb; - assert_eq!((wb.sheet(0).unwrap().view, wb.sheet(1).unwrap().view), (SheetViewMode::PageLayout, SheetViewMode::Normal)); - assert!(!fresh.doc().unwrap().is_dirty(), "switching views is not an edit"); - s.execute("view.pageLayout", json!({"sheet": 0})).unwrap(); - s.execute("view.normal", json!({"sheet": 0})).unwrap(); - assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().view, SheetViewMode::Normal); - assert_eq!(s.execute("view.ruler", json!({"on": false})).unwrap()["on"], false); - assert!(s.hide_ruler); - assert_eq!(s.execute("view.ruler", json!({})).unwrap()["on"], true); - } - - #[test] - fn page_count_300_rows_letter_portrait() { - let mut s = s(); - let rows: Vec> = (1..=300).map(|i| vec![json!(i)]).collect(); - s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); - // 684pt of printable height / 15pt rows = 45 rows per page → 7 pages. - let (pdf, pages) = pdf_of(&mut s, json!({})); - assert_eq!(pages, 7); - assert!(pdf.starts_with(b"%PDF-1.7")); - assert!(contains(&pdf, "/Count 7")); - let pv = s.execute("file.printPreview", json!({})).unwrap(); - assert_eq!(pv["pages"], 7); - assert_eq!(pv["pageList"][0]["rows"], "1:45"); - assert_eq!(pv["pageList"][1]["rows"], "46:90"); - assert_eq!(s.execute("file.printPreview", json!({"page": 7})).unwrap()["page"]["rows"], "271:300"); - assert!(s.execute("file.printPreview", json!({"page": 8})).is_err()); - // Landscape: 504pt / 15 = 33 rows per page → 10 pages. - s.execute("pageLayout.pageSetup", json!({"orientation": "landscape"})).unwrap(); - assert_eq!(pdf_of(&mut s, json!({})).1, 10); - // Fit to one page. - assert_eq!(pdf_of(&mut s, json!({"fitToPage": true})).1, 1); - s.execute("pageLayout.pageSetup", json!({"fitWidth": 1, "fitHeight": 2, "orientation": "portrait"})).unwrap(); - assert_eq!(pdf_of(&mut s, json!({})).1, 2); - // Manual break and repeated title rows. - s.execute("pageLayout.pageSetup", json!({"scale": 100, "rowBreaks": [11], "titleRows": "1:1"})).unwrap(); - let pv = s.execute("file.printPreview", json!({})).unwrap(); - assert_eq!(pv["pageList"][0]["rows"], "1:10"); - // Title row takes one line on later pages: 44 body rows. - assert_eq!(pv["pageList"][1]["rows"], "11:54"); - } - - #[test] - fn wide_sheet_goes_down_then_over() { - let mut s = s(); - let rows: Vec> = (0..60).map(|r| (0..15).map(|c| json!(r * 100 + c)).collect()).collect(); - s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); - let pv = s.execute("file.printPreview", json!({})).unwrap(); - // 511.2pt / 48pt = 10 columns per page; 45 rows per page. - assert_eq!(pv["pages"], 4); - assert_eq!(pv["pageList"][0]["range"], "A1:J45"); - assert_eq!(pv["pageList"][1]["range"], "A46:J60"); - assert_eq!(pv["pageList"][2]["range"], "K1:O45"); - } - - #[test] - fn first_and_even_pages_print_their_own_headers() { - let mut s = s(); - s.execute("range.setValues", json!({"range": "A1", "values": [["a"], ["b"], ["c"], ["d"], ["e"], ["f"]]})).unwrap(); - s.execute("pageLayout.pageSetup", json!({"rowBreaks": [2, 4]})).unwrap(); - let r = s - .execute( - "pageLayout.headerFooter", - json!({"header": "&COdd &P", "evenHeader": "&CEven &P", "firstHeader": "&CCover", "differentFirst": true, "differentOddEven": true}), - ) - .unwrap(); - assert_eq!(r["firstHeader"], "&CCover"); - let (pdf, pages) = pdf_of(&mut s, json!({})); - assert_eq!(pages, 3); - assert!(contains(&pdf, "(Cover) Tj") && contains(&pdf, "(Even 2) Tj") && contains(&pdf, "(Odd 3) Tj")); - assert!(!contains(&pdf, "(Odd 1) Tj"), "the first page has its own header"); - // One section of the even-page footer, edited in place. - s.execute("pageLayout.headerFooter", json!({"page": "even", "part": "footer", "section": "right", "text": "&[Tab]"})).unwrap(); - assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().print.even_footer, "&R&A"); - assert!(s.execute("pageLayout.headerFooter", json!({"page": "last", "part": "header", "text": "x"})).is_err()); - // Turned off, every page prints the odd header again. - s.execute("pageLayout.headerFooter", json!({"differentFirst": false, "differentOddEven": false})).unwrap(); - let (pdf, _) = pdf_of(&mut s, json!({})); - assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj")); - } - - /// The x position and font size of the first `(text) Tj` in a PDF. - fn text_at(pdf: &[u8], text: &str) -> Option<(f32, f32)> { - let s = String::from_utf8_lossy(pdf); - let before = &s[..s.find(&format!("({text}) Tj"))?]; - let tf = before.rfind(" Tf")?; - let size = before[..tf].rsplit(' ').next()?.parse().ok()?; - let tm = before.rfind(" Tm")?; - let mut nums = before[..tm].rsplit([' ', '\n']); - let _y = nums.next()?; - let x = nums.next()?.parse().ok()?; - Some((x, size)) - } - - #[test] - fn headers_scale_with_the_sheet_and_align_with_its_margins_unless_turned_off() { - let mut s = s(); - s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap(); - s.execute("pageLayout.pageSetup", json!({"scale": 50, "left": 1.5, "right": 1.5})).unwrap(); - s.execute("pageLayout.headerFooter", json!({"header": "&LLeft&C&14Middle"})).unwrap(); - // Both on (the default): the text prints at half size, from the 1.5-inch left margin. - let (pdf, _) = pdf_of(&mut s, json!({})); - assert_eq!(text_at(&pdf, "Left"), Some((108.0, 5.0))); - assert_eq!(text_at(&pdf, "Middle").map(|t| t.1), Some(7.0)); - let r = s.execute("pageLayout.headerFooter", json!({"scaleWithDoc": false, "alignWithMargins": false})).unwrap(); - assert_eq!((&r["scaleWithDoc"], &r["alignWithMargins"]), (&json!(false), &json!(false))); - // Both off: full size, 0.7 inch from the paper's left edge. - let (pdf, _) = pdf_of(&mut s, json!({})); - assert_eq!(text_at(&pdf, "Left"), Some((50.4, 10.0))); - assert_eq!(text_at(&pdf, "Middle").map(|t| t.1), Some(14.0)); - s.execute("edit.undo", json!({})).unwrap(); - let pr = &s.doc().unwrap().wb.sheet(0).unwrap().print; - assert!(pr.hf_scale_with_doc && pr.hf_align_with_margins); - } - - #[test] - fn pdf_prints_cjk_text_instead_of_question_marks() { - let mut s = s(); - s.execute("range.setValues", json!({"range": "A1", "values": [["签字清单", 2]]})).unwrap(); - let (pdf, _) = pdf_of(&mut s, json!({})); - assert!(!contains(&pdf, "(????) Tj"), "CJK cell printed as (????) Tj"); - assert!(contains(&pdf, "<7B7E5B576E055355> Tj")); - assert!(contains(&pdf, "(2) Tj")); - } - - #[test] - fn pdf_contains_text_and_header_codes_expand() { - let mut s = s(); - s.execute("range.setValues", json!({"range": "A1", "values": [["Quarterly Report", null], ["Total", 1234.5]]})).unwrap(); - s.execute("home.bold", json!({"range": "A1"})).unwrap(); - s.execute( - "pageLayout.pageSetup", - json!({"header": "&LLeft &A&CMiddle&RPage &P of &N", "footer": "&C&F", "gridlines": true, "headings": true}), - ) - .unwrap(); - let (pdf, pages) = pdf_of(&mut s, json!({})); - assert_eq!(pages, 1); - assert!(contains(&pdf, "(Quarterly Report) Tj")); - assert!(contains(&pdf, "(1234.5) Tj")); - assert!(contains(&pdf, "(Left Sheet1) Tj")); - assert!(contains(&pdf, "(Page 1 of 1) Tj")); - assert!(contains(&pdf, "(Book1) Tj")); - assert!(contains(&pdf, "/Helvetica-Bold")); - let cx = HfContext { - page: 3, - pages: 9, - sheet: "Data".into(), - file: "b.xlsx".into(), - date: "1/2/2026".into(), - time: "9:00 AM".into(), - ..Default::default() - }; - let [l, c, r] = expand_header_footer("&L&\"Arial,Bold\"&A &D&C&14&BTitle && more&RP&P+1/&N &T", &cx); - assert_eq!(l.text, "Data 1/2/2026"); - assert!(l.bold); - assert_eq!(c.text, "Title & more"); - assert!(c.bold); - assert_eq!(c.size, Some(14.0)); - assert_eq!(r.text, "P4/9 9:00 AM"); - assert_eq!(expand_header_footer("plain", &cx)[1].text, "plain"); - assert_eq!(expand_header_footer("&", &cx)[1].text, ""); - } - - #[test] - fn conditional_number_format_prints() { - // #1163: a rule's number format replaces the cell's own, whether that is General or not; - // cells the rule doesn't match keep theirs. - let mut s = s(); - s.execute("range.setValues", json!({"range": "A1", "values": [[1], [3], [4]]})).unwrap(); - s.execute("home.numberFormat", json!({"range": "A3", "code": "0.0"})).unwrap(); - let rule = json!({"type": "cellIs", "operator": "greater", "value": "2", "style": {"num_fmt": "0.00"}}); - s.execute("home.conditionalFormat", json!({"range": "A1:A3", "rule": rule})).unwrap(); - let (pdf, _) = pdf_of(&mut s, json!({})); - assert!(contains(&pdf, "(1) Tj"), "an unmatched cell keeps General"); - assert!(contains(&pdf, "(3.00) Tj") && !contains(&pdf, "(3) Tj"), "General cell under the rule"); - assert!(contains(&pdf, "(4.00) Tj") && !contains(&pdf, "(4.0) Tj"), "formatted cell under the rule"); - } - - #[test] - fn pdf_uses_indexed_number_format_palette_color() { - let mut s = s(); - s.execute("cell.set", json!({"cell": "A1", "input": "42"})).unwrap(); - s.execute("home.numberFormat", json!({"range": "A1", "format": "[Color4]0"})).unwrap(); - let (pdf, pages) = pdf_of(&mut s, json!({})); - assert_eq!(pages, 1); - assert!(contains(&pdf, "0 1 0 rg"), "expected indexed Color4 green in PDF text stream"); - } - - #[test] - fn export_writes_file() { - let mut s = s(); - s.execute("cell.set", json!({"cell": "B2", "input": "hello"})).unwrap(); - let dir = std::env::temp_dir().join(format!("gridcraft-print-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("out.pdf"); - let r = s.execute("file.exportPdf", json!({"path": path.to_str().unwrap()})).unwrap(); - assert_eq!(r["pages"], 1); - let bytes = std::fs::read(&path).unwrap(); - assert!(bytes.starts_with(b"%PDF-")); - assert!(contains(&bytes, "(hello) Tj")); - let _ = std::fs::remove_dir_all(&dir); - let r = s.execute("file.print", json!({})).unwrap(); - assert!(r["base64"].as_str().unwrap().len() > 100); - } - - #[test] - fn save_as_pdf_writes_a_pdf_and_keeps_the_workbook_file() { - let mut s = s(); - s.execute("cell.set", json!({"cell": "B2", "input": "hello"})).unwrap(); - let dir = std::env::temp_dir().join(format!("gridcraft-save-pdf-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("Out.PDF"); - let r = s.execute("file.saveAs", json!({"path": path.to_str().unwrap()})).unwrap(); - assert_eq!(r["pages"], 1); - let bytes = std::fs::read(&path).unwrap(); - assert!(bytes.starts_with(b"%PDF-")); - assert!(contains(&bytes, "(hello) Tj")); - // The PDF is a print, not the workbook's file: Save still asks where to put the workbook. - let d = s.doc().unwrap(); - assert!(d.path.is_none() && d.is_dirty()); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn rich_sheet_renders() { - let mut s = Session::new(); - s.execute("file.new", json!({"sample": "sales"})).unwrap(); - s.execute("range.setValues", json!({"range": "H1", "values": [["A long piece of text that overflows into neighbours"]]})).unwrap(); - s.execute("cell.set", json!({"cell": "H3", "input": "Wrapped text across several lines in a narrow cell"})).unwrap(); - s.execute("home.wrapText", json!({"range": "H3"})).unwrap_or_default(); - s.execute("selection.set", json!({"range": "J5:K6"})).unwrap(); - s.execute("home.mergeCenter", json!({})).unwrap(); - s.execute("cell.set", json!({"cell": "J5", "input": "Merged"})).unwrap(); - s.execute("home.borders", json!({"range": "J5:K6", "preset": "outside", "style": "thick"})).unwrap_or_default(); - let (pdf, pages) = pdf_of(&mut s, json!({"sheets": "all"})); - assert!(pages >= 1); - assert!(contains(&pdf, "(Merged) Tj")); - assert!(contains(&pdf, "(A long piece of text that overflows into neighbours) Tj")); - } - - #[test] - fn charts_and_images_are_drawn() { - let mut s = s(); - s.execute("range.setValues", json!({"range": "A1", "values": [["Fruit", "Qty"], ["Apples", 3], ["Pears", 5]]})).unwrap(); - s.execute("insert.chart", json!({"range": "A1:B3", "type": "pie"})).unwrap_or_default(); - s.execute("insert.chart", json!({"range": "A1:B3"})).unwrap_or_default(); - // A 2×2 PNG with transparency. - let mut png = Vec::new(); - let img = image::RgbaImage::from_raw(2, 2, vec![255, 0, 0, 255, 0, 255, 0, 128, 0, 0, 255, 255, 0, 0, 0, 0]).unwrap(); - image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); - let d = s.doc_mut().unwrap(); - let wb = std::sync::Arc::make_mut(&mut d.wb); - let id = wb.next_object_id(); - wb.sheet_mut(0).unwrap().images.push(Image { - id, - anchor: Anchor { cell: CellRef::new(5, 5), dx: 0.0, dy: 0.0, width: 40.0, height: 40.0, mode: gridcraft_model::AnchorMode::MoveAndSize }, - data: png.into(), - mime: "image/png".into(), - alt: String::new(), - }); - let charts = s.doc().unwrap().wb.active().unwrap().charts.len(); - let (pdf, _) = pdf_of(&mut s, json!({})); - assert!(contains(&pdf, "/Subtype /Image")); - assert!(contains(&pdf, "/SMask")); - if charts > 0 { - assert!(contains(&pdf, "(Apples) Tj") || contains(&pdf, "(Qty) Tj")); - } - } - - #[test] - fn page_setup_edits_settings() { - let mut s = s(); - let r = s - .execute( - "pageLayout.pageSetup", - json!({"paper": "A4", "orientation": "landscape", "margins": {"left": 1, "top": 0.5}, "right": 0.25, "centerH": true, "printArea": "A1:D20", "titleRows": "1:2", "titleCols": "A:A", "colBreaks": ["C"]}), - ) - .unwrap(); - assert_eq!(r["paper"], "A4"); - assert_eq!(r["orientation"], "Landscape"); - assert_eq!(r["margins"][0], 1.0); - assert_eq!(r["margins"][1], 0.25); - assert_eq!(r["margins"][2], 0.5); - assert_eq!(r["center_h"], true); - assert_eq!(r["title_rows"], json!([0, 1])); - assert_eq!(r["col_breaks"], json!([2])); - let sh = s.doc().unwrap().wb.active().unwrap().print.clone(); - assert_eq!(sh.print_area, RangeRef::parse("A1:D20")); - s.execute("pageLayout.pageSetup", json!({"printArea": ""})).unwrap(); - assert_eq!(s.doc().unwrap().wb.active().unwrap().print.print_area, None); - assert!(s.execute("pageLayout.pageSetup", json!({"paper": "Napkin"})).is_err()); - assert!(s.execute("pageLayout.pageSetup", json!({"orientation": "sideways"})).is_err()); - // Undoable as one step. - s.execute("edit.undo", json!({})).unwrap(); - assert!(s.doc().unwrap().wb.active().unwrap().print.print_area.is_some()); - } - - /// The Japanese paper sizes are the JIS ones: `B4`/`B5`/`B6` changed meaning from ISO, and the - /// ISO B series stays reachable by name. The postcards and envelopes are the JIS sizes too. - #[test] - fn japanese_paper_sizes_are_jis() { - for (name, w, h) in [ - ("A4", 595.28, 841.89), // unchanged - ("B4", 728.50, 1031.81), - ("B5", 515.91, 728.50), - ("B6", 362.83, 515.91), - ("B4 (JIS)", 728.50, 1031.81), - ("b5-jis", 515.91, 728.50), - ("B4 (ISO)", 708.66, 1000.63), - ("B5 (ISO)", 498.90, 708.66), - ("B6 (ISO)", 354.33, 498.90), - ("A6", 297.64, 419.53), - ("Japanese Postcard", 283.46, 419.53), - ("japanese double postcard", 419.53, 566.93), - ("Japanese Envelope #3", 340.16, 666.14), - ("chou #4", 255.12, 581.10), - ("Japanese Envelope Kaku #2", 680.31, 941.10), - ("kaku #3", 612.28, 785.20), - ] { - let got = paper_size(name); - assert!((got.0 - w).abs() < 0.01 && (got.1 - h).abs() < 0.01, "{name}: {got:?}"); - } - // 長形4号 is 90×205 mm (90×225 mm is 長形40号), so its height is 205 mm = 581.10 pt — not the - // 225 mm it once had. - let chou4 = paper_size("Japanese Envelope #4"); - assert_eq!((chou4.0, chou4.1), (255.12, 581.10)); - assert_eq!(chou4, paper_size("chou #4")); - // Unknown names keep the old Letter fallback. - assert_eq!(paper_size("Napkin"), (612.0, 792.0)); - } - - /// Page Setup's paper reaches the PDF page: the MediaBox is the paper's, A4 is unchanged, B5 is - /// JIS B5, and the Japanese postcard prints at 283.46×419.53 pt. Landscape swaps the sides and - /// fit-to-page keeps the paper size. - #[test] - fn page_setup_paper_sizes_reach_the_pdf_page() { - let mut s = s(); - s.execute("range.setValues", json!({"range": "A1", "values": [["x"]]})).unwrap(); - let close = |v: &Json, want: f64| (v.as_f64().unwrap() - want).abs() < 0.01; - for (paper, w, h) in [ - ("A4", 595.28f64, 841.89f64), - ("B5", 515.91, 728.50), - ("Japanese Postcard", 283.46, 419.53), - ("Japanese Double Postcard", 419.53, 566.93), - ("Japanese Envelope Kaku #2", 680.31, 941.10), - ] { - s.execute("pageLayout.pageSetup", json!({"paper": paper, "orientation": "portrait"})).unwrap(); - let pv = s.execute("file.printPreview", json!({})).unwrap(); - let page = &pv["pageList"][0]; - assert_eq!(page["paper"], paper); - assert!(close(&page["width"], w) && close(&page["height"], h), "{paper}: {page}"); - let (pdf, pages) = pdf_of(&mut s, json!({})); - assert_eq!(pages, 1); - let want = format!("/MediaBox [0 0 {w} {h}]"); - assert!(contains(&pdf, &want), "{paper}: wanted {want}"); - } - // Landscape swaps the sides, and fit-to-page does not change the paper. - s.execute("pageLayout.pageSetup", json!({"paper": "B5", "orientation": "landscape"})).unwrap(); - let (pdf, pages) = pdf_of(&mut s, json!({})); - assert_eq!(pages, 1); - assert!(contains(&pdf, "/MediaBox [0 0 728.5 515.91]")); - s.execute("pageLayout.pageSetup", json!({"paper": "B5", "orientation": "portrait"})).unwrap(); - let (pdf, _) = pdf_of(&mut s, json!({"fitToPage": true})); - assert!(contains(&pdf, "/MediaBox [0 0 515.91 728.5]")); - // `pageLayout.size` still takes any name, and an unknown one prints on Letter as before. - s.execute("pageLayout.size", json!({"paper": "Napkin"})).unwrap(); - let pv = s.execute("file.printPreview", json!({})).unwrap(); - assert!(close(&pv["pageList"][0]["width"], 612.0) && close(&pv["pageList"][0]["height"], 792.0)); - // Page Setup refuses an unknown name. - assert!(s.execute("pageLayout.pageSetup", json!({"paper": "Napkin"})).is_err()); - } - - #[test] - fn hostile_settings_never_panic_and_cap_pages() { - let mut s = s(); - s.execute("cell.set", json!({"cell": "XFD1048576", "input": "far"})).unwrap(); - s.execute("cell.set", json!({"cell": "A1", "input": "near"})).unwrap(); - s.execute("pageLayout.pageSetup", json!({"scale": 400, "margins": {"left": 20, "right": 20, "top": 20, "bottom": 20}})).unwrap(); - let pv = s.execute("file.printPreview", json!({})).unwrap(); - assert!(pv["pages"].as_u64().unwrap() <= MAX_PAGES as u64); - assert_eq!(pv["pages"].as_u64().unwrap(), MAX_PAGES as u64); - let r = s.execute("file.exportPdf", json!({})).unwrap(); - assert_eq!(r["pages"], MAX_PAGES); - s.execute("pageLayout.pageSetup", json!({"fitWidth": 1, "fitHeight": 1, "titleRows": "1:1048576", "titleCols": "A:XFD", "headings": true})) - .unwrap(); - let pv = s.execute("file.printPreview", json!({})).unwrap(); - assert!(pv["pages"].as_u64().unwrap() <= MAX_PAGES as u64); - assert!(s.execute("file.exportPdf", json!({"range": "A1:C3"})).is_ok()); - assert!(s.execute("file.exportPdf", json!({"range": "nope"})).is_err()); - assert!(s.execute("file.exportPdf", json!({"sheets": "some"})).is_err()); - // Empty workbook still prints one blank page. - let mut e = Session::new(); - e.new_workbook(); - let r = e.execute("file.exportPdf", json!({})).unwrap(); - assert_eq!(r["pages"], 1); - let r = e.execute("file.exportPdf", json!({"sheets": "all"})).unwrap(); - assert_eq!(r["pages"], 1); - // Weird row sizes and huge fonts. - s.execute("pageLayout.pageSetup", json!({"fitToPage": false, "scale": 10, "printArea": "A1:B3", "titleRows": "", "titleCols": ""})).unwrap(); - s.execute("home.fontSize", json!({"range": "A1", "size": 409})).unwrap_or_default(); - assert!(s.execute("file.exportPdf", json!({})).is_ok()); - } - - #[test] - fn export_pdf_without_a_path_returns_bytes_and_never_opens_a_dialog() { - let mut s = s(); - for params in [json!({}), json!({"path": ""}), json!({"path": null})] { - let r = s.execute("file.exportPdf", params).unwrap(); - assert!(r.get("path").is_none(), "no file is written without a path"); - let bytes = crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap(); - assert!(bytes.starts_with(b"%PDF-")); - } - assert!(s.take_ui_requests().is_empty(), "a programmatic call never queues a dialog"); - // Headless callers (CLI, MCP) get the same bytes, not an error. - assert!(s.execute_headless("file.exportPdf", json!({})).is_ok()); - // With no workbook open it is a clear error, not a panic. - let mut none = Session::new(); - assert!(none.execute("file.exportPdf", json!({})).is_err()); - } - - #[test] - fn bands_unit() { - let b = bands(0, 9, |_| 10.0, |_| 35.0, &[], 100); - assert_eq!(b, vec![(0, 2), (3, 5), (6, 8), (9, 9)]); - let b = bands(0, 9, |_| 10.0, |_| 35.0, &[5], 100); - assert_eq!(b, vec![(0, 2), (3, 4), (5, 7), (8, 9)]); - // A line larger than the page gets its own band. - let b = bands(0, 2, |r| if r == 1 { 100.0 } else { 10.0 }, |_| 35.0, &[], 100); - assert_eq!(b, vec![(0, 0), (1, 1), (2, 2)]); - assert_eq!(bands(0, 1_000_000, |_| 10.0, |_| 5.0, &[], 3).len(), 3); - let w = wrap_lines("the quick brown fox jumps", Font::Helvetica, 10.0, 50.0); - assert!(w.len() >= 2); - assert!(w.iter().all(|l| gridcraft_pdf::text_width(Font::Helvetica, 10.0, l) <= 50.0 || !l.contains(' '))); - let w = wrap_lines("Supercalifragilistic", Font::Helvetica, 10.0, 20.0); - assert!(w.len() > 2); - } - - #[test] - fn center_across_spans_following_empty_center_across_cells() { - let mut styles = StyleTable::default(); - let across = styles.intern(Style { align: Alignment { h: HAlign::CenterAcross, ..Alignment::default() }, ..Style::default() }); - let mut sh = Sheet::new("S"); - for col in 0..3 { - sh.set_style(CellRef::new(0, col), across); - } - sh.set_value(CellRef::new(0, 0), Value::Text("Title".into())); - let a1 = CellRef::new(0, 0); - assert_eq!(center_across_width(&styles, &sh, a1, &[]), sh.col_width(1) + sh.col_width(2)); - sh.set_value(CellRef::new(0, 2), Value::Text("x".into())); - assert_eq!(center_across_width(&styles, &sh, a1, &[]), sh.col_width(1)); - let merged = [RangeRef::new(CellRef::new(0, 1), CellRef::new(1, 1))]; - assert_eq!(center_across_width(&styles, &sh, a1, &merged), 0.0); - } - - #[test] - fn rotated_wrapped_text_prints_rotated() { - // Upright headers over narrow columns (mark sheets) combine rotation with Wrap Text. - let mut s = s(); - let header = "Term 1 : Drama : Continuous Assessment"; - s.execute("range.setValues", json!({"range": "A1", "values": [[header, header]]})).unwrap(); - s.execute("home.wrapText", json!({"range": "A1:B1", "on": true})).unwrap(); - s.execute("home.orientation", json!({"range": "A1", "angle": "up"})).unwrap(); - s.execute("home.orientation", json!({"range": "B1", "angle": "down"})).unwrap(); - s.execute("home.columnWidth", json!({"cols": "A:B", "chars": 6})).unwrap(); - s.execute("home.rowHeight", json!({"rows": "1:1", "height": 267})).unwrap(); - let (pdf, _) = pdf_of(&mut s, json!({})); - let pdf = String::from_utf8_lossy(&pdf); - // The page holds only these two cells, so every text block on it is a piece of a header. - let blocks: Vec<&str> = pdf.split("BT\n").skip(1).filter(|b| b.contains(" Tj")).collect(); - let up = blocks.iter().filter(|b| b.contains("\n0 1 -1 0 ")).count(); - let down = blocks.iter().filter(|b| b.contains("\n0 -1 1 0 ")).count(); - // Every piece of both headers is rotated, each wrapped along the row height into a few long lines. - assert_eq!(up + down, blocks.len(), "a header line printed flat: {blocks:?}"); - assert!((1..=3).contains(&up) && (1..=3).contains(&down), "up {up}, down {down}: {blocks:?}"); - } -} +mod tests; diff --git a/crates/engine/src/cmd/print/tests.rs b/crates/engine/src/cmd/print/tests.rs new file mode 100644 index 000000000..89b7b62a7 --- /dev/null +++ b/crates/engine/src/cmd/print/tests.rs @@ -0,0 +1,570 @@ +use serde_json::json; + +use super::*; + +fn s() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s +} +fn pdf_of(s: &mut Session, p: Json) -> (Vec, u64) { + let r = s.execute("file.exportPdf", p).unwrap(); + let bytes = crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap(); + (bytes, r["pages"].as_u64().unwrap()) +} +fn contains(hay: &[u8], needle: &str) -> bool { + hay.windows(needle.len()).any(|w| w == needle.as_bytes()) +} + +#[test] +fn pdf_export_keeps_safe_links_on_their_printed_pages() { + let mut s = s(); + s.execute("insert.link", json!({"cell": "A1", "target": "https://example.org/report"})).unwrap(); + s.execute("insert.link", json!({"cell": "A2", "target": "Sheet1!A60"})).unwrap(); + s.execute("insert.link", json!({"cell": "A3", "target": "file:///private/report"})).unwrap(); + s.execute("cell.set", json!({"cell": "A60", "input": "Destination"})).unwrap(); + let (pdf, pages) = pdf_of(&mut s, json!({})); + assert_eq!(pages, 2); + let text = String::from_utf8_lossy(&pdf); + assert_eq!(text.matches("/Subtype /Link").count(), 2); + assert!(text.contains("/URI (https://example.org/report)")); + assert!(text.contains("/Dest [") && text.contains("/XYZ ")); + assert!(!text.contains("/URI (file:")); + + // A link to a cell omitted by the selected print range must not point to a nonexistent + // destination. The external link in the selected range remains clickable. + let (cropped, _) = pdf_of(&mut s, json!({"range": "A1:A3"})); + let cropped = String::from_utf8_lossy(&cropped); + assert_eq!(cropped.matches("/Subtype /Link").count(), 1); + assert!(!cropped.contains("/Dest [")); +} + +#[test] +fn header_sections_split_join_and_field_names() { + let spec = "&L&\"Arial,Bold\"Sales &&Co&CPage &P of &N&R&D"; + let parts = split_header_footer(spec); + assert_eq!(parts, ["&\"Arial,Bold\"Sales &&Co".to_string(), "Page &P of &N".into(), "&D".into()]); + assert_eq!(join_header_footer(&parts), spec); + // Text before any section code is the centre section, as when printing. + assert_eq!(split_header_footer("Report"), [String::new(), "Report".into(), String::new()]); + assert_eq!(codes_to_fields("Page &P of &N, &A (&F) &&"), "Page &[Page] of &[Pages], &[Tab] (&[File]) &&"); + assert_eq!(fields_to_codes("Page &[Page] of &[Pages], &[Tab] (&[File]) &&"), "Page &P of &N, &A (&F) &&"); + // A dangling ampersand stays literal instead of eating the next section's code. + assert_eq!(fields_to_codes("R&"), "R&&"); + let joined = join_header_footer(&[fields_to_codes("R&"), "x".into(), String::new()]); + assert_eq!(split_header_footer(&joined)[0], "R&&"); + let cx = HfContext { page: 2, pages: 5, sheet: "Data".into(), ..Default::default() }; + let out = expand_header_footer(&join_header_footer(&[String::new(), fields_to_codes("&[Page] / &[Pages]"), fields_to_codes("&[Tab]")]), &cx); + assert_eq!((out[1].text.as_str(), out[2].text.as_str()), ("2 / 5", "Data")); + // Hostile input never panics. + for t in ["&", "&\"", "&\"unterminated", "&&&", "&[Pag", "\u{1F600}&[Page]"] { + let _ = join_header_footer(&split_header_footer(t)); + let _ = fields_to_codes(&codes_to_fields(t)); + } +} + +#[test] +fn header_footer_date_follows_the_workbook_date_system() { + let mut wb = Workbook::new(); + let date_1900 = hf_date_time(&wb).0; + wb.date_system = gridcraft_core::DateSystem::D1904; + assert_eq!(hf_date_time(&wb).0, date_1900); +} + +#[test] +fn header_footer_section_edits_are_undoable_and_views_are_per_sheet() { + let mut s = s(); + s.execute("pageLayout.headerFooter", json!({"header": "&LLeft&RRight"})).unwrap(); + let r = s.execute("pageLayout.headerFooter", json!({"part": "header", "section": "center", "text": "Page &[Page] of &[Pages]"})).unwrap(); + assert_eq!(r["header"], "&LLeft&CPage &P of &N&RRight"); + s.execute("pageLayout.headerFooter", json!({"part": "footer", "section": "right", "text": "&[Date]"})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().print.footer, "&R&D"); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().print.header, "&LLeft&RRight"); + assert!(s.execute("pageLayout.headerFooter", json!({"part": "middle", "text": "x"})).is_err()); + assert!(s.execute("pageLayout.headerFooter", json!({"section": "top"})).is_err()); + let long = "x".repeat(1000); + s.execute("pageLayout.headerFooter", json!({"part": "header", "section": "left", "text": long})).unwrap(); + assert!(s.doc().unwrap().wb.sheet(0).unwrap().print.header.chars().count() <= 255); + // The view is per sheet and doesn't dirty a clean workbook. + let mut fresh = Session::new(); + fresh.new_workbook(); + fresh.execute("home.insertSheet", json!({})).unwrap(); + fresh.doc_mut().unwrap().saved = fresh.doc().unwrap().wb.clone(); + fresh.execute("view.pageLayout", json!({"sheet": 0})).unwrap(); + let wb = &fresh.doc().unwrap().wb; + assert_eq!((wb.sheet(0).unwrap().view, wb.sheet(1).unwrap().view), (SheetViewMode::PageLayout, SheetViewMode::Normal)); + assert!(!fresh.doc().unwrap().is_dirty(), "switching views is not an edit"); + s.execute("view.pageLayout", json!({"sheet": 0})).unwrap(); + s.execute("view.normal", json!({"sheet": 0})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().view, SheetViewMode::Normal); + assert_eq!(s.execute("view.ruler", json!({"on": false})).unwrap()["on"], false); + assert!(s.hide_ruler); + assert_eq!(s.execute("view.ruler", json!({})).unwrap()["on"], true); +} + +#[test] +fn page_count_300_rows_letter_portrait() { + let mut s = s(); + let rows: Vec> = (1..=300).map(|i| vec![json!(i)]).collect(); + s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); + // 684pt of printable height / 15pt rows = 45 rows per page → 7 pages. + let (pdf, pages) = pdf_of(&mut s, json!({})); + assert_eq!(pages, 7); + assert!(pdf.starts_with(b"%PDF-1.7")); + assert!(contains(&pdf, "/Count 7")); + let pv = s.execute("file.printPreview", json!({})).unwrap(); + assert_eq!(pv["pages"], 7); + assert_eq!(pv["pageList"][0]["rows"], "1:45"); + assert_eq!(pv["pageList"][1]["rows"], "46:90"); + assert_eq!(s.execute("file.printPreview", json!({"page": 7})).unwrap()["page"]["rows"], "271:300"); + assert!(s.execute("file.printPreview", json!({"page": 8})).is_err()); + // Landscape: 504pt / 15 = 33 rows per page → 10 pages. + s.execute("pageLayout.pageSetup", json!({"orientation": "landscape"})).unwrap(); + assert_eq!(pdf_of(&mut s, json!({})).1, 10); + // Fit to one page. + assert_eq!(pdf_of(&mut s, json!({"fitToPage": true})).1, 1); + s.execute("pageLayout.pageSetup", json!({"fitWidth": 1, "fitHeight": 2, "orientation": "portrait"})).unwrap(); + assert_eq!(pdf_of(&mut s, json!({})).1, 2); + // Manual break and repeated title rows. + s.execute("pageLayout.pageSetup", json!({"scale": 100, "rowBreaks": [11], "titleRows": "1:1"})).unwrap(); + let pv = s.execute("file.printPreview", json!({})).unwrap(); + assert_eq!(pv["pageList"][0]["rows"], "1:10"); + // Title row takes one line on later pages: 44 body rows. + assert_eq!(pv["pageList"][1]["rows"], "11:54"); +} + +#[test] +fn wide_sheet_goes_down_then_over() { + let mut s = s(); + let rows: Vec> = (0..60).map(|r| (0..15).map(|c| json!(r * 100 + c)).collect()).collect(); + s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap(); + let pv = s.execute("file.printPreview", json!({})).unwrap(); + // 511.2pt / 48pt = 10 columns per page; 45 rows per page. + assert_eq!(pv["pages"], 4); + assert_eq!(pv["pageList"][0]["range"], "A1:J45"); + assert_eq!(pv["pageList"][1]["range"], "A46:J60"); + assert_eq!(pv["pageList"][2]["range"], "K1:O45"); +} + +#[test] +fn first_and_even_pages_print_their_own_headers() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["a"], ["b"], ["c"], ["d"], ["e"], ["f"]]})).unwrap(); + s.execute("pageLayout.pageSetup", json!({"rowBreaks": [2, 4]})).unwrap(); + let r = s + .execute( + "pageLayout.headerFooter", + json!({"header": "&COdd &P", "evenHeader": "&CEven &P", "firstHeader": "&CCover", "differentFirst": true, "differentOddEven": true}), + ) + .unwrap(); + assert_eq!(r["firstHeader"], "&CCover"); + let (pdf, pages) = pdf_of(&mut s, json!({})); + assert_eq!(pages, 3); + assert!(contains(&pdf, "(Cover) Tj") && contains(&pdf, "(Even 2) Tj") && contains(&pdf, "(Odd 3) Tj")); + assert!(!contains(&pdf, "(Odd 1) Tj"), "the first page has its own header"); + // One section of the even-page footer, edited in place. + s.execute("pageLayout.headerFooter", json!({"page": "even", "part": "footer", "section": "right", "text": "&[Tab]"})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().print.even_footer, "&R&A"); + assert!(s.execute("pageLayout.headerFooter", json!({"page": "last", "part": "header", "text": "x"})).is_err()); + // Turned off, every page prints the odd header again. + s.execute("pageLayout.headerFooter", json!({"differentFirst": false, "differentOddEven": false})).unwrap(); + let (pdf, _) = pdf_of(&mut s, json!({})); + assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj")); +} + +/// The x position and font size of the first `(text) Tj` in a PDF. +fn text_at(pdf: &[u8], text: &str) -> Option<(f32, f32)> { + let s = String::from_utf8_lossy(pdf); + let before = &s[..s.find(&format!("({text}) Tj"))?]; + let tf = before.rfind(" Tf")?; + let size = before[..tf].rsplit(' ').next()?.parse().ok()?; + let tm = before.rfind(" Tm")?; + let mut nums = before[..tm].rsplit([' ', '\n']); + let _y = nums.next()?; + let x = nums.next()?.parse().ok()?; + Some((x, size)) +} + +#[test] +fn headers_scale_with_the_sheet_and_align_with_its_margins_unless_turned_off() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap(); + s.execute("pageLayout.pageSetup", json!({"scale": 50, "left": 1.5, "right": 1.5})).unwrap(); + s.execute("pageLayout.headerFooter", json!({"header": "&LLeft&C&14Middle"})).unwrap(); + // Both on (the default): the text prints at half size, from the 1.5-inch left margin. + let (pdf, _) = pdf_of(&mut s, json!({})); + assert_eq!(text_at(&pdf, "Left"), Some((108.0, 5.0))); + assert_eq!(text_at(&pdf, "Middle").map(|t| t.1), Some(7.0)); + let r = s.execute("pageLayout.headerFooter", json!({"scaleWithDoc": false, "alignWithMargins": false})).unwrap(); + assert_eq!((&r["scaleWithDoc"], &r["alignWithMargins"]), (&json!(false), &json!(false))); + // Both off: full size, 0.7 inch from the paper's left edge. + let (pdf, _) = pdf_of(&mut s, json!({})); + assert_eq!(text_at(&pdf, "Left"), Some((50.4, 10.0))); + assert_eq!(text_at(&pdf, "Middle").map(|t| t.1), Some(14.0)); + s.execute("edit.undo", json!({})).unwrap(); + let pr = &s.doc().unwrap().wb.sheet(0).unwrap().print; + assert!(pr.hf_scale_with_doc && pr.hf_align_with_margins); +} + +#[test] +fn pdf_prints_cjk_text_instead_of_question_marks() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["签字清单", 2]]})).unwrap(); + let (pdf, _) = pdf_of(&mut s, json!({})); + assert!(!contains(&pdf, "(????) Tj"), "CJK cell printed as (????) Tj"); + assert!(contains(&pdf, "<7B7E5B576E055355> Tj")); + assert!(contains(&pdf, "(2) Tj")); +} + +#[test] +fn pdf_contains_text_and_header_codes_expand() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Quarterly Report", null], ["Total", 1234.5]]})).unwrap(); + s.execute("home.bold", json!({"range": "A1"})).unwrap(); + s.execute( + "pageLayout.pageSetup", + json!({"header": "&LLeft &A&CMiddle&RPage &P of &N", "footer": "&C&F", "gridlines": true, "headings": true}), + ) + .unwrap(); + let (pdf, pages) = pdf_of(&mut s, json!({})); + assert_eq!(pages, 1); + assert!(contains(&pdf, "(Quarterly Report) Tj")); + assert!(contains(&pdf, "(1234.5) Tj")); + assert!(contains(&pdf, "(Left Sheet1) Tj")); + assert!(contains(&pdf, "(Page 1 of 1) Tj")); + assert!(contains(&pdf, "(Book1) Tj")); + assert!(contains(&pdf, "/Helvetica-Bold")); + let cx = HfContext { + page: 3, + pages: 9, + sheet: "Data".into(), + file: "b.xlsx".into(), + date: "1/2/2026".into(), + time: "9:00 AM".into(), + ..Default::default() + }; + let [l, c, r] = expand_header_footer("&L&\"Arial,Bold\"&A &D&C&14&BTitle && more&RP&P+1/&N &T", &cx); + assert_eq!(l.text, "Data 1/2/2026"); + assert!(l.bold); + assert_eq!(c.text, "Title & more"); + assert!(c.bold); + assert_eq!(c.size, Some(14.0)); + assert_eq!(r.text, "P4/9 9:00 AM"); + assert_eq!(expand_header_footer("plain", &cx)[1].text, "plain"); + assert_eq!(expand_header_footer("&", &cx)[1].text, ""); +} + +#[test] +fn conditional_number_format_prints() { + // #1163: a rule's number format replaces the cell's own, whether that is General or not; + // cells the rule doesn't match keep theirs. + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1], [3], [4]]})).unwrap(); + s.execute("home.numberFormat", json!({"range": "A3", "code": "0.0"})).unwrap(); + let rule = json!({"type": "cellIs", "operator": "greater", "value": "2", "style": {"num_fmt": "0.00"}}); + s.execute("home.conditionalFormat", json!({"range": "A1:A3", "rule": rule})).unwrap(); + let (pdf, _) = pdf_of(&mut s, json!({})); + assert!(contains(&pdf, "(1) Tj"), "an unmatched cell keeps General"); + assert!(contains(&pdf, "(3.00) Tj") && !contains(&pdf, "(3) Tj"), "General cell under the rule"); + assert!(contains(&pdf, "(4.00) Tj") && !contains(&pdf, "(4.0) Tj"), "formatted cell under the rule"); +} + +#[test] +fn pdf_uses_indexed_number_format_palette_color() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "42"})).unwrap(); + s.execute("home.numberFormat", json!({"range": "A1", "format": "[Color4]0"})).unwrap(); + let (pdf, pages) = pdf_of(&mut s, json!({})); + assert_eq!(pages, 1); + assert!(contains(&pdf, "0 1 0 rg"), "expected indexed Color4 green in PDF text stream"); +} + +#[test] +fn export_writes_file() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "B2", "input": "hello"})).unwrap(); + let dir = std::env::temp_dir().join(format!("gridcraft-print-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("out.pdf"); + let r = s.execute("file.exportPdf", json!({"path": path.to_str().unwrap()})).unwrap(); + assert_eq!(r["pages"], 1); + let bytes = std::fs::read(&path).unwrap(); + assert!(bytes.starts_with(b"%PDF-")); + assert!(contains(&bytes, "(hello) Tj")); + let _ = std::fs::remove_dir_all(&dir); + let r = s.execute("file.print", json!({})).unwrap(); + assert!(r["base64"].as_str().unwrap().len() > 100); +} + +#[test] +fn save_as_pdf_writes_a_pdf_and_keeps_the_workbook_file() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "B2", "input": "hello"})).unwrap(); + let dir = std::env::temp_dir().join(format!("gridcraft-save-pdf-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Out.PDF"); + let r = s.execute("file.saveAs", json!({"path": path.to_str().unwrap()})).unwrap(); + assert_eq!(r["pages"], 1); + let bytes = std::fs::read(&path).unwrap(); + assert!(bytes.starts_with(b"%PDF-")); + assert!(contains(&bytes, "(hello) Tj")); + // The PDF is a print, not the workbook's file: Save still asks where to put the workbook. + let d = s.doc().unwrap(); + assert!(d.path.is_none() && d.is_dirty()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn rich_sheet_renders() { + let mut s = Session::new(); + s.execute("file.new", json!({"sample": "sales"})).unwrap(); + s.execute("range.setValues", json!({"range": "H1", "values": [["A long piece of text that overflows into neighbours"]]})).unwrap(); + s.execute("cell.set", json!({"cell": "H3", "input": "Wrapped text across several lines in a narrow cell"})).unwrap(); + s.execute("home.wrapText", json!({"range": "H3"})).unwrap_or_default(); + s.execute("selection.set", json!({"range": "J5:K6"})).unwrap(); + s.execute("home.mergeCenter", json!({})).unwrap(); + s.execute("cell.set", json!({"cell": "J5", "input": "Merged"})).unwrap(); + s.execute("home.borders", json!({"range": "J5:K6", "preset": "outside", "style": "thick"})).unwrap_or_default(); + let (pdf, pages) = pdf_of(&mut s, json!({"sheets": "all"})); + assert!(pages >= 1); + assert!(contains(&pdf, "(Merged) Tj")); + assert!(contains(&pdf, "(A long piece of text that overflows into neighbours) Tj")); +} + +#[test] +fn charts_and_images_are_drawn() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Fruit", "Qty"], ["Apples", 3], ["Pears", 5]]})).unwrap(); + s.execute("insert.chart", json!({"range": "A1:B3", "type": "pie"})).unwrap_or_default(); + s.execute("insert.chart", json!({"range": "A1:B3"})).unwrap_or_default(); + // A 2×2 PNG with transparency. + let mut png = Vec::new(); + let img = image::RgbaImage::from_raw(2, 2, vec![255, 0, 0, 255, 0, 255, 0, 128, 0, 0, 255, 255, 0, 0, 0, 0]).unwrap(); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let d = s.doc_mut().unwrap(); + let wb = std::sync::Arc::make_mut(&mut d.wb); + let id = wb.next_object_id(); + wb.sheet_mut(0).unwrap().images.push(Image { + id, + anchor: Anchor { cell: CellRef::new(5, 5), dx: 0.0, dy: 0.0, width: 40.0, height: 40.0, mode: gridcraft_model::AnchorMode::MoveAndSize }, + data: png.into(), + mime: "image/png".into(), + alt: String::new(), + }); + let charts = s.doc().unwrap().wb.active().unwrap().charts.len(); + let (pdf, _) = pdf_of(&mut s, json!({})); + assert!(contains(&pdf, "/Subtype /Image")); + assert!(contains(&pdf, "/SMask")); + if charts > 0 { + assert!(contains(&pdf, "(Apples) Tj") || contains(&pdf, "(Qty) Tj")); + } +} + +#[test] +fn page_setup_edits_settings() { + let mut s = s(); + let r = s + .execute( + "pageLayout.pageSetup", + json!({"paper": "A4", "orientation": "landscape", "margins": {"left": 1, "top": 0.5}, "right": 0.25, "centerH": true, "printArea": "A1:D20", "titleRows": "1:2", "titleCols": "A:A", "colBreaks": ["C"]}), + ) + .unwrap(); + assert_eq!(r["paper"], "A4"); + assert_eq!(r["orientation"], "Landscape"); + assert_eq!(r["margins"][0], 1.0); + assert_eq!(r["margins"][1], 0.25); + assert_eq!(r["margins"][2], 0.5); + assert_eq!(r["center_h"], true); + assert_eq!(r["title_rows"], json!([0, 1])); + assert_eq!(r["col_breaks"], json!([2])); + let sh = s.doc().unwrap().wb.active().unwrap().print.clone(); + assert_eq!(sh.print_area, RangeRef::parse("A1:D20")); + s.execute("pageLayout.pageSetup", json!({"printArea": ""})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().print.print_area, None); + assert!(s.execute("pageLayout.pageSetup", json!({"paper": "Napkin"})).is_err()); + assert!(s.execute("pageLayout.pageSetup", json!({"orientation": "sideways"})).is_err()); + // Undoable as one step. + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.doc().unwrap().wb.active().unwrap().print.print_area.is_some()); +} + +/// The Japanese paper sizes are the JIS ones: `B4`/`B5`/`B6` changed meaning from ISO, and the +/// ISO B series stays reachable by name. The postcards and envelopes are the JIS sizes too. +#[test] +fn japanese_paper_sizes_are_jis() { + for (name, w, h) in [ + ("A4", 595.28, 841.89), // unchanged + ("B4", 728.50, 1031.81), + ("B5", 515.91, 728.50), + ("B6", 362.83, 515.91), + ("B4 (JIS)", 728.50, 1031.81), + ("b5-jis", 515.91, 728.50), + ("B4 (ISO)", 708.66, 1000.63), + ("B5 (ISO)", 498.90, 708.66), + ("B6 (ISO)", 354.33, 498.90), + ("A6", 297.64, 419.53), + ("Japanese Postcard", 283.46, 419.53), + ("japanese double postcard", 419.53, 566.93), + ("Japanese Envelope #3", 340.16, 666.14), + ("chou #4", 255.12, 581.10), + ("Japanese Envelope Kaku #2", 680.31, 941.10), + ("kaku #3", 612.28, 785.20), + ] { + let got = paper_size(name); + assert!((got.0 - w).abs() < 0.01 && (got.1 - h).abs() < 0.01, "{name}: {got:?}"); + } + // 長形4号 is 90×205 mm (90×225 mm is 長形40号), so its height is 205 mm = 581.10 pt — not the + // 225 mm it once had. + let chou4 = paper_size("Japanese Envelope #4"); + assert_eq!((chou4.0, chou4.1), (255.12, 581.10)); + assert_eq!(chou4, paper_size("chou #4")); + // Unknown names keep the old Letter fallback. + assert_eq!(paper_size("Napkin"), (612.0, 792.0)); +} + +/// Page Setup's paper reaches the PDF page: the MediaBox is the paper's, A4 is unchanged, B5 is +/// JIS B5, and the Japanese postcard prints at 283.46×419.53 pt. Landscape swaps the sides and +/// fit-to-page keeps the paper size. +#[test] +fn page_setup_paper_sizes_reach_the_pdf_page() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["x"]]})).unwrap(); + let close = |v: &Json, want: f64| (v.as_f64().unwrap() - want).abs() < 0.01; + for (paper, w, h) in [ + ("A4", 595.28f64, 841.89f64), + ("B5", 515.91, 728.50), + ("Japanese Postcard", 283.46, 419.53), + ("Japanese Double Postcard", 419.53, 566.93), + ("Japanese Envelope Kaku #2", 680.31, 941.10), + ] { + s.execute("pageLayout.pageSetup", json!({"paper": paper, "orientation": "portrait"})).unwrap(); + let pv = s.execute("file.printPreview", json!({})).unwrap(); + let page = &pv["pageList"][0]; + assert_eq!(page["paper"], paper); + assert!(close(&page["width"], w) && close(&page["height"], h), "{paper}: {page}"); + let (pdf, pages) = pdf_of(&mut s, json!({})); + assert_eq!(pages, 1); + let want = format!("/MediaBox [0 0 {w} {h}]"); + assert!(contains(&pdf, &want), "{paper}: wanted {want}"); + } + // Landscape swaps the sides, and fit-to-page does not change the paper. + s.execute("pageLayout.pageSetup", json!({"paper": "B5", "orientation": "landscape"})).unwrap(); + let (pdf, pages) = pdf_of(&mut s, json!({})); + assert_eq!(pages, 1); + assert!(contains(&pdf, "/MediaBox [0 0 728.5 515.91]")); + s.execute("pageLayout.pageSetup", json!({"paper": "B5", "orientation": "portrait"})).unwrap(); + let (pdf, _) = pdf_of(&mut s, json!({"fitToPage": true})); + assert!(contains(&pdf, "/MediaBox [0 0 515.91 728.5]")); + // `pageLayout.size` still takes any name, and an unknown one prints on Letter as before. + s.execute("pageLayout.size", json!({"paper": "Napkin"})).unwrap(); + let pv = s.execute("file.printPreview", json!({})).unwrap(); + assert!(close(&pv["pageList"][0]["width"], 612.0) && close(&pv["pageList"][0]["height"], 792.0)); + // Page Setup refuses an unknown name. + assert!(s.execute("pageLayout.pageSetup", json!({"paper": "Napkin"})).is_err()); +} + +#[test] +fn hostile_settings_never_panic_and_cap_pages() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "XFD1048576", "input": "far"})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "near"})).unwrap(); + s.execute("pageLayout.pageSetup", json!({"scale": 400, "margins": {"left": 20, "right": 20, "top": 20, "bottom": 20}})).unwrap(); + let pv = s.execute("file.printPreview", json!({})).unwrap(); + assert!(pv["pages"].as_u64().unwrap() <= MAX_PAGES as u64); + assert_eq!(pv["pages"].as_u64().unwrap(), MAX_PAGES as u64); + let r = s.execute("file.exportPdf", json!({})).unwrap(); + assert_eq!(r["pages"], MAX_PAGES); + s.execute("pageLayout.pageSetup", json!({"fitWidth": 1, "fitHeight": 1, "titleRows": "1:1048576", "titleCols": "A:XFD", "headings": true})) + .unwrap(); + let pv = s.execute("file.printPreview", json!({})).unwrap(); + assert!(pv["pages"].as_u64().unwrap() <= MAX_PAGES as u64); + assert!(s.execute("file.exportPdf", json!({"range": "A1:C3"})).is_ok()); + assert!(s.execute("file.exportPdf", json!({"range": "nope"})).is_err()); + assert!(s.execute("file.exportPdf", json!({"sheets": "some"})).is_err()); + // Empty workbook still prints one blank page. + let mut e = Session::new(); + e.new_workbook(); + let r = e.execute("file.exportPdf", json!({})).unwrap(); + assert_eq!(r["pages"], 1); + let r = e.execute("file.exportPdf", json!({"sheets": "all"})).unwrap(); + assert_eq!(r["pages"], 1); + // Weird row sizes and huge fonts. + s.execute("pageLayout.pageSetup", json!({"fitToPage": false, "scale": 10, "printArea": "A1:B3", "titleRows": "", "titleCols": ""})).unwrap(); + s.execute("home.fontSize", json!({"range": "A1", "size": 409})).unwrap_or_default(); + assert!(s.execute("file.exportPdf", json!({})).is_ok()); +} + +#[test] +fn export_pdf_without_a_path_returns_bytes_and_never_opens_a_dialog() { + let mut s = s(); + for params in [json!({}), json!({"path": ""}), json!({"path": null})] { + let r = s.execute("file.exportPdf", params).unwrap(); + assert!(r.get("path").is_none(), "no file is written without a path"); + let bytes = crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap(); + assert!(bytes.starts_with(b"%PDF-")); + } + assert!(s.take_ui_requests().is_empty(), "a programmatic call never queues a dialog"); + // Headless callers (CLI, MCP) get the same bytes, not an error. + assert!(s.execute_headless("file.exportPdf", json!({})).is_ok()); + // With no workbook open it is a clear error, not a panic. + let mut none = Session::new(); + assert!(none.execute("file.exportPdf", json!({})).is_err()); +} + +#[test] +fn bands_unit() { + let b = bands(0, 9, |_| 10.0, |_| 35.0, &[], 100); + assert_eq!(b, vec![(0, 2), (3, 5), (6, 8), (9, 9)]); + let b = bands(0, 9, |_| 10.0, |_| 35.0, &[5], 100); + assert_eq!(b, vec![(0, 2), (3, 4), (5, 7), (8, 9)]); + // A line larger than the page gets its own band. + let b = bands(0, 2, |r| if r == 1 { 100.0 } else { 10.0 }, |_| 35.0, &[], 100); + assert_eq!(b, vec![(0, 0), (1, 1), (2, 2)]); + assert_eq!(bands(0, 1_000_000, |_| 10.0, |_| 5.0, &[], 3).len(), 3); + let w = wrap_lines("the quick brown fox jumps", Font::Helvetica, 10.0, 50.0); + assert!(w.len() >= 2); + assert!(w.iter().all(|l| gridcraft_pdf::text_width(Font::Helvetica, 10.0, l) <= 50.0 || !l.contains(' '))); + let w = wrap_lines("Supercalifragilistic", Font::Helvetica, 10.0, 20.0); + assert!(w.len() > 2); +} + +#[test] +fn center_across_spans_following_empty_center_across_cells() { + let mut styles = StyleTable::default(); + let across = styles.intern(Style { align: Alignment { h: HAlign::CenterAcross, ..Alignment::default() }, ..Style::default() }); + let mut sh = Sheet::new("S"); + for col in 0..3 { + sh.set_style(CellRef::new(0, col), across); + } + sh.set_value(CellRef::new(0, 0), Value::Text("Title".into())); + let a1 = CellRef::new(0, 0); + assert_eq!(center_across_width(&styles, &sh, a1, &[]), sh.col_width(1) + sh.col_width(2)); + sh.set_value(CellRef::new(0, 2), Value::Text("x".into())); + assert_eq!(center_across_width(&styles, &sh, a1, &[]), sh.col_width(1)); + let merged = [RangeRef::new(CellRef::new(0, 1), CellRef::new(1, 1))]; + assert_eq!(center_across_width(&styles, &sh, a1, &merged), 0.0); +} + +#[test] +fn rotated_wrapped_text_prints_rotated() { + // Upright headers over narrow columns (mark sheets) combine rotation with Wrap Text. + let mut s = s(); + let header = "Term 1 : Drama : Continuous Assessment"; + s.execute("range.setValues", json!({"range": "A1", "values": [[header, header]]})).unwrap(); + s.execute("home.wrapText", json!({"range": "A1:B1", "on": true})).unwrap(); + s.execute("home.orientation", json!({"range": "A1", "angle": "up"})).unwrap(); + s.execute("home.orientation", json!({"range": "B1", "angle": "down"})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "A:B", "chars": 6})).unwrap(); + s.execute("home.rowHeight", json!({"rows": "1:1", "height": 267})).unwrap(); + let (pdf, _) = pdf_of(&mut s, json!({})); + let pdf = String::from_utf8_lossy(&pdf); + // The page holds only these two cells, so every text block on it is a piece of a header. + let blocks: Vec<&str> = pdf.split("BT\n").skip(1).filter(|b| b.contains(" Tj")).collect(); + let up = blocks.iter().filter(|b| b.contains("\n0 1 -1 0 ")).count(); + let down = blocks.iter().filter(|b| b.contains("\n0 -1 1 0 ")).count(); + // Every piece of both headers is rotated, each wrapped along the row height into a few long lines. + assert_eq!(up + down, blocks.len(), "a header line printed flat: {blocks:?}"); + assert!((1..=3).contains(&up) && (1..=3).contains(&down), "up {up}, down {down}: {blocks:?}"); +} From 6fee0e9134c6722e50571f7463fac35af8c12e5f Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:42 +0000 Subject: [PATCH 30/76] refactor(engine): move cmd/solver tests to src/cmd/solver/tests.rs --- crates/engine/src/cmd/solver.rs | 480 +------------------------- crates/engine/src/cmd/solver/tests.rs | 477 +++++++++++++++++++++++++ 2 files changed, 478 insertions(+), 479 deletions(-) create mode 100644 crates/engine/src/cmd/solver/tests.rs diff --git a/crates/engine/src/cmd/solver.rs b/crates/engine/src/cmd/solver.rs index 21dbc1926..191493841 100644 --- a/crates/engine/src/cmd/solver.rs +++ b/crates/engine/src/cmd/solver.rs @@ -2127,482 +2127,4 @@ fn run(s: &mut Session, p: &Json) -> Result { } #[cfg(test)] -mod tests { - use gridcraft_core::{CellRef, Value}; - use serde_json::json; - - use crate::Session; - - fn s() -> Session { - let mut s = Session::new(); - s.new_workbook(); - s - } - fn n(s: &Session, a: &str) -> f64 { - let d = s.doc().unwrap(); - d.wb.active().unwrap().value(CellRef::parse(a).unwrap()).as_f64().unwrap_or(f64::NAN) - } - /// The first saved-model name scoped to the active sheet. - fn saved(s: &Session) -> String { - let d = s.doc().unwrap(); - d.wb.names.iter().find(|n| n.scope == Some(d.wb.active_sheet())).map(|n| n.name.clone()).unwrap_or_default() - } - fn set(s: &mut Session, cells: &[(&str, &str)]) { - for (c, v) in cells { - s.execute("cell.set", json!({"cell": c, "input": v})).unwrap(); - } - } - - #[test] - fn simplex_solves_the_wyndor_glass_lp() { - // Hillier & Lieberman's Wyndor Glass problem: max 3x + 5y, x ≤ 4, 2y ≤ 12, 3x + 2y ≤ 18 - // → x = 2, y = 6, Z = 36. - let mut s = s(); - set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=3*A1+5*A2"), ("B2", "=2*A2"), ("B3", "=3*A1+2*A2")]); - let r = s - .execute( - "data.solver", - json!({"objective": "B1", "goal": "max", "variables": "A1:A2", "constraints": ["A1 <= 4", "B2 <= 12", {"lhs": "B3", "op": "<=", "rhs": 18}], "method": "simplex"}), - ) - .unwrap(); - assert_eq!(r["status"], "optimal", "{r}"); - assert!((n(&s, "A1") - 2.0).abs() < 1e-9 && (n(&s, "A2") - 6.0).abs() < 1e-9); - assert!((n(&s, "B1") - 36.0).abs() < 1e-9); - // The model is saved as hidden names, survives XLSX and solves again from them. - let names: Vec = s.doc().unwrap().wb.names.iter().map(|n| n.name.clone()).collect(); - assert!(names.iter().any(|n| n == "solver_opt") && names.iter().any(|n| n == "solver_lhs3")); - let b64 = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].as_str().unwrap().to_string(); - s.execute("file.open", json!({"name": "m.xlsx", "base64": b64})).unwrap(); - set(&mut s, &[("A1", "0"), ("A2", "0")]); - let m = s.execute("data.solver", json!({})).unwrap(); - assert_eq!(m["model"]["objective"], "B1"); - assert_eq!(m["model"]["method"], "simplex"); - let r = s.execute("data.solver", json!({"saved": true})).unwrap(); - assert_eq!(r["status"], "optimal"); - assert!((n(&s, "B1") - 36.0).abs() < 1e-9); - // Minimise with keep: false leaves the cells alone. - set(&mut s, &[("A1", "1"), ("A2", "1")]); - let r = s.execute("data.solver", json!({"saved": true, "goal": "min", "keep": false})).unwrap(); - assert_eq!(r["objective"], 0.0); - assert_eq!((n(&s, "A1"), n(&s, "A2")), (1.0, 1.0)); - } - - #[test] - fn integer_and_binary_constraints_use_branch_and_bound() { - // max 5x + 4y, 6x + 4y ≤ 24, x + 2y ≤ 6: the LP optimum (3, 1.5) = 21; integers give - // (4, 0) = 20. - let mut s = s(); - set(&mut s, &[("B1", "=5*A1+4*A2"), ("B2", "=6*A1+4*A2"), ("B3", "=A1+2*A2")]); - let base = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 24", "B3 <= 6"], "method": "simplex"}); - let r = s.execute("data.solver", base.clone()).unwrap(); - assert!((r["objective"].as_f64().unwrap() - 21.0).abs() < 1e-9); - let mut p = base.clone(); - p["constraints"].as_array_mut().unwrap().push(json!("A1:A2 int")); - let r = s.execute("data.solver", p).unwrap(); - assert_eq!(r["status"], "optimal"); - assert_eq!((n(&s, "A1"), n(&s, "A2"), n(&s, "B1")), (4.0, 0.0, 20.0)); - // 0/1 knapsack: values 10, 13, 7, weights 5, 6, 4, capacity 10 → items 2 and 3 (20). - set(&mut s, &[("D1", "=10*C1+13*C2+7*C3"), ("D2", "=5*C1+6*C2+4*C3")]); - let r = s - .execute("data.solver", json!({"objective": "D1", "variables": "C1:C3", "constraints": ["D2 <= 10", "C1:C3 bin"], "method": "simplex"})) - .unwrap(); - assert_eq!(r["status"], "optimal"); - assert_eq!((n(&s, "C1"), n(&s, "C2"), n(&s, "C3"), n(&s, "D1")), (0.0, 1.0, 1.0, 20.0)); - } - - #[test] - fn grg_finds_smooth_optima() { - // Largest rectangle with perimeter at most 20: a 5 × 5 square, area 25. - let mut s = s(); - set(&mut s, &[("A1", "1"), ("A2", "1"), ("B1", "=A1*A2"), ("B2", "=2*A1+2*A2")]); - let r = s.execute("data.solver", json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 20"]})).unwrap(); - assert_eq!(r["status"], "converged", "{r}"); - assert!((n(&s, "A1") - 5.0).abs() < 1e-3 && (n(&s, "A2") - 5.0).abs() < 1e-3, "{r}"); - // Unconstrained minimum of (x − 3)² + (y + 1)² at (3, −1) once negatives are allowed. - set(&mut s, &[("C1", "0"), ("C2", "0"), ("D1", "=(C1-3)^2+(C2+1)^2")]); - s.execute("data.solver", json!({"objective": "D1", "goal": "min", "variables": "C1:C2", "nonNegative": false})).unwrap(); - assert!((n(&s, "C1") - 3.0).abs() < 1e-4 && (n(&s, "C2") + 1.0).abs() < 1e-4); - // Value Of: x³ = 8 → x = 2. - set(&mut s, &[("E1", "1"), ("F1", "=E1^3")]); - s.execute("data.solver", json!({"objective": "F1", "goal": "value", "value": 8, "variables": "E1"})).unwrap(); - assert!((n(&s, "E1") - 2.0).abs() < 1e-4); - } - - #[test] - fn evolutionary_finds_the_global_minimum_of_non_smooth_and_multimodal_models() { - let mut s = s(); - // |x − 3| + |y + 2| has a kink at its minimum (3, −2). - set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=ABS(A1-3)+ABS(A2+2)")]); - let p = - json!({"objective": "B1", "goal": "min", "variables": "A1:A2", "constraints": ["A1:A2 >= -10", "A1:A2 <= 10"], "method": "evolutionary"}); - let r = s.execute("data.solver", p.clone()).unwrap(); - assert!(r["status"] == "converged" || r["status"] == "cannotImprove", "{r}"); - assert!((n(&s, "A1") - 3.0).abs() < 1e-6 && (n(&s, "A2") + 2.0).abs() < 1e-6, "{r}"); - // The same seed repeats the run exactly. - set(&mut s, &[("A1", "0"), ("A2", "0")]); - let again = s.execute("data.solver", p).unwrap(); - assert_eq!(again["values"], r["values"]); - assert_eq!(again["evaluations"], r["evaluations"]); - // Rastrigin's function 20 + Σ (x² − 10 cos 2πx) on [−5.12, 5.12]² has its global minimum 0 - // at the origin among many local minima; GRG from (3.3, −2.7) stops at a local one. - set(&mut s, &[("C1", "3.3"), ("C2", "-2.7"), ("D1", "=20+C1^2-10*COS(2*PI()*C1)+C2^2-10*COS(2*PI()*C2)")]); - let base = json!({"objective": "D1", "goal": "min", "variables": "C1:C2", "constraints": ["C1:C2 >= -5.12", "C1:C2 <= 5.12"]}); - s.execute("data.solver", base.clone()).unwrap(); - assert!(n(&s, "D1") > 0.5, "GRG is local: {}", n(&s, "D1")); - set(&mut s, &[("C1", "3.3"), ("C2", "-2.7")]); - let mut p = base.clone(); - p["method"] = json!("evolutionary"); - let r = s.execute("data.solver", p).unwrap(); - assert!(n(&s, "D1") < 1e-8 && n(&s, "C1").abs() < 1e-5 && n(&s, "C2").abs() < 1e-5, "{r}"); - // A stepped model: ROUND makes it flat almost everywhere; the minimum is at 7.2. - set(&mut s, &[("E1", "0"), ("F1", "=ABS(ROUND(E1,0)-7)+ABS(E1-7.2)/100")]); - s.execute("data.solver", json!({"objective": "F1", "goal": "min", "variables": "E1", "constraints": ["E1 <= 20"], "method": "evolutionary"})) - .unwrap(); - assert!((n(&s, "E1") - 7.2).abs() < 1e-6, "{}", n(&s, "E1")); - } - - #[test] - fn evolutionary_handles_integers_bounds_and_the_saved_model() { - let mut s = s(); - // The 0/1 knapsack of the branch-and-bound test: items 2 and 3, value 20. - set(&mut s, &[("D1", "=10*C1+13*C2+7*C3"), ("D2", "=5*C1+6*C2+4*C3")]); - let p = json!({"objective": "D1", "variables": "C1:C3", "constraints": ["D2 <= 10", "C1:C3 bin"], "method": "evolutionary", "populationSize": 30, "randomSeed": 7}); - let r = s.execute("data.solver", p).unwrap(); - assert!(r["status"] == "converged" || r["status"] == "cannotImprove", "{r}"); - assert_eq!((n(&s, "C1"), n(&s, "C2"), n(&s, "C3"), n(&s, "D1")), (0.0, 1.0, 1.0, 20.0)); - // The method and its options are saved, survive XLSX and solve again. - let b64 = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].as_str().unwrap().to_string(); - s.execute("file.open", json!({"name": "e.xlsx", "base64": b64})).unwrap(); - let m = s.execute("data.solver", json!({})).unwrap(); - assert_eq!(m["model"]["method"], "evolutionary"); - assert_eq!((m["model"]["populationSize"].as_f64(), m["model"]["randomSeed"].as_f64()), (Some(30.0), Some(7.0))); - set(&mut s, &[("C1", "0"), ("C2", "0"), ("C3", "0")]); - s.execute("data.solver", json!({"saved": true})).unwrap(); - assert_eq!(n(&s, "D1"), 20.0); - // Integer constraints: max x + y with x + 2y ≤ 7 on integers in [0, 10] → (7, 0). - set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=A1+A2"), ("B2", "=A1+2*A2")]); - let r = s - .execute( - "data.solver", - json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 7", "A1:A2 <= 10", "A1:A2 int"], "method": "evolutionary"}), - ) - .unwrap(); - assert_eq!((n(&s, "A1"), n(&s, "A2")), (7.0, 0.0), "{r}"); - // Every variable needs bounds unless requireBounds is off; then it searches around the start. - set(&mut s, &[("E1", "1"), ("F1", "=ABS(E1-4)")]); - let p = json!({"objective": "F1", "goal": "min", "variables": "E1", "method": "evolutionary"}); - assert!(s.execute("data.solver", p.clone()).is_err()); - let mut p = p; - p["requireBounds"] = json!(false); - s.execute("data.solver", p).unwrap(); - assert!((n(&s, "E1") - 4.0).abs() < 1e-6); - // Infeasible constraints and a tiny budget are reported without changing the cells. - set(&mut s, &[("E1", "1")]); - let r = s - .execute("data.solver", json!({"objective": "F1", "variables": "E1", "constraints": ["E1 <= 3", "F1 >= 10"], "method": "evolutionary"})) - .unwrap(); - assert_eq!(r["status"], "infeasible", "{r}"); - let r = s - .execute( - "data.solver", - json!({"objective": "F1", "variables": "E1", "constraints": ["E1 <= 3"], "method": "evolutionary", "maxEvaluations": 3}), - ) - .unwrap(); - assert_eq!(r["status"], "iterationLimit", "{r}"); - assert_eq!(n(&s, "E1"), 1.0); - } - - /// Text or number at `a1` on sheet `name`. - fn at(s: &Session, name: &str, a1: &str) -> Value { - let d = s.doc().unwrap(); - d.wb.sheet(d.wb.sheet_index(name).unwrap()).unwrap().value(CellRef::parse(a1).unwrap()) - } - - /// The row of the first cell holding `text` on sheet `name`. - fn row_of(s: &Session, name: &str, text: &str) -> u32 { - let d = s.doc().unwrap(); - let sh = d.wb.sheet(d.wb.sheet_index(name).unwrap()).unwrap(); - (0..60).find(|r| (0..8).any(|c| sh.value(CellRef::new(*r, c)) == Value::text(text))).unwrap() - } - - #[test] - fn answer_and_limits_reports_describe_the_wyndor_glass_solution() { - // Wyndor Glass with labels: doors and windows across, the three plants' hours down. - let mut s = s(); - s.execute( - "range.setValues", - json!({"range": "A1", "values": [[null, "Doors", "Windows", "Used", "Available"], ["Batches", 0, 0, null, null], ["Plant 1", 1, 0, null, 4], ["Plant 2", 0, 2, null, 12], ["Plant 3", 3, 2, null, 18], ["Profit", 3, 5, null, null]]}), - ) - .unwrap(); - for r in 3..=6 { - set(&mut s, &[(&format!("D{r}"), &format!("=SUMPRODUCT(B{r}:C{r},$B$2:$C$2)"))]); - } - let solved = s - .execute( - "data.solver", - json!({"objective": "D6", "variables": "B2:C2", "constraints": ["D3:D5 <= E3:E5"], "method": "simplex", "reports": ["answer", "limits"]}), - ) - .unwrap(); - assert_eq!(solved["status"], "optimal", "{solved}"); - assert_eq!(solved["reports"], json!(["Answer Report 1", "Limits Report 1"])); - assert_eq!(solved["original"], json!({"B2": 0.0, "C2": 0.0})); - let d = s.doc().unwrap(); - let names: Vec<&str> = d.wb.sheets.iter().map(|x| x.name.as_str()).collect(); - assert_eq!(names, ["Answer Report 1", "Limits Report 1", "Sheet1"]); - assert_eq!(d.wb.active().unwrap().name, "Sheet1", "the model's sheet stays active"); - assert_eq!(saved(&s), "solver_opt", "the saved model follows its sheet"); - let a = "Answer Report 1"; - assert_eq!(at(&s, a, "A1"), Value::text("GridCraft Answer Report")); - // Objective: $D$6 "Profit Used", from 0 to 36. - let r = row_of(&s, a, "$D$6"); - assert_eq!(at(&s, a, &format!("C{}", r + 1)), Value::text("Profit Used")); - assert_eq!((at(&s, a, &format!("D{}", r + 1)), at(&s, a, &format!("E{}", r + 1))), (Value::number(0.0), Value::number(36.0))); - // Variables: doors 0 → 2, windows 0 → 6, continuous. - let r = row_of(&s, a, "$B$2") + 1; - assert_eq!(at(&s, a, &format!("C{r}")), Value::text("Batches Doors")); - assert_eq!(at(&s, a, &format!("E{r}")), Value::number(2.0)); - assert_eq!(at(&s, a, &format!("F{r}")), Value::text("Contin")); - assert_eq!(at(&s, a, &format!("E{}", r + 1)), Value::number(6.0)); - // Constraints: plant 1 has 2 hours to spare; plants 2 and 3 are binding. - let r = row_of(&s, a, "$D$3") + 1; - assert_eq!(at(&s, a, &format!("E{r}")), Value::text("$D$3<=$E$3")); - assert_eq!((at(&s, a, &format!("F{r}")), at(&s, a, &format!("G{r}"))), (Value::text("Not Binding"), Value::number(2.0))); - for k in 1..=2 { - assert_eq!((at(&s, a, &format!("F{}", r + k)), at(&s, a, &format!("G{}", r + k))), (Value::text("Binding"), Value::number(0.0))); - } - // Limits with the other variable held: doors 0..2 (profit 30..36), windows 0..6 (6..36). - let l = "Limits Report 1"; - let r = row_of(&s, l, "$B$2") + 1; - let row = |r: u32| ["D", "E", "F", "G", "H"].map(|c| at(&s, l, &format!("{c}{r}")).as_f64().unwrap()); - assert_eq!(row(r), [2.0, 0.0, 30.0, 2.0, 36.0]); - assert_eq!(row(r + 1), [6.0, 0.0, 6.0, 6.0, 36.0]); - // data.solverReport writes reports from the saved model and a given solution. - let out = - s.execute("data.solverReport", json!({"reports": ["answer"], "values": {"B2": 2, "C2": 6}, "original": {"B2": 1, "C2": 1}})).unwrap(); - assert_eq!(out["sheets"], json!(["Answer Report 2"])); - let r = row_of(&s, "Answer Report 2", "$D$6") + 1; - assert_eq!(at(&s, "Answer Report 2", &format!("D{r}")), Value::number(8.0)); - // Undo removes the reports and keeps the solution; refused requests write nothing. - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(s.doc().unwrap().wb.sheets.len(), 3); - for p in [ - json!({}), - json!({"reports": []}), - json!({"reports": "answer"}), - json!({"reports": ["population"]}), - json!({"reports": ["answer"], "values": {"Z9": 1}}), - json!({"reports": ["answer"], "values": {"B2": "x"}}), - json!({"reports": ["limits"], "constraints": ["D3:D5 <= E3:E5", "B2:C2 int"]}), - json!({"reports": ["limits"], "method": "evolutionary", "constraints": ["D3:D5 <= E3:E5", "B2:C2 <= 10"]}), - ] { - assert!(s.execute("data.solverReport", p.clone()).is_err(), "{p}"); - } - assert_eq!(s.doc().unwrap().wb.sheets.len(), 3); - // With the solve, its reports and the saved model undone (one step), the result still - // describes the solution. - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!((s.doc().unwrap().wb.sheets.len(), n(&s, "B2")), (1, 0.0)); - let mut p = solved.clone(); - p["reports"] = json!(["answer"]); - assert_eq!(s.execute("data.solverReport", p).unwrap()["sheets"], json!(["Answer Report 1"])); - let r = row_of(&s, "Answer Report 1", "$B$2") + 1; - assert_eq!(at(&s, "Answer Report 1", &format!("E{r}")), Value::number(2.0)); - } - - #[test] - fn refused_solver_reports_preserve_the_document_and_history() { - for (protected, method, constraints, reports, error) in [ - (true, "simplex", json!(["A1 <= 4"]), json!(["answer"]), "workbook is protected"), - (false, "simplex", json!(["A1 <= 4", "A1 int"]), json!(["answer", "limits"]), "integer or binary"), - (false, "evolutionary", json!(["A1 <= 4"]), json!(["answer", "limits"]), "Simplex LP and GRG"), - ] { - for (keep, save) in [(true, true), (false, true), (true, false), (false, false)] { - let mut s = s(); - set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]); - s.execute( - "data.solver", - json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 2"], "method": "simplex", "keep": false}), - ) - .unwrap(); - set(&mut s, &[("C1", "redo")]); - s.execute("edit.undo", json!({})).unwrap(); - let d = s.doc_mut().unwrap(); - std::sync::Arc::make_mut(&mut d.wb).protected_structure = protected; - d.saved = d.wb.clone(); - let before = d.clone(); - let journal = s.journal.clone(); - let err = s - .execute( - "data.solver", - json!({"objective": "B1", "variables": "A1", "constraints": constraints, "method": method, - "reports": reports, "keep": keep, "saveModel": save}), - ) - .unwrap_err(); - assert!(err.to_string().contains(error), "{err}"); - let d = s.doc().unwrap(); - assert!(std::sync::Arc::ptr_eq(&d.wb, &before.wb), "{method}: keep={keep}, save={save}: {err}"); - assert!(!d.is_dirty()); - assert_eq!(d.revision, before.revision); - assert_eq!(d.selection, before.selection); - assert_eq!(d.sheet_selections, before.sheet_selections); - for (after, before) in [(&d.undo, &before.undo), (&d.redo, &before.redo)] { - assert_eq!(after.len(), before.len()); - for (a, b) in after.iter().zip(before) { - assert!(std::sync::Arc::ptr_eq(&a.wb, &b.wb)); - assert_eq!((&a.label, &a.selection), (&b.label, &b.selection)); - } - } - assert_eq!(s.journal, journal); - } - } - } - - #[test] - fn solver_reports_and_optional_solution_and_model_share_one_undo() { - for (keep, save) in [(true, true), (false, true), (true, false), (false, false)] { - let mut s = s(); - set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]); - let before = s.doc().unwrap().clone(); - let out = s - .execute( - "data.solver", - json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 4"], "method": "simplex", - "reports": ["answer"], "keep": keep, "saveModel": save}), - ) - .unwrap(); - assert_eq!(out["status"], "optimal"); - assert_eq!(out["reports"], json!(["Answer Report 1"])); - assert_eq!(out["model"]["objective"], "Sheet1!B1"); - assert_eq!(n(&s, "A1"), if keep { 4.0 } else { 1.0 }); - assert_eq!(saved(&s), if save { "solver_opt" } else { "" }); - assert_eq!(s.doc().unwrap().undo.len(), before.undo.len() + 1); - assert_eq!(s.doc().unwrap().wb.active().unwrap().name, "Sheet1"); - let r = row_of(&s, "Answer Report 1", "$A$1") + 1; - assert_eq!(at(&s, "Answer Report 1", &format!("E{r}")), Value::number(4.0)); - s.execute("edit.undo", json!({})).unwrap(); - assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before.wb)); - s.execute("edit.redo", json!({})).unwrap(); - assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); - assert_eq!(n(&s, "A1"), if keep { 4.0 } else { 1.0 }); - } - } - - #[test] - fn solver_reports_keep_row_autofit_on_the_model_sheet() { - let mut s = s(); - set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]); - s.execute("home.columnWidth", json!({"cols": "A:A", "width": 30})).unwrap(); - s.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap(); - let before = s.doc().unwrap().wb.active().unwrap().row_height(0); - s.execute( - "data.solver", - json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 1000000"], "method": "simplex", "reports": ["answer"]}), - ) - .unwrap(); - let sh = s.doc().unwrap().wb.active().unwrap(); - assert_eq!(sh.name, "Sheet1"); - assert_eq!(sh.value(CellRef::new(0, 0)), Value::number(1_000_000.0)); - assert!(sh.row_height(0) > before, "the solved wrapped number must re-fit its row after the report shifts its sheet"); - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(0), before); - } - - #[test] - fn solver_report_insertion_keeps_variable_edit_guards() { - for array in [false, true] { - let mut s = s(); - set(&mut s, &[("A2", "1"), ("B1", "=2*A2")]); - let error = if array { - s.execute("selection.set", json!({"range": "A1:A2"})).unwrap(); - s.execute("cell.set", json!({"input": "=1", "array": true})).unwrap(); - "part of an array" - } else { - s.execute("review.protectSheet", json!({})).unwrap(); - "protected sheet" - }; - let before = s.doc().unwrap().clone(); - let err = s - .execute( - "data.solver", - json!({"objective": "B1", "variables": "A2", "constraints": ["A2 <= 4"], "method": "simplex", "reports": ["answer"]}), - ) - .unwrap_err(); - assert!(err.to_string().contains(error), "{err}"); - let d = s.doc().unwrap(); - assert!(std::sync::Arc::ptr_eq(&d.wb, &before.wb)); - assert_eq!(d.undo.len(), before.undo.len()); - assert_eq!(d.sheet_selections, before.sheet_selections); - } - } - - #[test] - fn limits_report_of_a_nonlinear_model() { - // The largest rectangle with perimeter at most 20 (5 × 5): with the height held at 5, the - // width ranges over 0..5, the area over 0..25. - let mut s = s(); - set(&mut s, &[("A1", "1"), ("A2", "1"), ("B1", "=A1*A2"), ("B2", "=2*A1+2*A2")]); - let r = - s.execute("data.solver", json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 20"], "reports": ["limits"]})).unwrap(); - assert_eq!(r["reports"], json!(["Limits Report 1"]), "{r}"); - let l = "Limits Report 1"; - let r = row_of(&s, l, "$A$1") + 1; - let got: Vec = ["E", "F", "G", "H"].iter().map(|c| at(&s, l, &format!("{c}{r}")).as_f64().unwrap()).collect(); - for (g, want) in got.iter().zip([0.0, 0.0, 5.0, 25.0]) { - assert!((g - want).abs() < 1e-3, "{got:?}"); - } - } - - #[test] - fn infeasible_unbounded_and_nonlinear_models_are_reported() { - let mut s = s(); - set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]); - for method in ["simplex", "grg"] { - let r = s - .execute("data.solver", json!({"objective": "B1", "variables": "A1", "constraints": ["A1 >= 5", "B1 <= 6"], "method": method})) - .unwrap(); - assert_eq!(r["status"], "infeasible", "{method}: {r}"); - assert_eq!(r["changed"], false); - assert_eq!(n(&s, "A1"), 1.0, "an infeasible model leaves the cells alone"); - } - let r = s.execute("data.solver", json!({"objective": "B1", "variables": "A1", "method": "simplex"})).unwrap(); - assert_eq!(r["status"], "unbounded"); - let r = s.execute("data.solver", json!({"objective": "B1", "variables": "A1", "method": "grg", "maxEvaluations": 5000})).unwrap(); - assert!(r["status"] == "unbounded" || r["status"] == "iterationLimit", "{r}"); - set(&mut s, &[("A1", "1"), ("C1", "=A1^2")]); - let r = s.execute("data.solver", json!({"objective": "C1", "goal": "min", "variables": "A1", "method": "simplex"})).unwrap(); - assert_eq!(r["status"], "notLinear"); - } - - #[test] - fn hostile_models_are_refused_without_panicking() { - let mut s = s(); - let r = s.execute("data.solver", json!({})).unwrap(); - assert!(r["model"].is_null()); - assert!(s.take_ui_requests().iter().any(|q| matches!(q, crate::UiRequest::Dialog(n, _) if n == "solver"))); - set(&mut s, &[("B1", "=SUM(A1:A1000)"), ("C1", "5")]); - for p in [ - json!({"objective": "B1", "variables": "A1:A1000"}), - json!({"objective": "B1", "variables": "A1:XFD1048576"}), - json!({"objective": "C1", "variables": "A1"}), - json!({"objective": "B1:B2", "variables": "A1"}), - json!({"objective": "B1", "variables": "B1"}), - json!({"objective": "B1", "variables": "A1", "goal": "sideways"}), - json!({"objective": "B1", "variables": "A1", "goal": "value"}), - json!({"objective": "B1", "variables": "A1", "method": "evolutionary"}), - json!({"objective": "B1", "variables": "A1", "method": "annealing"}), - json!({"objective": "B1", "variables": "A1", "constraints": ["A1 ?? 3"]}), - json!({"objective": "B1", "variables": "A1", "constraints": "A1 <= 3"}), - json!({"objective": "B1", "variables": "A1", "constraints": ["A1:A3 <= C1:C2"]}), - json!({"objective": "B1", "variables": "A1", "constraints": ["C1 int"]}), - json!({"objective": "B1", "variables": "A1", "constraints": ["A1:A1048576 <= 1"]}), - json!({"objective": "B1", "variables": "A1", "constraints": vec![json!("A1 <= 1"); 1001]}), - json!({"objective": "Nope!B1", "variables": "A1"}), - ] { - assert!(s.execute("data.solver", p.clone()).is_err(), "{p}"); - } - // Huge evaluation requests are capped, and a model erroring everywhere doesn't panic. - set(&mut s, &[("D1", "=1/0+A1")]); - let r = s.execute("data.solver", json!({"objective": "D1", "variables": "A1", "maxEvaluations": 1e300})).unwrap(); - assert_ne!(r["status"], "converged"); - } -} +mod tests; diff --git a/crates/engine/src/cmd/solver/tests.rs b/crates/engine/src/cmd/solver/tests.rs new file mode 100644 index 000000000..bac1328b7 --- /dev/null +++ b/crates/engine/src/cmd/solver/tests.rs @@ -0,0 +1,477 @@ +use gridcraft_core::{CellRef, Value}; +use serde_json::json; + +use crate::Session; + +fn s() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s +} +fn n(s: &Session, a: &str) -> f64 { + let d = s.doc().unwrap(); + d.wb.active().unwrap().value(CellRef::parse(a).unwrap()).as_f64().unwrap_or(f64::NAN) +} +/// The first saved-model name scoped to the active sheet. +fn saved(s: &Session) -> String { + let d = s.doc().unwrap(); + d.wb.names.iter().find(|n| n.scope == Some(d.wb.active_sheet())).map(|n| n.name.clone()).unwrap_or_default() +} +fn set(s: &mut Session, cells: &[(&str, &str)]) { + for (c, v) in cells { + s.execute("cell.set", json!({"cell": c, "input": v})).unwrap(); + } +} + +#[test] +fn simplex_solves_the_wyndor_glass_lp() { + // Hillier & Lieberman's Wyndor Glass problem: max 3x + 5y, x ≤ 4, 2y ≤ 12, 3x + 2y ≤ 18 + // → x = 2, y = 6, Z = 36. + let mut s = s(); + set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=3*A1+5*A2"), ("B2", "=2*A2"), ("B3", "=3*A1+2*A2")]); + let r = s + .execute( + "data.solver", + json!({"objective": "B1", "goal": "max", "variables": "A1:A2", "constraints": ["A1 <= 4", "B2 <= 12", {"lhs": "B3", "op": "<=", "rhs": 18}], "method": "simplex"}), + ) + .unwrap(); + assert_eq!(r["status"], "optimal", "{r}"); + assert!((n(&s, "A1") - 2.0).abs() < 1e-9 && (n(&s, "A2") - 6.0).abs() < 1e-9); + assert!((n(&s, "B1") - 36.0).abs() < 1e-9); + // The model is saved as hidden names, survives XLSX and solves again from them. + let names: Vec = s.doc().unwrap().wb.names.iter().map(|n| n.name.clone()).collect(); + assert!(names.iter().any(|n| n == "solver_opt") && names.iter().any(|n| n == "solver_lhs3")); + let b64 = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].as_str().unwrap().to_string(); + s.execute("file.open", json!({"name": "m.xlsx", "base64": b64})).unwrap(); + set(&mut s, &[("A1", "0"), ("A2", "0")]); + let m = s.execute("data.solver", json!({})).unwrap(); + assert_eq!(m["model"]["objective"], "B1"); + assert_eq!(m["model"]["method"], "simplex"); + let r = s.execute("data.solver", json!({"saved": true})).unwrap(); + assert_eq!(r["status"], "optimal"); + assert!((n(&s, "B1") - 36.0).abs() < 1e-9); + // Minimise with keep: false leaves the cells alone. + set(&mut s, &[("A1", "1"), ("A2", "1")]); + let r = s.execute("data.solver", json!({"saved": true, "goal": "min", "keep": false})).unwrap(); + assert_eq!(r["objective"], 0.0); + assert_eq!((n(&s, "A1"), n(&s, "A2")), (1.0, 1.0)); +} + +#[test] +fn integer_and_binary_constraints_use_branch_and_bound() { + // max 5x + 4y, 6x + 4y ≤ 24, x + 2y ≤ 6: the LP optimum (3, 1.5) = 21; integers give + // (4, 0) = 20. + let mut s = s(); + set(&mut s, &[("B1", "=5*A1+4*A2"), ("B2", "=6*A1+4*A2"), ("B3", "=A1+2*A2")]); + let base = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 24", "B3 <= 6"], "method": "simplex"}); + let r = s.execute("data.solver", base.clone()).unwrap(); + assert!((r["objective"].as_f64().unwrap() - 21.0).abs() < 1e-9); + let mut p = base.clone(); + p["constraints"].as_array_mut().unwrap().push(json!("A1:A2 int")); + let r = s.execute("data.solver", p).unwrap(); + assert_eq!(r["status"], "optimal"); + assert_eq!((n(&s, "A1"), n(&s, "A2"), n(&s, "B1")), (4.0, 0.0, 20.0)); + // 0/1 knapsack: values 10, 13, 7, weights 5, 6, 4, capacity 10 → items 2 and 3 (20). + set(&mut s, &[("D1", "=10*C1+13*C2+7*C3"), ("D2", "=5*C1+6*C2+4*C3")]); + let r = s + .execute("data.solver", json!({"objective": "D1", "variables": "C1:C3", "constraints": ["D2 <= 10", "C1:C3 bin"], "method": "simplex"})) + .unwrap(); + assert_eq!(r["status"], "optimal"); + assert_eq!((n(&s, "C1"), n(&s, "C2"), n(&s, "C3"), n(&s, "D1")), (0.0, 1.0, 1.0, 20.0)); +} + +#[test] +fn grg_finds_smooth_optima() { + // Largest rectangle with perimeter at most 20: a 5 × 5 square, area 25. + let mut s = s(); + set(&mut s, &[("A1", "1"), ("A2", "1"), ("B1", "=A1*A2"), ("B2", "=2*A1+2*A2")]); + let r = s.execute("data.solver", json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 20"]})).unwrap(); + assert_eq!(r["status"], "converged", "{r}"); + assert!((n(&s, "A1") - 5.0).abs() < 1e-3 && (n(&s, "A2") - 5.0).abs() < 1e-3, "{r}"); + // Unconstrained minimum of (x − 3)² + (y + 1)² at (3, −1) once negatives are allowed. + set(&mut s, &[("C1", "0"), ("C2", "0"), ("D1", "=(C1-3)^2+(C2+1)^2")]); + s.execute("data.solver", json!({"objective": "D1", "goal": "min", "variables": "C1:C2", "nonNegative": false})).unwrap(); + assert!((n(&s, "C1") - 3.0).abs() < 1e-4 && (n(&s, "C2") + 1.0).abs() < 1e-4); + // Value Of: x³ = 8 → x = 2. + set(&mut s, &[("E1", "1"), ("F1", "=E1^3")]); + s.execute("data.solver", json!({"objective": "F1", "goal": "value", "value": 8, "variables": "E1"})).unwrap(); + assert!((n(&s, "E1") - 2.0).abs() < 1e-4); +} + +#[test] +fn evolutionary_finds_the_global_minimum_of_non_smooth_and_multimodal_models() { + let mut s = s(); + // |x − 3| + |y + 2| has a kink at its minimum (3, −2). + set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=ABS(A1-3)+ABS(A2+2)")]); + let p = + json!({"objective": "B1", "goal": "min", "variables": "A1:A2", "constraints": ["A1:A2 >= -10", "A1:A2 <= 10"], "method": "evolutionary"}); + let r = s.execute("data.solver", p.clone()).unwrap(); + assert!(r["status"] == "converged" || r["status"] == "cannotImprove", "{r}"); + assert!((n(&s, "A1") - 3.0).abs() < 1e-6 && (n(&s, "A2") + 2.0).abs() < 1e-6, "{r}"); + // The same seed repeats the run exactly. + set(&mut s, &[("A1", "0"), ("A2", "0")]); + let again = s.execute("data.solver", p).unwrap(); + assert_eq!(again["values"], r["values"]); + assert_eq!(again["evaluations"], r["evaluations"]); + // Rastrigin's function 20 + Σ (x² − 10 cos 2πx) on [−5.12, 5.12]² has its global minimum 0 + // at the origin among many local minima; GRG from (3.3, −2.7) stops at a local one. + set(&mut s, &[("C1", "3.3"), ("C2", "-2.7"), ("D1", "=20+C1^2-10*COS(2*PI()*C1)+C2^2-10*COS(2*PI()*C2)")]); + let base = json!({"objective": "D1", "goal": "min", "variables": "C1:C2", "constraints": ["C1:C2 >= -5.12", "C1:C2 <= 5.12"]}); + s.execute("data.solver", base.clone()).unwrap(); + assert!(n(&s, "D1") > 0.5, "GRG is local: {}", n(&s, "D1")); + set(&mut s, &[("C1", "3.3"), ("C2", "-2.7")]); + let mut p = base.clone(); + p["method"] = json!("evolutionary"); + let r = s.execute("data.solver", p).unwrap(); + assert!(n(&s, "D1") < 1e-8 && n(&s, "C1").abs() < 1e-5 && n(&s, "C2").abs() < 1e-5, "{r}"); + // A stepped model: ROUND makes it flat almost everywhere; the minimum is at 7.2. + set(&mut s, &[("E1", "0"), ("F1", "=ABS(ROUND(E1,0)-7)+ABS(E1-7.2)/100")]); + s.execute("data.solver", json!({"objective": "F1", "goal": "min", "variables": "E1", "constraints": ["E1 <= 20"], "method": "evolutionary"})) + .unwrap(); + assert!((n(&s, "E1") - 7.2).abs() < 1e-6, "{}", n(&s, "E1")); +} + +#[test] +fn evolutionary_handles_integers_bounds_and_the_saved_model() { + let mut s = s(); + // The 0/1 knapsack of the branch-and-bound test: items 2 and 3, value 20. + set(&mut s, &[("D1", "=10*C1+13*C2+7*C3"), ("D2", "=5*C1+6*C2+4*C3")]); + let p = json!({"objective": "D1", "variables": "C1:C3", "constraints": ["D2 <= 10", "C1:C3 bin"], "method": "evolutionary", "populationSize": 30, "randomSeed": 7}); + let r = s.execute("data.solver", p).unwrap(); + assert!(r["status"] == "converged" || r["status"] == "cannotImprove", "{r}"); + assert_eq!((n(&s, "C1"), n(&s, "C2"), n(&s, "C3"), n(&s, "D1")), (0.0, 1.0, 1.0, 20.0)); + // The method and its options are saved, survive XLSX and solve again. + let b64 = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].as_str().unwrap().to_string(); + s.execute("file.open", json!({"name": "e.xlsx", "base64": b64})).unwrap(); + let m = s.execute("data.solver", json!({})).unwrap(); + assert_eq!(m["model"]["method"], "evolutionary"); + assert_eq!((m["model"]["populationSize"].as_f64(), m["model"]["randomSeed"].as_f64()), (Some(30.0), Some(7.0))); + set(&mut s, &[("C1", "0"), ("C2", "0"), ("C3", "0")]); + s.execute("data.solver", json!({"saved": true})).unwrap(); + assert_eq!(n(&s, "D1"), 20.0); + // Integer constraints: max x + y with x + 2y ≤ 7 on integers in [0, 10] → (7, 0). + set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=A1+A2"), ("B2", "=A1+2*A2")]); + let r = s + .execute( + "data.solver", + json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 7", "A1:A2 <= 10", "A1:A2 int"], "method": "evolutionary"}), + ) + .unwrap(); + assert_eq!((n(&s, "A1"), n(&s, "A2")), (7.0, 0.0), "{r}"); + // Every variable needs bounds unless requireBounds is off; then it searches around the start. + set(&mut s, &[("E1", "1"), ("F1", "=ABS(E1-4)")]); + let p = json!({"objective": "F1", "goal": "min", "variables": "E1", "method": "evolutionary"}); + assert!(s.execute("data.solver", p.clone()).is_err()); + let mut p = p; + p["requireBounds"] = json!(false); + s.execute("data.solver", p).unwrap(); + assert!((n(&s, "E1") - 4.0).abs() < 1e-6); + // Infeasible constraints and a tiny budget are reported without changing the cells. + set(&mut s, &[("E1", "1")]); + let r = s + .execute("data.solver", json!({"objective": "F1", "variables": "E1", "constraints": ["E1 <= 3", "F1 >= 10"], "method": "evolutionary"})) + .unwrap(); + assert_eq!(r["status"], "infeasible", "{r}"); + let r = s + .execute( + "data.solver", + json!({"objective": "F1", "variables": "E1", "constraints": ["E1 <= 3"], "method": "evolutionary", "maxEvaluations": 3}), + ) + .unwrap(); + assert_eq!(r["status"], "iterationLimit", "{r}"); + assert_eq!(n(&s, "E1"), 1.0); +} + +/// Text or number at `a1` on sheet `name`. +fn at(s: &Session, name: &str, a1: &str) -> Value { + let d = s.doc().unwrap(); + d.wb.sheet(d.wb.sheet_index(name).unwrap()).unwrap().value(CellRef::parse(a1).unwrap()) +} + +/// The row of the first cell holding `text` on sheet `name`. +fn row_of(s: &Session, name: &str, text: &str) -> u32 { + let d = s.doc().unwrap(); + let sh = d.wb.sheet(d.wb.sheet_index(name).unwrap()).unwrap(); + (0..60).find(|r| (0..8).any(|c| sh.value(CellRef::new(*r, c)) == Value::text(text))).unwrap() +} + +#[test] +fn answer_and_limits_reports_describe_the_wyndor_glass_solution() { + // Wyndor Glass with labels: doors and windows across, the three plants' hours down. + let mut s = s(); + s.execute( + "range.setValues", + json!({"range": "A1", "values": [[null, "Doors", "Windows", "Used", "Available"], ["Batches", 0, 0, null, null], ["Plant 1", 1, 0, null, 4], ["Plant 2", 0, 2, null, 12], ["Plant 3", 3, 2, null, 18], ["Profit", 3, 5, null, null]]}), + ) + .unwrap(); + for r in 3..=6 { + set(&mut s, &[(&format!("D{r}"), &format!("=SUMPRODUCT(B{r}:C{r},$B$2:$C$2)"))]); + } + let solved = s + .execute( + "data.solver", + json!({"objective": "D6", "variables": "B2:C2", "constraints": ["D3:D5 <= E3:E5"], "method": "simplex", "reports": ["answer", "limits"]}), + ) + .unwrap(); + assert_eq!(solved["status"], "optimal", "{solved}"); + assert_eq!(solved["reports"], json!(["Answer Report 1", "Limits Report 1"])); + assert_eq!(solved["original"], json!({"B2": 0.0, "C2": 0.0})); + let d = s.doc().unwrap(); + let names: Vec<&str> = d.wb.sheets.iter().map(|x| x.name.as_str()).collect(); + assert_eq!(names, ["Answer Report 1", "Limits Report 1", "Sheet1"]); + assert_eq!(d.wb.active().unwrap().name, "Sheet1", "the model's sheet stays active"); + assert_eq!(saved(&s), "solver_opt", "the saved model follows its sheet"); + let a = "Answer Report 1"; + assert_eq!(at(&s, a, "A1"), Value::text("GridCraft Answer Report")); + // Objective: $D$6 "Profit Used", from 0 to 36. + let r = row_of(&s, a, "$D$6"); + assert_eq!(at(&s, a, &format!("C{}", r + 1)), Value::text("Profit Used")); + assert_eq!((at(&s, a, &format!("D{}", r + 1)), at(&s, a, &format!("E{}", r + 1))), (Value::number(0.0), Value::number(36.0))); + // Variables: doors 0 → 2, windows 0 → 6, continuous. + let r = row_of(&s, a, "$B$2") + 1; + assert_eq!(at(&s, a, &format!("C{r}")), Value::text("Batches Doors")); + assert_eq!(at(&s, a, &format!("E{r}")), Value::number(2.0)); + assert_eq!(at(&s, a, &format!("F{r}")), Value::text("Contin")); + assert_eq!(at(&s, a, &format!("E{}", r + 1)), Value::number(6.0)); + // Constraints: plant 1 has 2 hours to spare; plants 2 and 3 are binding. + let r = row_of(&s, a, "$D$3") + 1; + assert_eq!(at(&s, a, &format!("E{r}")), Value::text("$D$3<=$E$3")); + assert_eq!((at(&s, a, &format!("F{r}")), at(&s, a, &format!("G{r}"))), (Value::text("Not Binding"), Value::number(2.0))); + for k in 1..=2 { + assert_eq!((at(&s, a, &format!("F{}", r + k)), at(&s, a, &format!("G{}", r + k))), (Value::text("Binding"), Value::number(0.0))); + } + // Limits with the other variable held: doors 0..2 (profit 30..36), windows 0..6 (6..36). + let l = "Limits Report 1"; + let r = row_of(&s, l, "$B$2") + 1; + let row = |r: u32| ["D", "E", "F", "G", "H"].map(|c| at(&s, l, &format!("{c}{r}")).as_f64().unwrap()); + assert_eq!(row(r), [2.0, 0.0, 30.0, 2.0, 36.0]); + assert_eq!(row(r + 1), [6.0, 0.0, 6.0, 6.0, 36.0]); + // data.solverReport writes reports from the saved model and a given solution. + let out = + s.execute("data.solverReport", json!({"reports": ["answer"], "values": {"B2": 2, "C2": 6}, "original": {"B2": 1, "C2": 1}})).unwrap(); + assert_eq!(out["sheets"], json!(["Answer Report 2"])); + let r = row_of(&s, "Answer Report 2", "$D$6") + 1; + assert_eq!(at(&s, "Answer Report 2", &format!("D{r}")), Value::number(8.0)); + // Undo removes the reports and keeps the solution; refused requests write nothing. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 3); + for p in [ + json!({}), + json!({"reports": []}), + json!({"reports": "answer"}), + json!({"reports": ["population"]}), + json!({"reports": ["answer"], "values": {"Z9": 1}}), + json!({"reports": ["answer"], "values": {"B2": "x"}}), + json!({"reports": ["limits"], "constraints": ["D3:D5 <= E3:E5", "B2:C2 int"]}), + json!({"reports": ["limits"], "method": "evolutionary", "constraints": ["D3:D5 <= E3:E5", "B2:C2 <= 10"]}), + ] { + assert!(s.execute("data.solverReport", p.clone()).is_err(), "{p}"); + } + assert_eq!(s.doc().unwrap().wb.sheets.len(), 3); + // With the solve, its reports and the saved model undone (one step), the result still + // describes the solution. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!((s.doc().unwrap().wb.sheets.len(), n(&s, "B2")), (1, 0.0)); + let mut p = solved.clone(); + p["reports"] = json!(["answer"]); + assert_eq!(s.execute("data.solverReport", p).unwrap()["sheets"], json!(["Answer Report 1"])); + let r = row_of(&s, "Answer Report 1", "$B$2") + 1; + assert_eq!(at(&s, "Answer Report 1", &format!("E{r}")), Value::number(2.0)); +} + +#[test] +fn refused_solver_reports_preserve_the_document_and_history() { + for (protected, method, constraints, reports, error) in [ + (true, "simplex", json!(["A1 <= 4"]), json!(["answer"]), "workbook is protected"), + (false, "simplex", json!(["A1 <= 4", "A1 int"]), json!(["answer", "limits"]), "integer or binary"), + (false, "evolutionary", json!(["A1 <= 4"]), json!(["answer", "limits"]), "Simplex LP and GRG"), + ] { + for (keep, save) in [(true, true), (false, true), (true, false), (false, false)] { + let mut s = s(); + set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]); + s.execute( + "data.solver", + json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 2"], "method": "simplex", "keep": false}), + ) + .unwrap(); + set(&mut s, &[("C1", "redo")]); + s.execute("edit.undo", json!({})).unwrap(); + let d = s.doc_mut().unwrap(); + std::sync::Arc::make_mut(&mut d.wb).protected_structure = protected; + d.saved = d.wb.clone(); + let before = d.clone(); + let journal = s.journal.clone(); + let err = s + .execute( + "data.solver", + json!({"objective": "B1", "variables": "A1", "constraints": constraints, "method": method, + "reports": reports, "keep": keep, "saveModel": save}), + ) + .unwrap_err(); + assert!(err.to_string().contains(error), "{err}"); + let d = s.doc().unwrap(); + assert!(std::sync::Arc::ptr_eq(&d.wb, &before.wb), "{method}: keep={keep}, save={save}: {err}"); + assert!(!d.is_dirty()); + assert_eq!(d.revision, before.revision); + assert_eq!(d.selection, before.selection); + assert_eq!(d.sheet_selections, before.sheet_selections); + for (after, before) in [(&d.undo, &before.undo), (&d.redo, &before.redo)] { + assert_eq!(after.len(), before.len()); + for (a, b) in after.iter().zip(before) { + assert!(std::sync::Arc::ptr_eq(&a.wb, &b.wb)); + assert_eq!((&a.label, &a.selection), (&b.label, &b.selection)); + } + } + assert_eq!(s.journal, journal); + } + } +} + +#[test] +fn solver_reports_and_optional_solution_and_model_share_one_undo() { + for (keep, save) in [(true, true), (false, true), (true, false), (false, false)] { + let mut s = s(); + set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]); + let before = s.doc().unwrap().clone(); + let out = s + .execute( + "data.solver", + json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 4"], "method": "simplex", + "reports": ["answer"], "keep": keep, "saveModel": save}), + ) + .unwrap(); + assert_eq!(out["status"], "optimal"); + assert_eq!(out["reports"], json!(["Answer Report 1"])); + assert_eq!(out["model"]["objective"], "Sheet1!B1"); + assert_eq!(n(&s, "A1"), if keep { 4.0 } else { 1.0 }); + assert_eq!(saved(&s), if save { "solver_opt" } else { "" }); + assert_eq!(s.doc().unwrap().undo.len(), before.undo.len() + 1); + assert_eq!(s.doc().unwrap().wb.active().unwrap().name, "Sheet1"); + let r = row_of(&s, "Answer Report 1", "$A$1") + 1; + assert_eq!(at(&s, "Answer Report 1", &format!("E{r}")), Value::number(4.0)); + s.execute("edit.undo", json!({})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before.wb)); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + assert_eq!(n(&s, "A1"), if keep { 4.0 } else { 1.0 }); + } +} + +#[test] +fn solver_reports_keep_row_autofit_on_the_model_sheet() { + let mut s = s(); + set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]); + s.execute("home.columnWidth", json!({"cols": "A:A", "width": 30})).unwrap(); + s.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap(); + let before = s.doc().unwrap().wb.active().unwrap().row_height(0); + s.execute( + "data.solver", + json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 1000000"], "method": "simplex", "reports": ["answer"]}), + ) + .unwrap(); + let sh = s.doc().unwrap().wb.active().unwrap(); + assert_eq!(sh.name, "Sheet1"); + assert_eq!(sh.value(CellRef::new(0, 0)), Value::number(1_000_000.0)); + assert!(sh.row_height(0) > before, "the solved wrapped number must re-fit its row after the report shifts its sheet"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(0), before); +} + +#[test] +fn solver_report_insertion_keeps_variable_edit_guards() { + for array in [false, true] { + let mut s = s(); + set(&mut s, &[("A2", "1"), ("B1", "=2*A2")]); + let error = if array { + s.execute("selection.set", json!({"range": "A1:A2"})).unwrap(); + s.execute("cell.set", json!({"input": "=1", "array": true})).unwrap(); + "part of an array" + } else { + s.execute("review.protectSheet", json!({})).unwrap(); + "protected sheet" + }; + let before = s.doc().unwrap().clone(); + let err = s + .execute( + "data.solver", + json!({"objective": "B1", "variables": "A2", "constraints": ["A2 <= 4"], "method": "simplex", "reports": ["answer"]}), + ) + .unwrap_err(); + assert!(err.to_string().contains(error), "{err}"); + let d = s.doc().unwrap(); + assert!(std::sync::Arc::ptr_eq(&d.wb, &before.wb)); + assert_eq!(d.undo.len(), before.undo.len()); + assert_eq!(d.sheet_selections, before.sheet_selections); + } +} + +#[test] +fn limits_report_of_a_nonlinear_model() { + // The largest rectangle with perimeter at most 20 (5 × 5): with the height held at 5, the + // width ranges over 0..5, the area over 0..25. + let mut s = s(); + set(&mut s, &[("A1", "1"), ("A2", "1"), ("B1", "=A1*A2"), ("B2", "=2*A1+2*A2")]); + let r = + s.execute("data.solver", json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 20"], "reports": ["limits"]})).unwrap(); + assert_eq!(r["reports"], json!(["Limits Report 1"]), "{r}"); + let l = "Limits Report 1"; + let r = row_of(&s, l, "$A$1") + 1; + let got: Vec = ["E", "F", "G", "H"].iter().map(|c| at(&s, l, &format!("{c}{r}")).as_f64().unwrap()).collect(); + for (g, want) in got.iter().zip([0.0, 0.0, 5.0, 25.0]) { + assert!((g - want).abs() < 1e-3, "{got:?}"); + } +} + +#[test] +fn infeasible_unbounded_and_nonlinear_models_are_reported() { + let mut s = s(); + set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]); + for method in ["simplex", "grg"] { + let r = s + .execute("data.solver", json!({"objective": "B1", "variables": "A1", "constraints": ["A1 >= 5", "B1 <= 6"], "method": method})) + .unwrap(); + assert_eq!(r["status"], "infeasible", "{method}: {r}"); + assert_eq!(r["changed"], false); + assert_eq!(n(&s, "A1"), 1.0, "an infeasible model leaves the cells alone"); + } + let r = s.execute("data.solver", json!({"objective": "B1", "variables": "A1", "method": "simplex"})).unwrap(); + assert_eq!(r["status"], "unbounded"); + let r = s.execute("data.solver", json!({"objective": "B1", "variables": "A1", "method": "grg", "maxEvaluations": 5000})).unwrap(); + assert!(r["status"] == "unbounded" || r["status"] == "iterationLimit", "{r}"); + set(&mut s, &[("A1", "1"), ("C1", "=A1^2")]); + let r = s.execute("data.solver", json!({"objective": "C1", "goal": "min", "variables": "A1", "method": "simplex"})).unwrap(); + assert_eq!(r["status"], "notLinear"); +} + +#[test] +fn hostile_models_are_refused_without_panicking() { + let mut s = s(); + let r = s.execute("data.solver", json!({})).unwrap(); + assert!(r["model"].is_null()); + assert!(s.take_ui_requests().iter().any(|q| matches!(q, crate::UiRequest::Dialog(n, _) if n == "solver"))); + set(&mut s, &[("B1", "=SUM(A1:A1000)"), ("C1", "5")]); + for p in [ + json!({"objective": "B1", "variables": "A1:A1000"}), + json!({"objective": "B1", "variables": "A1:XFD1048576"}), + json!({"objective": "C1", "variables": "A1"}), + json!({"objective": "B1:B2", "variables": "A1"}), + json!({"objective": "B1", "variables": "B1"}), + json!({"objective": "B1", "variables": "A1", "goal": "sideways"}), + json!({"objective": "B1", "variables": "A1", "goal": "value"}), + json!({"objective": "B1", "variables": "A1", "method": "evolutionary"}), + json!({"objective": "B1", "variables": "A1", "method": "annealing"}), + json!({"objective": "B1", "variables": "A1", "constraints": ["A1 ?? 3"]}), + json!({"objective": "B1", "variables": "A1", "constraints": "A1 <= 3"}), + json!({"objective": "B1", "variables": "A1", "constraints": ["A1:A3 <= C1:C2"]}), + json!({"objective": "B1", "variables": "A1", "constraints": ["C1 int"]}), + json!({"objective": "B1", "variables": "A1", "constraints": ["A1:A1048576 <= 1"]}), + json!({"objective": "B1", "variables": "A1", "constraints": vec![json!("A1 <= 1"); 1001]}), + json!({"objective": "Nope!B1", "variables": "A1"}), + ] { + assert!(s.execute("data.solver", p.clone()).is_err(), "{p}"); + } + // Huge evaluation requests are capped, and a model erroring everywhere doesn't panic. + set(&mut s, &[("D1", "=1/0+A1")]); + let r = s.execute("data.solver", json!({"objective": "D1", "variables": "A1", "maxEvaluations": 1e300})).unwrap(); + assert_ne!(r["status"], "converged"); +} From 8c338b7cbe1376362de72dcca94ec51301bbb8ed Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:42 +0000 Subject: [PATCH 31/76] refactor(engine): move cmd/whatif tests to src/cmd/whatif/tests.rs --- crates/engine/src/cmd/whatif.rs | 302 +------------------------- crates/engine/src/cmd/whatif/tests.rs | 299 +++++++++++++++++++++++++ 2 files changed, 300 insertions(+), 301 deletions(-) create mode 100644 crates/engine/src/cmd/whatif/tests.rs diff --git a/crates/engine/src/cmd/whatif.rs b/crates/engine/src/cmd/whatif.rs index 53b0dc3cc..1b9ed0f54 100644 --- a/crates/engine/src/cmd/whatif.rs +++ b/crates/engine/src/cmd/whatif.rs @@ -1132,304 +1132,4 @@ fn consolidate(s: &mut Session, p: &Json) -> Result { } #[cfg(test)] -mod tests { - use gridcraft_core::{CellRef, Value}; - use serde_json::json; - - use crate::Session; - - fn s() -> Session { - let mut s = Session::new(); - s.new_workbook(); - s - } - fn v(s: &Session, a: &str) -> Value { - let d = s.doc().unwrap(); - let (sh, body) = super::split_sheet(a); - let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet()); - d.wb.sheet(si).unwrap().value(CellRef::parse(body).unwrap()) - } - fn n(s: &Session, a: &str) -> f64 { - v(s, a).as_f64().unwrap_or(f64::NAN) - } - - #[test] - fn goal_seek_linear_and_nonlinear() { - let mut s = s(); - s.execute("cell.set", json!({"cell": "B2", "input": "1"})).unwrap(); - s.execute("cell.set", json!({"cell": "B5", "input": "=B2*3+4"})).unwrap(); - let r = s.execute("data.goalSeek", json!({"set": "B5", "to": 25, "changing": "B2"})).unwrap(); - assert_eq!(r["converged"], true); - assert!((n(&s, "B2") - 7.0).abs() < 0.001, "{r}"); - assert!((n(&s, "B5") - 25.0).abs() < 0.001); - // One undo step restores the input. - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(n(&s, "B2"), 1.0); - // Nonlinear: x^2 = 2 from x = 1. - s.execute("cell.set", json!({"cell": "C1", "input": "=B2^2"})).unwrap(); - let r = s.execute("data.goalSeek", json!({"set": "C1", "to": 2, "changing": "B2"})).unwrap(); - assert_eq!(r["converged"], true); - assert!((n(&s, "C1") - 2.0).abs() <= 0.001); - // Loan payment: what rate makes PMT = -500? - s.execute("range.setValues", json!({"range": "E1", "values": [[0.01], [10000], [24]]})).unwrap(); - s.execute("cell.set", json!({"cell": "E4", "input": "=PMT(E1/12,E3,E2)"})).unwrap(); - let r = s.execute("data.goalSeek", json!({"set": "E4", "to": -500, "changing": "E1"})).unwrap(); - assert_eq!(r["converged"], true, "{r}"); - assert!((n(&s, "E4") + 500.0).abs() <= 0.001); - // Starting at zero. - s.execute("cell.set", json!({"cell": "G1", "input": "0"})).unwrap(); - s.execute("cell.set", json!({"cell": "G2", "input": "=G1*G1*G1-27"})).unwrap(); - let r = s.execute("data.goalSeek", json!({"set": "G2", "to": 0, "changing": "G1"})).unwrap(); - assert_eq!(r["converged"], true, "{r}"); - assert!((n(&s, "G1") - 3.0).abs() < 0.01); - } - - #[test] - fn goal_seek_errors() { - let mut s = s(); - s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap(); - s.execute("cell.set", json!({"cell": "A2", "input": "=A1"})).unwrap(); - assert!(s.execute("data.goalSeek", json!({"set": "A1", "to": 1, "changing": "A1"})).is_err()); - assert!(s.execute("data.goalSeek", json!({"set": "A2", "to": 1, "changing": "A2"})).is_err()); - assert!(s.execute("data.goalSeek", json!({"set": "A2", "changing": "A1"})).is_err()); - // Unreachable target: not converged, but no error and no crash. - s.execute("cell.set", json!({"cell": "A3", "input": "=A1^2"})).unwrap(); - let r = s.execute("data.goalSeek", json!({"set": "A3", "to": -4, "changing": "A1"})).unwrap(); - assert_eq!(r["converged"], false); - assert!(r["iterations"].as_u64().unwrap() <= 100); - } - - #[test] - fn seek_unit() { - let mut f = |x: f64| (x - 1e6) / 3.0; - let r = super::seek(0.0, &mut f, 100, 0.001); - assert!(r.converged, "{r:?}"); - let mut g = |x: f64| if x < 0.0 { f64::NAN } else { x.sqrt() - 5.0 }; - let r = super::seek(1.0, &mut g, 100, 0.001); - assert!(r.converged, "{r:?}"); - let mut h = |_: f64| f64::NAN; - let r = super::seek(1.0, &mut h, 100, 0.001); - assert!(!r.converged); - } - - #[test] - fn scenario_show_restores_a_percentage_cells_stored_number() { - let mut s = s(); - s.execute("home.numberFormat", json!({"range": "B2", "format": "Percentage"})).unwrap(); - s.execute("cell.set", json!({"cell": "B2", "input": "5"})).unwrap(); - s.execute("data.scenarioAdd", json!({"name": "Low", "changing": "B2"})).unwrap(); - s.execute("cell.set", json!({"cell": "B2", "input": "9"})).unwrap(); - s.execute("data.scenarioShow", json!({"name": "Low"})).unwrap(); - assert_eq!(n(&s, "B2"), 0.05); - } - - #[test] - fn scenarios() { - let mut s = s(); - s.execute("range.setValues", json!({"range": "B2", "values": [[10], [20]]})).unwrap(); - s.execute("cell.set", json!({"cell": "B10", "input": "=B2*B3"})).unwrap(); - s.execute("data.scenarioAdd", json!({"name": "Base", "changing": "B2:B3"})).unwrap(); - s.execute("data.scenarioAdd", json!({"name": "Best", "changing": "B2:B3", "values": [100, 3], "comment": "optimistic"})).unwrap(); - assert!(s.execute("data.scenarioAdd", json!({"name": "best", "changing": "B2:B3", "values": [1, 2]})).is_err()); - assert!(s.execute("data.scenarioAdd", json!({"name": "Bad", "changing": "B2:B3", "values": [1]})).is_err()); - let l = s.execute("data.scenarioManager", json!({})).unwrap(); - assert_eq!(l["scenarios"].as_array().unwrap().len(), 2); - assert_eq!(l["scenarios"][1]["comment"], "optimistic"); - s.execute("data.scenarioShow", json!({"name": "Best"})).unwrap(); - assert_eq!(n(&s, "B10"), 300.0); - s.execute("data.scenarioShow", json!({"name": "Base"})).unwrap(); - assert_eq!(n(&s, "B10"), 200.0); - // Stored in a hidden workbook name. - let d = s.doc().unwrap(); - assert!(d.wb.names.iter().any(|n| n.name == super::SCENARIO_NAME && n.hidden)); - assert_eq!(super::load_scenarios(&d.wb).len(), 2); - // Long data is split into 250-character literals and survives an xlsx round trip. - s.execute("data.scenarioAdd", json!({"name": "Long", "changing": "B2", "values": [1], "comment": "x".repeat(600)})).unwrap(); - let f = s.doc().unwrap().wb.names.iter().find(|n| n.name == super::SCENARIO_NAME).unwrap().formula.clone(); - assert!(f.matches("\"&\"").count() >= 2, "{f}"); - let b64 = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].as_str().unwrap().to_string(); - s.execute("file.open", json!({"name": "sc.xlsx", "base64": b64})).unwrap(); - let l = s.execute("data.scenarioManager", json!({})).unwrap(); - assert_eq!(l["scenarios"].as_array().unwrap().len(), 3, "{l}"); - assert_eq!(l["scenarios"][2]["comment"].as_str().unwrap().len(), 600); - s.execute("data.scenarioDelete", json!({"name": "Long"})).unwrap(); - let r = s.execute("data.scenarioSummary", json!({"resultCells": "B10"})).unwrap(); - assert_eq!(r["sheet"], "Scenario Summary"); - assert_eq!(s.doc().unwrap().wb.active().unwrap().name, "Scenario Summary"); - assert_eq!(v(&s, "'Scenario Summary'!D3"), Value::from("Base")); - assert_eq!(v(&s, "'Scenario Summary'!B5"), Value::from("$B$2")); - assert_eq!(n(&s, "'Scenario Summary'!E5"), 100.0); - // Result row: current, Base, Best. - assert_eq!(n(&s, "'Scenario Summary'!C8"), 200.0); - assert_eq!(n(&s, "'Scenario Summary'!E8"), 300.0); - s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); - s.execute("data.scenarioDelete", json!({"name": "Best"})).unwrap(); - assert_eq!(s.execute("data.scenarioManager", json!({})).unwrap()["scenarios"].as_array().unwrap().len(), 1); - s.execute("data.scenarioDelete", json!({"name": "Base"})).unwrap(); - assert!(s.doc().unwrap().wb.names.iter().all(|n| n.name != super::SCENARIO_NAME)); - assert!(s.execute("data.scenarioSummary", json!({})).is_err()); - } - - #[test] - fn data_tables() { - let mut s = s(); - // Loan: rate B1, amount B2; payment formula. - s.execute("range.setValues", json!({"range": "B1", "values": [[0.05], [1000]]})).unwrap(); - // One-variable, column oriented: amounts down D3:D5, formula in E2. - s.execute("range.setValues", json!({"range": "D3", "values": [[100], [200], [300]]})).unwrap(); - s.execute("cell.set", json!({"cell": "E2", "input": "=B2*(1+B1)"})).unwrap(); - s.execute("cell.set", json!({"cell": "F2", "input": "=B2*2"})).unwrap(); - let r = s.execute("data.dataTable", json!({"range": "D2:F5", "columnInput": "B2"})).unwrap(); - assert_eq!(r["cells"], 6); - assert!((n(&s, "E3") - 105.0).abs() < 1e-9); - assert_eq!(n(&s, "F5"), 600.0); - // The input cell is untouched. - assert_eq!(n(&s, "B2"), 1000.0); - // Row oriented: rates across H2:J2, formula in G3. - s.execute("range.setValues", json!({"range": "H2", "values": [[0.1, 0.2, 0.3]]})).unwrap(); - s.execute("cell.set", json!({"cell": "G3", "input": "=B2*(1+B1)"})).unwrap(); - s.execute("data.dataTable", json!({"range": "G2:J3", "rowInput": "B1"})).unwrap(); - assert!((n(&s, "J3") - 1300.0).abs() < 1e-9); - // Two-variable: formula at L1, rates across, amounts down. - s.execute("cell.set", json!({"cell": "L1", "input": "=B2*(1+B1)"})).unwrap(); - s.execute("range.setValues", json!({"range": "M1", "values": [[0.5, 1]]})).unwrap(); - s.execute("range.setValues", json!({"range": "L2", "values": [[10], [20]]})).unwrap(); - let r = s.execute("data.dataTable", json!({"range": "L1:N3", "rowInput": "B1", "columnInput": "B2"})).unwrap(); - assert_eq!(r["mode"], "twoVariable"); - assert_eq!(n(&s, "M2"), 15.0); - assert_eq!(n(&s, "N3"), 40.0); - assert!(s.execute("data.dataTable", json!({"range": "L1:N3"})).is_err()); - assert!(s.execute("data.dataTable", json!({"range": "A1:XFD100000", "columnInput": "B2"})).is_err()); - assert!(s.execute("data.dataTable", json!({"range": "L1:N3", "columnInput": "M2"})).is_err()); - } - - #[test] - fn advanced_filter_in_place_and_copy() { - let mut s = s(); - s.execute( - "range.setValues", - json!({"range": "A1", "values": [["Region", "Sales"], ["East", 50], ["West", 150], ["East", 200], ["North", 120], ["East", 200]]}), - ) - .unwrap(); - // East AND Sales > 100, OR North. - s.execute("range.setValues", json!({"range": "H1", "values": [["Region", "Sales"], ["East", ">100"], ["North", null]]})).unwrap(); - let r = s.execute("data.advancedFilter", json!({"range": "A1:B6", "criteria": "H1:I3"})).unwrap(); - assert_eq!(r["matched"], 3); - let sh = || s.doc().unwrap().wb.active().unwrap().clone(); - assert!(sh().is_row_hidden(1)); - assert!(sh().is_row_hidden(2)); - assert!(!sh().is_row_hidden(3)); - assert!(!sh().is_row_hidden(4)); - // Unique, copied. - let r = s.execute("data.advancedFilter", json!({"range": "A1:B6", "criteria": "H1:I3", "copyTo": "K1", "unique": true})).unwrap(); - assert_eq!(r["matched"], 2); - assert_eq!(r["range"], "K1:L3"); - assert_eq!(v(&s, "K1"), Value::from("Region")); - assert_eq!(v(&s, "K2"), Value::from("East")); - assert_eq!(n(&s, "L2"), 200.0); - assert_eq!(v(&s, "K3"), Value::from("North")); - assert_eq!(v(&s, "K4"), Value::Empty); - // Wildcards and exact text. - s.execute("range.setValues", json!({"range": "H5", "values": [["Region"], ["*st"]]})).unwrap(); - let r = s.execute("data.advancedFilter", json!({"range": "A1:B6", "criteria": "H5:H6"})).unwrap(); - assert_eq!(r["matched"], 4); - // Computed criterion (blank header): Sales above 100. - s.execute("cell.set", json!({"cell": "J8", "input": "=B2>100"})).unwrap(); - let r = s.execute("data.advancedFilter", json!({"range": "A1:B6", "criteria": "J7:J8"})).unwrap(); - assert_eq!(r["matched"], 4); - // Overlap is rejected. - assert!(s.execute("data.advancedFilter", json!({"range": "A1:B6", "criteria": "H1:I3", "copyTo": "B2"})).is_err()); - } - - #[test] - fn advanced_filter_copy_preserves_cells_beyond_result() { - let mut s = s(); - s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["A", 1], ["B", 2], ["C", 3]]})).unwrap(); - s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["B"]]})).unwrap(); - s.execute("cell.set", json!({"cell": "K4", "input": "Keep me"})).unwrap(); - let r = s.execute("data.advancedFilter", json!({"range": "A1:B4", "criteria": "H1:H2", "copyTo": "K1"})).unwrap(); - assert_eq!(r["range"], "K1:L2"); - assert_eq!(v(&s, "K2"), Value::from("B")); - assert_eq!(v(&s, "K4"), Value::from("Keep me")); - } - - #[test] - fn advanced_filter_copy_rejects_overflow_without_mutation() { - let mut s = s(); - s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["A", 1]]})).unwrap(); - s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["A"]]})).unwrap(); - for dest in ["XFD1", "K1048576"] { - s.execute("cell.set", json!({"cell": dest, "input": "Keep me"})).unwrap(); - assert!(s.execute("data.advancedFilter", json!({"range": "A1:B2", "criteria": "H1:H2", "copyTo": dest})).is_err()); - assert_eq!(v(&s, dest), Value::from("Keep me")); - } - } - - #[test] - fn advanced_filter_copy_allows_output_above_source_when_only_old_clear_area_overlaps() { - let mut s = s(); - s.execute("range.setValues", json!({"range": "C4", "values": [["Name", "Score"], ["A", 1], ["B", 2], ["C", 3]]})).unwrap(); - s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["A"]]})).unwrap(); - let r = s.execute("data.advancedFilter", json!({"range": "C4:D7", "criteria": "H1:H2", "copyTo": "B1"})).unwrap(); - assert_eq!(r["range"], "B1:C2"); - assert_eq!(v(&s, "C4"), Value::from("Name")); - assert_eq!(v(&s, "C2"), Value::Number(1.0)); - } - - #[test] - fn advanced_filter_unique_without_criteria() { - let mut s = s(); - s.execute("range.setValues", json!({"range": "A1", "values": [["Name"], ["A"], ["A"], ["B"]]})).unwrap(); - let r = s.execute("data.advancedFilter", json!({"range": "A1:A4", "copyTo": "C1", "unique": true})).unwrap(); - assert_eq!(r["matched"], 2); - assert_eq!(r["range"], "C1:C3"); - assert_eq!(v(&s, "C2"), Value::from("A")); - assert_eq!(v(&s, "C3"), Value::from("B")); - } - - #[test] - fn criteria_semantics() { - use super::criterion_matches as m; - let t = |x: &str| Value::from(x); - assert!(m(&Value::Number(150.0), "150", &t(">100"))); - assert!(!m(&Value::Number(50.0), "50", &t(">100"))); - assert!(!m(&t("abc"), "abc", &t(">100"))); - assert!(m(&t("Apple"), "Apple", &t("a"))); - assert!(m(&t("apple pie"), "apple pie", &t("=apple*"))); - assert!(!m(&t("apple pie"), "apple pie", &t("=apple"))); - assert!(m(&Value::Empty, "", &t("="))); - assert!(m(&t("x"), "x", &t("<>"))); - assert!(m(&Value::Number(5.0), "5", &Value::Number(5.0))); - assert!(m(&t("b"), "b", &t(">a"))); - assert!(m(&t("x"), "x", &t("<>y"))); - } - - #[test] - fn consolidate_by_position_and_label() { - let mut s = s(); - s.execute("home.insertSheet", json!({})).unwrap(); - s.execute("sheet.rename", json!({"name": "East"})).unwrap(); - s.execute("range.setValues", json!({"range": "A1", "values": [["", "Q1", "Q2"], ["Apples", 10, 20], ["Pears", 5, 6]]})).unwrap(); - s.execute("home.insertSheet", json!({})).unwrap(); - s.execute("sheet.rename", json!({"name": "West"})).unwrap(); - s.execute("range.setValues", json!({"range": "A1", "values": [["", "Q2", "Q3"], ["Pears", 1, 2], ["Plums", 7, 8]]})).unwrap(); - s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); - let r = s - .execute("data.consolidate", json!({"sources": ["East!A1:C3", "West!A1:C3"], "destination": "A1", "topRow": true, "leftColumn": true})) - .unwrap(); - assert_eq!(r["range"], "A1:D4"); - assert_eq!(v(&s, "Sheet1!B1"), Value::from("Q1")); - assert_eq!(v(&s, "Sheet1!D1"), Value::from("Q3")); - assert_eq!(v(&s, "Sheet1!A3"), Value::from("Pears")); - assert_eq!(n(&s, "Sheet1!C3"), 7.0); - assert_eq!(n(&s, "Sheet1!D4"), 8.0); - assert_eq!(v(&s, "Sheet1!B4"), Value::Empty); - // By position, average. - let r = s.execute("data.consolidate", json!({"sources": ["East!B2:C3", "West!B2:C3"], "destination": "F1", "function": "average"})).unwrap(); - assert_eq!(r["range"], "F1:G2"); - assert_eq!(n(&s, "Sheet1!F1"), 5.5); - assert_eq!(n(&s, "Sheet1!G2"), 7.0); - assert!(s.execute("data.consolidate", json!({"sources": []})).is_err()); - assert!(s.execute("data.consolidate", json!({"sources": ["A1"], "function": "median"})).is_err()); - } -} +mod tests; diff --git a/crates/engine/src/cmd/whatif/tests.rs b/crates/engine/src/cmd/whatif/tests.rs new file mode 100644 index 000000000..c1476e401 --- /dev/null +++ b/crates/engine/src/cmd/whatif/tests.rs @@ -0,0 +1,299 @@ +use gridcraft_core::{CellRef, Value}; +use serde_json::json; + +use crate::Session; + +fn s() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s +} +fn v(s: &Session, a: &str) -> Value { + let d = s.doc().unwrap(); + let (sh, body) = super::split_sheet(a); + let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet()); + d.wb.sheet(si).unwrap().value(CellRef::parse(body).unwrap()) +} +fn n(s: &Session, a: &str) -> f64 { + v(s, a).as_f64().unwrap_or(f64::NAN) +} + +#[test] +fn goal_seek_linear_and_nonlinear() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "B2", "input": "1"})).unwrap(); + s.execute("cell.set", json!({"cell": "B5", "input": "=B2*3+4"})).unwrap(); + let r = s.execute("data.goalSeek", json!({"set": "B5", "to": 25, "changing": "B2"})).unwrap(); + assert_eq!(r["converged"], true); + assert!((n(&s, "B2") - 7.0).abs() < 0.001, "{r}"); + assert!((n(&s, "B5") - 25.0).abs() < 0.001); + // One undo step restores the input. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(n(&s, "B2"), 1.0); + // Nonlinear: x^2 = 2 from x = 1. + s.execute("cell.set", json!({"cell": "C1", "input": "=B2^2"})).unwrap(); + let r = s.execute("data.goalSeek", json!({"set": "C1", "to": 2, "changing": "B2"})).unwrap(); + assert_eq!(r["converged"], true); + assert!((n(&s, "C1") - 2.0).abs() <= 0.001); + // Loan payment: what rate makes PMT = -500? + s.execute("range.setValues", json!({"range": "E1", "values": [[0.01], [10000], [24]]})).unwrap(); + s.execute("cell.set", json!({"cell": "E4", "input": "=PMT(E1/12,E3,E2)"})).unwrap(); + let r = s.execute("data.goalSeek", json!({"set": "E4", "to": -500, "changing": "E1"})).unwrap(); + assert_eq!(r["converged"], true, "{r}"); + assert!((n(&s, "E4") + 500.0).abs() <= 0.001); + // Starting at zero. + s.execute("cell.set", json!({"cell": "G1", "input": "0"})).unwrap(); + s.execute("cell.set", json!({"cell": "G2", "input": "=G1*G1*G1-27"})).unwrap(); + let r = s.execute("data.goalSeek", json!({"set": "G2", "to": 0, "changing": "G1"})).unwrap(); + assert_eq!(r["converged"], true, "{r}"); + assert!((n(&s, "G1") - 3.0).abs() < 0.01); +} + +#[test] +fn goal_seek_errors() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "input": "=A1"})).unwrap(); + assert!(s.execute("data.goalSeek", json!({"set": "A1", "to": 1, "changing": "A1"})).is_err()); + assert!(s.execute("data.goalSeek", json!({"set": "A2", "to": 1, "changing": "A2"})).is_err()); + assert!(s.execute("data.goalSeek", json!({"set": "A2", "changing": "A1"})).is_err()); + // Unreachable target: not converged, but no error and no crash. + s.execute("cell.set", json!({"cell": "A3", "input": "=A1^2"})).unwrap(); + let r = s.execute("data.goalSeek", json!({"set": "A3", "to": -4, "changing": "A1"})).unwrap(); + assert_eq!(r["converged"], false); + assert!(r["iterations"].as_u64().unwrap() <= 100); +} + +#[test] +fn seek_unit() { + let mut f = |x: f64| (x - 1e6) / 3.0; + let r = super::seek(0.0, &mut f, 100, 0.001); + assert!(r.converged, "{r:?}"); + let mut g = |x: f64| if x < 0.0 { f64::NAN } else { x.sqrt() - 5.0 }; + let r = super::seek(1.0, &mut g, 100, 0.001); + assert!(r.converged, "{r:?}"); + let mut h = |_: f64| f64::NAN; + let r = super::seek(1.0, &mut h, 100, 0.001); + assert!(!r.converged); +} + +#[test] +fn scenario_show_restores_a_percentage_cells_stored_number() { + let mut s = s(); + s.execute("home.numberFormat", json!({"range": "B2", "format": "Percentage"})).unwrap(); + s.execute("cell.set", json!({"cell": "B2", "input": "5"})).unwrap(); + s.execute("data.scenarioAdd", json!({"name": "Low", "changing": "B2"})).unwrap(); + s.execute("cell.set", json!({"cell": "B2", "input": "9"})).unwrap(); + s.execute("data.scenarioShow", json!({"name": "Low"})).unwrap(); + assert_eq!(n(&s, "B2"), 0.05); +} + +#[test] +fn scenarios() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "B2", "values": [[10], [20]]})).unwrap(); + s.execute("cell.set", json!({"cell": "B10", "input": "=B2*B3"})).unwrap(); + s.execute("data.scenarioAdd", json!({"name": "Base", "changing": "B2:B3"})).unwrap(); + s.execute("data.scenarioAdd", json!({"name": "Best", "changing": "B2:B3", "values": [100, 3], "comment": "optimistic"})).unwrap(); + assert!(s.execute("data.scenarioAdd", json!({"name": "best", "changing": "B2:B3", "values": [1, 2]})).is_err()); + assert!(s.execute("data.scenarioAdd", json!({"name": "Bad", "changing": "B2:B3", "values": [1]})).is_err()); + let l = s.execute("data.scenarioManager", json!({})).unwrap(); + assert_eq!(l["scenarios"].as_array().unwrap().len(), 2); + assert_eq!(l["scenarios"][1]["comment"], "optimistic"); + s.execute("data.scenarioShow", json!({"name": "Best"})).unwrap(); + assert_eq!(n(&s, "B10"), 300.0); + s.execute("data.scenarioShow", json!({"name": "Base"})).unwrap(); + assert_eq!(n(&s, "B10"), 200.0); + // Stored in a hidden workbook name. + let d = s.doc().unwrap(); + assert!(d.wb.names.iter().any(|n| n.name == super::SCENARIO_NAME && n.hidden)); + assert_eq!(super::load_scenarios(&d.wb).len(), 2); + // Long data is split into 250-character literals and survives an xlsx round trip. + s.execute("data.scenarioAdd", json!({"name": "Long", "changing": "B2", "values": [1], "comment": "x".repeat(600)})).unwrap(); + let f = s.doc().unwrap().wb.names.iter().find(|n| n.name == super::SCENARIO_NAME).unwrap().formula.clone(); + assert!(f.matches("\"&\"").count() >= 2, "{f}"); + let b64 = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].as_str().unwrap().to_string(); + s.execute("file.open", json!({"name": "sc.xlsx", "base64": b64})).unwrap(); + let l = s.execute("data.scenarioManager", json!({})).unwrap(); + assert_eq!(l["scenarios"].as_array().unwrap().len(), 3, "{l}"); + assert_eq!(l["scenarios"][2]["comment"].as_str().unwrap().len(), 600); + s.execute("data.scenarioDelete", json!({"name": "Long"})).unwrap(); + let r = s.execute("data.scenarioSummary", json!({"resultCells": "B10"})).unwrap(); + assert_eq!(r["sheet"], "Scenario Summary"); + assert_eq!(s.doc().unwrap().wb.active().unwrap().name, "Scenario Summary"); + assert_eq!(v(&s, "'Scenario Summary'!D3"), Value::from("Base")); + assert_eq!(v(&s, "'Scenario Summary'!B5"), Value::from("$B$2")); + assert_eq!(n(&s, "'Scenario Summary'!E5"), 100.0); + // Result row: current, Base, Best. + assert_eq!(n(&s, "'Scenario Summary'!C8"), 200.0); + assert_eq!(n(&s, "'Scenario Summary'!E8"), 300.0); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + s.execute("data.scenarioDelete", json!({"name": "Best"})).unwrap(); + assert_eq!(s.execute("data.scenarioManager", json!({})).unwrap()["scenarios"].as_array().unwrap().len(), 1); + s.execute("data.scenarioDelete", json!({"name": "Base"})).unwrap(); + assert!(s.doc().unwrap().wb.names.iter().all(|n| n.name != super::SCENARIO_NAME)); + assert!(s.execute("data.scenarioSummary", json!({})).is_err()); +} + +#[test] +fn data_tables() { + let mut s = s(); + // Loan: rate B1, amount B2; payment formula. + s.execute("range.setValues", json!({"range": "B1", "values": [[0.05], [1000]]})).unwrap(); + // One-variable, column oriented: amounts down D3:D5, formula in E2. + s.execute("range.setValues", json!({"range": "D3", "values": [[100], [200], [300]]})).unwrap(); + s.execute("cell.set", json!({"cell": "E2", "input": "=B2*(1+B1)"})).unwrap(); + s.execute("cell.set", json!({"cell": "F2", "input": "=B2*2"})).unwrap(); + let r = s.execute("data.dataTable", json!({"range": "D2:F5", "columnInput": "B2"})).unwrap(); + assert_eq!(r["cells"], 6); + assert!((n(&s, "E3") - 105.0).abs() < 1e-9); + assert_eq!(n(&s, "F5"), 600.0); + // The input cell is untouched. + assert_eq!(n(&s, "B2"), 1000.0); + // Row oriented: rates across H2:J2, formula in G3. + s.execute("range.setValues", json!({"range": "H2", "values": [[0.1, 0.2, 0.3]]})).unwrap(); + s.execute("cell.set", json!({"cell": "G3", "input": "=B2*(1+B1)"})).unwrap(); + s.execute("data.dataTable", json!({"range": "G2:J3", "rowInput": "B1"})).unwrap(); + assert!((n(&s, "J3") - 1300.0).abs() < 1e-9); + // Two-variable: formula at L1, rates across, amounts down. + s.execute("cell.set", json!({"cell": "L1", "input": "=B2*(1+B1)"})).unwrap(); + s.execute("range.setValues", json!({"range": "M1", "values": [[0.5, 1]]})).unwrap(); + s.execute("range.setValues", json!({"range": "L2", "values": [[10], [20]]})).unwrap(); + let r = s.execute("data.dataTable", json!({"range": "L1:N3", "rowInput": "B1", "columnInput": "B2"})).unwrap(); + assert_eq!(r["mode"], "twoVariable"); + assert_eq!(n(&s, "M2"), 15.0); + assert_eq!(n(&s, "N3"), 40.0); + assert!(s.execute("data.dataTable", json!({"range": "L1:N3"})).is_err()); + assert!(s.execute("data.dataTable", json!({"range": "A1:XFD100000", "columnInput": "B2"})).is_err()); + assert!(s.execute("data.dataTable", json!({"range": "L1:N3", "columnInput": "M2"})).is_err()); +} + +#[test] +fn advanced_filter_in_place_and_copy() { + let mut s = s(); + s.execute( + "range.setValues", + json!({"range": "A1", "values": [["Region", "Sales"], ["East", 50], ["West", 150], ["East", 200], ["North", 120], ["East", 200]]}), + ) + .unwrap(); + // East AND Sales > 100, OR North. + s.execute("range.setValues", json!({"range": "H1", "values": [["Region", "Sales"], ["East", ">100"], ["North", null]]})).unwrap(); + let r = s.execute("data.advancedFilter", json!({"range": "A1:B6", "criteria": "H1:I3"})).unwrap(); + assert_eq!(r["matched"], 3); + let sh = || s.doc().unwrap().wb.active().unwrap().clone(); + assert!(sh().is_row_hidden(1)); + assert!(sh().is_row_hidden(2)); + assert!(!sh().is_row_hidden(3)); + assert!(!sh().is_row_hidden(4)); + // Unique, copied. + let r = s.execute("data.advancedFilter", json!({"range": "A1:B6", "criteria": "H1:I3", "copyTo": "K1", "unique": true})).unwrap(); + assert_eq!(r["matched"], 2); + assert_eq!(r["range"], "K1:L3"); + assert_eq!(v(&s, "K1"), Value::from("Region")); + assert_eq!(v(&s, "K2"), Value::from("East")); + assert_eq!(n(&s, "L2"), 200.0); + assert_eq!(v(&s, "K3"), Value::from("North")); + assert_eq!(v(&s, "K4"), Value::Empty); + // Wildcards and exact text. + s.execute("range.setValues", json!({"range": "H5", "values": [["Region"], ["*st"]]})).unwrap(); + let r = s.execute("data.advancedFilter", json!({"range": "A1:B6", "criteria": "H5:H6"})).unwrap(); + assert_eq!(r["matched"], 4); + // Computed criterion (blank header): Sales above 100. + s.execute("cell.set", json!({"cell": "J8", "input": "=B2>100"})).unwrap(); + let r = s.execute("data.advancedFilter", json!({"range": "A1:B6", "criteria": "J7:J8"})).unwrap(); + assert_eq!(r["matched"], 4); + // Overlap is rejected. + assert!(s.execute("data.advancedFilter", json!({"range": "A1:B6", "criteria": "H1:I3", "copyTo": "B2"})).is_err()); +} + +#[test] +fn advanced_filter_copy_preserves_cells_beyond_result() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["A", 1], ["B", 2], ["C", 3]]})).unwrap(); + s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["B"]]})).unwrap(); + s.execute("cell.set", json!({"cell": "K4", "input": "Keep me"})).unwrap(); + let r = s.execute("data.advancedFilter", json!({"range": "A1:B4", "criteria": "H1:H2", "copyTo": "K1"})).unwrap(); + assert_eq!(r["range"], "K1:L2"); + assert_eq!(v(&s, "K2"), Value::from("B")); + assert_eq!(v(&s, "K4"), Value::from("Keep me")); +} + +#[test] +fn advanced_filter_copy_rejects_overflow_without_mutation() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["A", 1]]})).unwrap(); + s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["A"]]})).unwrap(); + for dest in ["XFD1", "K1048576"] { + s.execute("cell.set", json!({"cell": dest, "input": "Keep me"})).unwrap(); + assert!(s.execute("data.advancedFilter", json!({"range": "A1:B2", "criteria": "H1:H2", "copyTo": dest})).is_err()); + assert_eq!(v(&s, dest), Value::from("Keep me")); + } +} + +#[test] +fn advanced_filter_copy_allows_output_above_source_when_only_old_clear_area_overlaps() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "C4", "values": [["Name", "Score"], ["A", 1], ["B", 2], ["C", 3]]})).unwrap(); + s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["A"]]})).unwrap(); + let r = s.execute("data.advancedFilter", json!({"range": "C4:D7", "criteria": "H1:H2", "copyTo": "B1"})).unwrap(); + assert_eq!(r["range"], "B1:C2"); + assert_eq!(v(&s, "C4"), Value::from("Name")); + assert_eq!(v(&s, "C2"), Value::Number(1.0)); +} + +#[test] +fn advanced_filter_unique_without_criteria() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Name"], ["A"], ["A"], ["B"]]})).unwrap(); + let r = s.execute("data.advancedFilter", json!({"range": "A1:A4", "copyTo": "C1", "unique": true})).unwrap(); + assert_eq!(r["matched"], 2); + assert_eq!(r["range"], "C1:C3"); + assert_eq!(v(&s, "C2"), Value::from("A")); + assert_eq!(v(&s, "C3"), Value::from("B")); +} + +#[test] +fn criteria_semantics() { + use super::criterion_matches as m; + let t = |x: &str| Value::from(x); + assert!(m(&Value::Number(150.0), "150", &t(">100"))); + assert!(!m(&Value::Number(50.0), "50", &t(">100"))); + assert!(!m(&t("abc"), "abc", &t(">100"))); + assert!(m(&t("Apple"), "Apple", &t("a"))); + assert!(m(&t("apple pie"), "apple pie", &t("=apple*"))); + assert!(!m(&t("apple pie"), "apple pie", &t("=apple"))); + assert!(m(&Value::Empty, "", &t("="))); + assert!(m(&t("x"), "x", &t("<>"))); + assert!(m(&Value::Number(5.0), "5", &Value::Number(5.0))); + assert!(m(&t("b"), "b", &t(">a"))); + assert!(m(&t("x"), "x", &t("<>y"))); +} + +#[test] +fn consolidate_by_position_and_label() { + let mut s = s(); + s.execute("home.insertSheet", json!({})).unwrap(); + s.execute("sheet.rename", json!({"name": "East"})).unwrap(); + s.execute("range.setValues", json!({"range": "A1", "values": [["", "Q1", "Q2"], ["Apples", 10, 20], ["Pears", 5, 6]]})).unwrap(); + s.execute("home.insertSheet", json!({})).unwrap(); + s.execute("sheet.rename", json!({"name": "West"})).unwrap(); + s.execute("range.setValues", json!({"range": "A1", "values": [["", "Q2", "Q3"], ["Pears", 1, 2], ["Plums", 7, 8]]})).unwrap(); + s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + let r = s + .execute("data.consolidate", json!({"sources": ["East!A1:C3", "West!A1:C3"], "destination": "A1", "topRow": true, "leftColumn": true})) + .unwrap(); + assert_eq!(r["range"], "A1:D4"); + assert_eq!(v(&s, "Sheet1!B1"), Value::from("Q1")); + assert_eq!(v(&s, "Sheet1!D1"), Value::from("Q3")); + assert_eq!(v(&s, "Sheet1!A3"), Value::from("Pears")); + assert_eq!(n(&s, "Sheet1!C3"), 7.0); + assert_eq!(n(&s, "Sheet1!D4"), 8.0); + assert_eq!(v(&s, "Sheet1!B4"), Value::Empty); + // By position, average. + let r = s.execute("data.consolidate", json!({"sources": ["East!B2:C3", "West!B2:C3"], "destination": "F1", "function": "average"})).unwrap(); + assert_eq!(r["range"], "F1:G2"); + assert_eq!(n(&s, "Sheet1!F1"), 5.5); + assert_eq!(n(&s, "Sheet1!G2"), 7.0); + assert!(s.execute("data.consolidate", json!({"sources": []})).is_err()); + assert!(s.execute("data.consolidate", json!({"sources": ["A1"], "function": "median"})).is_err()); +} From e8091a19527d733862e398063ee7706700cb3246 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:42 +0000 Subject: [PATCH 32/76] refactor(engine): move io tests to src/io/tests.rs --- crates/engine/src/io.rs | 570 +--------------------------------- crates/engine/src/io/tests.rs | 567 +++++++++++++++++++++++++++++++++ 2 files changed, 568 insertions(+), 569 deletions(-) create mode 100644 crates/engine/src/io/tests.rs diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 77467b210..f51fcbe61 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -744,572 +744,4 @@ pub fn image_size(data: &[u8]) -> Option<(u32, u32)> { } #[cfg(test)] -mod tests { - use super::*; - - fn book(rtl: bool, cells: &[(&str, gridcraft_core::Value, gridcraft_model::HAlign)]) -> Workbook { - let mut wb = Workbook::new(); - let ids: Vec<_> = cells.iter().map(|(_, _, h)| wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.align.h = *h)).collect(); - let sh = wb.sheet_mut(0).unwrap(); - sh.right_to_left = rtl; - for ((at, value, _), id) in cells.iter().zip(ids) { - let c = CellRef::parse(at).unwrap(); - sh.set_value(c, value.clone()); - sh.set_style(c, id); - } - wb - } - - fn copied(wb: &Workbook, range: &str) -> String { - let r = RangeRef::parse(range).unwrap(); - range_html(wb, 0, r, &crate::display::range_text(wb, 0, r)).unwrap() - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn save_fails_upfront_when_the_temp_name_is_taken() { - // Another local user who can write the directory must not be able to point the - // save's predictable temporary name at their own file: creation is exclusive. - let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-temp-name-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let victim = dir.join("victim.txt"); - std::fs::write(&victim, b"original").unwrap(); - let dest = dir.join("book.xlsx"); - std::os::unix::fs::symlink(&victim, dir.join("book.xlsx.sctmp")).unwrap(); - let err = write_file(dest.to_str().unwrap(), b"new").unwrap_err(); - assert_eq!(std::fs::read(&victim).unwrap(), b"original"); - assert!(!dir.join("book.xlsx").exists(), "{err}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[cfg(not(target_arch = "wasm32"))] - #[cfg(unix)] - #[test] - fn save_keeps_the_destination_access_restrictions() { - use std::os::unix::fs::PermissionsExt; - let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-permissions-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let dest = dir.join("private.xlsx"); - std::fs::write(&dest, b"v1").unwrap(); - std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o600)).unwrap(); - write_file(dest.to_str().unwrap(), b"v2").unwrap(); - let mode = std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o600); - // And a brand-new file is created with the restrictive, not permissive, default. - let fresh = dir.join("new.xlsx"); - write_file(fresh.to_str().unwrap(), b"v").unwrap(); - let mode = std::fs::metadata(&fresh).unwrap().permissions().mode() & 0o777; - assert!(mode & 0o022 == 0, "mode {mode:o}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn open_refuses_directories_and_unknowable_lengths() { - let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-open-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - assert!(read_file(dir.to_str().unwrap()).is_err(), "a directory is not a regular file"); - assert!(read_file(dir.join("missing").to_str().unwrap()).is_err()); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn html_export_refuses_an_outsized_grid_instead_of_growing_forever() { - // #2387: the used rectangle alone bounds the output (every row writes the whole - // column span), so a sparse sheet with one cell at A1 and another far right - // 300 rows down is 4.9M cells → refused before a byte of `` is written. - let mut wb = Workbook::new(); - let sh = wb.sheet_mut(0).unwrap(); - sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::Text("start".into())); - sh.set_value(CellRef::new(300, 16383), gridcraft_core::Value::Text("corner".into())); - let err = to_html(&wb, 0).unwrap_err(); - assert!(err.to_string().contains("too tall × wide"), "{err}"); - // A normal sheet is fine. - let mut ok = Workbook::new(); - ok.sheet_mut(0).unwrap().set_value(CellRef::new(5, 2), gridcraft_core::Value::Text("t".into())); - assert!(to_html(&ok, 0).is_ok()); - } - - #[test] - fn html_export_embeds_images_as_data_uris() { - let mut wb = Workbook::new(); - wb.sheet_mut(0).unwrap().images.push(gridcraft_model::Image { - id: 1, - anchor: gridcraft_model::Anchor { cell: CellRef::new(0, 0), dx: 0.0, dy: 0.0, width: 72.0, height: 36.0, mode: Default::default() }, - data: vec![1, 2, 3].into(), - mime: "image/png".into(), - alt: "a \"logo\"".into(), - }); - let html = to_html(&wb, 0).unwrap(); - assert!( - html.contains("\"a"), - "{html}" - ); - assert!(html.contains("
"); - } - - #[test] - fn html_export_places_images_at_their_anchor_using_sheet_geometry() { - let mut wb = Workbook::new(); - let sh = wb.sheet_mut(0).unwrap(); - sh.cols.insert(1, gridcraft_model::LineInfo { size: Some(100.0), ..Default::default() }); - sh.rows.insert(1, gridcraft_model::LineInfo { size: Some(30.0), ..Default::default() }); - sh.images.push(gridcraft_model::Image { - id: 1, - anchor: gridcraft_model::Anchor { cell: CellRef::new(3, 2), dx: 5.0, dy: 7.0, width: 10.0, height: 20.0, mode: Default::default() }, - data: vec![1].into(), - mime: "image/png".into(), - alt: String::new(), - }); - sh.set_value(CellRef::new(1, 1), gridcraft_core::Value::Number(1.0)); - let html = to_html(&wb, 0).unwrap(); - // The table starts at B2; the picture is at C4: one 100 px column and two rows (30 + 20) further on. - assert!(html.contains(""), "{html}"); - assert!(html.contains(""), "{html}"); - assert!(html.contains("left:105px;top:57px;width:10px;height:20px"), "{html}"); - } - - fn book_with_picture(anchor: &str, cell_at: (u32, u32)) -> Workbook { - let mut wb = Workbook::new(); - let sh = wb.sheet_mut(0).unwrap(); - sh.set_value(CellRef::new(cell_at.0, cell_at.1), gridcraft_core::Value::Number(1.0)); - sh.images.push(gridcraft_model::Image { - id: 1, - anchor: gridcraft_model::Anchor { - cell: CellRef::parse(anchor).unwrap(), - dx: 0.0, - dy: 0.0, - width: 10.0, - height: 10.0, - mode: Default::default(), - }, - data: vec![1].into(), - mime: "image/png".into(), - alt: String::new(), - }); - wb - } - - #[test] - fn html_export_places_images_past_hidden_columns_and_rows() { - let mut wb = book_with_picture("C3", (0, 0)); - let sh = wb.sheet_mut(0).unwrap(); - sh.set_value(CellRef::new(2, 2), gridcraft_core::Value::Number(2.0)); - sh.cols.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() }); - sh.rows.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() }); - let html = to_html(&wb, 0).unwrap(); - // Column B and row 2 take no room: C3 is one default column and row from A1. - assert_eq!(html.matches(""), "{html}"); - assert!(html.contains(""), "Latin text keeps its own direction: {html}"); - assert!( - html.contains(""), - "numbers mirror to the left and keep their sign in front: {html}" - ); - let clip = copied(&wb, "A1:B1"); - assert!(clip.starts_with("
Hello!-5
-5"), "{clip}"); - - let ltr = book(false, &[("A1", Value::text("Hello!"), HAlign::General)]); - assert!(to_html(<r, 0).unwrap().contains("
")); - assert!(copied(<r, "A1").starts_with("
مرحبا!"), "General Arabic text aligns by its direction: {html}"); - assert!(html.contains(""), "explicit alignment stays: {html}"); - assert!(html.contains(""), "{html}"); - assert!(html.contains(""), "{html}"); - assert!(html.contains("
שלוםمرحباHello
"), "a left-to-right sheet has no table direction: {html}"); - - let clip = copied(&wb, "A1:D1"); - let cells: Vec<&str> = clip.split("") && html.contains(">far")); - } - - #[test] - fn macros_are_saved_only_into_xlsm() { - let mut wb = Workbook::new(); - wb.vba_project = Some(b"vba".to_vec().into()); - let has_macros = |path: &str| { - let bytes = save_bytes(&wb, path).unwrap(); - gridcraft_xlsx::read_xlsx(&bytes).unwrap().0.vba_project.is_some() - }; - assert!(has_macros("book.xlsm")); - assert!(has_macros("BOOK.XLSM")); - assert!(!has_macros("book.xlsx"), "an .xlsx never carries a VBA project"); - assert!(has_macros("book.xltm"), "a macro-enabled template keeps the VBA project"); - assert!(!has_macros("book.xltx"), "a macro-free template never carries a VBA project"); - assert!(wb.vba_project.is_some(), "the open workbook keeps its macros"); - } - - #[test] - fn csv_named_history_does_not_get_the_reserved_sheet_name() { - for file in ["History.csv", "hIsToRy.csv", "history.tsv"] { - let (wb, _) = open_bytes(file, b"1\n").unwrap(); - let name = wb.sheets[0].name.clone(); - assert!(wb.check_sheet_name(&name, Some(0)).is_ok(), "{file} opened as {name:?}"); - } - let (wb, _) = open_bytes("Sales.csv", b"1\n").unwrap(); - assert_eq!(wb.sheets[0].name, "Sales"); - } - - /// A save that can't be put in place (here a folder already has the workbook's name, as - /// with a file another app holds locked) fails without leaving `.sctmp` behind. - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn failed_save_leaves_no_temporary_file() { - let dir = std::env::temp_dir().join(format!("gridcraft-write-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let blocked = dir.join("Book1.xlsx"); - std::fs::create_dir(&blocked).unwrap(); - assert!(write_file(blocked.to_str().unwrap(), b"workbook").is_err()); - assert!(!dir.join("Book1.xlsx.sctmp").exists(), "the temporary file is removed"); - // An ordinary save still works and leaves only the workbook. - let ok = dir.join("Book2.xlsx"); - write_file(ok.to_str().unwrap(), b"workbook").unwrap(); - assert_eq!(std::fs::read(&ok).unwrap(), b"workbook"); - assert!(!dir.join("Book2.xlsx.sctmp").exists()); - let _ = std::fs::remove_dir_all(&dir); - } - - /// While a script runs (#2178), file reads and writes are refused whatever command or - /// parameter asked for them; nesting and dropping the sandbox restore access. - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn script_sandbox_refuses_file_access_until_dropped() { - let dir = std::env::temp_dir().join(format!("gridcraft-sandbox-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let existing = dir.join("in.csv"); - std::fs::write(&existing, b"a,b\n").unwrap(); - let (existing, out) = (existing.to_string_lossy().to_string(), dir.join("out.xlsx").to_string_lossy().to_string()); - { - let _outer = ScriptSandbox::enter(); - { - let _inner = ScriptSandbox::enter(); - assert!(read_file(&existing).is_err()); - } - assert!(read_file(&existing).is_err(), "the outer sandbox still holds"); - assert!(write_file(&out, b"leak").is_err()); - assert!(!std::path::Path::new(&out).exists()); - } - assert_eq!(read_file(&existing).unwrap(), b"a,b\n"); - write_file(&out, b"ok").unwrap(); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a - /// root entry and the given stream names. - fn cfb(streams: &[&str]) -> Vec { - let mut b = vec![0u8; 512 * 3]; - let mut put = |at: usize, v: &[u8]| b[at..at + v.len()].copy_from_slice(v); - put(0, &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]); - put(30, &9u16.to_le_bytes()); - put(44, &1u32.to_le_bytes()); - put(48, &1u32.to_le_bytes()); - put(68, &0xFFFF_FFFEu32.to_le_bytes()); - for i in 1..109 { - put(76 + i * 4, &0xFFFF_FFFFu32.to_le_bytes()); - } - put(512, &0xFFFF_FFFDu32.to_le_bytes()); - put(516, &0xFFFF_FFFEu32.to_le_bytes()); - for (k, name) in std::iter::once("Root Entry").chain(streams.iter().copied()).enumerate() { - let units: Vec = name.encode_utf16().flat_map(u16::to_le_bytes).collect(); - put(1024 + k * 128, &units); - put(1024 + k * 128 + 64, &(units.len() as u16 + 2).to_le_bytes()); - } - b - } - - /// Cut-list rows as a CAD/CAM program exports them: `;`-separated, CRLF, part codes in the last - /// two columns, a part name containing a comma. - const CUT_LIST: &str = "1;511;184;P3_L1_led;2162PE;16;1MM_2162PE;1MM_2162PE;;;1244121406FB;\r\n\ - 1;562.5;510;Komoda, lijevo;2162PE;19;1MM_2162PE;;;;12441214071A;124412140710\r\n"; - - #[test] - fn csv_save_keeps_the_separator_it_was_opened_with() { - let (wb, _) = open_bytes("cut list.csv", CUT_LIST.as_bytes()).unwrap(); - assert_eq!(wb.csv_delimiter, Some(b';')); - assert_eq!(String::from_utf8(save_bytes(&wb, "cut list.csv").unwrap()).unwrap(), CUT_LIST); - // Save As another .csv name keeps it too; a comma file stays a comma file (quoting only - // fields that contain the separator in use). - assert_eq!(String::from_utf8(save_bytes(&wb, "copy.CSV").unwrap()).unwrap(), CUT_LIST); - let commas = "\"Komoda, lijevo\",b\r\n1,x;y\r\n"; - let (wb, _) = open_bytes("plain.csv", commas.as_bytes()).unwrap(); - assert_eq!(String::from_utf8(save_bytes(&wb, "plain.csv").unwrap()).unwrap(), commas); - // A tab-separated file named .csv round-trips with tabs. - let tabs = "a\tb\r\n1\t2\r\n"; - let (wb, _) = open_bytes("tabs.csv", tabs.as_bytes()).unwrap(); - assert_eq!(String::from_utf8(save_bytes(&wb, "tabs.csv").unwrap()).unwrap(), tabs); - } - - #[test] - fn csv_save_of_a_workbook_not_read_from_csv_uses_commas() { - let mut wb = Workbook::new(); - if let Some(sh) = wb.sheet_mut(0) { - sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::text("a")); - sh.set_value(CellRef::new(0, 1), gridcraft_core::Value::Number(1.0)); - } - assert_eq!(save_bytes(&wb, "new.csv").unwrap(), b"a,1\r\n"); - // Through .xlsx the CSV origin is gone, as in Excel. - let (from_csv, _) = open_bytes("cut list.csv", CUT_LIST.as_bytes()).unwrap(); - let (reopened, _) = open_bytes("book.xlsx", &save_bytes(&from_csv, "book.xlsx").unwrap()).unwrap(); - assert_eq!(reopened.csv_delimiter, None); - assert!(String::from_utf8(save_bytes(&reopened, "again.csv").unwrap()).unwrap().starts_with("1,511,184,")); - // A .tsv/.txt saved as .csv gets commas; as .tsv it keeps tabs. - let (tsv, _) = open_bytes("list.txt", b"a\tb\r\n").unwrap(); - assert_eq!(save_bytes(&tsv, "list.csv").unwrap(), b"a,b\r\n"); - assert_eq!(save_bytes(&tsv, "list.tsv").unwrap(), b"a\tb\r\n"); - } - - #[test] - fn encrypted_workbook_is_reported_not_misread() { - let file = gridcraft_xlsx::testing::encrypt_workbook(b"package", "pw"); - let msg = open_bytes("secret.xlsx", &file).unwrap_err().to_string(); - assert!(msg.contains(PASSWORD_REQUIRED), "got: {msg}"); - assert!(!msg.contains("zip"), "must not surface the zip error: {msg}"); - // A compound file naming the streams but unreadable is damage: no password is asked for, - // and none helps. - let file = cfb(&["EncryptionInfo", "EncryptedPackage"]); - for password in [None, Some("pw")] { - let msg = open_bytes_with_password("secret.xlsx", &file, password).unwrap_err().to_string(); - assert!(msg.contains("damaged") && !msg.contains(PASSWORD_REQUIRED), "got: {msg}"); - } - } - - #[test] - fn encryption_no_password_opens_is_reported_without_asking_for_one() { - let info: &[u8] = &[3, 0, 2, 0, 4, 0, 0, 0]; // Standard header flags: RC4, not AES - let file = gridcraft_xlsx::testing::compound_file(&[("EncryptionInfo", info), ("EncryptedPackage", &[0; 8])]); - let msg = open_bytes("rc4.xlsx", &file).unwrap_err().to_string(); - assert!(msg.contains("RC4") && !msg.contains(PASSWORD_REQUIRED), "got: {msg}"); - let irm = gridcraft_xlsx::testing::compound_file(&[("EncryptedPackage", &[0; 8])]); - let msg = open_bytes("irm.xlsx", &irm).unwrap_err().to_string(); - assert!(msg.contains("rights-management") && !msg.contains("damaged"), "got: {msg}"); - } - - #[test] - fn other_compound_files_are_not_called_encrypted() { - let err = open_bytes("old.doc", &cfb(&["WordDocument", "\u{5}SummaryInformation"])).unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("not an Excel workbook"), "got: {msg}"); - assert!(!msg.contains("password"), "got: {msg}"); - } - - /// Save As CSV/TSV writes cells as displayed: number formats applied, formula results, - /// whole values in narrow columns (never `#####`), quoting unchanged. - #[test] - fn csv_writes_displayed_text() { - use gridcraft_core::{CellError, Value}; - use gridcraft_model::{Cell, Formula, LineInfo, NumFmt, StyleId}; - let mut wb = Workbook::new(); - let mut styled = |code: &str| wb.styles.derive(StyleId::DEFAULT, |s| s.num_fmt = NumFmt::new(code)); - let (date, money, pct, frac, text, fixed, thousands) = - (styled("yyyy-mm-dd"), styled("$#,##0.00"), styled("0.0%"), styled("# ?/?"), styled("@"), styled("0.00"), styled("#,##0")); - let sh = wb.sheet_mut(0).unwrap(); - let cells = [ - ("A1", Cell { value: Value::Number(46023.0), formula: None, style: date }), - ("B1", Cell { value: Value::Number(1234.5), formula: None, style: money }), - ("C1", Cell { value: Value::Number(0.125), formula: None, style: pct }), - ("D1", Cell { value: Value::Number(1.5), formula: None, style: frac }), - ("E1", Cell { value: Value::text("007"), formula: None, style: text }), - ("F1", Cell { value: Value::Error(CellError::NA), formula: None, style: money }), - ("G1", Cell { value: Value::Bool(true), formula: None, style: StyleId::DEFAULT }), - ("A2", Cell { value: Value::Number(46024.0), style: date, ..Cell::formula(Formula::new("A1+1")) }), - ("B2", Cell { value: Value::Number(12345678.9), formula: None, style: fixed }), - ("C2", Cell { value: Value::Number(-0.25), formula: None, style: StyleId::DEFAULT }), - ("D2", Cell { value: Value::Number(1234567.0), formula: None, style: thousands }), - ]; - for (a, c) in cells { - sh.set_cell(CellRef::parse(a).unwrap(), c); - } - // Column B is far too narrow for its numbers: the grid shows #####, the file must not. - sh.cols.insert(1, LineInfo { size: Some(1.0), ..Default::default() }); - sh.show_formulas = true; - let csv = String::from_utf8(save_bytes(&wb, "out.csv").unwrap()).unwrap(); - assert_eq!(csv, "2026-01-01,\"$1,234.50\",12.5%,1 1/2,007,#N/A,TRUE\r\n2026-01-02,12345678.90,-0.25,\"1,234,567\",,,\r\n"); - let tsv = String::from_utf8(save_bytes(&wb, "out.tsv").unwrap()).unwrap(); - assert!(tsv.starts_with("2026-01-01\t$1,234.50\t12.5%\t"), "{tsv}"); - } - - #[test] - fn delimited_exports_preserve_general_precision_and_explicit_number_formats() { - use gridcraft_core::Value; - use gridcraft_model::{Cell, NumFmt, StyleId}; - let mut workbook = Workbook::new(); - let scientific = workbook.styles.derive(StyleId::DEFAULT, |style| style.num_fmt = NumFmt::new("0.00E+00")); - let sheet = workbook.sheet_mut(0).unwrap(); - sheet.set_cell(CellRef::new(0, 0), Cell { value: Value::Number(124412140710.0), ..Default::default() }); - sheet.set_cell(CellRef::new(0, 1), Cell { value: Value::Number(1.23456789012345), ..Default::default() }); - sheet.set_cell(CellRef::new(0, 2), Cell { value: Value::Number(124412140710.0), style: scientific, ..Default::default() }); - for (path, separator) in [("data.csv", ","), ("data.tsv", "\t")] { - let output = String::from_utf8(save_bytes(&workbook, path).unwrap()).unwrap(); - assert_eq!(output, format!("124412140710{separator}1.23456789012345{separator}1.24E+11\r\n")); - } - } - - #[test] - fn template_extension_saves_template_content_type() { - let bytes = save_bytes(&Workbook::new(), "Budget.xltx").unwrap(); - let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); - let mut types = String::new(); - std::io::Read::read_to_string(&mut zip.by_name("[Content_Types].xml").unwrap(), &mut types).unwrap(); - assert!( - types.contains( - r#"PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml""# - ), - "{types}" - ); - } - - /// Where `part`'s entry starts in the central directory of `package`. - fn central_entry(package: &[u8], part: &str) -> usize { - let mut at = 0; - while let Some(i) = package.get(at..).and_then(|rest| rest.windows(4).position(|w| w == b"PK\x01\x02")) { - let entry = at + i; - let len = u16::from_le_bytes([package[entry + 28], package[entry + 29]]) as usize; - if &package[entry + 46..entry + 46 + len] == part.as_bytes() { - return entry; - } - at = entry + 46; - } - panic!("{part} is not in the package"); - } - - /// `package` with `part` declaring `size` unpacked bytes: the reader refuses the part on the - /// declared size, so no real 512 MB part has to be built. - fn declaring(mut package: Vec, part: &str, size: u32) -> Vec { - let at = central_entry(&package, part); - package[at + 24..at + 28].copy_from_slice(&size.to_le_bytes()); - package - } - - #[test] - fn a_part_over_the_size_limit_says_how_to_get_the_data_in() { - let mut wb = Workbook::new(); - wb.sheet_mut(0).unwrap().set_value(CellRef::new(0, 0), gridcraft_core::Value::text("big")); - let package = save_bytes(&wb, "big.xlsx").unwrap(); - // The size reported in storytold/gridcraft#1242. - let msg = open_bytes("big.xlsx", &declaring(package.clone(), "xl/worksheets/sheet1.xml", 540_278_731)).unwrap_err().to_string(); - assert!(msg.starts_with("We can't open 'big.xlsx': "), "{msg}"); - assert!(msg.contains("xl/worksheets/sheet1.xml") && msg.contains("515 MB") && msg.contains("512 MB"), "{msg}"); - assert!(msg.contains("several sheets") && msg.contains("CSV"), "{msg}"); - assert!(!msg.contains("bytes"), "{msg}"); - // Splitting sheets doesn't shrink the shared strings, which every sheet uses: only CSV helps. - let msg = open_bytes("big.xlsx", &declaring(package.clone(), "xl/sharedStrings.xml", 540_278_731)).unwrap_err().to_string(); - assert!(msg.contains("xl/sharedStrings.xml") && msg.contains("515 MB") && msg.contains("CSV"), "{msg}"); - assert!(!msg.contains("several sheets"), "{msg}"); - // Just over the limit never reads as a size equal to it. - let msg = open_bytes("big.xlsx", &declaring(package, "xl/worksheets/sheet1.xml", 512 * 1024 * 1024 + 1)).unwrap_err().to_string(); - assert!(msg.contains("more than 512 MB"), "{msg}"); - // A package that fails for another reason keeps its own error, oversized part or not. - let mut broken = declaring(save_bytes(&wb, "big.xlsx").unwrap(), "xl/worksheets/sheet1.xml", 540_278_731); - let at = central_entry(&broken, "xl/workbook.xml"); - broken[at + 46 + "xl/workbook.xml".len() - 1] = b'x'; - let msg = open_bytes("big.xlsx", &broken).unwrap_err().to_string(); - assert!(msg.starts_with("We can't open 'big.xlsx': ") && !msg.contains("unpacks"), "{msg}"); - } - - #[test] - fn content_wins_over_a_mismatched_extension() { - let mut wb = Workbook::new(); - wb.sheet_mut(0).unwrap().set_value(CellRef::new(0, 0), gridcraft_core::Value::Number(7.0)); - let package = save_bytes(&wb, "book.xlsx").unwrap(); - for name in ["book.xlsx", "book.ods", "book.xlsb", "book.csv"] { - let (opened, _) = open_bytes(name, &package).unwrap_or_else(|e| panic!("{name}: {e}")); - assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 0)), gridcraft_core::Value::Number(7.0), "{name}"); - } - for name in ["data.xlsx", "data.csv"] { - let (opened, _) = open_bytes(name, b"a,b\n1,2\n").unwrap_or_else(|e| panic!("{name}: {e}")); - assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 1)), gridcraft_core::Value::text("b"), "{name}"); - } - } -} +mod tests; diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs new file mode 100644 index 000000000..36e5cad89 --- /dev/null +++ b/crates/engine/src/io/tests.rs @@ -0,0 +1,567 @@ +use super::*; + +fn book(rtl: bool, cells: &[(&str, gridcraft_core::Value, gridcraft_model::HAlign)]) -> Workbook { + let mut wb = Workbook::new(); + let ids: Vec<_> = cells.iter().map(|(_, _, h)| wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.align.h = *h)).collect(); + let sh = wb.sheet_mut(0).unwrap(); + sh.right_to_left = rtl; + for ((at, value, _), id) in cells.iter().zip(ids) { + let c = CellRef::parse(at).unwrap(); + sh.set_value(c, value.clone()); + sh.set_style(c, id); + } + wb +} + +fn copied(wb: &Workbook, range: &str) -> String { + let r = RangeRef::parse(range).unwrap(); + range_html(wb, 0, r, &crate::display::range_text(wb, 0, r)).unwrap() +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn save_fails_upfront_when_the_temp_name_is_taken() { + // Another local user who can write the directory must not be able to point the + // save's predictable temporary name at their own file: creation is exclusive. + let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-temp-name-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let victim = dir.join("victim.txt"); + std::fs::write(&victim, b"original").unwrap(); + let dest = dir.join("book.xlsx"); + std::os::unix::fs::symlink(&victim, dir.join("book.xlsx.sctmp")).unwrap(); + let err = write_file(dest.to_str().unwrap(), b"new").unwrap_err(); + assert_eq!(std::fs::read(&victim).unwrap(), b"original"); + assert!(!dir.join("book.xlsx").exists(), "{err}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(not(target_arch = "wasm32"))] +#[cfg(unix)] +#[test] +fn save_keeps_the_destination_access_restrictions() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-permissions-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let dest = dir.join("private.xlsx"); + std::fs::write(&dest, b"v1").unwrap(); + std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o600)).unwrap(); + write_file(dest.to_str().unwrap(), b"v2").unwrap(); + let mode = std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + // And a brand-new file is created with the restrictive, not permissive, default. + let fresh = dir.join("new.xlsx"); + write_file(fresh.to_str().unwrap(), b"v").unwrap(); + let mode = std::fs::metadata(&fresh).unwrap().permissions().mode() & 0o777; + assert!(mode & 0o022 == 0, "mode {mode:o}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn open_refuses_directories_and_unknowable_lengths() { + let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-open-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + assert!(read_file(dir.to_str().unwrap()).is_err(), "a directory is not a regular file"); + assert!(read_file(dir.join("missing").to_str().unwrap()).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn html_export_refuses_an_outsized_grid_instead_of_growing_forever() { + // #2387: the used rectangle alone bounds the output (every row writes the whole + // column span), so a sparse sheet with one cell at A1 and another far right + // 300 rows down is 4.9M cells → refused before a byte of `"), "{html}"); + assert!(html.contains(""), "{html}"); + assert!(html.contains("left:105px;top:57px;width:10px;height:20px"), "{html}"); +} + +fn book_with_picture(anchor: &str, cell_at: (u32, u32)) -> Workbook { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.set_value(CellRef::new(cell_at.0, cell_at.1), gridcraft_core::Value::Number(1.0)); + sh.images.push(gridcraft_model::Image { + id: 1, + anchor: gridcraft_model::Anchor { + cell: CellRef::parse(anchor).unwrap(), + dx: 0.0, + dy: 0.0, + width: 10.0, + height: 10.0, + mode: Default::default(), + }, + data: vec![1].into(), + mime: "image/png".into(), + alt: String::new(), + }); + wb +} + +#[test] +fn html_export_places_images_past_hidden_columns_and_rows() { + let mut wb = book_with_picture("C3", (0, 0)); + let sh = wb.sheet_mut(0).unwrap(); + sh.set_value(CellRef::new(2, 2), gridcraft_core::Value::Number(2.0)); + sh.cols.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() }); + sh.rows.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() }); + let html = to_html(&wb, 0).unwrap(); + // Column B and row 2 take no room: C3 is one default column and row from A1. + assert_eq!(html.matches("
"), HAlign::General), - ("B2", Value::Number(3.0), HAlign::General), - ("A3", Value::text("Total"), HAlign::Right), - ], - ); - wb.sheet_mut(0).unwrap().merges.push(RangeRef::parse("A3:B3").unwrap()); - assert_eq!( - to_html(&wb, 0).unwrap(), - "Sheet1\ - \ - \ - \ -
NameQty
Apples & <pears>3
Total
" - ); - let cell = "border:1px solid #d4d4d4;padding:2px 4px;white-space:pre-wrap;"; - assert_eq!( - copied(&wb, "A1:B2"), - format!( - "\ - \ -
NameQty
Apples & <pears>3
" - ) - ); - } - - #[test] - fn html_export_fails_instead_of_dropping_cells_past_the_row_limit() { - let mut wb = Workbook::new(); - let sh = wb.sheet_mut(0).unwrap(); - sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::text("near")); - sh.set_value(CellRef::new(149_999, 0), gridcraft_core::Value::text("far")); - assert!(save_bytes(&wb, "book.html").is_err()); - let sh = wb.sheet_mut(0).unwrap(); - sh.remove_cell(CellRef::new(149_999, 0)); - sh.set_value(CellRef::new(100_000, 0), gridcraft_core::Value::text("far")); - let html = String::from_utf8(save_bytes(&wb, "book.html").unwrap()).unwrap(); - assert!(html.contains(">near
` is written. + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::Text("start".into())); + sh.set_value(CellRef::new(300, 16383), gridcraft_core::Value::Text("corner".into())); + let err = to_html(&wb, 0).unwrap_err(); + assert!(err.to_string().contains("too tall × wide"), "{err}"); + // A normal sheet is fine. + let mut ok = Workbook::new(); + ok.sheet_mut(0).unwrap().set_value(CellRef::new(5, 2), gridcraft_core::Value::Text("t".into())); + assert!(to_html(&ok, 0).is_ok()); +} + +#[test] +fn html_export_embeds_images_as_data_uris() { + let mut wb = Workbook::new(); + wb.sheet_mut(0).unwrap().images.push(gridcraft_model::Image { + id: 1, + anchor: gridcraft_model::Anchor { cell: CellRef::new(0, 0), dx: 0.0, dy: 0.0, width: 72.0, height: 36.0, mode: Default::default() }, + data: vec![1, 2, 3].into(), + mime: "image/png".into(), + alt: "a \"logo\"".into(), + }); + let html = to_html(&wb, 0).unwrap(); + assert!( + html.contains("\"a"), + "{html}" + ); + assert!(html.contains("
"); +} + +#[test] +fn html_export_places_images_at_their_anchor_using_sheet_geometry() { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.cols.insert(1, gridcraft_model::LineInfo { size: Some(100.0), ..Default::default() }); + sh.rows.insert(1, gridcraft_model::LineInfo { size: Some(30.0), ..Default::default() }); + sh.images.push(gridcraft_model::Image { + id: 1, + anchor: gridcraft_model::Anchor { cell: CellRef::new(3, 2), dx: 5.0, dy: 7.0, width: 10.0, height: 20.0, mode: Default::default() }, + data: vec![1].into(), + mime: "image/png".into(), + alt: String::new(), + }); + sh.set_value(CellRef::new(1, 1), gridcraft_core::Value::Number(1.0)); + let html = to_html(&wb, 0).unwrap(); + // The table starts at B2; the picture is at C4: one 100 px column and two rows (30 + 20) further on. + assert!(html.contains("
"), "{html}"); + assert!(html.contains(""), "Latin text keeps its own direction: {html}"); + assert!( + html.contains(""), + "numbers mirror to the left and keep their sign in front: {html}" + ); + let clip = copied(&wb, "A1:B1"); + assert!(clip.starts_with("
Hello!-5
-5"), "{clip}"); + + let ltr = book(false, &[("A1", Value::text("Hello!"), HAlign::General)]); + assert!(to_html(<r, 0).unwrap().contains("
")); + assert!(copied(<r, "A1").starts_with("
مرحبا!"), "General Arabic text aligns by its direction: {html}"); + assert!(html.contains(""), "explicit alignment stays: {html}"); + assert!(html.contains(""), "{html}"); + assert!(html.contains(""), "{html}"); + assert!(html.contains("
שלוםمرحباHello
"), "a left-to-right sheet has no table direction: {html}"); + + let clip = copied(&wb, "A1:D1"); + let cells: Vec<&str> = clip.split("") && html.contains(">far")); +} + +#[test] +fn macros_are_saved_only_into_xlsm() { + let mut wb = Workbook::new(); + wb.vba_project = Some(b"vba".to_vec().into()); + let has_macros = |path: &str| { + let bytes = save_bytes(&wb, path).unwrap(); + gridcraft_xlsx::read_xlsx(&bytes).unwrap().0.vba_project.is_some() + }; + assert!(has_macros("book.xlsm")); + assert!(has_macros("BOOK.XLSM")); + assert!(!has_macros("book.xlsx"), "an .xlsx never carries a VBA project"); + assert!(has_macros("book.xltm"), "a macro-enabled template keeps the VBA project"); + assert!(!has_macros("book.xltx"), "a macro-free template never carries a VBA project"); + assert!(wb.vba_project.is_some(), "the open workbook keeps its macros"); +} + +#[test] +fn csv_named_history_does_not_get_the_reserved_sheet_name() { + for file in ["History.csv", "hIsToRy.csv", "history.tsv"] { + let (wb, _) = open_bytes(file, b"1\n").unwrap(); + let name = wb.sheets[0].name.clone(); + assert!(wb.check_sheet_name(&name, Some(0)).is_ok(), "{file} opened as {name:?}"); + } + let (wb, _) = open_bytes("Sales.csv", b"1\n").unwrap(); + assert_eq!(wb.sheets[0].name, "Sales"); +} + +/// A save that can't be put in place (here a folder already has the workbook's name, as +/// with a file another app holds locked) fails without leaving `.sctmp` behind. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn failed_save_leaves_no_temporary_file() { + let dir = std::env::temp_dir().join(format!("gridcraft-write-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let blocked = dir.join("Book1.xlsx"); + std::fs::create_dir(&blocked).unwrap(); + assert!(write_file(blocked.to_str().unwrap(), b"workbook").is_err()); + assert!(!dir.join("Book1.xlsx.sctmp").exists(), "the temporary file is removed"); + // An ordinary save still works and leaves only the workbook. + let ok = dir.join("Book2.xlsx"); + write_file(ok.to_str().unwrap(), b"workbook").unwrap(); + assert_eq!(std::fs::read(&ok).unwrap(), b"workbook"); + assert!(!dir.join("Book2.xlsx.sctmp").exists()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// While a script runs (#2178), file reads and writes are refused whatever command or +/// parameter asked for them; nesting and dropping the sandbox restore access. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn script_sandbox_refuses_file_access_until_dropped() { + let dir = std::env::temp_dir().join(format!("gridcraft-sandbox-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let existing = dir.join("in.csv"); + std::fs::write(&existing, b"a,b\n").unwrap(); + let (existing, out) = (existing.to_string_lossy().to_string(), dir.join("out.xlsx").to_string_lossy().to_string()); + { + let _outer = ScriptSandbox::enter(); + { + let _inner = ScriptSandbox::enter(); + assert!(read_file(&existing).is_err()); + } + assert!(read_file(&existing).is_err(), "the outer sandbox still holds"); + assert!(write_file(&out, b"leak").is_err()); + assert!(!std::path::Path::new(&out).exists()); + } + assert_eq!(read_file(&existing).unwrap(), b"a,b\n"); + write_file(&out, b"ok").unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a +/// root entry and the given stream names. +fn cfb(streams: &[&str]) -> Vec { + let mut b = vec![0u8; 512 * 3]; + let mut put = |at: usize, v: &[u8]| b[at..at + v.len()].copy_from_slice(v); + put(0, &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]); + put(30, &9u16.to_le_bytes()); + put(44, &1u32.to_le_bytes()); + put(48, &1u32.to_le_bytes()); + put(68, &0xFFFF_FFFEu32.to_le_bytes()); + for i in 1..109 { + put(76 + i * 4, &0xFFFF_FFFFu32.to_le_bytes()); + } + put(512, &0xFFFF_FFFDu32.to_le_bytes()); + put(516, &0xFFFF_FFFEu32.to_le_bytes()); + for (k, name) in std::iter::once("Root Entry").chain(streams.iter().copied()).enumerate() { + let units: Vec = name.encode_utf16().flat_map(u16::to_le_bytes).collect(); + put(1024 + k * 128, &units); + put(1024 + k * 128 + 64, &(units.len() as u16 + 2).to_le_bytes()); + } + b +} + +/// Cut-list rows as a CAD/CAM program exports them: `;`-separated, CRLF, part codes in the last +/// two columns, a part name containing a comma. +const CUT_LIST: &str = "1;511;184;P3_L1_led;2162PE;16;1MM_2162PE;1MM_2162PE;;;1244121406FB;\r\n\ + 1;562.5;510;Komoda, lijevo;2162PE;19;1MM_2162PE;;;;12441214071A;124412140710\r\n"; + +#[test] +fn csv_save_keeps_the_separator_it_was_opened_with() { + let (wb, _) = open_bytes("cut list.csv", CUT_LIST.as_bytes()).unwrap(); + assert_eq!(wb.csv_delimiter, Some(b';')); + assert_eq!(String::from_utf8(save_bytes(&wb, "cut list.csv").unwrap()).unwrap(), CUT_LIST); + // Save As another .csv name keeps it too; a comma file stays a comma file (quoting only + // fields that contain the separator in use). + assert_eq!(String::from_utf8(save_bytes(&wb, "copy.CSV").unwrap()).unwrap(), CUT_LIST); + let commas = "\"Komoda, lijevo\",b\r\n1,x;y\r\n"; + let (wb, _) = open_bytes("plain.csv", commas.as_bytes()).unwrap(); + assert_eq!(String::from_utf8(save_bytes(&wb, "plain.csv").unwrap()).unwrap(), commas); + // A tab-separated file named .csv round-trips with tabs. + let tabs = "a\tb\r\n1\t2\r\n"; + let (wb, _) = open_bytes("tabs.csv", tabs.as_bytes()).unwrap(); + assert_eq!(String::from_utf8(save_bytes(&wb, "tabs.csv").unwrap()).unwrap(), tabs); +} + +#[test] +fn csv_save_of_a_workbook_not_read_from_csv_uses_commas() { + let mut wb = Workbook::new(); + if let Some(sh) = wb.sheet_mut(0) { + sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::text("a")); + sh.set_value(CellRef::new(0, 1), gridcraft_core::Value::Number(1.0)); + } + assert_eq!(save_bytes(&wb, "new.csv").unwrap(), b"a,1\r\n"); + // Through .xlsx the CSV origin is gone, as in Excel. + let (from_csv, _) = open_bytes("cut list.csv", CUT_LIST.as_bytes()).unwrap(); + let (reopened, _) = open_bytes("book.xlsx", &save_bytes(&from_csv, "book.xlsx").unwrap()).unwrap(); + assert_eq!(reopened.csv_delimiter, None); + assert!(String::from_utf8(save_bytes(&reopened, "again.csv").unwrap()).unwrap().starts_with("1,511,184,")); + // A .tsv/.txt saved as .csv gets commas; as .tsv it keeps tabs. + let (tsv, _) = open_bytes("list.txt", b"a\tb\r\n").unwrap(); + assert_eq!(save_bytes(&tsv, "list.csv").unwrap(), b"a,b\r\n"); + assert_eq!(save_bytes(&tsv, "list.tsv").unwrap(), b"a\tb\r\n"); +} + +#[test] +fn encrypted_workbook_is_reported_not_misread() { + let file = gridcraft_xlsx::testing::encrypt_workbook(b"package", "pw"); + let msg = open_bytes("secret.xlsx", &file).unwrap_err().to_string(); + assert!(msg.contains(PASSWORD_REQUIRED), "got: {msg}"); + assert!(!msg.contains("zip"), "must not surface the zip error: {msg}"); + // A compound file naming the streams but unreadable is damage: no password is asked for, + // and none helps. + let file = cfb(&["EncryptionInfo", "EncryptedPackage"]); + for password in [None, Some("pw")] { + let msg = open_bytes_with_password("secret.xlsx", &file, password).unwrap_err().to_string(); + assert!(msg.contains("damaged") && !msg.contains(PASSWORD_REQUIRED), "got: {msg}"); + } +} + +#[test] +fn encryption_no_password_opens_is_reported_without_asking_for_one() { + let info: &[u8] = &[3, 0, 2, 0, 4, 0, 0, 0]; // Standard header flags: RC4, not AES + let file = gridcraft_xlsx::testing::compound_file(&[("EncryptionInfo", info), ("EncryptedPackage", &[0; 8])]); + let msg = open_bytes("rc4.xlsx", &file).unwrap_err().to_string(); + assert!(msg.contains("RC4") && !msg.contains(PASSWORD_REQUIRED), "got: {msg}"); + let irm = gridcraft_xlsx::testing::compound_file(&[("EncryptedPackage", &[0; 8])]); + let msg = open_bytes("irm.xlsx", &irm).unwrap_err().to_string(); + assert!(msg.contains("rights-management") && !msg.contains("damaged"), "got: {msg}"); +} + +#[test] +fn other_compound_files_are_not_called_encrypted() { + let err = open_bytes("old.doc", &cfb(&["WordDocument", "\u{5}SummaryInformation"])).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("not an Excel workbook"), "got: {msg}"); + assert!(!msg.contains("password"), "got: {msg}"); +} + +/// Save As CSV/TSV writes cells as displayed: number formats applied, formula results, +/// whole values in narrow columns (never `#####`), quoting unchanged. +#[test] +fn csv_writes_displayed_text() { + use gridcraft_core::{CellError, Value}; + use gridcraft_model::{Cell, Formula, LineInfo, NumFmt, StyleId}; + let mut wb = Workbook::new(); + let mut styled = |code: &str| wb.styles.derive(StyleId::DEFAULT, |s| s.num_fmt = NumFmt::new(code)); + let (date, money, pct, frac, text, fixed, thousands) = + (styled("yyyy-mm-dd"), styled("$#,##0.00"), styled("0.0%"), styled("# ?/?"), styled("@"), styled("0.00"), styled("#,##0")); + let sh = wb.sheet_mut(0).unwrap(); + let cells = [ + ("A1", Cell { value: Value::Number(46023.0), formula: None, style: date }), + ("B1", Cell { value: Value::Number(1234.5), formula: None, style: money }), + ("C1", Cell { value: Value::Number(0.125), formula: None, style: pct }), + ("D1", Cell { value: Value::Number(1.5), formula: None, style: frac }), + ("E1", Cell { value: Value::text("007"), formula: None, style: text }), + ("F1", Cell { value: Value::Error(CellError::NA), formula: None, style: money }), + ("G1", Cell { value: Value::Bool(true), formula: None, style: StyleId::DEFAULT }), + ("A2", Cell { value: Value::Number(46024.0), style: date, ..Cell::formula(Formula::new("A1+1")) }), + ("B2", Cell { value: Value::Number(12345678.9), formula: None, style: fixed }), + ("C2", Cell { value: Value::Number(-0.25), formula: None, style: StyleId::DEFAULT }), + ("D2", Cell { value: Value::Number(1234567.0), formula: None, style: thousands }), + ]; + for (a, c) in cells { + sh.set_cell(CellRef::parse(a).unwrap(), c); + } + // Column B is far too narrow for its numbers: the grid shows #####, the file must not. + sh.cols.insert(1, LineInfo { size: Some(1.0), ..Default::default() }); + sh.show_formulas = true; + let csv = String::from_utf8(save_bytes(&wb, "out.csv").unwrap()).unwrap(); + assert_eq!(csv, "2026-01-01,\"$1,234.50\",12.5%,1 1/2,007,#N/A,TRUE\r\n2026-01-02,12345678.90,-0.25,\"1,234,567\",,,\r\n"); + let tsv = String::from_utf8(save_bytes(&wb, "out.tsv").unwrap()).unwrap(); + assert!(tsv.starts_with("2026-01-01\t$1,234.50\t12.5%\t"), "{tsv}"); +} + +#[test] +fn delimited_exports_preserve_general_precision_and_explicit_number_formats() { + use gridcraft_core::Value; + use gridcraft_model::{Cell, NumFmt, StyleId}; + let mut workbook = Workbook::new(); + let scientific = workbook.styles.derive(StyleId::DEFAULT, |style| style.num_fmt = NumFmt::new("0.00E+00")); + let sheet = workbook.sheet_mut(0).unwrap(); + sheet.set_cell(CellRef::new(0, 0), Cell { value: Value::Number(124412140710.0), ..Default::default() }); + sheet.set_cell(CellRef::new(0, 1), Cell { value: Value::Number(1.23456789012345), ..Default::default() }); + sheet.set_cell(CellRef::new(0, 2), Cell { value: Value::Number(124412140710.0), style: scientific, ..Default::default() }); + for (path, separator) in [("data.csv", ","), ("data.tsv", "\t")] { + let output = String::from_utf8(save_bytes(&workbook, path).unwrap()).unwrap(); + assert_eq!(output, format!("124412140710{separator}1.23456789012345{separator}1.24E+11\r\n")); + } +} + +#[test] +fn template_extension_saves_template_content_type() { + let bytes = save_bytes(&Workbook::new(), "Budget.xltx").unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut types = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("[Content_Types].xml").unwrap(), &mut types).unwrap(); + assert!( + types.contains( + r#"PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml""# + ), + "{types}" + ); +} + +/// Where `part`'s entry starts in the central directory of `package`. +fn central_entry(package: &[u8], part: &str) -> usize { + let mut at = 0; + while let Some(i) = package.get(at..).and_then(|rest| rest.windows(4).position(|w| w == b"PK\x01\x02")) { + let entry = at + i; + let len = u16::from_le_bytes([package[entry + 28], package[entry + 29]]) as usize; + if &package[entry + 46..entry + 46 + len] == part.as_bytes() { + return entry; + } + at = entry + 46; + } + panic!("{part} is not in the package"); +} + +/// `package` with `part` declaring `size` unpacked bytes: the reader refuses the part on the +/// declared size, so no real 512 MB part has to be built. +fn declaring(mut package: Vec, part: &str, size: u32) -> Vec { + let at = central_entry(&package, part); + package[at + 24..at + 28].copy_from_slice(&size.to_le_bytes()); + package +} + +#[test] +fn a_part_over_the_size_limit_says_how_to_get_the_data_in() { + let mut wb = Workbook::new(); + wb.sheet_mut(0).unwrap().set_value(CellRef::new(0, 0), gridcraft_core::Value::text("big")); + let package = save_bytes(&wb, "big.xlsx").unwrap(); + // The size reported in storytold/gridcraft#1242. + let msg = open_bytes("big.xlsx", &declaring(package.clone(), "xl/worksheets/sheet1.xml", 540_278_731)).unwrap_err().to_string(); + assert!(msg.starts_with("We can't open 'big.xlsx': "), "{msg}"); + assert!(msg.contains("xl/worksheets/sheet1.xml") && msg.contains("515 MB") && msg.contains("512 MB"), "{msg}"); + assert!(msg.contains("several sheets") && msg.contains("CSV"), "{msg}"); + assert!(!msg.contains("bytes"), "{msg}"); + // Splitting sheets doesn't shrink the shared strings, which every sheet uses: only CSV helps. + let msg = open_bytes("big.xlsx", &declaring(package.clone(), "xl/sharedStrings.xml", 540_278_731)).unwrap_err().to_string(); + assert!(msg.contains("xl/sharedStrings.xml") && msg.contains("515 MB") && msg.contains("CSV"), "{msg}"); + assert!(!msg.contains("several sheets"), "{msg}"); + // Just over the limit never reads as a size equal to it. + let msg = open_bytes("big.xlsx", &declaring(package, "xl/worksheets/sheet1.xml", 512 * 1024 * 1024 + 1)).unwrap_err().to_string(); + assert!(msg.contains("more than 512 MB"), "{msg}"); + // A package that fails for another reason keeps its own error, oversized part or not. + let mut broken = declaring(save_bytes(&wb, "big.xlsx").unwrap(), "xl/worksheets/sheet1.xml", 540_278_731); + let at = central_entry(&broken, "xl/workbook.xml"); + broken[at + 46 + "xl/workbook.xml".len() - 1] = b'x'; + let msg = open_bytes("big.xlsx", &broken).unwrap_err().to_string(); + assert!(msg.starts_with("We can't open 'big.xlsx': ") && !msg.contains("unpacks"), "{msg}"); +} + +#[test] +fn content_wins_over_a_mismatched_extension() { + let mut wb = Workbook::new(); + wb.sheet_mut(0).unwrap().set_value(CellRef::new(0, 0), gridcraft_core::Value::Number(7.0)); + let package = save_bytes(&wb, "book.xlsx").unwrap(); + for name in ["book.xlsx", "book.ods", "book.xlsb", "book.csv"] { + let (opened, _) = open_bytes(name, &package).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 0)), gridcraft_core::Value::Number(7.0), "{name}"); + } + for name in ["data.xlsx", "data.csv"] { + let (opened, _) = open_bytes(name, b"a,b\n1,2\n").unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 1)), gridcraft_core::Value::text("b"), "{name}"); + } +} From 341ae6848e63f35d2e7ff53725b5c8bc110062ba Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:43 +0000 Subject: [PATCH 33/76] refactor(functions): move financial tests to src/financial/tests.rs --- crates/functions/src/financial.rs | 325 +----------------------- crates/functions/src/financial/tests.rs | 322 +++++++++++++++++++++++ 2 files changed, 323 insertions(+), 324 deletions(-) create mode 100644 crates/functions/src/financial/tests.rs diff --git a/crates/functions/src/financial.rs b/crates/functions/src/financial.rs index 55c570c06..22c926185 100644 --- a/crates/functions/src/financial.rs +++ b/crates/functions/src/financial.rs @@ -1774,327 +1774,4 @@ pub(crate) fn specs() -> Vec { } #[cfg(test)] -mod tests { - use crate::Arg; - use crate::util::testutil::*; - use gridcraft_core::{CellError, DateSystem, Value, date::serial_from_ymd}; - - fn d(y: i64, m: i64, dd: i64) -> Arg { - n(serial_from_ymd(DateSystem::D1900, y, m, dd).unwrap()) - } - - #[test] - fn tvm() { - close_tol(ev("PMT", vec![n(0.08 / 12.0), n(10.0), n(10000.0)]), -1037.032089, 1e-9); - close_tol(ev("PMT", vec![n(0.06 / 12.0), n(216.0), n(0.0), n(50000.0)]), -129.0811609, 1e-9); - close(ev("PMT", vec![n(0.0), n(10.0), n(1000.0)]), -100.0); - is_err(ev("PMT", vec![n(0.1), n(0.0), n(1000.0)]), CellError::Num); - close_tol(ev("FV", vec![n(0.06 / 12.0), n(10.0), n(-200.0), n(-500.0), n(1.0)]), 2581.403374, 1e-9); - close_tol(ev("FV", vec![n(0.01), n(12.0), n(-1000.0)]), 12682.50301, 1e-9); - close(ev("FV", vec![n(0.0), n(10.0), n(-100.0)]), 1000.0); - close_tol(ev("PV", vec![n(0.08 / 12.0), n(240.0), n(500.0)]), -59777.14585, 1e-9); - close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0), n(10000.0), n(1.0)]), 59.6738657, 1e-8); - close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0), n(10000.0)]), 60.0821229, 1e-8); - close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0)]), -9.57859404, 1e-8); - close_tol(ev("IPMT", vec![n(0.1 / 12.0), n(1.0), n(36.0), n(8000.0)]), -66.66666667, 1e-9); - close_tol(ev("IPMT", vec![n(0.1), n(3.0), n(3.0), n(8000.0)]), -292.4471299, 1e-9); - is_err(ev("IPMT", vec![n(0.1), n(4.0), n(3.0), n(8000.0)]), CellError::Num); - close_tol(ev("PPMT", vec![n(0.1 / 12.0), n(1.0), n(24.0), n(2000.0)]), -75.62318601, 1e-9); - close_tol(ev("PPMT", vec![n(0.08), n(10.0), n(10.0), n(200000.0)]), -27598.05346, 1e-9); - close_tol(ev("RATE", vec![n(48.0), n(-200.0), n(8000.0)]), 0.0077014725, 1e-7); - close_tol(ev("CUMIPMT", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(13.0), n(24.0), n(0.0)]), -11135.23213, 1e-9); - close_tol(ev("CUMIPMT", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), -937.5, 1e-9); - close_tol(ev("CUMPRINC", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(13.0), n(24.0), n(0.0)]), -934.1071234, 1e-9); - close_tol(ev("CUMPRINC", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), -68.27827118, 1e-9); - is_err(ev("CUMIPMT", vec![n(0.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), CellError::Num); - is_err(ev("CUMIPMT", vec![n(0.01), n(360.0), n(125000.0), n(1.0), n(1.0), n(2.0)]), CellError::Num); - } - - /// PMT within about 4 ULPs of Excel's (Value2 of synthetic probes; libm differs by an ULP across - /// platforms). The old `(1 + rate)^nper` form was 25 to 91 ULPs off on these, enough to flip a loan schedule's final `IF(p - SUM(r) > 0, ...)`. - #[test] - fn pmt_matches_excel_within_a_few_ulps() { - for (rate, nper, pv, fv, typ, excel) in [ - (0.141 / 12.0, 12.0, -4653300.0, 0.0, 0.0, 418025.37230211904), - (0.005716666666666666, 26.0, 42678411.0, -26010.62, 0.0, -1770235.3828507778), - (0.012164, 29.0, 16722649.0, 0.0, 1.0, -679516.0784762775), - (0.009916666666666666, 5.0, 8590842.8, 0.0, 0.0, -1769620.32624324), - (0.011158333333333333, 36.0, 21457108.0, 49218.26, 1.0, -720095.3912155787), - (-0.5, 2.5, 100.0, 0.0, 0.0, -10.736861692729645), - ] { - let got = ev("PMT", vec![n(rate), n(nper), n(pv), n(fv), n(typ)]); - close_tol(got, excel, 1e-15); - } - } - - /// Edge cases observed in Excel: a rate of -1 or less is #NUM!, a huge nper leaves -pv*rate, and a - /// tiny rate no longer loses its low digits in 1 + rate. - #[test] - fn pmt_edge_cases_follow_excel() { - is_err(ev("PMT", vec![n(-2.0), n(3.0), n(100.0)]), CellError::Num); - is_err(ev("PMT", vec![n(-1.0), n(3.0), n(100.0)]), CellError::Num); - is_err(ev("PMT", vec![n(-2.0), n(2.5), n(100.0)]), CellError::Num); - assert_eq!(ev("PMT", vec![n(0.1), n(10000.0), n(100.0)]), Value::Number(-10.0)); - assert_eq!(ev("PMT", vec![n(5.0), n(1000.0), n(100.0)]), Value::Number(-500.0)); - assert_eq!(ev("PMT", vec![n(1e-300), n(10.0), n(100.0)]), Value::Number(-10.0)); - close_tol(ev("PMT", vec![n(1e-12), n(10.0), n(100.0)]), -10.000000000055001, 1e-15); - close_tol(ev("PMT", vec![n(1e-300), n(10.0), n(1e12)]), -1e11, 1e-15); - close_tol(ev("PMT", vec![n(-0.999), n(5.0), n(100.0)]), -9.990_000_000_000_092e-14, 1e-15); - close_tol(ev("PMT", vec![n(0.05), n(-10.0), n(100.0)]), 7.950_457_496_545_667, 1e-15); - close_tol(ev("PMT", vec![n(-1e-17), n(1.0), n(1e18), n(-1e18)]), 10.0, 1e-9); - close_tol(ev("PMT", vec![n(-0.5), n(60.0), n(1e18)]), -0.433_680_868_994_201_8, 1e-6); - close_tol(ev("PMT", vec![n(0.05), n(10.0), n(100.0), n(50.0), n(1.0)]), -16.119701185541427, 1e-15); - } - - #[test] - fn cash_flows() { - close_tol(ev("NPV", vec![n(0.1), n(-10000.0), n(3000.0), n(4200.0), n(6800.0)]), 1188.443412, 1e-9); - close_tol(ev("NPV", vec![n(0.08), rf(col(&[8000.0, 9200.0, 10000.0, 12000.0, 14500.0]))]), 41922.06155, 1e-9); - is_err(ev("NPV", vec![n(-1.0), n(1.0)]), CellError::Div0); - let flows = col(&[-70000.0, 12000.0, 15000.0, 18000.0, 21000.0]); - close_tol(ev("IRR", vec![rf(flows)]), -0.021244848, 1e-7); - close_tol(ev("IRR", vec![rf(col(&[-70000.0, 12000.0, 15000.0, 18000.0, 21000.0, 26000.0]))]), 0.086630948, 1e-7); - close_tol(ev("IRR", vec![rf(col(&[-70000.0, 12000.0, 15000.0])), n(-0.1)]), -0.44350694, 1e-7); - is_err(ev("IRR", vec![rf(col(&[1.0, 2.0]))]), CellError::Num); - let vals = col(&[-10000.0, 2750.0, 4250.0, 3250.0, 2750.0]); - let dates = col(&[39448.0, 39508.0, 39751.0, 39859.0, 39904.0]); - close_tol(ev("XNPV", vec![n(0.09), rf(vals.clone()), rf(dates.clone())]), 2086.647602, 1e-9); - close_tol(ev("XIRR", vec![rf(vals.clone()), rf(dates)]), 0.373362535, 1e-7); - is_err(ev("XNPV", vec![n(0.09), rf(vals), rf(col(&[1.0, 2.0]))]), CellError::Num); - let m = col(&[-120000.0, 39000.0, 30000.0, 21000.0, 37000.0, 46000.0]); - close_tol(ev("MIRR", vec![rf(m), n(0.1), n(0.12)]), 0.1260943, 1e-6); - close_tol(ev("MIRR", vec![rf(col(&[-120000.0, 39000.0, 30000.0, 21000.0])), n(0.1), n(0.12)]), -0.048044655, 1e-7); - is_err(ev("MIRR", vec![rf(col(&[1.0, 2.0])), n(0.1), n(0.1)]), CellError::Div0); - // Excel 365 gives -1 when every flow is negative: the positive flows are worth nothing. - close_tol(ev("MIRR", vec![rf(col(&[-10000.0, -3400.0, -6500.0, -1000.0])), n(0.07), n(0.01)]), -1.0, 1e-12); - close(ev("FVSCHEDULE", vec![n(1.0), av(row(&[0.09, 0.11, 0.1]))]), 1.33089); - close(ev("EFFECT", vec![n(0.0525), n(4.0)]), 0.0535426673707584); - is_err(ev("EFFECT", vec![n(-0.1), n(4.0)]), CellError::Num); - close_tol(ev("NOMINAL", vec![n(0.053543), n(4.0)]), 0.052500319, 1e-8); - close_tol(ev("ISPMT", vec![n(0.1 / 12.0), n(0.0), n(36.0), n(8000000.0)]), -66666.66667, 1e-9); - is_err(ev("ISPMT", vec![n(0.1), n(1.0), n(0.0), n(1.0)]), CellError::Div0); - close_tol(ev("PDURATION", vec![n(0.025), n(2000.0), n(2200.0)]), 3.859866163, 1e-9); - is_err(ev("PDURATION", vec![n(0.0), n(2000.0), n(2200.0)]), CellError::Num); - close_tol(ev("RRI", vec![n(96.0), n(10000.0), n(11000.0)]), 0.0009933, 1e-4); - close(ev("DOLLARDE", vec![n(1.02), n(16.0)]), 1.125); - close(ev("DOLLARDE", vec![n(1.1), n(32.0)]), 1.3125); - close(ev("DOLLARFR", vec![n(1.125), n(16.0)]), 1.02); - close(ev("DOLLARFR", vec![n(1.125), n(32.0)]), 1.04); - is_err(ev("DOLLARDE", vec![n(1.02), n(0.0)]), CellError::Div0); - is_err(ev("DOLLARDE", vec![n(1.02), n(-1.0)]), CellError::Num); - } - - #[test] - fn depreciation() { - close(ev("SLN", vec![n(30000.0), n(7500.0), n(10.0)]), 2250.0); - is_err(ev("SLN", vec![n(30000.0), n(7500.0), n(0.0)]), CellError::Div0); - close_tol(ev("SYD", vec![n(30000.0), n(7500.0), n(10.0), n(1.0)]), 4090.909091, 1e-9); - close_tol(ev("SYD", vec![n(30000.0), n(7500.0), n(10.0), n(10.0)]), 409.0909091, 1e-9); - let dbv = |p: f64| ev("DB", vec![n(1000000.0), n(100000.0), n(6.0), n(p), n(7.0)]); - close_tol(dbv(1.0), 186083.3333, 1e-9); - close_tol(dbv(2.0), 259639.4167, 1e-9); - close_tol(dbv(3.0), 176814.4428, 1e-9); - close_tol(dbv(7.0), 15845.09847, 1e-9); - is_err(dbv(8.0), CellError::Num); - close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(3650.0), n(1.0)]), 1.315068493, 1e-8); - close(ev("DDB", vec![n(2400.0), n(300.0), n(120.0), n(1.0), n(2.0)]), 40.0); - close(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(1.0), n(2.0)]), 480.0); - close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(2.0), n(1.5)]), 306.0, 1e-12); - close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(10.0)]), 22.1225472, 1e-8); - is_err(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(11.0)]), CellError::Num); - close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(3650.0), n(0.0), n(1.0)]), 1.315068493, 1e-8); - close(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(0.0), n(1.0)]), 40.0); - close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(1.0)]), 480.0); - close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(6.0), n(18.0)]), 396.3060533, 1e-8); - close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(6.0), n(18.0), n(1.5)]), 311.8089367, 1e-8); - close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(0.875), n(1.5)]), 315.0); - // Over the whole life the total depreciation is cost - salvage. - close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(10.0)]), 2100.0); - is_err(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(5.0), n(4.0)]), CellError::Num); - close_tol(ev("AMORLINC", vec![n(2400.0), d(2008, 8, 19), d(2008, 12, 31), n(300.0), n(1.0), n(0.15), n(1.0)]), 360.0, 1e-9); - close(ev("AMORDEGRC", vec![n(2400.0), d(2008, 8, 19), d(2008, 12, 31), n(300.0), n(1.0), n(0.15), n(1.0)]), 776.0); - } - - #[test] - fn coupons() { - let args = |f: &str| ev(f, vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(1.0)]); - close(args("COUPDAYBS"), 71.0); - close(args("COUPDAYS"), 181.0); - close(args("COUPDAYSNC"), 110.0); - close(args("COUPNCD"), 40678.0); - close(args("COUPPCD"), 40497.0); - close(args("COUPNUM"), 2.0); - close(ev("COUPDAYS", vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(0.0)]), 180.0); - close(ev("COUPDAYS", vec![d(2011, 1, 25), d(2011, 11, 15), n(4.0), n(3.0)]), 91.25); - close(ev("COUPNUM", vec![d(2007, 1, 25), d(2008, 11, 15), n(2.0), n(1.0)]), 4.0); - is_err(ev("COUPNUM", vec![d(2012, 1, 25), d(2011, 11, 15), n(2.0)]), CellError::Num); - is_err(ev("COUPNUM", vec![d(2011, 1, 25), d(2011, 11, 15), n(3.0)]), CellError::Num); - is_err(ev("COUPNUM", vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(5.0)]), CellError::Num); - // End-of-month maturity keeps month-end coupon dates. - close(ev("COUPPCD", vec![d(2011, 3, 15), d(2011, 11, 30), n(2.0)]), serial_from_ymd(DateSystem::D1900, 2010, 11, 30).unwrap()); - close(ev("COUPNCD", vec![d(2011, 3, 15), d(2011, 11, 30), n(2.0)]), serial_from_ymd(DateSystem::D1900, 2011, 5, 31).unwrap()); - } - - #[test] - fn securities() { - close_tol(ev("DISC", vec![d(2007, 1, 25), d(2007, 6, 15), n(97.975), n(100.0), n(1.0)]), 0.052420213, 1e-8); - close_tol(ev("PRICEDISC", vec![d(2008, 2, 16), d(2008, 3, 1), n(0.0525), n(100.0), n(2.0)]), 99.79583333, 1e-9); - close_tol(ev("YIELDDISC", vec![d(2008, 2, 16), d(2008, 3, 1), n(99.795), n(100.0), n(2.0)]), 0.052823, 1e-5); - close_tol(ev("INTRATE", vec![d(2008, 2, 15), d(2008, 5, 15), n(1000000.0), n(1014420.0), n(2.0)]), 0.05768, 1e-9); - close_tol(ev("RECEIVED", vec![d(2008, 2, 15), d(2008, 5, 15), n(1000000.0), n(0.0575), n(2.0)]), 1014584.654, 1e-9); - is_err(ev("DISC", vec![d(2008, 2, 15), d(2008, 1, 15), n(97.0), n(100.0)]), CellError::Num); - close_tol(ev("TBILLPRICE", vec![d(2008, 3, 31), d(2008, 6, 1), n(0.09)]), 98.45, 1e-12); - close_tol(ev("TBILLYIELD", vec![d(2008, 3, 31), d(2008, 6, 1), n(98.45)]), 0.091417, 1e-6); - close_tol(ev("TBILLEQ", vec![d(2008, 3, 31), d(2008, 6, 1), n(0.0914)]), 0.094151, 1e-6); - is_err(ev("TBILLPRICE", vec![d(2008, 3, 31), d(2009, 6, 1), n(0.09)]), CellError::Num); - close_tol(ev("ACCRINTM", vec![d(2008, 4, 1), d(2008, 6, 15), n(0.1), n(1000.0), n(3.0)]), 20.54794521, 1e-9); - is_err(ev("ACCRINTM", vec![d(2008, 6, 15), d(2008, 4, 1), n(0.1)]), CellError::Num); - close_tol(ev("ACCRINT", vec![d(2008, 3, 1), d(2008, 8, 31), d(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(0.0)]), 16.66666667, 1e-9); - close_tol(ev("ACCRINT", vec![d(2008, 3, 5), d(2008, 8, 31), d(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(0.0), b(false)]), 15.55555556, 1e-9); - close_tol(ev("PRICEMAT", vec![d(2008, 2, 15), d(2008, 4, 13), d(2007, 11, 11), n(0.061), n(0.061), n(0.0)]), 99.98449888, 1e-9); - close_tol(ev("YIELDMAT", vec![d(2008, 3, 15), d(2008, 11, 3), d(2007, 11, 8), n(0.0625), n(100.0123), n(0.0)]), 0.060954, 1e-6); - close_tol(ev("PRICE", vec![d(2008, 2, 15), d(2017, 11, 15), n(0.0575), n(0.065), n(100.0), n(2.0), n(0.0)]), 94.63436, 1e-7); - close_tol(ev("YIELD", vec![d(2008, 2, 15), d(2016, 11, 15), n(0.0575), n(95.04287), n(100.0), n(2.0), n(0.0)]), 0.065, 1e-6); - is_err(ev("PRICE", vec![d(2008, 2, 15), d(2017, 11, 15), n(-0.01), n(0.065), n(100.0), n(2.0)]), CellError::Num); - close_tol(ev("DURATION", vec![d(2018, 7, 1), d(2048, 1, 1), n(0.08), n(0.09), n(2.0), n(1.0)]), 10.9191453, 1e-8); - close_tol(ev("MDURATION", vec![d(2008, 1, 1), d(2016, 1, 1), n(0.08), n(0.09), n(2.0), n(1.0)]), 5.73567, 1e-6); - // A one-coupon bond uses the closed form for yield; PRICE and YIELD invert each other. - let p = ev("PRICE", vec![d(2008, 2, 15), d(2008, 6, 15), n(0.05), n(0.04), n(100.0), n(2.0)]); - let pv = match p { - Value::Number(x) => x, - _ => panic!(), - }; - close_tol(ev("YIELD", vec![d(2008, 2, 15), d(2008, 6, 15), n(0.05), n(pv), n(100.0), n(2.0)]), 0.04, 1e-9); - } - - fn number(v: Value) -> f64 { - match v { - Value::Number(x) => x, - other => panic!("expected a number, got {other:?}"), - } - } - - #[test] - fn odd_periods() { - // The worked examples on Microsoft's function pages (results as printed there). - let first = |x: f64, rest: &[f64]| { - let mut a = vec![d(2008, 11, 11), d(2021, 3, 1), d(2008, 10, 15), d(2009, 3, 1), n(x)]; - a.extend(rest.iter().map(|v| n(*v))); - a - }; - close_tol(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 1.0])), 113.60, 0.005 / 113.6); - close_tol(ev("ODDFYIELD", first(0.0575, &[84.50, 100.0, 2.0, 0.0])), 0.0772, 0.00005); - let lp = vec![d(2008, 2, 7), d(2008, 6, 15), d(2007, 10, 15), n(0.0375), n(0.0405), n(100.0), n(2.0), n(0.0)]; - close_tol(ev("ODDLPRICE", lp), 99.88, 0.005 / 99.88); - let ly = vec![d(2008, 4, 20), d(2008, 6, 15), d(2007, 12, 24), n(0.0375), n(99.875), n(100.0), n(2.0), n(0.0)]; - close_tol(ev("ODDLYIELD", ly), 0.04519, 0.000005); - - // Yield → price → yield, for a short and a long odd first period and a long odd last period. - for (issue, basis) in [(d(2008, 10, 15), 0.0), (d(2007, 1, 20), 1.0), (d(2008, 1, 31), 3.0)] { - let args = |x: f64| vec![d(2008, 11, 11), d(2021, 3, 1), issue.clone(), d(2009, 3, 1), n(0.06), n(x), n(100.0), n(2.0), n(basis)]; - let p = number(ev("ODDFPRICE", args(0.05))); - close_tol(ev("ODDFYIELD", args(p)), 0.05, 1e-9); - } - let last = |x: f64| vec![d(2009, 2, 1), d(2009, 12, 15), d(2008, 3, 15), n(0.04), n(x), n(100.0), n(4.0), n(1.0)]; - let p = number(ev("ODDLPRICE", last(0.07))); - close_tol(ev("ODDLYIELD", last(p)), 0.07, 1e-9); - - // Argument checks: frequency, basis, date order, negative yield, non-positive price. - is_err(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 3.0, 1.0])), CellError::Num); - is_err(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 5.0])), CellError::Num); - is_err(ev("ODDFPRICE", first(0.0785, &[-0.01, 100.0, 2.0, 1.0])), CellError::Num); - is_err(ev("ODDFYIELD", first(0.0575, &[0.0, 100.0, 2.0, 0.0])), CellError::Num); - is_err( - ev("ODDFPRICE", vec![d(2009, 4, 1), d(2021, 3, 1), d(2008, 10, 15), d(2009, 3, 1), n(0.07), n(0.06), n(100.0), n(2.0)]), - CellError::Num, - ); - is_err(ev("ODDLPRICE", vec![d(2007, 10, 1), d(2008, 6, 15), d(2007, 10, 15), n(0.0375), n(0.0405), n(100.0), n(2.0)]), CellError::Num); - is_err(ev("ODDLYIELD", vec![d(2008, 4, 20), d(2008, 6, 15), d(2007, 12, 24), n(0.0375), n(-1.0), n(100.0), n(2.0)]), CellError::Num); - is_err( - ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 1.0]).into_iter().take(4).chain([t("x"), n(0.06), n(100.0), n(2.0)]).collect()), - CellError::Value, - ); - } - - #[test] - fn odd_periods_survive_hostile_input() { - // Quarterly quasi-periods across the whole date range, huge rates and yields: a value or - // an error, never a panic or a hang. - let wide = |x: f64, y: f64| vec![n(2_957_000.0), n(2_958_465.0), n(400.0), n(2_957_900.0), n(x), n(y), n(1e300), n(4.0), n(1.0)]; - for (x, y) in [(0.05, 0.05), (1e300, 1e300), (0.0, 1e-300)] { - let _ = ev("ODDFPRICE", wide(x, y)); - let _ = ev("ODDFYIELD", wide(x, y)); - } - // The long walk itself works: settled early in an odd first period of ~32,000 quarters, - // a small yield round-trips. - let long = |y: f64| vec![n(500.0), n(2_958_465.0), n(400.0), n(2_957_900.0), n(0.05), n(y), n(100.0), n(4.0), n(1.0)]; - let p = number(ev("ODDFPRICE", long(0.0001))); - close_tol(ev("ODDFYIELD", long(p)), 0.0001, 1e-9); - let last = |x: f64, y: f64| vec![n(2_958_000.0), n(2_958_465.0), n(400.0), n(x), n(y), n(1e300), n(4.0), n(1.0)]; - for (x, y) in [(0.05, 0.05), (1e300, 1e300), (0.0, f64::MAX)] { - let _ = ev("ODDLPRICE", last(x, y)); - let _ = ev("ODDLYIELD", last(x, y)); - } - is_err(ev("ODDFPRICE", vec![n(-5.0), n(1e20), n(0.0), n(1e10), n(0.05), n(0.05), n(100.0), n(2.0)]), CellError::Num); - } - - #[test] - fn euroconvert() { - // The examples on Microsoft's EUROCONVERT page. - let ec = |x: f64, s: &str, tg: &str, rest: Vec| { - let mut a = vec![n(x), t(s), t(tg)]; - a.extend(rest); - ev("EUROCONVERT", a) - }; - close(ec(1.2, "DEM", "EUR", vec![]), 0.61); - close(ec(1.0, "FRF", "EUR", vec![b(true), n(3.0)]), 0.152); - close(ec(1.0, "FRF", "EUR", vec![b(false), n(3.0)]), 0.15); - close(ec(1.0, "FRF", "DEM", vec![b(true), n(3.0)]), 0.29728616); - // Euro to a member currency rounds to the target's calculation precision (lira: 0). - close(ec(10.0, "eur", "ITL", vec![]), 19363.0); - close(ec(100.0, "EUR", "IEP", vec![b(true)]), 78.7564); - // Same currency returns the number untouched. - close(ec(1.23456, "DEM", "DEM", vec![]), 1.23456); - // Unknown codes and a triangulation precision below 3 are #VALUE!. - is_err(ec(1.0, "USD", "EUR", vec![]), CellError::Value); - is_err(ec(1.0, "FRF", "DEM", vec![b(true), n(2.0)]), CellError::Value); - // Hostile magnitudes give a number or #NUM!, never a panic. - is_err(ec(f64::MAX, "EUR", "ITL", vec![]), CellError::Num); - close(ec(1.0, "FRF", "DEM", vec![b(true), n(1e300)]), 1.95583 / 6.55957); - } - - #[test] - fn lifting() { - let v = ev("SLN", vec![av(row(&[100.0, 200.0])), n(0.0), n(10.0)]); - assert_eq!(rows_of(&v), vec![vec![nv(10.0), nv(20.0)]]); - is_err(ev("PMT", vec![t("abc"), n(1.0), n(1.0)]), CellError::Value); - is_err(ev("PMT", vec![e(CellError::NA), n(1.0), n(1.0)]), CellError::NA); - } - - #[test] - fn securities_read_dates_in_the_1904_system() { - let d4 = |y, m, dd| n(serial_from_ymd(DateSystem::D1904, y, m, dd).unwrap()); - let ev4 = |name: &str, args: Vec| { - let mut ctx = TestCtx { sys: DateSystem::D1904, ..TestCtx::default() }; - crate::call(crate::lookup(name).unwrap(), &args, &mut ctx) - }; - let (s, m) = (d4(2011, 3, 15), d4(2011, 11, 30)); - close(ev4("COUPNCD", vec![s.clone(), m.clone(), n(2.0)]), 39232.0); - close(ev4("COUPPCD", vec![s.clone(), m.clone(), n(4.0), n(0.0)]), 39140.0); - close(ev4("COUPDAYBS", vec![s.clone(), m.clone(), n(4.0), n(0.0)]), 15.0); - let accrint = vec![d4(2008, 3, 1), d4(2008, 8, 31), d4(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(1.0)]; - close_tol(ev4("ACCRINT", accrint), 16.576086956522, 1e-9); - // Results that already matched Excel keep matching. - let semi = |name| ev4(name, vec![s.clone(), m.clone(), n(2.0), n(0.0)]); - close(semi("COUPPCD"), 39050.0); - close(semi("COUPDAYS"), 180.0); - close(semi("COUPDAYBS"), 105.0); - close(semi("COUPDAYSNC"), 75.0); - close(semi("COUPNUM"), 2.0); - let price = vec![s.clone(), m.clone(), n(0.0575), n(0.065), n(100.0), n(2.0), n(0.0)]; - close_tol(ev4("PRICE", price), 99.47768286339101, 1e-9); - close_tol(ev4("DURATION", vec![s, m, n(0.08), n(0.09), n(2.0), n(0.0)]), 0.6890136809022, 1e-9); - let odd = vec![d4(2008, 11, 11), d4(2021, 3, 1), d4(2008, 10, 15), d4(2009, 3, 1), n(0.0785), n(0.0625), n(100.0), n(2.0), n(1.0)]; - close_tol(ev4("ODDFPRICE", odd), 113.597717474079, 1e-9); - } -} +mod tests; diff --git a/crates/functions/src/financial/tests.rs b/crates/functions/src/financial/tests.rs new file mode 100644 index 000000000..17f416412 --- /dev/null +++ b/crates/functions/src/financial/tests.rs @@ -0,0 +1,322 @@ +use crate::Arg; +use crate::util::testutil::*; +use gridcraft_core::{CellError, DateSystem, Value, date::serial_from_ymd}; + +fn d(y: i64, m: i64, dd: i64) -> Arg { + n(serial_from_ymd(DateSystem::D1900, y, m, dd).unwrap()) +} + +#[test] +fn tvm() { + close_tol(ev("PMT", vec![n(0.08 / 12.0), n(10.0), n(10000.0)]), -1037.032089, 1e-9); + close_tol(ev("PMT", vec![n(0.06 / 12.0), n(216.0), n(0.0), n(50000.0)]), -129.0811609, 1e-9); + close(ev("PMT", vec![n(0.0), n(10.0), n(1000.0)]), -100.0); + is_err(ev("PMT", vec![n(0.1), n(0.0), n(1000.0)]), CellError::Num); + close_tol(ev("FV", vec![n(0.06 / 12.0), n(10.0), n(-200.0), n(-500.0), n(1.0)]), 2581.403374, 1e-9); + close_tol(ev("FV", vec![n(0.01), n(12.0), n(-1000.0)]), 12682.50301, 1e-9); + close(ev("FV", vec![n(0.0), n(10.0), n(-100.0)]), 1000.0); + close_tol(ev("PV", vec![n(0.08 / 12.0), n(240.0), n(500.0)]), -59777.14585, 1e-9); + close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0), n(10000.0), n(1.0)]), 59.6738657, 1e-8); + close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0), n(10000.0)]), 60.0821229, 1e-8); + close_tol(ev("NPER", vec![n(0.01), n(-100.0), n(-1000.0)]), -9.57859404, 1e-8); + close_tol(ev("IPMT", vec![n(0.1 / 12.0), n(1.0), n(36.0), n(8000.0)]), -66.66666667, 1e-9); + close_tol(ev("IPMT", vec![n(0.1), n(3.0), n(3.0), n(8000.0)]), -292.4471299, 1e-9); + is_err(ev("IPMT", vec![n(0.1), n(4.0), n(3.0), n(8000.0)]), CellError::Num); + close_tol(ev("PPMT", vec![n(0.1 / 12.0), n(1.0), n(24.0), n(2000.0)]), -75.62318601, 1e-9); + close_tol(ev("PPMT", vec![n(0.08), n(10.0), n(10.0), n(200000.0)]), -27598.05346, 1e-9); + close_tol(ev("RATE", vec![n(48.0), n(-200.0), n(8000.0)]), 0.0077014725, 1e-7); + close_tol(ev("CUMIPMT", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(13.0), n(24.0), n(0.0)]), -11135.23213, 1e-9); + close_tol(ev("CUMIPMT", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), -937.5, 1e-9); + close_tol(ev("CUMPRINC", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(13.0), n(24.0), n(0.0)]), -934.1071234, 1e-9); + close_tol(ev("CUMPRINC", vec![n(0.09 / 12.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), -68.27827118, 1e-9); + is_err(ev("CUMIPMT", vec![n(0.0), n(360.0), n(125000.0), n(1.0), n(1.0), n(0.0)]), CellError::Num); + is_err(ev("CUMIPMT", vec![n(0.01), n(360.0), n(125000.0), n(1.0), n(1.0), n(2.0)]), CellError::Num); +} + +/// PMT within about 4 ULPs of Excel's (Value2 of synthetic probes; libm differs by an ULP across +/// platforms). The old `(1 + rate)^nper` form was 25 to 91 ULPs off on these, enough to flip a loan schedule's final `IF(p - SUM(r) > 0, ...)`. +#[test] +fn pmt_matches_excel_within_a_few_ulps() { + for (rate, nper, pv, fv, typ, excel) in [ + (0.141 / 12.0, 12.0, -4653300.0, 0.0, 0.0, 418025.37230211904), + (0.005716666666666666, 26.0, 42678411.0, -26010.62, 0.0, -1770235.3828507778), + (0.012164, 29.0, 16722649.0, 0.0, 1.0, -679516.0784762775), + (0.009916666666666666, 5.0, 8590842.8, 0.0, 0.0, -1769620.32624324), + (0.011158333333333333, 36.0, 21457108.0, 49218.26, 1.0, -720095.3912155787), + (-0.5, 2.5, 100.0, 0.0, 0.0, -10.736861692729645), + ] { + let got = ev("PMT", vec![n(rate), n(nper), n(pv), n(fv), n(typ)]); + close_tol(got, excel, 1e-15); + } +} + +/// Edge cases observed in Excel: a rate of -1 or less is #NUM!, a huge nper leaves -pv*rate, and a +/// tiny rate no longer loses its low digits in 1 + rate. +#[test] +fn pmt_edge_cases_follow_excel() { + is_err(ev("PMT", vec![n(-2.0), n(3.0), n(100.0)]), CellError::Num); + is_err(ev("PMT", vec![n(-1.0), n(3.0), n(100.0)]), CellError::Num); + is_err(ev("PMT", vec![n(-2.0), n(2.5), n(100.0)]), CellError::Num); + assert_eq!(ev("PMT", vec![n(0.1), n(10000.0), n(100.0)]), Value::Number(-10.0)); + assert_eq!(ev("PMT", vec![n(5.0), n(1000.0), n(100.0)]), Value::Number(-500.0)); + assert_eq!(ev("PMT", vec![n(1e-300), n(10.0), n(100.0)]), Value::Number(-10.0)); + close_tol(ev("PMT", vec![n(1e-12), n(10.0), n(100.0)]), -10.000000000055001, 1e-15); + close_tol(ev("PMT", vec![n(1e-300), n(10.0), n(1e12)]), -1e11, 1e-15); + close_tol(ev("PMT", vec![n(-0.999), n(5.0), n(100.0)]), -9.990_000_000_000_092e-14, 1e-15); + close_tol(ev("PMT", vec![n(0.05), n(-10.0), n(100.0)]), 7.950_457_496_545_667, 1e-15); + close_tol(ev("PMT", vec![n(-1e-17), n(1.0), n(1e18), n(-1e18)]), 10.0, 1e-9); + close_tol(ev("PMT", vec![n(-0.5), n(60.0), n(1e18)]), -0.433_680_868_994_201_8, 1e-6); + close_tol(ev("PMT", vec![n(0.05), n(10.0), n(100.0), n(50.0), n(1.0)]), -16.119701185541427, 1e-15); +} + +#[test] +fn cash_flows() { + close_tol(ev("NPV", vec![n(0.1), n(-10000.0), n(3000.0), n(4200.0), n(6800.0)]), 1188.443412, 1e-9); + close_tol(ev("NPV", vec![n(0.08), rf(col(&[8000.0, 9200.0, 10000.0, 12000.0, 14500.0]))]), 41922.06155, 1e-9); + is_err(ev("NPV", vec![n(-1.0), n(1.0)]), CellError::Div0); + let flows = col(&[-70000.0, 12000.0, 15000.0, 18000.0, 21000.0]); + close_tol(ev("IRR", vec![rf(flows)]), -0.021244848, 1e-7); + close_tol(ev("IRR", vec![rf(col(&[-70000.0, 12000.0, 15000.0, 18000.0, 21000.0, 26000.0]))]), 0.086630948, 1e-7); + close_tol(ev("IRR", vec![rf(col(&[-70000.0, 12000.0, 15000.0])), n(-0.1)]), -0.44350694, 1e-7); + is_err(ev("IRR", vec![rf(col(&[1.0, 2.0]))]), CellError::Num); + let vals = col(&[-10000.0, 2750.0, 4250.0, 3250.0, 2750.0]); + let dates = col(&[39448.0, 39508.0, 39751.0, 39859.0, 39904.0]); + close_tol(ev("XNPV", vec![n(0.09), rf(vals.clone()), rf(dates.clone())]), 2086.647602, 1e-9); + close_tol(ev("XIRR", vec![rf(vals.clone()), rf(dates)]), 0.373362535, 1e-7); + is_err(ev("XNPV", vec![n(0.09), rf(vals), rf(col(&[1.0, 2.0]))]), CellError::Num); + let m = col(&[-120000.0, 39000.0, 30000.0, 21000.0, 37000.0, 46000.0]); + close_tol(ev("MIRR", vec![rf(m), n(0.1), n(0.12)]), 0.1260943, 1e-6); + close_tol(ev("MIRR", vec![rf(col(&[-120000.0, 39000.0, 30000.0, 21000.0])), n(0.1), n(0.12)]), -0.048044655, 1e-7); + is_err(ev("MIRR", vec![rf(col(&[1.0, 2.0])), n(0.1), n(0.1)]), CellError::Div0); + // Excel 365 gives -1 when every flow is negative: the positive flows are worth nothing. + close_tol(ev("MIRR", vec![rf(col(&[-10000.0, -3400.0, -6500.0, -1000.0])), n(0.07), n(0.01)]), -1.0, 1e-12); + close(ev("FVSCHEDULE", vec![n(1.0), av(row(&[0.09, 0.11, 0.1]))]), 1.33089); + close(ev("EFFECT", vec![n(0.0525), n(4.0)]), 0.0535426673707584); + is_err(ev("EFFECT", vec![n(-0.1), n(4.0)]), CellError::Num); + close_tol(ev("NOMINAL", vec![n(0.053543), n(4.0)]), 0.052500319, 1e-8); + close_tol(ev("ISPMT", vec![n(0.1 / 12.0), n(0.0), n(36.0), n(8000000.0)]), -66666.66667, 1e-9); + is_err(ev("ISPMT", vec![n(0.1), n(1.0), n(0.0), n(1.0)]), CellError::Div0); + close_tol(ev("PDURATION", vec![n(0.025), n(2000.0), n(2200.0)]), 3.859866163, 1e-9); + is_err(ev("PDURATION", vec![n(0.0), n(2000.0), n(2200.0)]), CellError::Num); + close_tol(ev("RRI", vec![n(96.0), n(10000.0), n(11000.0)]), 0.0009933, 1e-4); + close(ev("DOLLARDE", vec![n(1.02), n(16.0)]), 1.125); + close(ev("DOLLARDE", vec![n(1.1), n(32.0)]), 1.3125); + close(ev("DOLLARFR", vec![n(1.125), n(16.0)]), 1.02); + close(ev("DOLLARFR", vec![n(1.125), n(32.0)]), 1.04); + is_err(ev("DOLLARDE", vec![n(1.02), n(0.0)]), CellError::Div0); + is_err(ev("DOLLARDE", vec![n(1.02), n(-1.0)]), CellError::Num); +} + +#[test] +fn depreciation() { + close(ev("SLN", vec![n(30000.0), n(7500.0), n(10.0)]), 2250.0); + is_err(ev("SLN", vec![n(30000.0), n(7500.0), n(0.0)]), CellError::Div0); + close_tol(ev("SYD", vec![n(30000.0), n(7500.0), n(10.0), n(1.0)]), 4090.909091, 1e-9); + close_tol(ev("SYD", vec![n(30000.0), n(7500.0), n(10.0), n(10.0)]), 409.0909091, 1e-9); + let dbv = |p: f64| ev("DB", vec![n(1000000.0), n(100000.0), n(6.0), n(p), n(7.0)]); + close_tol(dbv(1.0), 186083.3333, 1e-9); + close_tol(dbv(2.0), 259639.4167, 1e-9); + close_tol(dbv(3.0), 176814.4428, 1e-9); + close_tol(dbv(7.0), 15845.09847, 1e-9); + is_err(dbv(8.0), CellError::Num); + close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(3650.0), n(1.0)]), 1.315068493, 1e-8); + close(ev("DDB", vec![n(2400.0), n(300.0), n(120.0), n(1.0), n(2.0)]), 40.0); + close(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(1.0), n(2.0)]), 480.0); + close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(2.0), n(1.5)]), 306.0, 1e-12); + close_tol(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(10.0)]), 22.1225472, 1e-8); + is_err(ev("DDB", vec![n(2400.0), n(300.0), n(10.0), n(11.0)]), CellError::Num); + close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(3650.0), n(0.0), n(1.0)]), 1.315068493, 1e-8); + close(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(0.0), n(1.0)]), 40.0); + close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(1.0)]), 480.0); + close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(6.0), n(18.0)]), 396.3060533, 1e-8); + close_tol(ev("VDB", vec![n(2400.0), n(300.0), n(120.0), n(6.0), n(18.0), n(1.5)]), 311.8089367, 1e-8); + close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(0.875), n(1.5)]), 315.0); + // Over the whole life the total depreciation is cost - salvage. + close(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(0.0), n(10.0)]), 2100.0); + is_err(ev("VDB", vec![n(2400.0), n(300.0), n(10.0), n(5.0), n(4.0)]), CellError::Num); + close_tol(ev("AMORLINC", vec![n(2400.0), d(2008, 8, 19), d(2008, 12, 31), n(300.0), n(1.0), n(0.15), n(1.0)]), 360.0, 1e-9); + close(ev("AMORDEGRC", vec![n(2400.0), d(2008, 8, 19), d(2008, 12, 31), n(300.0), n(1.0), n(0.15), n(1.0)]), 776.0); +} + +#[test] +fn coupons() { + let args = |f: &str| ev(f, vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(1.0)]); + close(args("COUPDAYBS"), 71.0); + close(args("COUPDAYS"), 181.0); + close(args("COUPDAYSNC"), 110.0); + close(args("COUPNCD"), 40678.0); + close(args("COUPPCD"), 40497.0); + close(args("COUPNUM"), 2.0); + close(ev("COUPDAYS", vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(0.0)]), 180.0); + close(ev("COUPDAYS", vec![d(2011, 1, 25), d(2011, 11, 15), n(4.0), n(3.0)]), 91.25); + close(ev("COUPNUM", vec![d(2007, 1, 25), d(2008, 11, 15), n(2.0), n(1.0)]), 4.0); + is_err(ev("COUPNUM", vec![d(2012, 1, 25), d(2011, 11, 15), n(2.0)]), CellError::Num); + is_err(ev("COUPNUM", vec![d(2011, 1, 25), d(2011, 11, 15), n(3.0)]), CellError::Num); + is_err(ev("COUPNUM", vec![d(2011, 1, 25), d(2011, 11, 15), n(2.0), n(5.0)]), CellError::Num); + // End-of-month maturity keeps month-end coupon dates. + close(ev("COUPPCD", vec![d(2011, 3, 15), d(2011, 11, 30), n(2.0)]), serial_from_ymd(DateSystem::D1900, 2010, 11, 30).unwrap()); + close(ev("COUPNCD", vec![d(2011, 3, 15), d(2011, 11, 30), n(2.0)]), serial_from_ymd(DateSystem::D1900, 2011, 5, 31).unwrap()); +} + +#[test] +fn securities() { + close_tol(ev("DISC", vec![d(2007, 1, 25), d(2007, 6, 15), n(97.975), n(100.0), n(1.0)]), 0.052420213, 1e-8); + close_tol(ev("PRICEDISC", vec![d(2008, 2, 16), d(2008, 3, 1), n(0.0525), n(100.0), n(2.0)]), 99.79583333, 1e-9); + close_tol(ev("YIELDDISC", vec![d(2008, 2, 16), d(2008, 3, 1), n(99.795), n(100.0), n(2.0)]), 0.052823, 1e-5); + close_tol(ev("INTRATE", vec![d(2008, 2, 15), d(2008, 5, 15), n(1000000.0), n(1014420.0), n(2.0)]), 0.05768, 1e-9); + close_tol(ev("RECEIVED", vec![d(2008, 2, 15), d(2008, 5, 15), n(1000000.0), n(0.0575), n(2.0)]), 1014584.654, 1e-9); + is_err(ev("DISC", vec![d(2008, 2, 15), d(2008, 1, 15), n(97.0), n(100.0)]), CellError::Num); + close_tol(ev("TBILLPRICE", vec![d(2008, 3, 31), d(2008, 6, 1), n(0.09)]), 98.45, 1e-12); + close_tol(ev("TBILLYIELD", vec![d(2008, 3, 31), d(2008, 6, 1), n(98.45)]), 0.091417, 1e-6); + close_tol(ev("TBILLEQ", vec![d(2008, 3, 31), d(2008, 6, 1), n(0.0914)]), 0.094151, 1e-6); + is_err(ev("TBILLPRICE", vec![d(2008, 3, 31), d(2009, 6, 1), n(0.09)]), CellError::Num); + close_tol(ev("ACCRINTM", vec![d(2008, 4, 1), d(2008, 6, 15), n(0.1), n(1000.0), n(3.0)]), 20.54794521, 1e-9); + is_err(ev("ACCRINTM", vec![d(2008, 6, 15), d(2008, 4, 1), n(0.1)]), CellError::Num); + close_tol(ev("ACCRINT", vec![d(2008, 3, 1), d(2008, 8, 31), d(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(0.0)]), 16.66666667, 1e-9); + close_tol(ev("ACCRINT", vec![d(2008, 3, 5), d(2008, 8, 31), d(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(0.0), b(false)]), 15.55555556, 1e-9); + close_tol(ev("PRICEMAT", vec![d(2008, 2, 15), d(2008, 4, 13), d(2007, 11, 11), n(0.061), n(0.061), n(0.0)]), 99.98449888, 1e-9); + close_tol(ev("YIELDMAT", vec![d(2008, 3, 15), d(2008, 11, 3), d(2007, 11, 8), n(0.0625), n(100.0123), n(0.0)]), 0.060954, 1e-6); + close_tol(ev("PRICE", vec![d(2008, 2, 15), d(2017, 11, 15), n(0.0575), n(0.065), n(100.0), n(2.0), n(0.0)]), 94.63436, 1e-7); + close_tol(ev("YIELD", vec![d(2008, 2, 15), d(2016, 11, 15), n(0.0575), n(95.04287), n(100.0), n(2.0), n(0.0)]), 0.065, 1e-6); + is_err(ev("PRICE", vec![d(2008, 2, 15), d(2017, 11, 15), n(-0.01), n(0.065), n(100.0), n(2.0)]), CellError::Num); + close_tol(ev("DURATION", vec![d(2018, 7, 1), d(2048, 1, 1), n(0.08), n(0.09), n(2.0), n(1.0)]), 10.9191453, 1e-8); + close_tol(ev("MDURATION", vec![d(2008, 1, 1), d(2016, 1, 1), n(0.08), n(0.09), n(2.0), n(1.0)]), 5.73567, 1e-6); + // A one-coupon bond uses the closed form for yield; PRICE and YIELD invert each other. + let p = ev("PRICE", vec![d(2008, 2, 15), d(2008, 6, 15), n(0.05), n(0.04), n(100.0), n(2.0)]); + let pv = match p { + Value::Number(x) => x, + _ => panic!(), + }; + close_tol(ev("YIELD", vec![d(2008, 2, 15), d(2008, 6, 15), n(0.05), n(pv), n(100.0), n(2.0)]), 0.04, 1e-9); +} + +fn number(v: Value) -> f64 { + match v { + Value::Number(x) => x, + other => panic!("expected a number, got {other:?}"), + } +} + +#[test] +fn odd_periods() { + // The worked examples on Microsoft's function pages (results as printed there). + let first = |x: f64, rest: &[f64]| { + let mut a = vec![d(2008, 11, 11), d(2021, 3, 1), d(2008, 10, 15), d(2009, 3, 1), n(x)]; + a.extend(rest.iter().map(|v| n(*v))); + a + }; + close_tol(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 1.0])), 113.60, 0.005 / 113.6); + close_tol(ev("ODDFYIELD", first(0.0575, &[84.50, 100.0, 2.0, 0.0])), 0.0772, 0.00005); + let lp = vec![d(2008, 2, 7), d(2008, 6, 15), d(2007, 10, 15), n(0.0375), n(0.0405), n(100.0), n(2.0), n(0.0)]; + close_tol(ev("ODDLPRICE", lp), 99.88, 0.005 / 99.88); + let ly = vec![d(2008, 4, 20), d(2008, 6, 15), d(2007, 12, 24), n(0.0375), n(99.875), n(100.0), n(2.0), n(0.0)]; + close_tol(ev("ODDLYIELD", ly), 0.04519, 0.000005); + + // Yield → price → yield, for a short and a long odd first period and a long odd last period. + for (issue, basis) in [(d(2008, 10, 15), 0.0), (d(2007, 1, 20), 1.0), (d(2008, 1, 31), 3.0)] { + let args = |x: f64| vec![d(2008, 11, 11), d(2021, 3, 1), issue.clone(), d(2009, 3, 1), n(0.06), n(x), n(100.0), n(2.0), n(basis)]; + let p = number(ev("ODDFPRICE", args(0.05))); + close_tol(ev("ODDFYIELD", args(p)), 0.05, 1e-9); + } + let last = |x: f64| vec![d(2009, 2, 1), d(2009, 12, 15), d(2008, 3, 15), n(0.04), n(x), n(100.0), n(4.0), n(1.0)]; + let p = number(ev("ODDLPRICE", last(0.07))); + close_tol(ev("ODDLYIELD", last(p)), 0.07, 1e-9); + + // Argument checks: frequency, basis, date order, negative yield, non-positive price. + is_err(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 3.0, 1.0])), CellError::Num); + is_err(ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 5.0])), CellError::Num); + is_err(ev("ODDFPRICE", first(0.0785, &[-0.01, 100.0, 2.0, 1.0])), CellError::Num); + is_err(ev("ODDFYIELD", first(0.0575, &[0.0, 100.0, 2.0, 0.0])), CellError::Num); + is_err( + ev("ODDFPRICE", vec![d(2009, 4, 1), d(2021, 3, 1), d(2008, 10, 15), d(2009, 3, 1), n(0.07), n(0.06), n(100.0), n(2.0)]), + CellError::Num, + ); + is_err(ev("ODDLPRICE", vec![d(2007, 10, 1), d(2008, 6, 15), d(2007, 10, 15), n(0.0375), n(0.0405), n(100.0), n(2.0)]), CellError::Num); + is_err(ev("ODDLYIELD", vec![d(2008, 4, 20), d(2008, 6, 15), d(2007, 12, 24), n(0.0375), n(-1.0), n(100.0), n(2.0)]), CellError::Num); + is_err( + ev("ODDFPRICE", first(0.0785, &[0.0625, 100.0, 2.0, 1.0]).into_iter().take(4).chain([t("x"), n(0.06), n(100.0), n(2.0)]).collect()), + CellError::Value, + ); +} + +#[test] +fn odd_periods_survive_hostile_input() { + // Quarterly quasi-periods across the whole date range, huge rates and yields: a value or + // an error, never a panic or a hang. + let wide = |x: f64, y: f64| vec![n(2_957_000.0), n(2_958_465.0), n(400.0), n(2_957_900.0), n(x), n(y), n(1e300), n(4.0), n(1.0)]; + for (x, y) in [(0.05, 0.05), (1e300, 1e300), (0.0, 1e-300)] { + let _ = ev("ODDFPRICE", wide(x, y)); + let _ = ev("ODDFYIELD", wide(x, y)); + } + // The long walk itself works: settled early in an odd first period of ~32,000 quarters, + // a small yield round-trips. + let long = |y: f64| vec![n(500.0), n(2_958_465.0), n(400.0), n(2_957_900.0), n(0.05), n(y), n(100.0), n(4.0), n(1.0)]; + let p = number(ev("ODDFPRICE", long(0.0001))); + close_tol(ev("ODDFYIELD", long(p)), 0.0001, 1e-9); + let last = |x: f64, y: f64| vec![n(2_958_000.0), n(2_958_465.0), n(400.0), n(x), n(y), n(1e300), n(4.0), n(1.0)]; + for (x, y) in [(0.05, 0.05), (1e300, 1e300), (0.0, f64::MAX)] { + let _ = ev("ODDLPRICE", last(x, y)); + let _ = ev("ODDLYIELD", last(x, y)); + } + is_err(ev("ODDFPRICE", vec![n(-5.0), n(1e20), n(0.0), n(1e10), n(0.05), n(0.05), n(100.0), n(2.0)]), CellError::Num); +} + +#[test] +fn euroconvert() { + // The examples on Microsoft's EUROCONVERT page. + let ec = |x: f64, s: &str, tg: &str, rest: Vec| { + let mut a = vec![n(x), t(s), t(tg)]; + a.extend(rest); + ev("EUROCONVERT", a) + }; + close(ec(1.2, "DEM", "EUR", vec![]), 0.61); + close(ec(1.0, "FRF", "EUR", vec![b(true), n(3.0)]), 0.152); + close(ec(1.0, "FRF", "EUR", vec![b(false), n(3.0)]), 0.15); + close(ec(1.0, "FRF", "DEM", vec![b(true), n(3.0)]), 0.29728616); + // Euro to a member currency rounds to the target's calculation precision (lira: 0). + close(ec(10.0, "eur", "ITL", vec![]), 19363.0); + close(ec(100.0, "EUR", "IEP", vec![b(true)]), 78.7564); + // Same currency returns the number untouched. + close(ec(1.23456, "DEM", "DEM", vec![]), 1.23456); + // Unknown codes and a triangulation precision below 3 are #VALUE!. + is_err(ec(1.0, "USD", "EUR", vec![]), CellError::Value); + is_err(ec(1.0, "FRF", "DEM", vec![b(true), n(2.0)]), CellError::Value); + // Hostile magnitudes give a number or #NUM!, never a panic. + is_err(ec(f64::MAX, "EUR", "ITL", vec![]), CellError::Num); + close(ec(1.0, "FRF", "DEM", vec![b(true), n(1e300)]), 1.95583 / 6.55957); +} + +#[test] +fn lifting() { + let v = ev("SLN", vec![av(row(&[100.0, 200.0])), n(0.0), n(10.0)]); + assert_eq!(rows_of(&v), vec![vec![nv(10.0), nv(20.0)]]); + is_err(ev("PMT", vec![t("abc"), n(1.0), n(1.0)]), CellError::Value); + is_err(ev("PMT", vec![e(CellError::NA), n(1.0), n(1.0)]), CellError::NA); +} + +#[test] +fn securities_read_dates_in_the_1904_system() { + let d4 = |y, m, dd| n(serial_from_ymd(DateSystem::D1904, y, m, dd).unwrap()); + let ev4 = |name: &str, args: Vec| { + let mut ctx = TestCtx { sys: DateSystem::D1904, ..TestCtx::default() }; + crate::call(crate::lookup(name).unwrap(), &args, &mut ctx) + }; + let (s, m) = (d4(2011, 3, 15), d4(2011, 11, 30)); + close(ev4("COUPNCD", vec![s.clone(), m.clone(), n(2.0)]), 39232.0); + close(ev4("COUPPCD", vec![s.clone(), m.clone(), n(4.0), n(0.0)]), 39140.0); + close(ev4("COUPDAYBS", vec![s.clone(), m.clone(), n(4.0), n(0.0)]), 15.0); + let accrint = vec![d4(2008, 3, 1), d4(2008, 8, 31), d4(2008, 5, 1), n(0.1), n(1000.0), n(2.0), n(1.0)]; + close_tol(ev4("ACCRINT", accrint), 16.576086956522, 1e-9); + // Results that already matched Excel keep matching. + let semi = |name| ev4(name, vec![s.clone(), m.clone(), n(2.0), n(0.0)]); + close(semi("COUPPCD"), 39050.0); + close(semi("COUPDAYS"), 180.0); + close(semi("COUPDAYBS"), 105.0); + close(semi("COUPDAYSNC"), 75.0); + close(semi("COUPNUM"), 2.0); + let price = vec![s.clone(), m.clone(), n(0.0575), n(0.065), n(100.0), n(2.0), n(0.0)]; + close_tol(ev4("PRICE", price), 99.47768286339101, 1e-9); + close_tol(ev4("DURATION", vec![s, m, n(0.08), n(0.09), n(2.0), n(0.0)]), 0.6890136809022, 1e-9); + let odd = vec![d4(2008, 11, 11), d4(2021, 3, 1), d4(2008, 10, 15), d4(2009, 3, 1), n(0.0785), n(0.0625), n(100.0), n(2.0), n(1.0)]; + close_tol(ev4("ODDFPRICE", odd), 113.597717474079, 1e-9); +} From b3711f59ce152646a7f27aac60c3b911e7d20780 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:43 +0000 Subject: [PATCH 34/76] refactor(functions): move math tests to src/math/tests.rs --- crates/functions/src/math.rs | 495 +---------------------------- crates/functions/src/math/tests.rs | 492 ++++++++++++++++++++++++++++ 2 files changed, 493 insertions(+), 494 deletions(-) create mode 100644 crates/functions/src/math/tests.rs diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 9824da7bf..964af2e5e 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -1305,497 +1305,4 @@ pub(crate) fn specs() -> Vec { const NUM_RANGE_REV: &[bool] = &[false, true]; #[cfg(test)] -mod tests { - use super::*; - use crate::util::testutil::*; - use CellError::{Div0, NA, Num}; - - fn ev1(name: &str, x: f64) -> Value { - ev(name, vec![n(x)]) - } - fn ev2(name: &str, x: f64, y: f64) -> Value { - ev(name, vec![n(x), n(y)]) - } - - #[test] - fn percentof() { - // SUM(subset) / SUM(all): text, booleans and blanks inside ranges are skipped like SUM. - let all = arr(vec![vec![nv(10.0)], vec![nv(30.0)], vec![tv("x")], vec![Value::Empty], vec![nv(60.0)]]); - let part = arr(vec![vec![nv(10.0)], vec![nv(30.0)]]); - close(ev("PERCENTOF", vec![rf(part), rf(all.clone())]), 0.4); - close(ev("PERCENTOF", vec![n(25.0), n(200.0)]), 0.125); - is_err(ev("PERCENTOF", vec![n(1.0), rf(arr(vec![vec![nv(5.0), nv(-5.0)]]))]), Div0); - is_err(ev("PERCENTOF", vec![rf(arr(vec![vec![Value::Error(NA)]])), rf(all)]), NA); - is_err(ev("PERCENTOF", vec![t("abc"), n(1.0)]), CellError::Value); - } - - #[test] - fn sums() { - close(ev("SUM", vec![n(1.0), n(2.0), t("3"), b(true)]), 7.0); - let r = arr(vec![vec![nv(1.0), tv("x"), Value::Bool(true), Value::Empty, nv(4.0)]]); - close(ev("SUM", vec![rf(r.clone())]), 5.0); - is_err(ev("SUM", vec![t("abc")]), CellError::Value); - is_err(ev("SUM", vec![rf(arr(vec![vec![nv(1.0), Value::Error(Div0)]]))]), Div0); - close(ev("SUM", vec![rf(tv("abc"))]), 0.0); - close(ev("SUMSQ", vec![n(3.0), n(4.0)]), 25.0); - close(ev("PRODUCT", vec![rf(r), n(2.0)]), 8.0); - close(ev("PRODUCT", vec![rf(arr(vec![vec![tv("a")]]))]), 0.0); - let a1 = arr(vec![vec![nv(3.0), nv(4.0)], vec![nv(8.0), nv(6.0)], vec![nv(1.0), nv(9.0)]]); - let a2 = arr(vec![vec![nv(2.0), nv(7.0)], vec![nv(6.0), nv(7.0)], vec![nv(5.0), nv(3.0)]]); - close(ev("SUMPRODUCT", vec![rf(a1.clone()), rf(a2)]), 156.0); - close(ev("SUMPRODUCT", vec![rf(a1.clone())]), 31.0); - is_err(ev("SUMPRODUCT", vec![rf(a1), rf(row(&[1.0, 2.0]))]), CellError::Value); - close(ev("SUMPRODUCT", vec![av(row(&[1.0, 2.0])), av(arr(vec![vec![tv("x"), nv(3.0)]]))]), 6.0); - let x = row(&[2.0, 3.0, 9.0, 1.0, 8.0, 7.0, 5.0]); - let y = row(&[6.0, 5.0, 11.0, 7.0, 5.0, 4.0, 4.0]); - close(ev("SUMX2MY2", vec![av(x.clone()), av(y.clone())]), -55.0); - close(ev("SUMX2PY2", vec![av(x.clone()), av(y.clone())]), 521.0); - close(ev("SUMXMY2", vec![av(x.clone()), av(y)]), 79.0); - is_err(ev("SUMXMY2", vec![av(x), av(row(&[1.0]))]), NA); - } - - /// SUMPRODUCT cases observed in Excel (FormulaEvalTestData, row "Sumproduct"): scalars and - /// single-cell references multiply in, text in a scalar argument is `#VALUE!`, sizes must match. - #[test] - fn sumproduct_scalar_arguments() { - let cases: Vec<(Vec, Result)> = vec![ - (vec![n(3.0), rf(nv(534.0)), rf(nv(1.1))], Ok(1762.2)), - (vec![rf(nv(1.1)), n(3.0), rf(nv(534.0)), rf(nv(1.1))], Ok(1938.42)), - (vec![n(2.0), rf(col(&[1.00001, 2.99999]))], Err(CellError::Value)), - (vec![rf(tv("4")), rf(nv(2.0))], Err(CellError::Value)), - (vec![t("4"), n(2.0)], Err(CellError::Value)), - (vec![rf(nv(3.0)), rf(Value::Error(CellError::Ref))], Err(CellError::Ref)), - (vec![av(arr(vec![vec![tv("4")]])), n(2.0)], Ok(0.0)), - ]; - for (args, want) in cases { - match want { - Ok(x) => close(ev("SUMPRODUCT", args), x), - Err(err) => is_err(ev("SUMPRODUCT", args), err), - } - } - } - - #[test] - fn rounding() { - close(ev2("ROUND", 2.675, 2.0), 2.68); - close(ev2("ROUND", -1.5, 0.0), -2.0); - close(ev2("ROUND", 1234.567, -2.0), 1200.0); - close(ev2("ROUND", 2.15, 1.0), 2.2); - close(ev2("ROUNDUP", 3.2, 0.0), 4.0); - close(ev2("ROUNDUP", -3.14259, 1.0), -3.2); - close(ev2("ROUNDUP", 31415.92654, -2.0), 31500.0); - close(ev2("ROUNDDOWN", -3.9, 0.0), -3.0); - close(ev2("ROUNDDOWN", 2.71989, 3.0), 2.719); - close(ev2("ROUNDDOWN", 31415.92654, -2.0), 31400.0); - for digits in [-308.0, -309.0, -400.0] { - for x in [1234.0, -1234.0, 0.5] { - close(ev2("ROUNDDOWN", x, digits), 0.0); - close(ev2("TRUNC", x, digits), 0.0); - } - } - assert!(matches!(ev2("ROUNDUP", 1234.0, -309.0), Value::Error(CellError::Num))); - close(ev2("ROUNDUP", 0.1 + 0.2, 1.0), 0.3); - close(ev2("TRUNC", 8.987, 2.0), 8.98); - close(ev1("TRUNC", -8.9), -8.0); - close(ev1("INT", -8.9), -9.0); - close(ev1("INT", 8.9), 8.0); - close(ev2("MROUND", 10.0, 3.0), 9.0); - close(ev2("MROUND", -10.0, -3.0), -9.0); - close(ev2("MROUND", 1.3, 0.2), 1.4); - is_err(ev2("MROUND", 5.0, -2.0), Num); - close(ev2("CEILING", 2.5, 1.0), 3.0); - close(ev2("CEILING", -2.5, -2.0), -4.0); - close(ev2("CEILING", -2.5, 2.0), -2.0); - close(ev2("CEILING", 1.5, 0.1), 1.5); - close(ev2("CEILING", 0.234, 0.01), 0.24); - is_err(ev2("CEILING", 2.5, -1.0), Num); - close(ev2("FLOOR", 3.7, 2.0), 2.0); - close(ev2("FLOOR", -2.5, -2.0), -2.0); - close(ev2("FLOOR", -2.5, 2.0), -4.0); - is_err(ev2("FLOOR", 2.5, -2.0), Num); - is_err(ev2("FLOOR", 2.5, 0.0), Div0); - close(ev2("FLOOR", 1.58, 0.1), 1.5); - close(ev2("FLOOR", 0.234, 0.01), 0.23); - close(ev2("CEILING.MATH", 24.3, 5.0), 25.0); - close(ev1("CEILING.MATH", 6.7), 7.0); - close(ev2("CEILING.MATH", -8.1, 2.0), -8.0); - close(ev("CEILING.MATH", vec![n(-5.5), n(2.0), n(-1.0)]), -6.0); - close(ev2("FLOOR.MATH", 24.3, 5.0), 20.0); - close(ev1("FLOOR.MATH", 6.7), 6.0); - close(ev2("FLOOR.MATH", -8.1, 2.0), -10.0); - close(ev("FLOOR.MATH", vec![n(-5.5), n(2.0), n(-1.0)]), -4.0); - close(ev2("CEILING.PRECISE", -4.6, 2.0), -4.0); - close(ev2("CEILING.PRECISE", 4.3, -2.0), 6.0); - close(ev2("ISO.CEILING", 4.3, 0.0), 0.0); - close(ev2("FLOOR.PRECISE", -3.2, -1.0), -4.0); - close(ev1("FLOOR.PRECISE", 3.2), 3.0); - close(ev1("EVEN", 1.5), 2.0); - close(ev1("EVEN", 3.0), 4.0); - close(ev1("EVEN", 2.0), 2.0); - close(ev1("EVEN", -1.0), -2.0); - close(ev1("EVEN", 0.0), 0.0); - close(ev1("ODD", 1.5), 3.0); - close(ev1("ODD", 3.0), 3.0); - close(ev1("ODD", 2.0), 3.0); - close(ev1("ODD", -1.0), -1.0); - close(ev1("ODD", -2.0), -3.0); - close(ev1("ODD", 0.0), 1.0); - } - - #[test] - fn mod_tiny_divisor_stays_in_range() { - for (x, d) in [(3.0, 1e-300), (-1.0, 1e-300), (3.0, -1e-300), (-1.0, -1e-300)] { - match ev2("MOD", x, d) { - Value::Number(r) => { - assert!(r == 0.0 || (r < 0.0) == (d < 0.0), "MOD({x}, {d}) = {r}"); - assert!(r.abs() < d.abs(), "MOD({x}, {d}) = {r}"); - } - other => panic!("MOD({x}, {d}) = {other:?}"), - } - } - } - - #[test] - fn arithmetic() { - close(ev2("MOD", 3.0, 2.0), 1.0); - close(ev2("MOD", -3.0, 2.0), 1.0); - close(ev2("MOD", 3.0, -2.0), -1.0); - close(ev2("MOD", -3.0, -2.0), -1.0); - close(ev2("MOD", 5.5, 1.0), 0.5); - is_err(ev2("MOD", 1.0, 0.0), Div0); - close(ev2("QUOTIENT", 5.0, 2.0), 2.0); - close(ev2("QUOTIENT", -10.0, 3.0), -3.0); - is_err(ev2("QUOTIENT", 1.0, 0.0), Div0); - close(ev1("ABS", -4.0), 4.0); - close(ev1("SIGN", -0.0001), -1.0); - close(ev1("SIGN", 0.0), 0.0); - close(ev1("SQRT", 16.0), 4.0); - is_err(ev1("SQRT", -16.0), Num); - close(ev1("SQRTPI", 1.0), 1.772_453_850_905_516); - close(ev2("POWER", 5.0, 2.0), 25.0); - close(ev2("POWER", 4.0, 1.25), 5.656_854_249_492_381); - close(ev2("POWER", -2.0, 3.0), -8.0); - is_err(ev2("POWER", 0.0, 0.0), Num); - is_err(ev2("POWER", 0.0, -1.0), Div0); - is_err(ev2("POWER", -8.0, 1.0 / 3.0), Num); - is_err(ev2("POWER", 10.0, 400.0), Num); - close(ev1("EXP", 1.0), std::f64::consts::E); - close(ev2("LOG", 8.0, 2.0), 3.0); - close(ev1("LOG", 10.0), 1.0); - close(ev2("LOG", 81.0, 3.0), 4.0); - is_err(ev1("LN", 0.0), Num); - is_err(ev2("LOG", 10.0, 1.0), Div0); - is_err(ev1("LOG10", -1.0), Num); - close(ev1("LN", std::f64::consts::E), 1.0); - close(ev1("LOG10", 1e5), 5.0); - close(ev("PI", vec![]), PI); - close(ev("SQRT", vec![t("16")]), 4.0); - is_err(ev("SQRT", vec![t("x")]), CellError::Value); - } - - #[test] - fn combinatorics() { - close(ev1("FACT", 5.0), 120.0); - close(ev1("FACT", 1.9), 1.0); - close(ev1("FACT", 0.0), 1.0); - is_err(ev1("FACT", -1.0), Num); - is_err(ev1("FACT", 171.0), Num); - close(ev1("FACTDOUBLE", 6.0), 48.0); - close(ev1("FACTDOUBLE", 7.0), 105.0); - close(ev1("FACTDOUBLE", -1.0), 1.0); - is_err(ev1("FACTDOUBLE", -2.0), Num); - close(ev2("COMBIN", 8.0, 2.0), 28.0); - close(ev2("COMBIN", 8.9, 2.9), 28.0); - is_err(ev2("COMBIN", 2.0, 3.0), Num); - close(ev2("COMBIN", 60.0, 30.0), 118_264_581_564_861_424.0); - close(ev2("COMBINA", 4.0, 3.0), 20.0); - close(ev2("COMBINA", 10.0, 3.0), 220.0); - close(ev2("PERMUT", 100.0, 3.0), 970_200.0); - close(ev2("PERMUT", 3.0, 2.0), 6.0); - is_err(ev2("PERMUT", 2.0, 3.0), Num); - close(ev2("PERMUTATIONA", 3.0, 2.0), 9.0); - close(ev2("GCD", 24.0, 36.0), 12.0); - close(ev2("GCD", 5.0, 0.0), 5.0); - close(ev2("GCD", 7.0, 1.0), 1.0); - is_err(ev2("GCD", -1.0, 5.0), Num); - close(ev("GCD", vec![av(row(&[12.0, 18.0, 30.0]))]), 6.0); - close(ev2("LCM", 24.0, 36.0), 72.0); - close(ev2("LCM", 5.0, 2.0), 10.0); - close(ev2("LCM", 5.0, 0.0), 0.0); - close(ev("MULTINOMIAL", vec![n(2.0), n(3.0), n(4.0)]), 1260.0); - is_err(ev("MULTINOMIAL", vec![n(-1.0), n(2.0)]), Num); - } - - #[test] - fn trig() { - close(ev1("SIN", PI / 2.0), 1.0); - close(ev1("COS", 0.0), 1.0); - close(ev1("TAN", PI / 4.0), 1.0); - is_err(ev1("SIN", 1e10), Num); - close(ev1("ASIN", 1.0), PI / 2.0); - is_err(ev1("ACOS", 1.5), Num); - close(ev1("ACOT", 2.0), 0.463_647_609_000_806); - close(ev1("ACOTH", 6.0), 0.168_236_118_310_606); - is_err(ev1("ACOTH", 0.5), Num); - close(ev1("COT", 30.0), -0.156_119_952_161_659); - is_err(ev1("COT", 0.0), Div0); - close(ev1("COTH", 2.0), 1.037_314_720_727_548); - close(ev1("CSC", 15.0), 1.537_780_562_208_209); - close(ev1("CSCH", 1.5), 0.469_642_440_595_653); - close(ev1("SEC", 45.0), 1.903_594_407_014_6); - close_tol(ev1("SECH", 45.0), 5.725_037_161_098_787e-20, 1e-9); - close(ev2("ATAN2", 1.0, 1.0), PI / 4.0); - close(ev2("ATAN2", -1.0, -1.0), -3.0 * PI / 4.0); - is_err(ev2("ATAN2", 0.0, 0.0), Div0); - close(ev1("ASINH", -2.5), -1.647_231_146_371_096); - close(ev1("ACOSH", 10.0), 2.993_222_846_126_381); - is_err(ev1("ACOSH", 0.5), Num); - close(ev1("ATANH", 0.5), 0.549_306_144_334_055); - is_err(ev1("ATANH", 1.0), Num); - close(ev1("SINH", 1.0), 1.175_201_193_643_801); - close(ev1("DEGREES", PI), 180.0); - close(ev1("RADIANS", 270.0), 4.712_388_980_384_69); - } - - #[test] - fn number_systems() { - is_text(ev1("ROMAN", 499.0), "CDXCIX"); - is_text(ev2("ROMAN", 499.0, 1.0), "LDVLIV"); - is_text(ev2("ROMAN", 499.0, 2.0), "XDIX"); - is_text(ev2("ROMAN", 499.0, 3.0), "VDIV"); - is_text(ev2("ROMAN", 499.0, 4.0), "ID"); - is_text(ev("ROMAN", vec![n(499.0), b(false)]), "ID"); - is_text(ev1("ROMAN", 1999.0), "MCMXCIX"); - is_text(ev1("ROMAN", 3999.0), "MMMCMXCIX"); - is_text(ev1("ROMAN", 0.0), ""); - is_err(ev1("ROMAN", 4000.0), CellError::Value); - is_err(ev1("ROMAN", -1.0), CellError::Value); - close(ev("ARABIC", vec![t("LVII")]), 57.0); - close(ev("ARABIC", vec![t("mcmxii")]), 1912.0); - close(ev("ARABIC", vec![t("")]), 0.0); - close(ev("ARABIC", vec![t("-XIV")]), -14.0); - is_err(ev("ARABIC", vec![t("ABC")]), CellError::Value); - is_text(ev2("BASE", 7.0, 2.0), "111"); - is_text(ev2("BASE", 100.0, 16.0), "64"); - is_text(ev("BASE", vec![n(15.0), n(2.0), n(10.0)]), "0000001111"); - is_text(ev2("BASE", 0.0, 36.0), "0"); - is_err(ev2("BASE", 7.0, 1.0), Num); - is_err(ev2("BASE", -7.0, 2.0), Num); - close(ev("DECIMAL", vec![t("FF"), n(16.0)]), 255.0); - close(ev("DECIMAL", vec![t("111"), n(2.0)]), 7.0); - close(ev("DECIMAL", vec![t("zap"), n(36.0)]), 45745.0); - is_err(ev("DECIMAL", vec![t("12"), n(2.0)]), Num); - } - - /// Every number where Excel's forms 2 and 3 differ from a plain ratio limit (issue #733): - /// form 2 subtracts I from C and X from M, form 3 also V from M. - #[test] - fn roman_forms_two_and_three_match_excel() { - const HUNDREDS: [&str; 10] = ["", "C", "CC", "CCC", "CD", "D", "DC", "DCC", "DCCC", "CM"]; - const ONES: [&str; 10] = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"]; - let mut cases = Vec::new(); - for thousands in 0..4usize { - let m = "M".repeat(thousands); - for (h, hundreds) in HUNDREDS.iter().enumerate() { - if h != 4 && h != 9 { - cases.push((thousands * 1000 + h * 100 + 99, 2.0, format!("{m}{hundreds}IC"))); - } - } - for (o, ones) in ONES.iter().enumerate() { - cases.push((thousands * 1000 + 990 + o, 2.0, format!("{m}XM{ones}"))); - } - for (o, ones) in ONES.iter().enumerate().take(5) { - cases.push((thousands * 1000 + 995 + o, 3.0, format!("{m}VM{ones}"))); - } - } - assert_eq!(cases.len(), 92); - for (number, form, want) in &cases { - is_text(ev2("ROMAN", *number as f64, *form), want); - } - // Neighbours that already matched: form 2 still has no VD or ID, form 3 no ID. - is_text(ev2("ROMAN", 495.0, 2.0), "XDV"); - is_text(ev2("ROMAN", 495.0, 3.0), "VD"); - is_text(ev2("ROMAN", 1499.0, 2.0), "MXDIX"); - is_text(ev2("ROMAN", 999.0, 0.0), "CMXCIX"); - is_text(ev2("ROMAN", 999.0, 1.0), "LMVLIV"); - is_text(ev2("ROMAN", 999.0, 4.0), "IM"); - is_text(ev2("ROMAN", 995.0, 4.0), "VM"); - is_text(ev2("ROMAN", 99.0, 1.0), "VCIV"); - is_text(ev2("ROMAN", 99.0, 4.0), "IC"); - } - - #[test] - fn minverse_scale_relative() { - let big = ev("MINVERSE", vec![av(arr(vec![vec![nv(1e20), nv(0.0)], vec![nv(0.0), nv(1e20)]]))]); - let r = rows_of(&big); - close_tol(r[0][0].clone(), 1e-20, 1e-25); - close_tol(r[1][1].clone(), 1e-20, 1e-25); - let small = ev("MINVERSE", vec![av(arr(vec![vec![nv(1e-20), nv(0.0)], vec![nv(0.0), nv(1e-20)]]))]); - let r = rows_of(&small); - close_tol(r[0][0].clone(), 1e20, 1e10); - } - - #[test] - fn matrices() { - let a = arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(3.0), nv(4.0)]]); - let b2 = arr(vec![vec![nv(5.0), nv(6.0)], vec![nv(7.0), nv(8.0)]]); - assert_eq!(rows_of(&ev("MMULT", vec![av(a.clone()), av(b2)])), vec![vec![nv(19.0), nv(22.0)], vec![nv(43.0), nv(50.0)]]); - is_err(ev("MMULT", vec![av(a.clone()), av(row(&[1.0, 2.0]))]), CellError::Value); - is_err(ev("MMULT", vec![av(arr(vec![vec![tv("x")]])), av(row(&[1.0]))]), CellError::Value); - close(ev("MDETERM", vec![av(a.clone())]), -2.0); - let m3 = arr(vec![vec![nv(3.0), nv(6.0), nv(1.0)], vec![nv(1.0), nv(1.0), nv(0.0)], vec![nv(3.0), nv(10.0), nv(2.0)]]); - close(ev("MDETERM", vec![av(m3)]), 1.0); - let m4 = arr(vec![ - vec![nv(1.0), nv(3.0), nv(8.0), nv(5.0)], - vec![nv(1.0), nv(3.0), nv(6.0), nv(1.0)], - vec![nv(1.0), nv(1.0), nv(1.0), nv(0.0)], - vec![nv(7.0), nv(3.0), nv(10.0), nv(2.0)], - ]); - close(ev("MDETERM", vec![av(m4)]), 88.0); - is_err(ev("MDETERM", vec![av(row(&[1.0, 2.0]))]), CellError::Value); - let inv = ev("MINVERSE", vec![av(arr(vec![vec![nv(4.0), nv(-1.0)], vec![nv(2.0), nv(0.0)]]))]); - let r = rows_of(&inv); - close(r[0][0].clone(), 0.0); - close(r[0][1].clone(), 0.5); - close(r[1][0].clone(), -1.0); - close(r[1][1].clone(), 2.0); - is_err(ev("MINVERSE", vec![av(arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(2.0), nv(4.0)]]))]), Num); - assert_eq!(rows_of(&ev1("MUNIT", 2.0)), vec![vec![nv(1.0), nv(0.0)], vec![nv(0.0), nv(1.0)]]); - is_err(ev1("MUNIT", 0.0), CellError::Value); - is_err(ev1("MUNIT", 1e6), Num); - let coeffs = row(&[1.0, -0.5, 1.0 / 24.0, -1.0 / 720.0]); - close_tol(ev("SERIESSUM", vec![n(PI / 4.0), n(0.0), n(2.0), av(coeffs)]), 0.707_103_214_823, 1e-9); - } - - #[test] - fn random() { - let mut ctx = TestCtx::default(); - let spec = crate::lookup("RAND").unwrap(); - assert!(spec.volatile); - for _ in 0..50 { - let v = crate::call(spec, &[], &mut ctx); - let x = v.as_f64().unwrap(); - assert!((0.0..1.0).contains(&x)); - } - let spec = crate::lookup("RANDBETWEEN").unwrap(); - for _ in 0..50 { - let x = crate::call(spec, &[n(1.0), n(6.0)], &mut ctx).as_f64().unwrap(); - assert!((1.0..=6.0).contains(&x) && x.fract() == 0.0); - } - is_err(ev2("RANDBETWEEN", 5.0, 1.0), Num); - } - - #[test] - fn lifting() { - assert_eq!(rows_of(&ev("ABS", vec![av(row(&[-1.0, 2.0, -3.0]))])), vec![vec![nv(1.0), nv(2.0), nv(3.0)]]); - let v = ev("POWER", vec![av(col(&[1.0, 2.0])), av(row(&[2.0, 3.0]))]); - assert_eq!(rows_of(&v), vec![vec![nv(1.0), nv(1.0)], vec![nv(4.0), nv(8.0)]]); - close(ev("ROUND", vec![av(arr(vec![vec![nv(1.25)]])), n(1.0)]), 1.3); - } - - fn data() -> (Value, Value) { - let names = arr(vec![vec![tv("apple")], vec![tv("banana")], vec![tv("Apple")], vec![Value::Empty], vec![tv("cherry")]]); - let qty = col(&[10.0, 20.0, 30.0, 40.0, 50.0]); - (names, qty) - } - - #[test] - fn criteria_functions() { - let (names, qty) = data(); - close(ev("COUNTIF", vec![rf(names.clone()), t("apple")]), 2.0); - close(ev("COUNTIF", vec![rf(names.clone()), t("a*")]), 2.0); - close(ev("COUNTIF", vec![rf(names.clone()), t("<>apple")]), 3.0); - close(ev("COUNTIF", vec![rf(names.clone()), t("")]), 1.0); - close(ev("COUNTIF", vec![rf(qty.clone()), t(">20")]), 3.0); - close(ev("COUNTIF", vec![rf(qty.clone()), n(30.0)]), 1.0); - let multi = ev("COUNTIF", vec![rf(names.clone()), av(arr(vec![vec![tv("apple"), tv("cherry")]]))]); - assert_eq!(rows_of(&multi), vec![vec![nv(2.0), nv(1.0)]]); - close(ev("SUMIF", vec![rf(names.clone()), t("apple"), rf(qty.clone())]), 40.0); - close(ev("SUMIF", vec![rf(qty.clone()), t(">=30")]), 120.0); - close(ev("SUMIF", vec![rf(names.clone()), t("?????"), rf(col(&[1.0]))]), 1.0); - close(ev("AVERAGEIF", vec![rf(qty.clone()), t(">20")]), 40.0); - is_err(ev("AVERAGEIF", vec![rf(qty.clone()), t(">100")]), Div0); - close(ev("SUMIFS", vec![rf(qty.clone()), rf(names.clone()), t("apple"), rf(qty.clone()), t(">15")]), 30.0); - close(ev("COUNTIFS", vec![rf(names.clone()), t("*a*"), rf(qty.clone()), t("<25")]), 2.0); - close(ev("AVERAGEIFS", vec![rf(qty.clone()), rf(names.clone()), t("<>banana")]), 32.5); - close(ev("MAXIFS", vec![rf(qty.clone()), rf(names.clone()), t("apple")]), 30.0); - close(ev("MINIFS", vec![rf(qty.clone()), rf(names.clone()), t("apple")]), 10.0); - close(ev("MAXIFS", vec![rf(qty.clone()), rf(names.clone()), t("zzz")]), 0.0); - is_err(ev("SUMIFS", vec![rf(qty.clone()), rf(row(&[1.0, 2.0])), t("1")]), CellError::Value); - is_err(ev("COUNTIFS", vec![rf(qty.clone()), t(">1"), rf(qty)]), CellError::Value); - is_err(ev("SUMIF", vec![rf(col(&[1.0, 2.0])), t(">0"), rf(arr(vec![vec![Value::Error(NA)], vec![nv(1.0)]]))]), NA); - } - - #[test] - fn criteria_over_255_chars_are_value_errors_with_one_pair_and_truncated_with_more() { - let ok = "x".repeat(255); - let long = "x".repeat(256); - let cells = arr(vec![vec![tv(&ok)], vec![tv(&long)]]); - let qty = col(&[1.0, 2.0]); - close(ev("COUNTIF", vec![rf(cells.clone()), t(&ok)]), 1.0); - is_err(ev("COUNTIF", vec![rf(cells.clone()), t(&long)]), CellError::Value); - close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&ok)]), 1.0); - is_err(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long)]), CellError::Value); - is_err(ev("COUNTIFS", vec![rf(cells.clone()), t(&long)]), CellError::Value); - // With two or more pairs Excel matches the first 255 characters: the 255-character cell - // (quantity 1), not the 256-character one (quantity 2). Checked on Excel 2016 and 365. - let over = format!("{ok}y"); - close(ev("COUNTIFS", vec![rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); - close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); - close(ev("SUMIFS", vec![rf(qty.clone()), rf(qty.clone()), t(">0"), rf(cells.clone()), t(&over)]), 1.0); - close(ev("AVERAGEIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); - close(ev("MAXIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&"x".repeat(300)), rf(qty), t(">0")]), 1.0); - } - - #[test] - fn criteria_over_255_utf16_units_follow_excel() { - // The tester's sheet, identical on Excel 2016 and Microsoft 365: A1:A6 hold these - // strings, B is the row number, and each row uses its own string as the criterion. - let e = "😀"; - let strings = - [e.repeat(127), e.repeat(128), format!("{}{e}", "a".repeat(254)), format!("{}{e}", "a".repeat(253)), e.repeat(200), "я".repeat(256)]; - let cells = arr(strings.iter().map(|s| vec![tv(s)]).collect()); - let rows = col(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); - // COUNTIF, SUMIF, COUNTIFS with one pair, SUMIFS with a second pair `B,">0"`. - let expected: [Option<[f64; 4]>; 6] = [Some([1.0, 1.0, 1.0, 1.0]), None, None, Some([1.0, 4.0, 1.0, 4.0]), None, None]; - for (s, want) in strings.iter().zip(expected) { - let countif = ev("COUNTIF", vec![rf(cells.clone()), t(s)]); - let sumif = ev("SUMIF", vec![rf(cells.clone()), t(s), rf(rows.clone())]); - let countifs = ev("COUNTIFS", vec![rf(cells.clone()), t(s)]); - let sumifs = ev("SUMIFS", vec![rf(rows.clone()), rf(cells.clone()), t(s), rf(rows.clone()), t(">0")]); - match want { - Some([a, b, c, d]) => { - close(countif, a); - close(sumif, b); - close(countifs, c); - close(sumifs, d); - } - // Over 255 units: `#VALUE!` with one pair; cut to 255 units with two, which - // matches no cell (for emoji it splits a surrogate pair). - None => { - is_err(countif, CellError::Value); - is_err(sumif, CellError::Value); - is_err(countifs, CellError::Value); - close(sumifs, 0.0); - } - } - } - } - - #[test] - fn criteria_cut_inside_a_surrogate_pair() { - let e = "😀"; - let cells = arr(vec![vec![tv(&e.repeat(127))], vec![tv(&e.repeat(128))], vec![tv("zzz")], vec![Value::Empty]]); - let rows = col(&[1.0, 2.0, 3.0, 4.0]); - let sumifs = |crit: String| ev("SUMIFS", vec![rf(rows.clone()), rf(cells.clone()), t(&crit), rf(rows.clone()), t(">0")]); - // "=" + 😀×128 is 257 units: the cut after 255 keeps "=" and 127 whole emoji. - close(sumifs(format!("={}", e.repeat(128))), 1.0); - // With a two-unit prefix the cut keeps 126 emoji and half of the 127th. Such a criterion - // equals no cell text: "=" matches nothing and "<>" everything, blanks included. - close(sumifs(format!("=a{}", e.repeat(128))), 0.0); - close(sumifs(format!("<>{}", e.repeat(128))), 10.0); - // Comparisons treat it as sorting just above the 126 kept emoji. - close(sumifs(format!(">={}", e.repeat(128))), 1.0 + 2.0); - close(sumifs(format!("<={}", e.repeat(128))), 3.0); - } -} +mod tests; diff --git a/crates/functions/src/math/tests.rs b/crates/functions/src/math/tests.rs new file mode 100644 index 000000000..14f72d1e1 --- /dev/null +++ b/crates/functions/src/math/tests.rs @@ -0,0 +1,492 @@ +use super::*; +use crate::util::testutil::*; +use CellError::{Div0, NA, Num}; + +fn ev1(name: &str, x: f64) -> Value { + ev(name, vec![n(x)]) +} +fn ev2(name: &str, x: f64, y: f64) -> Value { + ev(name, vec![n(x), n(y)]) +} + +#[test] +fn percentof() { + // SUM(subset) / SUM(all): text, booleans and blanks inside ranges are skipped like SUM. + let all = arr(vec![vec![nv(10.0)], vec![nv(30.0)], vec![tv("x")], vec![Value::Empty], vec![nv(60.0)]]); + let part = arr(vec![vec![nv(10.0)], vec![nv(30.0)]]); + close(ev("PERCENTOF", vec![rf(part), rf(all.clone())]), 0.4); + close(ev("PERCENTOF", vec![n(25.0), n(200.0)]), 0.125); + is_err(ev("PERCENTOF", vec![n(1.0), rf(arr(vec![vec![nv(5.0), nv(-5.0)]]))]), Div0); + is_err(ev("PERCENTOF", vec![rf(arr(vec![vec![Value::Error(NA)]])), rf(all)]), NA); + is_err(ev("PERCENTOF", vec![t("abc"), n(1.0)]), CellError::Value); +} + +#[test] +fn sums() { + close(ev("SUM", vec![n(1.0), n(2.0), t("3"), b(true)]), 7.0); + let r = arr(vec![vec![nv(1.0), tv("x"), Value::Bool(true), Value::Empty, nv(4.0)]]); + close(ev("SUM", vec![rf(r.clone())]), 5.0); + is_err(ev("SUM", vec![t("abc")]), CellError::Value); + is_err(ev("SUM", vec![rf(arr(vec![vec![nv(1.0), Value::Error(Div0)]]))]), Div0); + close(ev("SUM", vec![rf(tv("abc"))]), 0.0); + close(ev("SUMSQ", vec![n(3.0), n(4.0)]), 25.0); + close(ev("PRODUCT", vec![rf(r), n(2.0)]), 8.0); + close(ev("PRODUCT", vec![rf(arr(vec![vec![tv("a")]]))]), 0.0); + let a1 = arr(vec![vec![nv(3.0), nv(4.0)], vec![nv(8.0), nv(6.0)], vec![nv(1.0), nv(9.0)]]); + let a2 = arr(vec![vec![nv(2.0), nv(7.0)], vec![nv(6.0), nv(7.0)], vec![nv(5.0), nv(3.0)]]); + close(ev("SUMPRODUCT", vec![rf(a1.clone()), rf(a2)]), 156.0); + close(ev("SUMPRODUCT", vec![rf(a1.clone())]), 31.0); + is_err(ev("SUMPRODUCT", vec![rf(a1), rf(row(&[1.0, 2.0]))]), CellError::Value); + close(ev("SUMPRODUCT", vec![av(row(&[1.0, 2.0])), av(arr(vec![vec![tv("x"), nv(3.0)]]))]), 6.0); + let x = row(&[2.0, 3.0, 9.0, 1.0, 8.0, 7.0, 5.0]); + let y = row(&[6.0, 5.0, 11.0, 7.0, 5.0, 4.0, 4.0]); + close(ev("SUMX2MY2", vec![av(x.clone()), av(y.clone())]), -55.0); + close(ev("SUMX2PY2", vec![av(x.clone()), av(y.clone())]), 521.0); + close(ev("SUMXMY2", vec![av(x.clone()), av(y)]), 79.0); + is_err(ev("SUMXMY2", vec![av(x), av(row(&[1.0]))]), NA); +} + +/// SUMPRODUCT cases observed in Excel (FormulaEvalTestData, row "Sumproduct"): scalars and +/// single-cell references multiply in, text in a scalar argument is `#VALUE!`, sizes must match. +#[test] +fn sumproduct_scalar_arguments() { + let cases: Vec<(Vec, Result)> = vec![ + (vec![n(3.0), rf(nv(534.0)), rf(nv(1.1))], Ok(1762.2)), + (vec![rf(nv(1.1)), n(3.0), rf(nv(534.0)), rf(nv(1.1))], Ok(1938.42)), + (vec![n(2.0), rf(col(&[1.00001, 2.99999]))], Err(CellError::Value)), + (vec![rf(tv("4")), rf(nv(2.0))], Err(CellError::Value)), + (vec![t("4"), n(2.0)], Err(CellError::Value)), + (vec![rf(nv(3.0)), rf(Value::Error(CellError::Ref))], Err(CellError::Ref)), + (vec![av(arr(vec![vec![tv("4")]])), n(2.0)], Ok(0.0)), + ]; + for (args, want) in cases { + match want { + Ok(x) => close(ev("SUMPRODUCT", args), x), + Err(err) => is_err(ev("SUMPRODUCT", args), err), + } + } +} + +#[test] +fn rounding() { + close(ev2("ROUND", 2.675, 2.0), 2.68); + close(ev2("ROUND", -1.5, 0.0), -2.0); + close(ev2("ROUND", 1234.567, -2.0), 1200.0); + close(ev2("ROUND", 2.15, 1.0), 2.2); + close(ev2("ROUNDUP", 3.2, 0.0), 4.0); + close(ev2("ROUNDUP", -3.14259, 1.0), -3.2); + close(ev2("ROUNDUP", 31415.92654, -2.0), 31500.0); + close(ev2("ROUNDDOWN", -3.9, 0.0), -3.0); + close(ev2("ROUNDDOWN", 2.71989, 3.0), 2.719); + close(ev2("ROUNDDOWN", 31415.92654, -2.0), 31400.0); + for digits in [-308.0, -309.0, -400.0] { + for x in [1234.0, -1234.0, 0.5] { + close(ev2("ROUNDDOWN", x, digits), 0.0); + close(ev2("TRUNC", x, digits), 0.0); + } + } + assert!(matches!(ev2("ROUNDUP", 1234.0, -309.0), Value::Error(CellError::Num))); + close(ev2("ROUNDUP", 0.1 + 0.2, 1.0), 0.3); + close(ev2("TRUNC", 8.987, 2.0), 8.98); + close(ev1("TRUNC", -8.9), -8.0); + close(ev1("INT", -8.9), -9.0); + close(ev1("INT", 8.9), 8.0); + close(ev2("MROUND", 10.0, 3.0), 9.0); + close(ev2("MROUND", -10.0, -3.0), -9.0); + close(ev2("MROUND", 1.3, 0.2), 1.4); + is_err(ev2("MROUND", 5.0, -2.0), Num); + close(ev2("CEILING", 2.5, 1.0), 3.0); + close(ev2("CEILING", -2.5, -2.0), -4.0); + close(ev2("CEILING", -2.5, 2.0), -2.0); + close(ev2("CEILING", 1.5, 0.1), 1.5); + close(ev2("CEILING", 0.234, 0.01), 0.24); + is_err(ev2("CEILING", 2.5, -1.0), Num); + close(ev2("FLOOR", 3.7, 2.0), 2.0); + close(ev2("FLOOR", -2.5, -2.0), -2.0); + close(ev2("FLOOR", -2.5, 2.0), -4.0); + is_err(ev2("FLOOR", 2.5, -2.0), Num); + is_err(ev2("FLOOR", 2.5, 0.0), Div0); + close(ev2("FLOOR", 1.58, 0.1), 1.5); + close(ev2("FLOOR", 0.234, 0.01), 0.23); + close(ev2("CEILING.MATH", 24.3, 5.0), 25.0); + close(ev1("CEILING.MATH", 6.7), 7.0); + close(ev2("CEILING.MATH", -8.1, 2.0), -8.0); + close(ev("CEILING.MATH", vec![n(-5.5), n(2.0), n(-1.0)]), -6.0); + close(ev2("FLOOR.MATH", 24.3, 5.0), 20.0); + close(ev1("FLOOR.MATH", 6.7), 6.0); + close(ev2("FLOOR.MATH", -8.1, 2.0), -10.0); + close(ev("FLOOR.MATH", vec![n(-5.5), n(2.0), n(-1.0)]), -4.0); + close(ev2("CEILING.PRECISE", -4.6, 2.0), -4.0); + close(ev2("CEILING.PRECISE", 4.3, -2.0), 6.0); + close(ev2("ISO.CEILING", 4.3, 0.0), 0.0); + close(ev2("FLOOR.PRECISE", -3.2, -1.0), -4.0); + close(ev1("FLOOR.PRECISE", 3.2), 3.0); + close(ev1("EVEN", 1.5), 2.0); + close(ev1("EVEN", 3.0), 4.0); + close(ev1("EVEN", 2.0), 2.0); + close(ev1("EVEN", -1.0), -2.0); + close(ev1("EVEN", 0.0), 0.0); + close(ev1("ODD", 1.5), 3.0); + close(ev1("ODD", 3.0), 3.0); + close(ev1("ODD", 2.0), 3.0); + close(ev1("ODD", -1.0), -1.0); + close(ev1("ODD", -2.0), -3.0); + close(ev1("ODD", 0.0), 1.0); +} + +#[test] +fn mod_tiny_divisor_stays_in_range() { + for (x, d) in [(3.0, 1e-300), (-1.0, 1e-300), (3.0, -1e-300), (-1.0, -1e-300)] { + match ev2("MOD", x, d) { + Value::Number(r) => { + assert!(r == 0.0 || (r < 0.0) == (d < 0.0), "MOD({x}, {d}) = {r}"); + assert!(r.abs() < d.abs(), "MOD({x}, {d}) = {r}"); + } + other => panic!("MOD({x}, {d}) = {other:?}"), + } + } +} + +#[test] +fn arithmetic() { + close(ev2("MOD", 3.0, 2.0), 1.0); + close(ev2("MOD", -3.0, 2.0), 1.0); + close(ev2("MOD", 3.0, -2.0), -1.0); + close(ev2("MOD", -3.0, -2.0), -1.0); + close(ev2("MOD", 5.5, 1.0), 0.5); + is_err(ev2("MOD", 1.0, 0.0), Div0); + close(ev2("QUOTIENT", 5.0, 2.0), 2.0); + close(ev2("QUOTIENT", -10.0, 3.0), -3.0); + is_err(ev2("QUOTIENT", 1.0, 0.0), Div0); + close(ev1("ABS", -4.0), 4.0); + close(ev1("SIGN", -0.0001), -1.0); + close(ev1("SIGN", 0.0), 0.0); + close(ev1("SQRT", 16.0), 4.0); + is_err(ev1("SQRT", -16.0), Num); + close(ev1("SQRTPI", 1.0), 1.772_453_850_905_516); + close(ev2("POWER", 5.0, 2.0), 25.0); + close(ev2("POWER", 4.0, 1.25), 5.656_854_249_492_381); + close(ev2("POWER", -2.0, 3.0), -8.0); + is_err(ev2("POWER", 0.0, 0.0), Num); + is_err(ev2("POWER", 0.0, -1.0), Div0); + is_err(ev2("POWER", -8.0, 1.0 / 3.0), Num); + is_err(ev2("POWER", 10.0, 400.0), Num); + close(ev1("EXP", 1.0), std::f64::consts::E); + close(ev2("LOG", 8.0, 2.0), 3.0); + close(ev1("LOG", 10.0), 1.0); + close(ev2("LOG", 81.0, 3.0), 4.0); + is_err(ev1("LN", 0.0), Num); + is_err(ev2("LOG", 10.0, 1.0), Div0); + is_err(ev1("LOG10", -1.0), Num); + close(ev1("LN", std::f64::consts::E), 1.0); + close(ev1("LOG10", 1e5), 5.0); + close(ev("PI", vec![]), PI); + close(ev("SQRT", vec![t("16")]), 4.0); + is_err(ev("SQRT", vec![t("x")]), CellError::Value); +} + +#[test] +fn combinatorics() { + close(ev1("FACT", 5.0), 120.0); + close(ev1("FACT", 1.9), 1.0); + close(ev1("FACT", 0.0), 1.0); + is_err(ev1("FACT", -1.0), Num); + is_err(ev1("FACT", 171.0), Num); + close(ev1("FACTDOUBLE", 6.0), 48.0); + close(ev1("FACTDOUBLE", 7.0), 105.0); + close(ev1("FACTDOUBLE", -1.0), 1.0); + is_err(ev1("FACTDOUBLE", -2.0), Num); + close(ev2("COMBIN", 8.0, 2.0), 28.0); + close(ev2("COMBIN", 8.9, 2.9), 28.0); + is_err(ev2("COMBIN", 2.0, 3.0), Num); + close(ev2("COMBIN", 60.0, 30.0), 118_264_581_564_861_424.0); + close(ev2("COMBINA", 4.0, 3.0), 20.0); + close(ev2("COMBINA", 10.0, 3.0), 220.0); + close(ev2("PERMUT", 100.0, 3.0), 970_200.0); + close(ev2("PERMUT", 3.0, 2.0), 6.0); + is_err(ev2("PERMUT", 2.0, 3.0), Num); + close(ev2("PERMUTATIONA", 3.0, 2.0), 9.0); + close(ev2("GCD", 24.0, 36.0), 12.0); + close(ev2("GCD", 5.0, 0.0), 5.0); + close(ev2("GCD", 7.0, 1.0), 1.0); + is_err(ev2("GCD", -1.0, 5.0), Num); + close(ev("GCD", vec![av(row(&[12.0, 18.0, 30.0]))]), 6.0); + close(ev2("LCM", 24.0, 36.0), 72.0); + close(ev2("LCM", 5.0, 2.0), 10.0); + close(ev2("LCM", 5.0, 0.0), 0.0); + close(ev("MULTINOMIAL", vec![n(2.0), n(3.0), n(4.0)]), 1260.0); + is_err(ev("MULTINOMIAL", vec![n(-1.0), n(2.0)]), Num); +} + +#[test] +fn trig() { + close(ev1("SIN", PI / 2.0), 1.0); + close(ev1("COS", 0.0), 1.0); + close(ev1("TAN", PI / 4.0), 1.0); + is_err(ev1("SIN", 1e10), Num); + close(ev1("ASIN", 1.0), PI / 2.0); + is_err(ev1("ACOS", 1.5), Num); + close(ev1("ACOT", 2.0), 0.463_647_609_000_806); + close(ev1("ACOTH", 6.0), 0.168_236_118_310_606); + is_err(ev1("ACOTH", 0.5), Num); + close(ev1("COT", 30.0), -0.156_119_952_161_659); + is_err(ev1("COT", 0.0), Div0); + close(ev1("COTH", 2.0), 1.037_314_720_727_548); + close(ev1("CSC", 15.0), 1.537_780_562_208_209); + close(ev1("CSCH", 1.5), 0.469_642_440_595_653); + close(ev1("SEC", 45.0), 1.903_594_407_014_6); + close_tol(ev1("SECH", 45.0), 5.725_037_161_098_787e-20, 1e-9); + close(ev2("ATAN2", 1.0, 1.0), PI / 4.0); + close(ev2("ATAN2", -1.0, -1.0), -3.0 * PI / 4.0); + is_err(ev2("ATAN2", 0.0, 0.0), Div0); + close(ev1("ASINH", -2.5), -1.647_231_146_371_096); + close(ev1("ACOSH", 10.0), 2.993_222_846_126_381); + is_err(ev1("ACOSH", 0.5), Num); + close(ev1("ATANH", 0.5), 0.549_306_144_334_055); + is_err(ev1("ATANH", 1.0), Num); + close(ev1("SINH", 1.0), 1.175_201_193_643_801); + close(ev1("DEGREES", PI), 180.0); + close(ev1("RADIANS", 270.0), 4.712_388_980_384_69); +} + +#[test] +fn number_systems() { + is_text(ev1("ROMAN", 499.0), "CDXCIX"); + is_text(ev2("ROMAN", 499.0, 1.0), "LDVLIV"); + is_text(ev2("ROMAN", 499.0, 2.0), "XDIX"); + is_text(ev2("ROMAN", 499.0, 3.0), "VDIV"); + is_text(ev2("ROMAN", 499.0, 4.0), "ID"); + is_text(ev("ROMAN", vec![n(499.0), b(false)]), "ID"); + is_text(ev1("ROMAN", 1999.0), "MCMXCIX"); + is_text(ev1("ROMAN", 3999.0), "MMMCMXCIX"); + is_text(ev1("ROMAN", 0.0), ""); + is_err(ev1("ROMAN", 4000.0), CellError::Value); + is_err(ev1("ROMAN", -1.0), CellError::Value); + close(ev("ARABIC", vec![t("LVII")]), 57.0); + close(ev("ARABIC", vec![t("mcmxii")]), 1912.0); + close(ev("ARABIC", vec![t("")]), 0.0); + close(ev("ARABIC", vec![t("-XIV")]), -14.0); + is_err(ev("ARABIC", vec![t("ABC")]), CellError::Value); + is_text(ev2("BASE", 7.0, 2.0), "111"); + is_text(ev2("BASE", 100.0, 16.0), "64"); + is_text(ev("BASE", vec![n(15.0), n(2.0), n(10.0)]), "0000001111"); + is_text(ev2("BASE", 0.0, 36.0), "0"); + is_err(ev2("BASE", 7.0, 1.0), Num); + is_err(ev2("BASE", -7.0, 2.0), Num); + close(ev("DECIMAL", vec![t("FF"), n(16.0)]), 255.0); + close(ev("DECIMAL", vec![t("111"), n(2.0)]), 7.0); + close(ev("DECIMAL", vec![t("zap"), n(36.0)]), 45745.0); + is_err(ev("DECIMAL", vec![t("12"), n(2.0)]), Num); +} + +/// Every number where Excel's forms 2 and 3 differ from a plain ratio limit (issue #733): +/// form 2 subtracts I from C and X from M, form 3 also V from M. +#[test] +fn roman_forms_two_and_three_match_excel() { + const HUNDREDS: [&str; 10] = ["", "C", "CC", "CCC", "CD", "D", "DC", "DCC", "DCCC", "CM"]; + const ONES: [&str; 10] = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"]; + let mut cases = Vec::new(); + for thousands in 0..4usize { + let m = "M".repeat(thousands); + for (h, hundreds) in HUNDREDS.iter().enumerate() { + if h != 4 && h != 9 { + cases.push((thousands * 1000 + h * 100 + 99, 2.0, format!("{m}{hundreds}IC"))); + } + } + for (o, ones) in ONES.iter().enumerate() { + cases.push((thousands * 1000 + 990 + o, 2.0, format!("{m}XM{ones}"))); + } + for (o, ones) in ONES.iter().enumerate().take(5) { + cases.push((thousands * 1000 + 995 + o, 3.0, format!("{m}VM{ones}"))); + } + } + assert_eq!(cases.len(), 92); + for (number, form, want) in &cases { + is_text(ev2("ROMAN", *number as f64, *form), want); + } + // Neighbours that already matched: form 2 still has no VD or ID, form 3 no ID. + is_text(ev2("ROMAN", 495.0, 2.0), "XDV"); + is_text(ev2("ROMAN", 495.0, 3.0), "VD"); + is_text(ev2("ROMAN", 1499.0, 2.0), "MXDIX"); + is_text(ev2("ROMAN", 999.0, 0.0), "CMXCIX"); + is_text(ev2("ROMAN", 999.0, 1.0), "LMVLIV"); + is_text(ev2("ROMAN", 999.0, 4.0), "IM"); + is_text(ev2("ROMAN", 995.0, 4.0), "VM"); + is_text(ev2("ROMAN", 99.0, 1.0), "VCIV"); + is_text(ev2("ROMAN", 99.0, 4.0), "IC"); +} + +#[test] +fn minverse_scale_relative() { + let big = ev("MINVERSE", vec![av(arr(vec![vec![nv(1e20), nv(0.0)], vec![nv(0.0), nv(1e20)]]))]); + let r = rows_of(&big); + close_tol(r[0][0].clone(), 1e-20, 1e-25); + close_tol(r[1][1].clone(), 1e-20, 1e-25); + let small = ev("MINVERSE", vec![av(arr(vec![vec![nv(1e-20), nv(0.0)], vec![nv(0.0), nv(1e-20)]]))]); + let r = rows_of(&small); + close_tol(r[0][0].clone(), 1e20, 1e10); +} + +#[test] +fn matrices() { + let a = arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(3.0), nv(4.0)]]); + let b2 = arr(vec![vec![nv(5.0), nv(6.0)], vec![nv(7.0), nv(8.0)]]); + assert_eq!(rows_of(&ev("MMULT", vec![av(a.clone()), av(b2)])), vec![vec![nv(19.0), nv(22.0)], vec![nv(43.0), nv(50.0)]]); + is_err(ev("MMULT", vec![av(a.clone()), av(row(&[1.0, 2.0]))]), CellError::Value); + is_err(ev("MMULT", vec![av(arr(vec![vec![tv("x")]])), av(row(&[1.0]))]), CellError::Value); + close(ev("MDETERM", vec![av(a.clone())]), -2.0); + let m3 = arr(vec![vec![nv(3.0), nv(6.0), nv(1.0)], vec![nv(1.0), nv(1.0), nv(0.0)], vec![nv(3.0), nv(10.0), nv(2.0)]]); + close(ev("MDETERM", vec![av(m3)]), 1.0); + let m4 = arr(vec![ + vec![nv(1.0), nv(3.0), nv(8.0), nv(5.0)], + vec![nv(1.0), nv(3.0), nv(6.0), nv(1.0)], + vec![nv(1.0), nv(1.0), nv(1.0), nv(0.0)], + vec![nv(7.0), nv(3.0), nv(10.0), nv(2.0)], + ]); + close(ev("MDETERM", vec![av(m4)]), 88.0); + is_err(ev("MDETERM", vec![av(row(&[1.0, 2.0]))]), CellError::Value); + let inv = ev("MINVERSE", vec![av(arr(vec![vec![nv(4.0), nv(-1.0)], vec![nv(2.0), nv(0.0)]]))]); + let r = rows_of(&inv); + close(r[0][0].clone(), 0.0); + close(r[0][1].clone(), 0.5); + close(r[1][0].clone(), -1.0); + close(r[1][1].clone(), 2.0); + is_err(ev("MINVERSE", vec![av(arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(2.0), nv(4.0)]]))]), Num); + assert_eq!(rows_of(&ev1("MUNIT", 2.0)), vec![vec![nv(1.0), nv(0.0)], vec![nv(0.0), nv(1.0)]]); + is_err(ev1("MUNIT", 0.0), CellError::Value); + is_err(ev1("MUNIT", 1e6), Num); + let coeffs = row(&[1.0, -0.5, 1.0 / 24.0, -1.0 / 720.0]); + close_tol(ev("SERIESSUM", vec![n(PI / 4.0), n(0.0), n(2.0), av(coeffs)]), 0.707_103_214_823, 1e-9); +} + +#[test] +fn random() { + let mut ctx = TestCtx::default(); + let spec = crate::lookup("RAND").unwrap(); + assert!(spec.volatile); + for _ in 0..50 { + let v = crate::call(spec, &[], &mut ctx); + let x = v.as_f64().unwrap(); + assert!((0.0..1.0).contains(&x)); + } + let spec = crate::lookup("RANDBETWEEN").unwrap(); + for _ in 0..50 { + let x = crate::call(spec, &[n(1.0), n(6.0)], &mut ctx).as_f64().unwrap(); + assert!((1.0..=6.0).contains(&x) && x.fract() == 0.0); + } + is_err(ev2("RANDBETWEEN", 5.0, 1.0), Num); +} + +#[test] +fn lifting() { + assert_eq!(rows_of(&ev("ABS", vec![av(row(&[-1.0, 2.0, -3.0]))])), vec![vec![nv(1.0), nv(2.0), nv(3.0)]]); + let v = ev("POWER", vec![av(col(&[1.0, 2.0])), av(row(&[2.0, 3.0]))]); + assert_eq!(rows_of(&v), vec![vec![nv(1.0), nv(1.0)], vec![nv(4.0), nv(8.0)]]); + close(ev("ROUND", vec![av(arr(vec![vec![nv(1.25)]])), n(1.0)]), 1.3); +} + +fn data() -> (Value, Value) { + let names = arr(vec![vec![tv("apple")], vec![tv("banana")], vec![tv("Apple")], vec![Value::Empty], vec![tv("cherry")]]); + let qty = col(&[10.0, 20.0, 30.0, 40.0, 50.0]); + (names, qty) +} + +#[test] +fn criteria_functions() { + let (names, qty) = data(); + close(ev("COUNTIF", vec![rf(names.clone()), t("apple")]), 2.0); + close(ev("COUNTIF", vec![rf(names.clone()), t("a*")]), 2.0); + close(ev("COUNTIF", vec![rf(names.clone()), t("<>apple")]), 3.0); + close(ev("COUNTIF", vec![rf(names.clone()), t("")]), 1.0); + close(ev("COUNTIF", vec![rf(qty.clone()), t(">20")]), 3.0); + close(ev("COUNTIF", vec![rf(qty.clone()), n(30.0)]), 1.0); + let multi = ev("COUNTIF", vec![rf(names.clone()), av(arr(vec![vec![tv("apple"), tv("cherry")]]))]); + assert_eq!(rows_of(&multi), vec![vec![nv(2.0), nv(1.0)]]); + close(ev("SUMIF", vec![rf(names.clone()), t("apple"), rf(qty.clone())]), 40.0); + close(ev("SUMIF", vec![rf(qty.clone()), t(">=30")]), 120.0); + close(ev("SUMIF", vec![rf(names.clone()), t("?????"), rf(col(&[1.0]))]), 1.0); + close(ev("AVERAGEIF", vec![rf(qty.clone()), t(">20")]), 40.0); + is_err(ev("AVERAGEIF", vec![rf(qty.clone()), t(">100")]), Div0); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(names.clone()), t("apple"), rf(qty.clone()), t(">15")]), 30.0); + close(ev("COUNTIFS", vec![rf(names.clone()), t("*a*"), rf(qty.clone()), t("<25")]), 2.0); + close(ev("AVERAGEIFS", vec![rf(qty.clone()), rf(names.clone()), t("<>banana")]), 32.5); + close(ev("MAXIFS", vec![rf(qty.clone()), rf(names.clone()), t("apple")]), 30.0); + close(ev("MINIFS", vec![rf(qty.clone()), rf(names.clone()), t("apple")]), 10.0); + close(ev("MAXIFS", vec![rf(qty.clone()), rf(names.clone()), t("zzz")]), 0.0); + is_err(ev("SUMIFS", vec![rf(qty.clone()), rf(row(&[1.0, 2.0])), t("1")]), CellError::Value); + is_err(ev("COUNTIFS", vec![rf(qty.clone()), t(">1"), rf(qty)]), CellError::Value); + is_err(ev("SUMIF", vec![rf(col(&[1.0, 2.0])), t(">0"), rf(arr(vec![vec![Value::Error(NA)], vec![nv(1.0)]]))]), NA); +} + +#[test] +fn criteria_over_255_chars_are_value_errors_with_one_pair_and_truncated_with_more() { + let ok = "x".repeat(255); + let long = "x".repeat(256); + let cells = arr(vec![vec![tv(&ok)], vec![tv(&long)]]); + let qty = col(&[1.0, 2.0]); + close(ev("COUNTIF", vec![rf(cells.clone()), t(&ok)]), 1.0); + is_err(ev("COUNTIF", vec![rf(cells.clone()), t(&long)]), CellError::Value); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&ok)]), 1.0); + is_err(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long)]), CellError::Value); + is_err(ev("COUNTIFS", vec![rf(cells.clone()), t(&long)]), CellError::Value); + // With two or more pairs Excel matches the first 255 characters: the 255-character cell + // (quantity 1), not the 256-character one (quantity 2). Checked on Excel 2016 and 365. + let over = format!("{ok}y"); + close(ev("COUNTIFS", vec![rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); + close(ev("SUMIFS", vec![rf(qty.clone()), rf(qty.clone()), t(">0"), rf(cells.clone()), t(&over)]), 1.0); + close(ev("AVERAGEIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0); + close(ev("MAXIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&"x".repeat(300)), rf(qty), t(">0")]), 1.0); +} + +#[test] +fn criteria_over_255_utf16_units_follow_excel() { + // The tester's sheet, identical on Excel 2016 and Microsoft 365: A1:A6 hold these + // strings, B is the row number, and each row uses its own string as the criterion. + let e = "😀"; + let strings = + [e.repeat(127), e.repeat(128), format!("{}{e}", "a".repeat(254)), format!("{}{e}", "a".repeat(253)), e.repeat(200), "я".repeat(256)]; + let cells = arr(strings.iter().map(|s| vec![tv(s)]).collect()); + let rows = col(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + // COUNTIF, SUMIF, COUNTIFS with one pair, SUMIFS with a second pair `B,">0"`. + let expected: [Option<[f64; 4]>; 6] = [Some([1.0, 1.0, 1.0, 1.0]), None, None, Some([1.0, 4.0, 1.0, 4.0]), None, None]; + for (s, want) in strings.iter().zip(expected) { + let countif = ev("COUNTIF", vec![rf(cells.clone()), t(s)]); + let sumif = ev("SUMIF", vec![rf(cells.clone()), t(s), rf(rows.clone())]); + let countifs = ev("COUNTIFS", vec![rf(cells.clone()), t(s)]); + let sumifs = ev("SUMIFS", vec![rf(rows.clone()), rf(cells.clone()), t(s), rf(rows.clone()), t(">0")]); + match want { + Some([a, b, c, d]) => { + close(countif, a); + close(sumif, b); + close(countifs, c); + close(sumifs, d); + } + // Over 255 units: `#VALUE!` with one pair; cut to 255 units with two, which + // matches no cell (for emoji it splits a surrogate pair). + None => { + is_err(countif, CellError::Value); + is_err(sumif, CellError::Value); + is_err(countifs, CellError::Value); + close(sumifs, 0.0); + } + } + } +} + +#[test] +fn criteria_cut_inside_a_surrogate_pair() { + let e = "😀"; + let cells = arr(vec![vec![tv(&e.repeat(127))], vec![tv(&e.repeat(128))], vec![tv("zzz")], vec![Value::Empty]]); + let rows = col(&[1.0, 2.0, 3.0, 4.0]); + let sumifs = |crit: String| ev("SUMIFS", vec![rf(rows.clone()), rf(cells.clone()), t(&crit), rf(rows.clone()), t(">0")]); + // "=" + 😀×128 is 257 units: the cut after 255 keeps "=" and 127 whole emoji. + close(sumifs(format!("={}", e.repeat(128))), 1.0); + // With a two-unit prefix the cut keeps 126 emoji and half of the 127th. Such a criterion + // equals no cell text: "=" matches nothing and "<>" everything, blanks included. + close(sumifs(format!("=a{}", e.repeat(128))), 0.0); + close(sumifs(format!("<>{}", e.repeat(128))), 10.0); + // Comparisons treat it as sorting just above the 126 kept emoji. + close(sumifs(format!(">={}", e.repeat(128))), 1.0 + 2.0); + close(sumifs(format!("<={}", e.repeat(128))), 3.0); +} From acade653ba1eb290a5b05997ba5a9fc908e36360 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:44 +0000 Subject: [PATCH 35/76] refactor(functions): move stat tests to src/stat/tests.rs --- crates/functions/src/stat.rs | 511 +---------------------------- crates/functions/src/stat/tests.rs | 508 ++++++++++++++++++++++++++++ 2 files changed, 509 insertions(+), 510 deletions(-) create mode 100644 crates/functions/src/stat/tests.rs diff --git a/crates/functions/src/stat.rs b/crates/functions/src/stat.rs index 344ba9ff8..554b75271 100644 --- a/crates/functions/src/stat.rs +++ b/crates/functions/src/stat.rs @@ -2273,513 +2273,4 @@ pub(crate) fn specs() -> Vec { } #[cfg(test)] -mod tests { - use super::{MAX_REGRESSORS, aggregate_values, aggregate_values_k, check_regression_size}; - use crate::util::testutil::*; - use gridcraft_core::{CellError, Value}; - - const D7: f64 = 1e-6; - - fn c(xs: &[f64]) -> crate::Arg { - av(col(xs)) - } - - // The two middle values are finite and their midpoint is representable even when - // adding them first would overflow. Exercise both callers of the shared helper. - #[test] - fn median_finite_midpoint_does_not_overflow() { - for (values, expected) in [ - (vec![1e308, 1e308], 1e308), - (vec![-1e308, -1e308], -1e308), - (vec![1.6e308, 1e308], 1.3e308), - (vec![-1e308, -1.6e308], -1.3e308), - (vec![f64::MAX, f64::MAX], f64::MAX), - (vec![-f64::MAX, -f64::MAX], -f64::MAX), - (vec![-f64::MAX, f64::MAX], 0.0), - (vec![1.7e308, -1.7e308, 1e308, 1.6e308], 1.3e308), - ] { - close(ev("MEDIAN", values.iter().copied().map(n).collect()), expected); - close(ev("MEDIAN", vec![rf(col(&values))]), expected); - close(aggregate_values(12, &values.iter().copied().map(nv).collect::>()), expected); - } - } - - #[test] - fn median_midpoint_keeps_small_values_and_argument_semantics() { - // Halving each operand unconditionally can lose the smallest numbers. Use exact - // equality here: the usual aggregate-test tolerance would accept zero. - for value in [f64::MIN_POSITIVE, -f64::MIN_POSITIVE, f64::from_bits(1), -f64::from_bits(1)] { - assert_eq!(ev("MEDIAN", vec![n(value), n(value)]), nv(value)); - assert_eq!(aggregate_values(12, &[nv(value), nv(value)]), nv(value)); - } - close(ev("MEDIAN", vec![c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0])]), 3.5); - close(ev("MEDIAN", vec![c(&[1e308, -1e308, 3.0])]), 3.0); - close(ev("MEDIAN", vec![n(1e308)]), 1e308); - close(ev("MEDIAN", vec![t("4"), b(true)]), 2.5); - close(ev("MEDIAN", vec![rf(arr(vec![vec![nv(1.0), tv("ignored"), Value::Empty, Value::Bool(true), nv(6.0)]]))]), 3.5); - is_err(ev("MEDIAN", vec![t("invalid")]), CellError::Value); - is_err(ev("MEDIAN", vec![e(CellError::NA), n(1e308)]), CellError::NA); - is_err(ev("MEDIAN", vec![rf(arr(vec![vec![tv("ignored"), Value::Empty]]))]), CellError::Num); - is_err(aggregate_values(12, &[]), CellError::Num); - is_err(aggregate_values(12, &[nv(1e308), Value::Error(CellError::Div0)]), CellError::Div0); - } - - #[test] - fn averages_and_counts() { - close(ev("AVERAGE", vec![n(1.0), n(2.0), n(6.0)]), 3.0); - is_err(ev("AVERAGE", vec![rf(arr(vec![vec![tv("a")]]))]), CellError::Div0); - let mixed = arr(vec![vec![nv(1.0), tv("x"), Value::Bool(true), Value::Empty, nv(3.0)]]); - close(ev("AVERAGE", vec![rf(mixed.clone())]), 2.0); - close(ev("AVERAGEA", vec![rf(mixed.clone())]), 1.25); - close(ev("AVERAGE", vec![t("4"), b(true)]), 2.5); - is_err(ev("AVERAGE", vec![t("abc")]), CellError::Value); - is_err(ev("AVERAGE", vec![rf(arr(vec![vec![nv(1.0), Value::Error(CellError::NA)]]))]), CellError::NA); - close(ev("COUNT", vec![rf(mixed.clone()), n(4.0), t("5"), t("x")]), 4.0); - close(ev("COUNT", vec![e(CellError::Div0)]), 0.0); - close(ev("COUNTA", vec![rf(mixed.clone())]), 4.0); - close(ev("COUNTA", vec![n(1.0), t("")]), 2.0); - let blanks = arr(vec![vec![Value::Empty, tv(""), nv(0.0), tv(" ")]]); - close(ev("COUNTBLANK", vec![rf(blanks)]), 2.0); - close(ev("MEDIAN", vec![c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0])]), 3.5); - close(ev("MEDIAN", vec![c(&[3.0, 1.0, 2.0])]), 2.0); - is_err(ev("MEDIAN", vec![rf(arr(vec![vec![tv("a")]]))]), CellError::Num); - close(ev("MODE.SNGL", vec![c(&[5.6, 4.0, 4.0, 3.0, 2.0, 4.0])]), 4.0); - close(ev("MODE", vec![c(&[1.0, 2.0, 2.0, 1.0])]), 1.0); - is_err(ev("MODE", vec![c(&[1.0, 2.0, 3.0])]), CellError::NA); - assert_eq!(rows_of(&ev("MODE.MULT", vec![c(&[1.0, 2.0, 3.0, 4.0, 3.0, 2.0, 1.0, 2.0, 3.0])])), vec![vec![nv(2.0)], vec![nv(3.0)]]); - } - - /// MODE argument semantics in Excel 365, on the inputs of FormulaEvalTestData (row "Mode"): - /// text is skipped, typed or referenced, but a single-cell reference to a blank cell is - /// `#VALUE!`; the first error from the left wins. The values cached in POI's file come from an - /// older Excel and say `#VALUE!` for `MODE(C9,E16,"1",B8)`, `MODE(B8,"1",2,3)` and - /// `MODE("1",1,#DIV/0!,2,3)`; Excel 365 gives `#N/A`, `#N/A` and `#DIV/0!`. - #[test] - fn mode_scalar_arguments() { - let data = arr(vec![vec![tv(""), nv(0.0), nv(1.0)], vec![tv("x"), nv(1.0), nv(1.001)], vec![tv("y"), nv(9e9), nv(2.3456)]]); - let cases: Vec<(Vec, Result)> = vec![ - (vec![n(1.0), n(1.0), n(2.0), n(3.0)], Ok(1.0)), - (vec![rf(data)], Ok(1.0)), - (vec![rf(arr(vec![vec![nv(-1.1), Value::Empty, tv("1.1")]])), n(-1.1)], Ok(-1.1)), - // MODE(C9,E16,"1",B8): E16 holds the text "1.1". - (vec![rf(nv(1.1)), rf(tv("1.1")), t("1"), rf(nv(1.0))], Err(CellError::NA)), - (vec![rf(nv(1.0)), t("1"), n(2.0), n(3.0)], Err(CellError::NA)), - (vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Div0)), - // MODE(B9,2,D7) with D7 blank. - (vec![rf(nv(2.0)), n(2.0), rf(Value::Empty)], Err(CellError::Value)), - (vec![n(1.0), n(1.0), e(CellError::Div0), t("1")], Err(CellError::Div0)), - ]; - for name in ["MODE", "MODE.SNGL"] { - for (args, want) in &cases { - match want { - Ok(x) => close(ev(name, args.clone()), *x), - Err(err) => is_err(ev(name, args.clone()), *err), - } - } - } - is_err(ev("MODE.MULT", vec![rf(nv(1.0)), t("1"), rf(Value::Empty)]), CellError::Value); - } - - /// Text and logical values typed as arguments are skipped, not coerced (Excel 365, #736). - #[test] - fn mode_skips_typed_text_and_logicals() { - let cases: Vec<(Vec, Result)> = vec![ - (vec![n(1.0), n(1.0), t("x")], Ok(1.0)), - (vec![n(1.0), n(2.0), t("x")], Err(CellError::NA)), - (vec![n(1.0), t("1"), n(2.0), n(3.0)], Err(CellError::NA)), - (vec![t("1"), n(1.0), n(2.0), n(3.0)], Err(CellError::NA)), - (vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Div0)), - (vec![n(1.0), b(true), b(true), n(2.0)], Err(CellError::NA)), - (vec![n(1.0), n(1.0), b(true)], Ok(1.0)), - ]; - for name in ["MODE", "MODE.SNGL"] { - for (args, want) in &cases { - match want { - Ok(x) => close(ev(name, args.clone()), *x), - Err(err) => is_err(ev(name, args.clone()), *err), - } - } - } - assert_eq!(rows_of(&ev("MODE.MULT", vec![n(1.0), t("1"), n(1.0), t("x")])), vec![vec![nv(1.0)]]); - is_err(ev("MODE.MULT", vec![n(1.0), b(true), b(true), n(2.0)]), CellError::NA); - } - - #[test] - fn spread() { - let d = [1345.0, 1301.0, 1368.0, 1322.0, 1310.0, 1370.0, 1318.0, 1350.0, 1303.0, 1299.0]; - close_tol(ev("STDEV.S", vec![c(&d)]), 27.46391572, 1e-9); - close_tol(ev("STDEV", vec![c(&d)]), 27.46391572, 1e-9); - close_tol(ev("STDEV.P", vec![c(&d)]), 26.05455814, 1e-9); - close_tol(ev("STDEVP", vec![c(&d)]), 26.05455814, 1e-9); - close_tol(ev("VAR.S", vec![c(&d)]), 754.2666667, 1e-9); - close(ev("VAR.P", vec![c(&d)]), 678.84); - close(ev("VARP", vec![c(&d)]), 678.84); - is_err(ev("STDEV.S", vec![n(1.0)]), CellError::Div0); - close(ev("STDEV.P", vec![n(1.0)]), 0.0); - let mixed = arr(vec![vec![nv(2.0), Value::Bool(true), tv("x")]]); - close(ev("VARA", vec![rf(mixed.clone())]), 1.0); - close(ev("VARPA", vec![rf(mixed.clone())]), 2.0 / 3.0); - close(ev("STDEVA", vec![rf(mixed.clone())]), 1.0); - close(ev("STDEVPA", vec![rf(mixed)]), (2.0f64 / 3.0).sqrt()); - let s = [4.0, 5.0, 6.0, 7.0, 5.0, 4.0, 3.0]; - close(ev("DEVSQ", vec![c(&[4.0, 5.0, 8.0, 7.0, 11.0, 4.0, 3.0])]), 48.0); - close_tol(ev("AVEDEV", vec![c(&s)]), 1.020408163, 1e-9); - let g = [4.0, 5.0, 8.0, 7.0, 11.0, 4.0, 3.0]; - close_tol(ev("GEOMEAN", vec![c(&g)]), 5.476986969, 1e-9); - close_tol(ev("HARMEAN", vec![c(&g)]), 5.028375962, 1e-9); - is_err(ev("GEOMEAN", vec![c(&[1.0, 0.0])]), CellError::Num); - close_tol(ev("TRIMMEAN", vec![c(&[4.0, 5.0, 6.0, 7.0, 2.0, 3.0, 4.0, 5.0, 1.0, 2.0, 3.0]), n(0.2)]), 3.777777778, 1e-9); - is_err(ev("TRIMMEAN", vec![c(&[1.0]), n(1.0)]), CellError::Num); - let k = [3.0, 4.0, 5.0, 2.0, 3.0, 4.0, 5.0, 6.0, 4.0, 7.0]; - close_tol(ev("KURT", vec![c(&k)]), -0.151799637, 1e-8); - close_tol(ev("SKEW", vec![c(&k)]), 0.359543071, 1e-8); - close_tol(ev("SKEW.P", vec![c(&k)]), 0.303193339, 1e-8); - is_err(ev("KURT", vec![c(&[1.0, 2.0, 3.0])]), CellError::Div0); - close_tol(ev("STANDARDIZE", vec![n(42.0), n(40.0), n(1.5)]), 1.333333333, 1e-9); - is_err(ev("STANDARDIZE", vec![n(42.0), n(40.0), n(0.0)]), CellError::Num); - } - - #[test] - fn min_max_order() { - close(ev("MIN", vec![c(&[3.0, -1.0, 2.0]), n(0.0)]), -1.0); - close(ev("MAX", vec![c(&[3.0, -1.0, 2.0]), n(10.0)]), 10.0); - close(ev("MIN", vec![]), 0.0); - close(ev("MAX", vec![rf(arr(vec![vec![tv("a")]]))]), 0.0); - close(ev("MAXA", vec![rf(arr(vec![vec![nv(-1.0), Value::Bool(true)]]))]), 1.0); - close(ev("MINA", vec![rf(arr(vec![vec![nv(1.0), tv("x")]]))]), 0.0); - let d = c(&[3.0, 5.0, 3.0, 5.0, 4.0, 4.0, 2.0, 4.0, 6.0, 7.0]); - close(ev("LARGE", vec![d.clone(), n(3.0)]), 5.0); - close(ev("SMALL", vec![d.clone(), n(4.0)]), 4.0); - is_err(ev("LARGE", vec![d.clone(), n(0.0)]), CellError::Num); - is_err(ev("SMALL", vec![d.clone(), n(11.0)]), CellError::Num); - assert_eq!(rows_of(&ev("LARGE", vec![d, av(row(&[1.0, 2.0]))])), vec![vec![nv(7.0), nv(6.0)]]); - let r = c(&[89.0, 88.0, 92.0, 101.0, 94.0, 97.0, 95.0]); - close(ev("RANK.AVG", vec![n(94.0), r.clone()]), 4.0); - close(ev("RANK.EQ", vec![n(101.0), r.clone()]), 1.0); - is_err(ev("RANK", vec![n(1.0), r]), CellError::NA); - let r2 = c(&[7.0, 3.5, 3.5, 1.0, 2.0]); - close(ev("RANK.EQ", vec![n(7.0), r2.clone(), n(1.0)]), 5.0); - close(ev("RANK.EQ", vec![n(3.5), r2.clone(), n(1.0)]), 3.0); - close(ev("RANK.AVG", vec![n(3.5), r2, n(1.0)]), 3.5); - } - - #[test] - fn percentiles() { - close(ev("PERCENTILE.INC", vec![c(&[1.0, 3.0, 2.0, 4.0]), n(0.3)]), 1.9); - close(ev("PERCENTILE", vec![c(&[1.0, 3.0, 2.0, 4.0]), n(1.0)]), 4.0); - is_err(ev("PERCENTILE.INC", vec![c(&[1.0]), n(1.5)]), CellError::Num); - let e9 = c(&[1.0, 2.0, 3.0, 6.0, 6.0, 6.0, 7.0, 8.0, 9.0]); - close(ev("PERCENTILE.EXC", vec![e9.clone(), n(0.25)]), 2.5); - is_err(ev("PERCENTILE.EXC", vec![e9.clone(), n(0.0)]), CellError::Num); - is_err(ev("PERCENTILE.EXC", vec![e9.clone(), n(0.01)]), CellError::Num); - close(ev("QUARTILE.INC", vec![c(&[1.0, 2.0, 4.0, 7.0, 8.0, 9.0, 10.0, 12.0]), n(1.0)]), 3.5); - close(ev("QUARTILE", vec![c(&[1.0, 2.0, 4.0, 7.0, 8.0, 9.0, 10.0, 12.0]), n(4.0)]), 12.0); - let q = c(&[6.0, 7.0, 15.0, 36.0, 39.0, 40.0, 41.0, 42.0, 43.0, 47.0, 49.0]); - close(ev("QUARTILE.EXC", vec![q.clone(), n(1.0)]), 15.0); - close(ev("QUARTILE.EXC", vec![q.clone(), n(3.0)]), 43.0); - is_err(ev("QUARTILE.EXC", vec![q, n(4.0)]), CellError::Num); - let p = c(&[13.0, 12.0, 11.0, 8.0, 4.0, 3.0, 2.0, 1.0, 1.0, 1.0]); - close(ev("PERCENTRANK.INC", vec![p.clone(), n(2.0)]), 0.333); - close(ev("PERCENTRANK.INC", vec![p.clone(), n(4.0)]), 0.555); - close(ev("PERCENTRANK.INC", vec![p.clone(), n(8.0)]), 0.666); - close(ev("PERCENTRANK", vec![p.clone(), n(5.0)]), 0.583); - close(ev("PERCENTRANK.INC", vec![p.clone(), n(5.0), n(1.0)]), 0.5); - is_err(ev("PERCENTRANK.INC", vec![p.clone(), n(0.5)]), CellError::NA); - is_err(ev("PERCENTRANK.INC", vec![p, n(5.0), n(0.0)]), CellError::Num); - close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(7.0)]), 0.7); - close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(5.43)]), 0.381); - close(ev("PERCENTRANK.EXC", vec![e9, n(5.43), n(1.0)]), 0.3); - let t = c(&[1.0, 2.0, 3.0]); - close(ev("PERCENTRANK", vec![t.clone(), n(2.0), n(400.0)]), 0.5); - close(ev("PERCENTRANK.INC", vec![t.clone(), n(2.0), n(400.0)]), 0.5); - close(ev("PERCENTRANK.INC", vec![t.clone(), n(1.0), n(400.0)]), 0.0); - close(ev("PERCENTRANK.EXC", vec![t, n(2.0), n(400.0)]), 0.5); - } - - #[test] - fn pairs_and_regression() { - close_tol(ev("CORREL", vec![c(&[3.0, 2.0, 4.0, 5.0, 6.0]), c(&[9.0, 7.0, 12.0, 15.0, 17.0])]), 0.997054486, 1e-9); - close_tol(ev("PEARSON", vec![c(&[9.0, 7.0, 5.0, 3.0, 1.0]), c(&[10.0, 6.0, 1.0, 5.0, 3.0])]), 0.699379, D7); - is_err(ev("CORREL", vec![c(&[1.0, 2.0]), c(&[1.0, 2.0, 3.0])]), CellError::NA); - is_err(ev("CORREL", vec![c(&[1.0, 1.0]), c(&[1.0, 2.0])]), CellError::Div0); - let y = c(&[2.0, 3.0, 9.0, 1.0, 8.0, 7.0, 5.0]); - let x = c(&[6.0, 5.0, 11.0, 7.0, 5.0, 4.0, 4.0]); - close_tol(ev("SLOPE", vec![y.clone(), x.clone()]), 0.305555556, 1e-8); - close_tol(ev("RSQ", vec![y.clone(), x.clone()]), 0.057950192, 1e-8); - close_tol(ev("STEYX", vec![y.clone(), x.clone()]), 3.305718950, 1e-8); - close_tol(ev("INTERCEPT", vec![c(&[2.0, 3.0, 9.0, 1.0, 8.0]), c(&[6.0, 5.0, 11.0, 7.0, 5.0])]), 0.048387097, 1e-8); - close_tol(ev("FORECAST", vec![n(30.0), c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 10.607253, D7); - close_tol(ev("FORECAST.LINEAR", vec![n(30.0), c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 10.607253, D7); - close(ev("COVARIANCE.P", vec![c(&[3.0, 2.0, 4.0, 5.0, 6.0]), c(&[9.0, 7.0, 12.0, 15.0, 17.0])]), 5.2); - close(ev("COVAR", vec![c(&[3.0, 2.0, 4.0, 5.0, 6.0]), c(&[9.0, 7.0, 12.0, 15.0, 17.0])]), 5.2); - close(ev("COVARIANCE.S", vec![c(&[2.0, 4.0, 8.0]), c(&[5.0, 11.0, 12.0])]), 29.0 / 3.0); - // Pairs with text are skipped. - let xs = arr(vec![vec![nv(1.0)], vec![tv("x")], vec![nv(3.0)]]); - close(ev("SLOPE", vec![c(&[2.0, 100.0, 6.0]), rf(xs)]), 2.0); - } - - #[test] - fn linest_family() { - let r = rows_of(&ev("LINEST", vec![c(&[1.0, 9.0, 5.0, 7.0]), c(&[0.0, 4.0, 2.0, 3.0])])); - assert_eq!(r.len(), 1); - close(r[0][0].clone(), 2.0); - close(r[0][1].clone(), 1.0); - // Statistics for a simple fit: y = 1,2,4 on x = 1,2,3. - let s = rows_of(&ev("LINEST", vec![c(&[1.0, 2.0, 4.0]), c(&[1.0, 2.0, 3.0]), b(true), b(true)])); - assert_eq!((s.len(), s[0].len()), (5, 2)); - close(s[0][0].clone(), 1.5); - close(s[0][1].clone(), -2.0 / 3.0); - close(s[2][0].clone(), 0.9642857142857143); // r² - close(s[3][1].clone(), 1.0); // df - close(s[4][0].clone(), 4.5); // ssreg - close(s[4][1].clone(), 1.0 / 6.0); // ssresid - close(s[2][1].clone(), (1.0f64 / 6.0).sqrt()); // sey - close(s[3][0].clone(), 27.0); // F - close(s[1][0].clone(), (1.0f64 / 6.0 / 2.0).sqrt()); // se slope - // No constant. - let z = rows_of(&ev("LINEST", vec![c(&[2.0, 4.0, 6.0]), c(&[1.0, 2.0, 3.0]), b(false)])); - close(z[0][0].clone(), 2.0); - close(z[0][1].clone(), 0.0); - // Two predictors: y = 1 + 2a + 3b. - let xs = arr(vec![vec![nv(1.0), nv(0.0)], vec![nv(0.0), nv(1.0)], vec![nv(1.0), nv(1.0)], vec![nv(2.0), nv(1.0)], vec![nv(3.0), nv(5.0)]]); - let ys: Vec = [(1.0, 0.0), (0.0, 1.0), (1.0, 1.0), (2.0, 1.0), (3.0, 5.0)].iter().map(|(a, b)| 1.0 + 2.0 * a + 3.0 * b).collect(); - let m = rows_of(&ev("LINEST", vec![c(&ys), av(xs)])); - close(m[0][0].clone(), 3.0); - close(m[0][1].clone(), 2.0); - close(m[0][2].clone(), 1.0); - let gy = [33100.0, 47300.0, 69000.0, 102000.0, 150000.0, 220000.0]; - let gx = [11.0, 12.0, 13.0, 14.0, 15.0, 16.0]; - let l = rows_of(&ev("LOGEST", vec![c(&gy), c(&gx)])); - close_tol(l[0][0].clone(), 1.463275628, 1e-9); - close_tol(l[0][1].clone(), 495.3047702, 1e-9); - let g = rows_of(&ev("GROWTH", vec![c(&gy), c(&gx), c(&[17.0, 18.0])])); - close_tol(g[0][0].clone(), 320196.7184, 1e-9); - close_tol(g[1][0].clone(), 468536.0539, 1e-9); - let ty = [133890.0, 135000.0, 135790.0, 137300.0, 138130.0, 139100.0, 139900.0, 141120.0, 141890.0, 143230.0, 144000.0, 145290.0]; - let tx: Vec = (1..=12).map(f64::from).collect(); - let t1 = ev("TREND", vec![c(&ty), c(&tx), n(13.0)]); - close_tol(Value::Number(rows_of(&t1)[0][0].as_f64().unwrap().round()), 146172.0, 1e-9); - let fitted = rows_of(&ev("TREND", vec![c(&[1.0, 9.0, 5.0, 7.0]), c(&[0.0, 4.0, 2.0, 3.0])])); - close(fitted[1][0].clone(), 9.0); - is_err(ev("LOGEST", vec![c(&[1.0, -2.0])]), CellError::Num); - is_err(ev("LINEST", vec![c(&[1.0, 2.0]), c(&[1.0, 2.0, 3.0])]), CellError::Ref); - } - - // #2177: one predictor row of 16,384 columns built three 16,384² matrices (6 GB) and an O(k³) - // inversion; a wider TOROW aborted the process. It is now refused before allocating. - #[test] - fn linest_family_caps_regressor_count() { - let wide = |k: usize| av(arr(vec![(0..k).map(|i| nv(i as f64)).collect()])); - let tall = |k: usize| av(arr((0..k).map(|i| vec![nv(i as f64), nv(1.0)]).collect())); - for f in ["LINEST", "LOGEST", "TREND", "GROWTH"] { - // y is one cell, so every column of x is a predictor. - is_err(ev(f, vec![n(1.0), wide(16_384)]), CellError::Value); - // y is a row, so every row of x is a predictor. - is_err(ev(f, vec![av(arr(vec![vec![nv(1.0), nv(2.0)]])), tall(16_384)]), CellError::Value); - } - is_err(ev("LINEST", vec![n(1.0), wide(MAX_REGRESSORS + 1)]), CellError::Value); - // At the cap the fit still runs (one observation: coefficients are zero, not an error). - assert_eq!(rows_of(&ev("LINEST", vec![n(1.0), wide(MAX_REGRESSORS)]))[0].len(), MAX_REGRESSORS + 1); - // The rows × k² work bound. - assert_eq!(check_regression_size(1_000, MAX_REGRESSORS), Ok(())); - assert_eq!(check_regression_size(1_000_000, 100), Err(CellError::Num)); - assert_eq!(check_regression_size(usize::MAX, 2), Err(CellError::Num)); - assert_eq!(check_regression_size(10_000_000, 1), Ok(())); - } - - #[test] - fn frequency_prob() { - let f = ev("FREQUENCY", vec![c(&[79.0, 85.0, 78.0, 85.0, 50.0, 81.0, 95.0, 88.0, 97.0]), c(&[70.0, 79.0, 89.0])]); - assert_eq!(rows_of(&f), vec![vec![nv(1.0)], vec![nv(2.0)], vec![nv(4.0)], vec![nv(2.0)]]); - let x = c(&[0.0, 1.0, 2.0, 3.0]); - let p = c(&[0.2, 0.3, 0.1, 0.4]); - close(ev("PROB", vec![x.clone(), p.clone(), n(2.0)]), 0.1); - close(ev("PROB", vec![x.clone(), p, n(1.0), n(3.0)]), 0.8); - is_err(ev("PROB", vec![x.clone(), c(&[0.5, 0.5, 0.5, 0.5]), n(1.0)]), CellError::Num); - // Excel: any probability <= 0 is #NUM!, even when it sums to one and lies outside the limits. - is_err(ev("PROB", vec![x.clone(), c(&[0.5, 0.0, 0.25, 0.25]), n(0.0), n(3.0)]), CellError::Num); - is_err(ev("PROB", vec![x, c(&[0.5, 0.0, 0.25, 0.25]), n(2.0), n(3.0)]), CellError::Num); - } - - #[test] - fn normal() { - close_tol(ev("NORM.DIST", vec![n(42.0), n(40.0), n(1.5), b(true)]), 0.9087888, D7); - close_tol(ev("NORM.DIST", vec![n(42.0), n(40.0), n(1.5), b(false)]), 0.10934005, D7); - close_tol(ev("NORMDIST", vec![n(42.0), n(40.0), n(1.5), b(true)]), 0.9087888, D7); - is_err(ev("NORM.DIST", vec![n(42.0), n(40.0), n(0.0), b(true)]), CellError::Num); - close_tol(ev("NORM.INV", vec![n(0.908789), n(40.0), n(1.5)]), 42.000002, D7); - close_tol(ev("NORMINV", vec![n(0.908789), n(40.0), n(1.5)]), 42.000002, D7); - is_err(ev("NORM.INV", vec![n(1.0), n(0.0), n(1.0)]), CellError::Num); - close_tol(ev("NORM.S.DIST", vec![n(1.333333), b(true)]), 0.908788726, 1e-8); - close_tol(ev("NORM.S.DIST", vec![n(0.0), b(false)]), 0.398942280, 1e-8); - close_tol(ev("NORMSDIST", vec![n(1.333333)]), 0.908788726, 1e-8); - close_tol(ev("NORM.S.INV", vec![n(0.908789)]), 1.3333347, D7); - close_tol(ev("NORMSINV", vec![n(0.975)]), 1.959963985, 1e-9); - close_tol(ev("PHI", vec![n(0.75)]), 0.301137432, 1e-8); - close_tol(ev("GAUSS", vec![n(2.0)]), 0.477249868, 1e-8); - close_tol(ev("FISHER", vec![n(0.75)]), 0.972955075, 1e-8); - close_tol(ev("FISHERINV", vec![n(0.972955)]), 0.75, D7); - is_err(ev("FISHER", vec![n(1.0)]), CellError::Num); - close_tol(ev("CONFIDENCE.NORM", vec![n(0.05), n(2.5), n(50.0)]), 0.692951912, 1e-8); - close_tol(ev("CONFIDENCE", vec![n(0.05), n(2.5), n(50.0)]), 0.692951912, 1e-8); - close_tol(ev("CONFIDENCE.T", vec![n(0.05), n(1.0), n(50.0)]), 0.284196855, 1e-8); - is_err(ev("CONFIDENCE.T", vec![n(0.05), n(1.0), n(1.0)]), CellError::Div0); - let z = c(&[3.0, 6.0, 7.0, 8.0, 6.0, 5.0, 4.0, 2.0, 1.0, 9.0]); - close_tol(ev("Z.TEST", vec![z.clone(), n(4.0)]), 0.090574, 1e-5); - close_tol(ev("ZTEST", vec![z, n(6.0)]), 0.863043, 1e-5); - close_tol(ev("LOGNORM.DIST", vec![n(4.0), n(3.5), n(1.2), b(true)]), 0.0390836, D7); - close_tol(ev("LOGNORM.DIST", vec![n(4.0), n(3.5), n(1.2), b(false)]), 0.0176176, D7); - close_tol(ev("LOGNORMDIST", vec![n(4.0), n(3.5), n(1.2)]), 0.0390836, D7); - close_tol(ev("LOGNORM.INV", vec![n(0.039084), n(3.5), n(1.2)]), 4.0000252, D7); - close_tol(ev("LOGINV", vec![n(0.039084), n(3.5), n(1.2)]), 4.0000252, D7); - } - - #[test] - fn t_chi_f() { - close_tol(ev("T.DIST", vec![n(60.0), n(1.0), b(true)]), 0.99469533, D7); - close_tol(ev("T.DIST", vec![n(8.0), n(3.0), b(false)]), 0.00073691, 1e-5); - close_tol(ev("T.DIST.2T", vec![n(1.959999998), n(60.0)]), 0.054644930, D7); - close_tol(ev("T.DIST.RT", vec![n(1.959999998), n(60.0)]), 0.027322465, D7); - close_tol(ev("TDIST", vec![n(1.959999998), n(60.0), n(2.0)]), 0.054644930, D7); - is_err(ev("TDIST", vec![n(-1.0), n(60.0), n(2.0)]), CellError::Num); - is_err(ev("T.DIST", vec![n(1.0), n(0.0), b(true)]), CellError::Num); - close_tol(ev("T.INV", vec![n(0.75), n(2.0)]), 0.8164966, D7); - close_tol(ev("T.INV", vec![n(0.25), n(2.0)]), -0.8164966, D7); - close_tol(ev("T.INV.2T", vec![n(0.546449), n(60.0)]), 0.606533, D7); - close_tol(ev("T.INV.2T", vec![n(0.05), n(10.0)]), 2.228138852, 1e-9); - close_tol(ev("TINV", vec![n(0.05), n(10.0)]), 2.228138852, 1e-9); - let a1 = c(&[3.0, 4.0, 5.0, 8.0, 9.0, 1.0, 2.0, 4.0, 5.0]); - let a2 = c(&[6.0, 19.0, 3.0, 2.0, 14.0, 4.0, 5.0, 17.0, 1.0]); - close_tol(ev("T.TEST", vec![a1.clone(), a2.clone(), n(2.0), n(1.0)]), 0.196016, 1e-5); - close_tol(ev("TTEST", vec![a1.clone(), a2.clone(), n(2.0), n(1.0)]), 0.196016, 1e-5); - close_tol(ev("T.TEST", vec![a1.clone(), a2.clone(), n(2.0), n(2.0)]), 0.191995887, 1e-6); - close_tol(ev("T.TEST", vec![a1.clone(), a2.clone(), n(1.0), n(3.0)]), 0.1012, 1e-3); - is_err(ev("T.TEST", vec![a1, a2, n(3.0), n(1.0)]), CellError::Num); - close_tol(ev("CHISQ.DIST", vec![n(0.5), n(1.0), b(true)]), 0.52049988, D7); - close_tol(ev("CHISQ.DIST", vec![n(2.0), n(3.0), b(false)]), 0.20755375, D7); - close_tol(ev("CHISQ.DIST.RT", vec![n(18.307), n(10.0)]), 0.0500006, 1e-5); - close_tol(ev("CHIDIST", vec![n(18.307), n(10.0)]), 0.0500006, 1e-5); - close_tol(ev("CHISQ.INV", vec![n(0.93), n(1.0)]), 3.283020287, 1e-8); - close_tol(ev("CHISQ.INV", vec![n(0.6), n(2.0)]), 1.832581464, 1e-8); - close_tol(ev("CHISQ.INV.RT", vec![n(0.050001), n(10.0)]), 18.30697, D7); - close_tol(ev("CHIINV", vec![n(0.050001), n(10.0)]), 18.30697, D7); - is_err(ev("CHISQ.DIST", vec![n(-1.0), n(1.0), b(true)]), CellError::Num); - let act = arr(vec![vec![nv(58.0), nv(35.0)], vec![nv(11.0), nv(25.0)], vec![nv(10.0), nv(23.0)]]); - let exp = arr(vec![vec![nv(45.35), nv(47.65)], vec![nv(17.56), nv(18.44)], vec![nv(16.09), nv(16.91)]]); - close_tol(ev("CHISQ.TEST", vec![av(act.clone()), av(exp.clone())]), 0.0003082, 1e-3); - close_tol(ev("CHITEST", vec![av(act), av(exp)]), 0.0003082, 1e-3); - close_tol(ev("F.DIST", vec![n(15.2069), n(6.0), n(4.0), b(true)]), 0.99, 1e-6); - close_tol(ev("F.DIST", vec![n(15.2069), n(6.0), n(4.0), b(false)]), 0.0012238, 1e-5); - close_tol(ev("F.DIST.RT", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6); - close_tol(ev("FDIST", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6); - close_tol(ev("F.INV", vec![n(0.01), n(6.0), n(4.0)]), 0.10930991, D7); - close_tol(ev("F.INV.RT", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7); - close_tol(ev("FINV", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7); - close_tol(ev("F.TEST", vec![c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 0.64831785, D7); - close_tol(ev("FTEST", vec![c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 0.64831785, D7); - is_err(ev("F.DIST", vec![n(1.0), n(0.0), n(4.0), b(true)]), CellError::Num); - } - - #[test] - fn binom_inv_huge_trials_returns() { - // Above 2^53 neighbouring f64 values are more than 1 apart; the bisection must still end. - let cases: [(&str, f64, f64, f64); 3] = [("BINOM.INV", 1e17, 0.5, 0.5), ("CRITBINOM", 1e300, 1.0, 0.5), ("BINOM.INV", 1e300, 0.5, 0.5)]; - for (name, trials, p, alpha) in cases { - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let r = ev(name, vec![n(trials), n(p), n(alpha)]); - let _ = tx.send(r); - }); - match rx.recv_timeout(std::time::Duration::from_secs(5)) { - Ok(Value::Number(_)) | Ok(Value::Error(CellError::Num)) => {} - Ok(other) => panic!("{name}({trials},{p},{alpha}) returned {other:?}"), - Err(_) => panic!("{name}({trials},{p},{alpha}) did not return within 5 s"), - } - } - } - - #[test] - fn discrete() { - close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12); - close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(true)]), 0.828125, 1e-12); - close_tol(ev("BINOMDIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12); - is_err(ev("BINOM.DIST", vec![n(11.0), n(10.0), n(0.5), b(false)]), CellError::Num); - close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(48.0)]), 0.083974967, 1e-6); - close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(45.0), n(50.0)]), 0.523629793, 1e-6); - close(ev("BINOM.INV", vec![n(6.0), n(0.5), n(0.75)]), 4.0); - close(ev("CRITBINOM", vec![n(6.0), n(0.5), n(0.75)]), 4.0); - close(ev("BINOM.INV", vec![n(1e9), n(0.5), n(0.5)]), 5e8); - close_tol(ev("POISSON.DIST", vec![n(2.0), n(5.0), b(true)]), 0.124652, 1e-5); - close_tol(ev("POISSON.DIST", vec![n(2.0), n(5.0), b(false)]), 0.084224, 1e-5); - close_tol(ev("POISSON", vec![n(2.0), n(5.0), b(false)]), 0.084224, 1e-5); - is_err(ev("POISSON.DIST", vec![n(-1.0), n(5.0), b(false)]), CellError::Num); - close_tol(ev("HYPGEOM.DIST", vec![n(1.0), n(4.0), n(8.0), n(20.0), b(true)]), 0.4654, 1e-4); - close_tol(ev("HYPGEOM.DIST", vec![n(1.0), n(4.0), n(8.0), n(20.0), b(false)]), 0.3633, 1e-4); - close_tol(ev("HYPGEOMDIST", vec![n(1.0), n(4.0), n(8.0), n(20.0)]), 0.363261094, 1e-8); - is_err(ev("HYPGEOMDIST", vec![n(5.0), n(4.0), n(8.0), n(20.0)]), CellError::Num); - close_tol(ev("NEGBINOM.DIST", vec![n(10.0), n(5.0), n(0.25), b(true)]), 0.3135141, D7); - close_tol(ev("NEGBINOM.DIST", vec![n(10.0), n(5.0), n(0.25), b(false)]), 0.0550487, D7); - close_tol(ev("NEGBINOMDIST", vec![n(10.0), n(5.0), n(0.25)]), 0.0550487, D7); - } - - #[test] - fn continuous() { - close_tol(ev("EXPON.DIST", vec![n(0.2), n(10.0), b(true)]), 0.86466472, D7); - close_tol(ev("EXPON.DIST", vec![n(0.2), n(10.0), b(false)]), 1.35335283, D7); - close_tol(ev("EXPONDIST", vec![n(0.2), n(10.0), b(true)]), 0.86466472, D7); - is_err(ev("EXPON.DIST", vec![n(0.2), n(0.0), b(true)]), CellError::Num); - close_tol(ev("GAMMA.DIST", vec![n(10.00001131), n(9.0), n(2.0), b(false)]), 0.032639, 1e-5); - close_tol(ev("GAMMA.DIST", vec![n(10.00001131), n(9.0), n(2.0), b(true)]), 0.068094, 1e-5); - close_tol(ev("GAMMADIST", vec![n(10.00001131), n(9.0), n(2.0), b(true)]), 0.068094, 1e-5); - close_tol(ev("GAMMA.INV", vec![n(0.068094), n(9.0), n(2.0)]), 10.0000112, 1e-6); - close_tol(ev("GAMMAINV", vec![n(0.068094), n(9.0), n(2.0)]), 10.0000112, 1e-6); - close_tol(ev("GAMMALN", vec![n(4.0)]), 1.791759469, 1e-9); - close_tol(ev("GAMMALN.PRECISE", vec![n(4.5)]), 2.453736571, 1e-9); - is_err(ev("GAMMALN", vec![n(0.0)]), CellError::Num); - close_tol(ev("GAMMA", vec![n(2.5)]), 1.329340388, 1e-9); - close_tol(ev("GAMMA", vec![n(-0.75)]), -4.834146544, 1e-8); - is_err(ev("GAMMA", vec![n(-1.0)]), CellError::Num); - is_err(ev("GAMMA", vec![n(0.0)]), CellError::Num); - close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(true), n(1.0), n(3.0)]), 0.6854706, D7); - close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(false), n(1.0), n(3.0)]), 1.4837646, D7); - close_tol(ev("BETADIST", vec![n(2.0), n(8.0), n(10.0), n(1.0), n(3.0)]), 0.6854706, D7); - close_tol(ev("BETA.INV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7); - close_tol(ev("BETAINV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7); - is_err(ev("BETA.DIST", vec![n(4.0), n(8.0), n(10.0), b(true), n(1.0), n(3.0)]), CellError::Num); - close_tol(ev("WEIBULL.DIST", vec![n(105.0), n(20.0), n(100.0), b(true)]), 0.929581, 1e-5); - close_tol(ev("WEIBULL.DIST", vec![n(105.0), n(20.0), n(100.0), b(false)]), 0.035589, 1e-4); - close_tol(ev("WEIBULL", vec![n(105.0), n(20.0), n(100.0), b(true)]), 0.929581, 1e-5); - // Lifting over an array of x values. - let r = rows_of(&ev("NORM.S.DIST", vec![av(row(&[0.0, 1.0])), b(true)])); - close(r[0][0].clone(), 0.5); - close_tol(r[0][1].clone(), 0.841344746, 1e-8); - } - - #[test] - fn aggregate_helper() { - let vals = vec![nv(1.0), nv(2.0), tv("x"), Value::Empty, Value::Bool(true), nv(6.0)]; - close(aggregate_values(1, &vals), 3.0); - close(aggregate_values(2, &vals), 3.0); - close(aggregate_values(3, &vals), 5.0); - close(aggregate_values(4, &vals), 6.0); - close(aggregate_values(105, &vals), 1.0); - close(aggregate_values(6, &vals), 12.0); - close(aggregate_values(9, &vals), 9.0); - close(aggregate_values(109, &vals), 9.0); - close(aggregate_values(11, &vals), 14.0 / 3.0); - close(aggregate_values(10, &vals), 7.0); - close(aggregate_values(12, &vals), 2.0); - is_err(aggregate_values(13, &vals), CellError::NA); - is_err(aggregate_values(14, &vals), CellError::Value); - is_err(aggregate_values(99, &vals), CellError::Value); - is_err(aggregate_values(9, &[nv(1.0), Value::Error(CellError::Div0)]), CellError::Div0); - close(aggregate_values_k(14, &vals, &nv(1.0)), 6.0); - close(aggregate_values_k(15, &vals, &nv(2.0)), 2.0); - close(aggregate_values_k(16, &vals, &nv(0.5)), 2.0); - close(aggregate_values_k(17, &vals, &nv(4.0)), 6.0); - close(aggregate_values_k(18, &vals, &nv(0.5)), 2.0); - close(aggregate_values_k(19, &vals, &nv(2.0)), 2.0); - close(aggregate_values_k(9, &vals, &nv(2.0)), 9.0); - is_err(aggregate_values_k(14, &vals, &nv(10.0)), CellError::Num); - } -} +mod tests; diff --git a/crates/functions/src/stat/tests.rs b/crates/functions/src/stat/tests.rs new file mode 100644 index 000000000..87ce19fac --- /dev/null +++ b/crates/functions/src/stat/tests.rs @@ -0,0 +1,508 @@ +use super::{MAX_REGRESSORS, aggregate_values, aggregate_values_k, check_regression_size}; +use crate::util::testutil::*; +use gridcraft_core::{CellError, Value}; + +const D7: f64 = 1e-6; + +fn c(xs: &[f64]) -> crate::Arg { + av(col(xs)) +} + +// The two middle values are finite and their midpoint is representable even when +// adding them first would overflow. Exercise both callers of the shared helper. +#[test] +fn median_finite_midpoint_does_not_overflow() { + for (values, expected) in [ + (vec![1e308, 1e308], 1e308), + (vec![-1e308, -1e308], -1e308), + (vec![1.6e308, 1e308], 1.3e308), + (vec![-1e308, -1.6e308], -1.3e308), + (vec![f64::MAX, f64::MAX], f64::MAX), + (vec![-f64::MAX, -f64::MAX], -f64::MAX), + (vec![-f64::MAX, f64::MAX], 0.0), + (vec![1.7e308, -1.7e308, 1e308, 1.6e308], 1.3e308), + ] { + close(ev("MEDIAN", values.iter().copied().map(n).collect()), expected); + close(ev("MEDIAN", vec![rf(col(&values))]), expected); + close(aggregate_values(12, &values.iter().copied().map(nv).collect::>()), expected); + } +} + +#[test] +fn median_midpoint_keeps_small_values_and_argument_semantics() { + // Halving each operand unconditionally can lose the smallest numbers. Use exact + // equality here: the usual aggregate-test tolerance would accept zero. + for value in [f64::MIN_POSITIVE, -f64::MIN_POSITIVE, f64::from_bits(1), -f64::from_bits(1)] { + assert_eq!(ev("MEDIAN", vec![n(value), n(value)]), nv(value)); + assert_eq!(aggregate_values(12, &[nv(value), nv(value)]), nv(value)); + } + close(ev("MEDIAN", vec![c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0])]), 3.5); + close(ev("MEDIAN", vec![c(&[1e308, -1e308, 3.0])]), 3.0); + close(ev("MEDIAN", vec![n(1e308)]), 1e308); + close(ev("MEDIAN", vec![t("4"), b(true)]), 2.5); + close(ev("MEDIAN", vec![rf(arr(vec![vec![nv(1.0), tv("ignored"), Value::Empty, Value::Bool(true), nv(6.0)]]))]), 3.5); + is_err(ev("MEDIAN", vec![t("invalid")]), CellError::Value); + is_err(ev("MEDIAN", vec![e(CellError::NA), n(1e308)]), CellError::NA); + is_err(ev("MEDIAN", vec![rf(arr(vec![vec![tv("ignored"), Value::Empty]]))]), CellError::Num); + is_err(aggregate_values(12, &[]), CellError::Num); + is_err(aggregate_values(12, &[nv(1e308), Value::Error(CellError::Div0)]), CellError::Div0); +} + +#[test] +fn averages_and_counts() { + close(ev("AVERAGE", vec![n(1.0), n(2.0), n(6.0)]), 3.0); + is_err(ev("AVERAGE", vec![rf(arr(vec![vec![tv("a")]]))]), CellError::Div0); + let mixed = arr(vec![vec![nv(1.0), tv("x"), Value::Bool(true), Value::Empty, nv(3.0)]]); + close(ev("AVERAGE", vec![rf(mixed.clone())]), 2.0); + close(ev("AVERAGEA", vec![rf(mixed.clone())]), 1.25); + close(ev("AVERAGE", vec![t("4"), b(true)]), 2.5); + is_err(ev("AVERAGE", vec![t("abc")]), CellError::Value); + is_err(ev("AVERAGE", vec![rf(arr(vec![vec![nv(1.0), Value::Error(CellError::NA)]]))]), CellError::NA); + close(ev("COUNT", vec![rf(mixed.clone()), n(4.0), t("5"), t("x")]), 4.0); + close(ev("COUNT", vec![e(CellError::Div0)]), 0.0); + close(ev("COUNTA", vec![rf(mixed.clone())]), 4.0); + close(ev("COUNTA", vec![n(1.0), t("")]), 2.0); + let blanks = arr(vec![vec![Value::Empty, tv(""), nv(0.0), tv(" ")]]); + close(ev("COUNTBLANK", vec![rf(blanks)]), 2.0); + close(ev("MEDIAN", vec![c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0])]), 3.5); + close(ev("MEDIAN", vec![c(&[3.0, 1.0, 2.0])]), 2.0); + is_err(ev("MEDIAN", vec![rf(arr(vec![vec![tv("a")]]))]), CellError::Num); + close(ev("MODE.SNGL", vec![c(&[5.6, 4.0, 4.0, 3.0, 2.0, 4.0])]), 4.0); + close(ev("MODE", vec![c(&[1.0, 2.0, 2.0, 1.0])]), 1.0); + is_err(ev("MODE", vec![c(&[1.0, 2.0, 3.0])]), CellError::NA); + assert_eq!(rows_of(&ev("MODE.MULT", vec![c(&[1.0, 2.0, 3.0, 4.0, 3.0, 2.0, 1.0, 2.0, 3.0])])), vec![vec![nv(2.0)], vec![nv(3.0)]]); +} + +/// MODE argument semantics in Excel 365, on the inputs of FormulaEvalTestData (row "Mode"): +/// text is skipped, typed or referenced, but a single-cell reference to a blank cell is +/// `#VALUE!`; the first error from the left wins. The values cached in POI's file come from an +/// older Excel and say `#VALUE!` for `MODE(C9,E16,"1",B8)`, `MODE(B8,"1",2,3)` and +/// `MODE("1",1,#DIV/0!,2,3)`; Excel 365 gives `#N/A`, `#N/A` and `#DIV/0!`. +#[test] +fn mode_scalar_arguments() { + let data = arr(vec![vec![tv(""), nv(0.0), nv(1.0)], vec![tv("x"), nv(1.0), nv(1.001)], vec![tv("y"), nv(9e9), nv(2.3456)]]); + let cases: Vec<(Vec, Result)> = vec![ + (vec![n(1.0), n(1.0), n(2.0), n(3.0)], Ok(1.0)), + (vec![rf(data)], Ok(1.0)), + (vec![rf(arr(vec![vec![nv(-1.1), Value::Empty, tv("1.1")]])), n(-1.1)], Ok(-1.1)), + // MODE(C9,E16,"1",B8): E16 holds the text "1.1". + (vec![rf(nv(1.1)), rf(tv("1.1")), t("1"), rf(nv(1.0))], Err(CellError::NA)), + (vec![rf(nv(1.0)), t("1"), n(2.0), n(3.0)], Err(CellError::NA)), + (vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Div0)), + // MODE(B9,2,D7) with D7 blank. + (vec![rf(nv(2.0)), n(2.0), rf(Value::Empty)], Err(CellError::Value)), + (vec![n(1.0), n(1.0), e(CellError::Div0), t("1")], Err(CellError::Div0)), + ]; + for name in ["MODE", "MODE.SNGL"] { + for (args, want) in &cases { + match want { + Ok(x) => close(ev(name, args.clone()), *x), + Err(err) => is_err(ev(name, args.clone()), *err), + } + } + } + is_err(ev("MODE.MULT", vec![rf(nv(1.0)), t("1"), rf(Value::Empty)]), CellError::Value); +} + +/// Text and logical values typed as arguments are skipped, not coerced (Excel 365, #736). +#[test] +fn mode_skips_typed_text_and_logicals() { + let cases: Vec<(Vec, Result)> = vec![ + (vec![n(1.0), n(1.0), t("x")], Ok(1.0)), + (vec![n(1.0), n(2.0), t("x")], Err(CellError::NA)), + (vec![n(1.0), t("1"), n(2.0), n(3.0)], Err(CellError::NA)), + (vec![t("1"), n(1.0), n(2.0), n(3.0)], Err(CellError::NA)), + (vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Div0)), + (vec![n(1.0), b(true), b(true), n(2.0)], Err(CellError::NA)), + (vec![n(1.0), n(1.0), b(true)], Ok(1.0)), + ]; + for name in ["MODE", "MODE.SNGL"] { + for (args, want) in &cases { + match want { + Ok(x) => close(ev(name, args.clone()), *x), + Err(err) => is_err(ev(name, args.clone()), *err), + } + } + } + assert_eq!(rows_of(&ev("MODE.MULT", vec![n(1.0), t("1"), n(1.0), t("x")])), vec![vec![nv(1.0)]]); + is_err(ev("MODE.MULT", vec![n(1.0), b(true), b(true), n(2.0)]), CellError::NA); +} + +#[test] +fn spread() { + let d = [1345.0, 1301.0, 1368.0, 1322.0, 1310.0, 1370.0, 1318.0, 1350.0, 1303.0, 1299.0]; + close_tol(ev("STDEV.S", vec![c(&d)]), 27.46391572, 1e-9); + close_tol(ev("STDEV", vec![c(&d)]), 27.46391572, 1e-9); + close_tol(ev("STDEV.P", vec![c(&d)]), 26.05455814, 1e-9); + close_tol(ev("STDEVP", vec![c(&d)]), 26.05455814, 1e-9); + close_tol(ev("VAR.S", vec![c(&d)]), 754.2666667, 1e-9); + close(ev("VAR.P", vec![c(&d)]), 678.84); + close(ev("VARP", vec![c(&d)]), 678.84); + is_err(ev("STDEV.S", vec![n(1.0)]), CellError::Div0); + close(ev("STDEV.P", vec![n(1.0)]), 0.0); + let mixed = arr(vec![vec![nv(2.0), Value::Bool(true), tv("x")]]); + close(ev("VARA", vec![rf(mixed.clone())]), 1.0); + close(ev("VARPA", vec![rf(mixed.clone())]), 2.0 / 3.0); + close(ev("STDEVA", vec![rf(mixed.clone())]), 1.0); + close(ev("STDEVPA", vec![rf(mixed)]), (2.0f64 / 3.0).sqrt()); + let s = [4.0, 5.0, 6.0, 7.0, 5.0, 4.0, 3.0]; + close(ev("DEVSQ", vec![c(&[4.0, 5.0, 8.0, 7.0, 11.0, 4.0, 3.0])]), 48.0); + close_tol(ev("AVEDEV", vec![c(&s)]), 1.020408163, 1e-9); + let g = [4.0, 5.0, 8.0, 7.0, 11.0, 4.0, 3.0]; + close_tol(ev("GEOMEAN", vec![c(&g)]), 5.476986969, 1e-9); + close_tol(ev("HARMEAN", vec![c(&g)]), 5.028375962, 1e-9); + is_err(ev("GEOMEAN", vec![c(&[1.0, 0.0])]), CellError::Num); + close_tol(ev("TRIMMEAN", vec![c(&[4.0, 5.0, 6.0, 7.0, 2.0, 3.0, 4.0, 5.0, 1.0, 2.0, 3.0]), n(0.2)]), 3.777777778, 1e-9); + is_err(ev("TRIMMEAN", vec![c(&[1.0]), n(1.0)]), CellError::Num); + let k = [3.0, 4.0, 5.0, 2.0, 3.0, 4.0, 5.0, 6.0, 4.0, 7.0]; + close_tol(ev("KURT", vec![c(&k)]), -0.151799637, 1e-8); + close_tol(ev("SKEW", vec![c(&k)]), 0.359543071, 1e-8); + close_tol(ev("SKEW.P", vec![c(&k)]), 0.303193339, 1e-8); + is_err(ev("KURT", vec![c(&[1.0, 2.0, 3.0])]), CellError::Div0); + close_tol(ev("STANDARDIZE", vec![n(42.0), n(40.0), n(1.5)]), 1.333333333, 1e-9); + is_err(ev("STANDARDIZE", vec![n(42.0), n(40.0), n(0.0)]), CellError::Num); +} + +#[test] +fn min_max_order() { + close(ev("MIN", vec![c(&[3.0, -1.0, 2.0]), n(0.0)]), -1.0); + close(ev("MAX", vec![c(&[3.0, -1.0, 2.0]), n(10.0)]), 10.0); + close(ev("MIN", vec![]), 0.0); + close(ev("MAX", vec![rf(arr(vec![vec![tv("a")]]))]), 0.0); + close(ev("MAXA", vec![rf(arr(vec![vec![nv(-1.0), Value::Bool(true)]]))]), 1.0); + close(ev("MINA", vec![rf(arr(vec![vec![nv(1.0), tv("x")]]))]), 0.0); + let d = c(&[3.0, 5.0, 3.0, 5.0, 4.0, 4.0, 2.0, 4.0, 6.0, 7.0]); + close(ev("LARGE", vec![d.clone(), n(3.0)]), 5.0); + close(ev("SMALL", vec![d.clone(), n(4.0)]), 4.0); + is_err(ev("LARGE", vec![d.clone(), n(0.0)]), CellError::Num); + is_err(ev("SMALL", vec![d.clone(), n(11.0)]), CellError::Num); + assert_eq!(rows_of(&ev("LARGE", vec![d, av(row(&[1.0, 2.0]))])), vec![vec![nv(7.0), nv(6.0)]]); + let r = c(&[89.0, 88.0, 92.0, 101.0, 94.0, 97.0, 95.0]); + close(ev("RANK.AVG", vec![n(94.0), r.clone()]), 4.0); + close(ev("RANK.EQ", vec![n(101.0), r.clone()]), 1.0); + is_err(ev("RANK", vec![n(1.0), r]), CellError::NA); + let r2 = c(&[7.0, 3.5, 3.5, 1.0, 2.0]); + close(ev("RANK.EQ", vec![n(7.0), r2.clone(), n(1.0)]), 5.0); + close(ev("RANK.EQ", vec![n(3.5), r2.clone(), n(1.0)]), 3.0); + close(ev("RANK.AVG", vec![n(3.5), r2, n(1.0)]), 3.5); +} + +#[test] +fn percentiles() { + close(ev("PERCENTILE.INC", vec![c(&[1.0, 3.0, 2.0, 4.0]), n(0.3)]), 1.9); + close(ev("PERCENTILE", vec![c(&[1.0, 3.0, 2.0, 4.0]), n(1.0)]), 4.0); + is_err(ev("PERCENTILE.INC", vec![c(&[1.0]), n(1.5)]), CellError::Num); + let e9 = c(&[1.0, 2.0, 3.0, 6.0, 6.0, 6.0, 7.0, 8.0, 9.0]); + close(ev("PERCENTILE.EXC", vec![e9.clone(), n(0.25)]), 2.5); + is_err(ev("PERCENTILE.EXC", vec![e9.clone(), n(0.0)]), CellError::Num); + is_err(ev("PERCENTILE.EXC", vec![e9.clone(), n(0.01)]), CellError::Num); + close(ev("QUARTILE.INC", vec![c(&[1.0, 2.0, 4.0, 7.0, 8.0, 9.0, 10.0, 12.0]), n(1.0)]), 3.5); + close(ev("QUARTILE", vec![c(&[1.0, 2.0, 4.0, 7.0, 8.0, 9.0, 10.0, 12.0]), n(4.0)]), 12.0); + let q = c(&[6.0, 7.0, 15.0, 36.0, 39.0, 40.0, 41.0, 42.0, 43.0, 47.0, 49.0]); + close(ev("QUARTILE.EXC", vec![q.clone(), n(1.0)]), 15.0); + close(ev("QUARTILE.EXC", vec![q.clone(), n(3.0)]), 43.0); + is_err(ev("QUARTILE.EXC", vec![q, n(4.0)]), CellError::Num); + let p = c(&[13.0, 12.0, 11.0, 8.0, 4.0, 3.0, 2.0, 1.0, 1.0, 1.0]); + close(ev("PERCENTRANK.INC", vec![p.clone(), n(2.0)]), 0.333); + close(ev("PERCENTRANK.INC", vec![p.clone(), n(4.0)]), 0.555); + close(ev("PERCENTRANK.INC", vec![p.clone(), n(8.0)]), 0.666); + close(ev("PERCENTRANK", vec![p.clone(), n(5.0)]), 0.583); + close(ev("PERCENTRANK.INC", vec![p.clone(), n(5.0), n(1.0)]), 0.5); + is_err(ev("PERCENTRANK.INC", vec![p.clone(), n(0.5)]), CellError::NA); + is_err(ev("PERCENTRANK.INC", vec![p, n(5.0), n(0.0)]), CellError::Num); + close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(7.0)]), 0.7); + close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(5.43)]), 0.381); + close(ev("PERCENTRANK.EXC", vec![e9, n(5.43), n(1.0)]), 0.3); + let t = c(&[1.0, 2.0, 3.0]); + close(ev("PERCENTRANK", vec![t.clone(), n(2.0), n(400.0)]), 0.5); + close(ev("PERCENTRANK.INC", vec![t.clone(), n(2.0), n(400.0)]), 0.5); + close(ev("PERCENTRANK.INC", vec![t.clone(), n(1.0), n(400.0)]), 0.0); + close(ev("PERCENTRANK.EXC", vec![t, n(2.0), n(400.0)]), 0.5); +} + +#[test] +fn pairs_and_regression() { + close_tol(ev("CORREL", vec![c(&[3.0, 2.0, 4.0, 5.0, 6.0]), c(&[9.0, 7.0, 12.0, 15.0, 17.0])]), 0.997054486, 1e-9); + close_tol(ev("PEARSON", vec![c(&[9.0, 7.0, 5.0, 3.0, 1.0]), c(&[10.0, 6.0, 1.0, 5.0, 3.0])]), 0.699379, D7); + is_err(ev("CORREL", vec![c(&[1.0, 2.0]), c(&[1.0, 2.0, 3.0])]), CellError::NA); + is_err(ev("CORREL", vec![c(&[1.0, 1.0]), c(&[1.0, 2.0])]), CellError::Div0); + let y = c(&[2.0, 3.0, 9.0, 1.0, 8.0, 7.0, 5.0]); + let x = c(&[6.0, 5.0, 11.0, 7.0, 5.0, 4.0, 4.0]); + close_tol(ev("SLOPE", vec![y.clone(), x.clone()]), 0.305555556, 1e-8); + close_tol(ev("RSQ", vec![y.clone(), x.clone()]), 0.057950192, 1e-8); + close_tol(ev("STEYX", vec![y.clone(), x.clone()]), 3.305718950, 1e-8); + close_tol(ev("INTERCEPT", vec![c(&[2.0, 3.0, 9.0, 1.0, 8.0]), c(&[6.0, 5.0, 11.0, 7.0, 5.0])]), 0.048387097, 1e-8); + close_tol(ev("FORECAST", vec![n(30.0), c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 10.607253, D7); + close_tol(ev("FORECAST.LINEAR", vec![n(30.0), c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 10.607253, D7); + close(ev("COVARIANCE.P", vec![c(&[3.0, 2.0, 4.0, 5.0, 6.0]), c(&[9.0, 7.0, 12.0, 15.0, 17.0])]), 5.2); + close(ev("COVAR", vec![c(&[3.0, 2.0, 4.0, 5.0, 6.0]), c(&[9.0, 7.0, 12.0, 15.0, 17.0])]), 5.2); + close(ev("COVARIANCE.S", vec![c(&[2.0, 4.0, 8.0]), c(&[5.0, 11.0, 12.0])]), 29.0 / 3.0); + // Pairs with text are skipped. + let xs = arr(vec![vec![nv(1.0)], vec![tv("x")], vec![nv(3.0)]]); + close(ev("SLOPE", vec![c(&[2.0, 100.0, 6.0]), rf(xs)]), 2.0); +} + +#[test] +fn linest_family() { + let r = rows_of(&ev("LINEST", vec![c(&[1.0, 9.0, 5.0, 7.0]), c(&[0.0, 4.0, 2.0, 3.0])])); + assert_eq!(r.len(), 1); + close(r[0][0].clone(), 2.0); + close(r[0][1].clone(), 1.0); + // Statistics for a simple fit: y = 1,2,4 on x = 1,2,3. + let s = rows_of(&ev("LINEST", vec![c(&[1.0, 2.0, 4.0]), c(&[1.0, 2.0, 3.0]), b(true), b(true)])); + assert_eq!((s.len(), s[0].len()), (5, 2)); + close(s[0][0].clone(), 1.5); + close(s[0][1].clone(), -2.0 / 3.0); + close(s[2][0].clone(), 0.9642857142857143); // r² + close(s[3][1].clone(), 1.0); // df + close(s[4][0].clone(), 4.5); // ssreg + close(s[4][1].clone(), 1.0 / 6.0); // ssresid + close(s[2][1].clone(), (1.0f64 / 6.0).sqrt()); // sey + close(s[3][0].clone(), 27.0); // F + close(s[1][0].clone(), (1.0f64 / 6.0 / 2.0).sqrt()); // se slope + // No constant. + let z = rows_of(&ev("LINEST", vec![c(&[2.0, 4.0, 6.0]), c(&[1.0, 2.0, 3.0]), b(false)])); + close(z[0][0].clone(), 2.0); + close(z[0][1].clone(), 0.0); + // Two predictors: y = 1 + 2a + 3b. + let xs = arr(vec![vec![nv(1.0), nv(0.0)], vec![nv(0.0), nv(1.0)], vec![nv(1.0), nv(1.0)], vec![nv(2.0), nv(1.0)], vec![nv(3.0), nv(5.0)]]); + let ys: Vec = [(1.0, 0.0), (0.0, 1.0), (1.0, 1.0), (2.0, 1.0), (3.0, 5.0)].iter().map(|(a, b)| 1.0 + 2.0 * a + 3.0 * b).collect(); + let m = rows_of(&ev("LINEST", vec![c(&ys), av(xs)])); + close(m[0][0].clone(), 3.0); + close(m[0][1].clone(), 2.0); + close(m[0][2].clone(), 1.0); + let gy = [33100.0, 47300.0, 69000.0, 102000.0, 150000.0, 220000.0]; + let gx = [11.0, 12.0, 13.0, 14.0, 15.0, 16.0]; + let l = rows_of(&ev("LOGEST", vec![c(&gy), c(&gx)])); + close_tol(l[0][0].clone(), 1.463275628, 1e-9); + close_tol(l[0][1].clone(), 495.3047702, 1e-9); + let g = rows_of(&ev("GROWTH", vec![c(&gy), c(&gx), c(&[17.0, 18.0])])); + close_tol(g[0][0].clone(), 320196.7184, 1e-9); + close_tol(g[1][0].clone(), 468536.0539, 1e-9); + let ty = [133890.0, 135000.0, 135790.0, 137300.0, 138130.0, 139100.0, 139900.0, 141120.0, 141890.0, 143230.0, 144000.0, 145290.0]; + let tx: Vec = (1..=12).map(f64::from).collect(); + let t1 = ev("TREND", vec![c(&ty), c(&tx), n(13.0)]); + close_tol(Value::Number(rows_of(&t1)[0][0].as_f64().unwrap().round()), 146172.0, 1e-9); + let fitted = rows_of(&ev("TREND", vec![c(&[1.0, 9.0, 5.0, 7.0]), c(&[0.0, 4.0, 2.0, 3.0])])); + close(fitted[1][0].clone(), 9.0); + is_err(ev("LOGEST", vec![c(&[1.0, -2.0])]), CellError::Num); + is_err(ev("LINEST", vec![c(&[1.0, 2.0]), c(&[1.0, 2.0, 3.0])]), CellError::Ref); +} + +// #2177: one predictor row of 16,384 columns built three 16,384² matrices (6 GB) and an O(k³) +// inversion; a wider TOROW aborted the process. It is now refused before allocating. +#[test] +fn linest_family_caps_regressor_count() { + let wide = |k: usize| av(arr(vec![(0..k).map(|i| nv(i as f64)).collect()])); + let tall = |k: usize| av(arr((0..k).map(|i| vec![nv(i as f64), nv(1.0)]).collect())); + for f in ["LINEST", "LOGEST", "TREND", "GROWTH"] { + // y is one cell, so every column of x is a predictor. + is_err(ev(f, vec![n(1.0), wide(16_384)]), CellError::Value); + // y is a row, so every row of x is a predictor. + is_err(ev(f, vec![av(arr(vec![vec![nv(1.0), nv(2.0)]])), tall(16_384)]), CellError::Value); + } + is_err(ev("LINEST", vec![n(1.0), wide(MAX_REGRESSORS + 1)]), CellError::Value); + // At the cap the fit still runs (one observation: coefficients are zero, not an error). + assert_eq!(rows_of(&ev("LINEST", vec![n(1.0), wide(MAX_REGRESSORS)]))[0].len(), MAX_REGRESSORS + 1); + // The rows × k² work bound. + assert_eq!(check_regression_size(1_000, MAX_REGRESSORS), Ok(())); + assert_eq!(check_regression_size(1_000_000, 100), Err(CellError::Num)); + assert_eq!(check_regression_size(usize::MAX, 2), Err(CellError::Num)); + assert_eq!(check_regression_size(10_000_000, 1), Ok(())); +} + +#[test] +fn frequency_prob() { + let f = ev("FREQUENCY", vec![c(&[79.0, 85.0, 78.0, 85.0, 50.0, 81.0, 95.0, 88.0, 97.0]), c(&[70.0, 79.0, 89.0])]); + assert_eq!(rows_of(&f), vec![vec![nv(1.0)], vec![nv(2.0)], vec![nv(4.0)], vec![nv(2.0)]]); + let x = c(&[0.0, 1.0, 2.0, 3.0]); + let p = c(&[0.2, 0.3, 0.1, 0.4]); + close(ev("PROB", vec![x.clone(), p.clone(), n(2.0)]), 0.1); + close(ev("PROB", vec![x.clone(), p, n(1.0), n(3.0)]), 0.8); + is_err(ev("PROB", vec![x.clone(), c(&[0.5, 0.5, 0.5, 0.5]), n(1.0)]), CellError::Num); + // Excel: any probability <= 0 is #NUM!, even when it sums to one and lies outside the limits. + is_err(ev("PROB", vec![x.clone(), c(&[0.5, 0.0, 0.25, 0.25]), n(0.0), n(3.0)]), CellError::Num); + is_err(ev("PROB", vec![x, c(&[0.5, 0.0, 0.25, 0.25]), n(2.0), n(3.0)]), CellError::Num); +} + +#[test] +fn normal() { + close_tol(ev("NORM.DIST", vec![n(42.0), n(40.0), n(1.5), b(true)]), 0.9087888, D7); + close_tol(ev("NORM.DIST", vec![n(42.0), n(40.0), n(1.5), b(false)]), 0.10934005, D7); + close_tol(ev("NORMDIST", vec![n(42.0), n(40.0), n(1.5), b(true)]), 0.9087888, D7); + is_err(ev("NORM.DIST", vec![n(42.0), n(40.0), n(0.0), b(true)]), CellError::Num); + close_tol(ev("NORM.INV", vec![n(0.908789), n(40.0), n(1.5)]), 42.000002, D7); + close_tol(ev("NORMINV", vec![n(0.908789), n(40.0), n(1.5)]), 42.000002, D7); + is_err(ev("NORM.INV", vec![n(1.0), n(0.0), n(1.0)]), CellError::Num); + close_tol(ev("NORM.S.DIST", vec![n(1.333333), b(true)]), 0.908788726, 1e-8); + close_tol(ev("NORM.S.DIST", vec![n(0.0), b(false)]), 0.398942280, 1e-8); + close_tol(ev("NORMSDIST", vec![n(1.333333)]), 0.908788726, 1e-8); + close_tol(ev("NORM.S.INV", vec![n(0.908789)]), 1.3333347, D7); + close_tol(ev("NORMSINV", vec![n(0.975)]), 1.959963985, 1e-9); + close_tol(ev("PHI", vec![n(0.75)]), 0.301137432, 1e-8); + close_tol(ev("GAUSS", vec![n(2.0)]), 0.477249868, 1e-8); + close_tol(ev("FISHER", vec![n(0.75)]), 0.972955075, 1e-8); + close_tol(ev("FISHERINV", vec![n(0.972955)]), 0.75, D7); + is_err(ev("FISHER", vec![n(1.0)]), CellError::Num); + close_tol(ev("CONFIDENCE.NORM", vec![n(0.05), n(2.5), n(50.0)]), 0.692951912, 1e-8); + close_tol(ev("CONFIDENCE", vec![n(0.05), n(2.5), n(50.0)]), 0.692951912, 1e-8); + close_tol(ev("CONFIDENCE.T", vec![n(0.05), n(1.0), n(50.0)]), 0.284196855, 1e-8); + is_err(ev("CONFIDENCE.T", vec![n(0.05), n(1.0), n(1.0)]), CellError::Div0); + let z = c(&[3.0, 6.0, 7.0, 8.0, 6.0, 5.0, 4.0, 2.0, 1.0, 9.0]); + close_tol(ev("Z.TEST", vec![z.clone(), n(4.0)]), 0.090574, 1e-5); + close_tol(ev("ZTEST", vec![z, n(6.0)]), 0.863043, 1e-5); + close_tol(ev("LOGNORM.DIST", vec![n(4.0), n(3.5), n(1.2), b(true)]), 0.0390836, D7); + close_tol(ev("LOGNORM.DIST", vec![n(4.0), n(3.5), n(1.2), b(false)]), 0.0176176, D7); + close_tol(ev("LOGNORMDIST", vec![n(4.0), n(3.5), n(1.2)]), 0.0390836, D7); + close_tol(ev("LOGNORM.INV", vec![n(0.039084), n(3.5), n(1.2)]), 4.0000252, D7); + close_tol(ev("LOGINV", vec![n(0.039084), n(3.5), n(1.2)]), 4.0000252, D7); +} + +#[test] +fn t_chi_f() { + close_tol(ev("T.DIST", vec![n(60.0), n(1.0), b(true)]), 0.99469533, D7); + close_tol(ev("T.DIST", vec![n(8.0), n(3.0), b(false)]), 0.00073691, 1e-5); + close_tol(ev("T.DIST.2T", vec![n(1.959999998), n(60.0)]), 0.054644930, D7); + close_tol(ev("T.DIST.RT", vec![n(1.959999998), n(60.0)]), 0.027322465, D7); + close_tol(ev("TDIST", vec![n(1.959999998), n(60.0), n(2.0)]), 0.054644930, D7); + is_err(ev("TDIST", vec![n(-1.0), n(60.0), n(2.0)]), CellError::Num); + is_err(ev("T.DIST", vec![n(1.0), n(0.0), b(true)]), CellError::Num); + close_tol(ev("T.INV", vec![n(0.75), n(2.0)]), 0.8164966, D7); + close_tol(ev("T.INV", vec![n(0.25), n(2.0)]), -0.8164966, D7); + close_tol(ev("T.INV.2T", vec![n(0.546449), n(60.0)]), 0.606533, D7); + close_tol(ev("T.INV.2T", vec![n(0.05), n(10.0)]), 2.228138852, 1e-9); + close_tol(ev("TINV", vec![n(0.05), n(10.0)]), 2.228138852, 1e-9); + let a1 = c(&[3.0, 4.0, 5.0, 8.0, 9.0, 1.0, 2.0, 4.0, 5.0]); + let a2 = c(&[6.0, 19.0, 3.0, 2.0, 14.0, 4.0, 5.0, 17.0, 1.0]); + close_tol(ev("T.TEST", vec![a1.clone(), a2.clone(), n(2.0), n(1.0)]), 0.196016, 1e-5); + close_tol(ev("TTEST", vec![a1.clone(), a2.clone(), n(2.0), n(1.0)]), 0.196016, 1e-5); + close_tol(ev("T.TEST", vec![a1.clone(), a2.clone(), n(2.0), n(2.0)]), 0.191995887, 1e-6); + close_tol(ev("T.TEST", vec![a1.clone(), a2.clone(), n(1.0), n(3.0)]), 0.1012, 1e-3); + is_err(ev("T.TEST", vec![a1, a2, n(3.0), n(1.0)]), CellError::Num); + close_tol(ev("CHISQ.DIST", vec![n(0.5), n(1.0), b(true)]), 0.52049988, D7); + close_tol(ev("CHISQ.DIST", vec![n(2.0), n(3.0), b(false)]), 0.20755375, D7); + close_tol(ev("CHISQ.DIST.RT", vec![n(18.307), n(10.0)]), 0.0500006, 1e-5); + close_tol(ev("CHIDIST", vec![n(18.307), n(10.0)]), 0.0500006, 1e-5); + close_tol(ev("CHISQ.INV", vec![n(0.93), n(1.0)]), 3.283020287, 1e-8); + close_tol(ev("CHISQ.INV", vec![n(0.6), n(2.0)]), 1.832581464, 1e-8); + close_tol(ev("CHISQ.INV.RT", vec![n(0.050001), n(10.0)]), 18.30697, D7); + close_tol(ev("CHIINV", vec![n(0.050001), n(10.0)]), 18.30697, D7); + is_err(ev("CHISQ.DIST", vec![n(-1.0), n(1.0), b(true)]), CellError::Num); + let act = arr(vec![vec![nv(58.0), nv(35.0)], vec![nv(11.0), nv(25.0)], vec![nv(10.0), nv(23.0)]]); + let exp = arr(vec![vec![nv(45.35), nv(47.65)], vec![nv(17.56), nv(18.44)], vec![nv(16.09), nv(16.91)]]); + close_tol(ev("CHISQ.TEST", vec![av(act.clone()), av(exp.clone())]), 0.0003082, 1e-3); + close_tol(ev("CHITEST", vec![av(act), av(exp)]), 0.0003082, 1e-3); + close_tol(ev("F.DIST", vec![n(15.2069), n(6.0), n(4.0), b(true)]), 0.99, 1e-6); + close_tol(ev("F.DIST", vec![n(15.2069), n(6.0), n(4.0), b(false)]), 0.0012238, 1e-5); + close_tol(ev("F.DIST.RT", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6); + close_tol(ev("FDIST", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6); + close_tol(ev("F.INV", vec![n(0.01), n(6.0), n(4.0)]), 0.10930991, D7); + close_tol(ev("F.INV.RT", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7); + close_tol(ev("FINV", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7); + close_tol(ev("F.TEST", vec![c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 0.64831785, D7); + close_tol(ev("FTEST", vec![c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 0.64831785, D7); + is_err(ev("F.DIST", vec![n(1.0), n(0.0), n(4.0), b(true)]), CellError::Num); +} + +#[test] +fn binom_inv_huge_trials_returns() { + // Above 2^53 neighbouring f64 values are more than 1 apart; the bisection must still end. + let cases: [(&str, f64, f64, f64); 3] = [("BINOM.INV", 1e17, 0.5, 0.5), ("CRITBINOM", 1e300, 1.0, 0.5), ("BINOM.INV", 1e300, 0.5, 0.5)]; + for (name, trials, p, alpha) in cases { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let r = ev(name, vec![n(trials), n(p), n(alpha)]); + let _ = tx.send(r); + }); + match rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(Value::Number(_)) | Ok(Value::Error(CellError::Num)) => {} + Ok(other) => panic!("{name}({trials},{p},{alpha}) returned {other:?}"), + Err(_) => panic!("{name}({trials},{p},{alpha}) did not return within 5 s"), + } + } +} + +#[test] +fn discrete() { + close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12); + close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(true)]), 0.828125, 1e-12); + close_tol(ev("BINOMDIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12); + is_err(ev("BINOM.DIST", vec![n(11.0), n(10.0), n(0.5), b(false)]), CellError::Num); + close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(48.0)]), 0.083974967, 1e-6); + close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(45.0), n(50.0)]), 0.523629793, 1e-6); + close(ev("BINOM.INV", vec![n(6.0), n(0.5), n(0.75)]), 4.0); + close(ev("CRITBINOM", vec![n(6.0), n(0.5), n(0.75)]), 4.0); + close(ev("BINOM.INV", vec![n(1e9), n(0.5), n(0.5)]), 5e8); + close_tol(ev("POISSON.DIST", vec![n(2.0), n(5.0), b(true)]), 0.124652, 1e-5); + close_tol(ev("POISSON.DIST", vec![n(2.0), n(5.0), b(false)]), 0.084224, 1e-5); + close_tol(ev("POISSON", vec![n(2.0), n(5.0), b(false)]), 0.084224, 1e-5); + is_err(ev("POISSON.DIST", vec![n(-1.0), n(5.0), b(false)]), CellError::Num); + close_tol(ev("HYPGEOM.DIST", vec![n(1.0), n(4.0), n(8.0), n(20.0), b(true)]), 0.4654, 1e-4); + close_tol(ev("HYPGEOM.DIST", vec![n(1.0), n(4.0), n(8.0), n(20.0), b(false)]), 0.3633, 1e-4); + close_tol(ev("HYPGEOMDIST", vec![n(1.0), n(4.0), n(8.0), n(20.0)]), 0.363261094, 1e-8); + is_err(ev("HYPGEOMDIST", vec![n(5.0), n(4.0), n(8.0), n(20.0)]), CellError::Num); + close_tol(ev("NEGBINOM.DIST", vec![n(10.0), n(5.0), n(0.25), b(true)]), 0.3135141, D7); + close_tol(ev("NEGBINOM.DIST", vec![n(10.0), n(5.0), n(0.25), b(false)]), 0.0550487, D7); + close_tol(ev("NEGBINOMDIST", vec![n(10.0), n(5.0), n(0.25)]), 0.0550487, D7); +} + +#[test] +fn continuous() { + close_tol(ev("EXPON.DIST", vec![n(0.2), n(10.0), b(true)]), 0.86466472, D7); + close_tol(ev("EXPON.DIST", vec![n(0.2), n(10.0), b(false)]), 1.35335283, D7); + close_tol(ev("EXPONDIST", vec![n(0.2), n(10.0), b(true)]), 0.86466472, D7); + is_err(ev("EXPON.DIST", vec![n(0.2), n(0.0), b(true)]), CellError::Num); + close_tol(ev("GAMMA.DIST", vec![n(10.00001131), n(9.0), n(2.0), b(false)]), 0.032639, 1e-5); + close_tol(ev("GAMMA.DIST", vec![n(10.00001131), n(9.0), n(2.0), b(true)]), 0.068094, 1e-5); + close_tol(ev("GAMMADIST", vec![n(10.00001131), n(9.0), n(2.0), b(true)]), 0.068094, 1e-5); + close_tol(ev("GAMMA.INV", vec![n(0.068094), n(9.0), n(2.0)]), 10.0000112, 1e-6); + close_tol(ev("GAMMAINV", vec![n(0.068094), n(9.0), n(2.0)]), 10.0000112, 1e-6); + close_tol(ev("GAMMALN", vec![n(4.0)]), 1.791759469, 1e-9); + close_tol(ev("GAMMALN.PRECISE", vec![n(4.5)]), 2.453736571, 1e-9); + is_err(ev("GAMMALN", vec![n(0.0)]), CellError::Num); + close_tol(ev("GAMMA", vec![n(2.5)]), 1.329340388, 1e-9); + close_tol(ev("GAMMA", vec![n(-0.75)]), -4.834146544, 1e-8); + is_err(ev("GAMMA", vec![n(-1.0)]), CellError::Num); + is_err(ev("GAMMA", vec![n(0.0)]), CellError::Num); + close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(true), n(1.0), n(3.0)]), 0.6854706, D7); + close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(false), n(1.0), n(3.0)]), 1.4837646, D7); + close_tol(ev("BETADIST", vec![n(2.0), n(8.0), n(10.0), n(1.0), n(3.0)]), 0.6854706, D7); + close_tol(ev("BETA.INV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7); + close_tol(ev("BETAINV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7); + is_err(ev("BETA.DIST", vec![n(4.0), n(8.0), n(10.0), b(true), n(1.0), n(3.0)]), CellError::Num); + close_tol(ev("WEIBULL.DIST", vec![n(105.0), n(20.0), n(100.0), b(true)]), 0.929581, 1e-5); + close_tol(ev("WEIBULL.DIST", vec![n(105.0), n(20.0), n(100.0), b(false)]), 0.035589, 1e-4); + close_tol(ev("WEIBULL", vec![n(105.0), n(20.0), n(100.0), b(true)]), 0.929581, 1e-5); + // Lifting over an array of x values. + let r = rows_of(&ev("NORM.S.DIST", vec![av(row(&[0.0, 1.0])), b(true)])); + close(r[0][0].clone(), 0.5); + close_tol(r[0][1].clone(), 0.841344746, 1e-8); +} + +#[test] +fn aggregate_helper() { + let vals = vec![nv(1.0), nv(2.0), tv("x"), Value::Empty, Value::Bool(true), nv(6.0)]; + close(aggregate_values(1, &vals), 3.0); + close(aggregate_values(2, &vals), 3.0); + close(aggregate_values(3, &vals), 5.0); + close(aggregate_values(4, &vals), 6.0); + close(aggregate_values(105, &vals), 1.0); + close(aggregate_values(6, &vals), 12.0); + close(aggregate_values(9, &vals), 9.0); + close(aggregate_values(109, &vals), 9.0); + close(aggregate_values(11, &vals), 14.0 / 3.0); + close(aggregate_values(10, &vals), 7.0); + close(aggregate_values(12, &vals), 2.0); + is_err(aggregate_values(13, &vals), CellError::NA); + is_err(aggregate_values(14, &vals), CellError::Value); + is_err(aggregate_values(99, &vals), CellError::Value); + is_err(aggregate_values(9, &[nv(1.0), Value::Error(CellError::Div0)]), CellError::Div0); + close(aggregate_values_k(14, &vals, &nv(1.0)), 6.0); + close(aggregate_values_k(15, &vals, &nv(2.0)), 2.0); + close(aggregate_values_k(16, &vals, &nv(0.5)), 2.0); + close(aggregate_values_k(17, &vals, &nv(4.0)), 6.0); + close(aggregate_values_k(18, &vals, &nv(0.5)), 2.0); + close(aggregate_values_k(19, &vals, &nv(2.0)), 2.0); + close(aggregate_values_k(9, &vals, &nv(2.0)), 9.0); + is_err(aggregate_values_k(14, &vals, &nv(10.0)), CellError::Num); +} From 3ec086c1bdf83b893b34401774904ca4f9a90dae Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:44 +0000 Subject: [PATCH 36/76] refactor(ui-egui): move code_editor tests to src/code_editor/tests.rs --- crates/ui-egui/src/code_editor.rs | 375 +----------------------- crates/ui-egui/src/code_editor/tests.rs | 372 +++++++++++++++++++++++ 2 files changed, 373 insertions(+), 374 deletions(-) create mode 100644 crates/ui-egui/src/code_editor/tests.rs diff --git a/crates/ui-egui/src/code_editor.rs b/crates/ui-egui/src/code_editor.rs index e462e46ef..ee65b7ec8 100644 --- a/crates/ui-egui/src/code_editor.rs +++ b/crates/ui-egui/src/code_editor.rs @@ -570,377 +570,4 @@ pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max } #[cfg(test)] -mod tests { - use super::*; - use egui::Modifiers; - - fn spans(text: &str, lang: Lang) -> Vec<(&str, Token)> { - highlight(text, lang).into_iter().map(|(r, k)| (text.get(r).unwrap_or("?"), k)).collect() - } - - #[test] - fn python_tokens() { - let s = spans("def f(x):\n return len(x) + 1.5 # done\nxl(\"A1:B2\")", Lang::Python); - assert_eq!( - s, - vec![ - ("def", Token::Keyword), - ("return", Token::Keyword), - ("len", Token::Builtin), - ("1.5", Token::Number), - ("# done", Token::Comment), - ("xl", Token::Builtin), - ("\"A1:B2\"", Token::String), - ] - ); - assert_eq!(spans("s = '''a\n'b'\nc'''", Lang::Python), vec![("'''a\n'b'\nc'''", Token::String)]); - assert_eq!(spans("x = 'it\\'s'", Lang::Python), vec![("'it\\'s'", Token::String)]); - assert_eq!(spans("hello", Lang::Plain), vec![]); - } - - #[test] - fn latex_tokens() { - let s = spans("\\frac{a^2}{3} % half\n\\{", Lang::Latex); - assert_eq!( - s, - vec![ - ("\\frac", Token::Keyword), - ("^", Token::Builtin), - ("2", Token::Number), - ("3", Token::Number), - ("% half", Token::Comment), - ("\\{", Token::Keyword), - ] - ); - } - - #[test] - fn hostile_input_never_panics_and_spans_stay_on_char_boundaries() { - let inputs = [ - "", - "'", - "\"", - "'''", - "\"\"\"x", - "#", - "\\", - "%", - "1", - ".", - "é'ü\\", - "θ = 'π' # ∑", - "\u{1F600}'\u{1F600}", - "'\\", - "\\\u{1F600}", - "\u{200B}\n\t'''\n", - "0x_é", - "a.b.c", - "____", - ]; - for text in inputs { - for lang in [Lang::Python, Lang::Latex, Lang::Plain] { - let mut last = 0; - for (r, _) in highlight(text, lang) { - assert!(r.start >= last && r.start <= r.end && r.end <= text.len(), "{text:?} {lang:?} {r:?}"); - assert!(text.is_char_boundary(r.start) && text.is_char_boundary(r.end), "{text:?} {lang:?} {r:?}"); - last = r.end; - } - let job = layout_job(text, lang, &FontId::default(), egui::Color32::BLACK, &Tokens::light().syntax, f32::INFINITY); - assert_eq!(job.text, text, "every character is laid out once"); - } - } - } - - #[test] - fn enter_keeps_indentation_and_indents_after_a_colon() { - let mut s = " x = 1".to_string(); - let e = newline(&mut s, (9, 9), Lang::Python); - assert_eq!(s, " x = 1\n "); - assert_eq!(e.caret, 14); - let mut s = "if x:".to_string(); - let e = newline(&mut s, (5, 5), Lang::Python); - assert_eq!(s, "if x:\n "); - assert_eq!(e.caret, 10); - // Plain text keeps the indentation but adds none after a colon. - let mut s = " a:".to_string(); - newline(&mut s, (4, 4), Lang::Plain); - assert_eq!(s, " a:\n "); - // A selection is replaced; out-of-range carets are clamped. - let mut s = "abcdef".to_string(); - let e = newline(&mut s, (4, 2), Lang::Plain); - assert_eq!((s.as_str(), e.caret), ("ab\nef", 3)); - let mut s = "é".to_string(); - let e = newline(&mut s, (99, 99), Lang::Plain); - assert_eq!((s.as_str(), e.caret), ("é\n", 2)); - } - - #[test] - /// Shift+Tab removes four leading spaces, while a plain Tab still inserts four spaces at the caret. - fn shift_tab_in_the_editor_dedents_rather_than_indents() { - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( - |ui, text: &mut String| { - if ui.ctx().cumulative_pass_nr() == 0 { - ui.ctx().set_fonts(theme::pinned_font_definitions()); - return; - } - let id = egui::Id::unique("code"); - ui.memory_mut(|m| m.request_focus(id)); - code_editor(ui, id, text, Lang::Python, 5); - }, - " x".to_string(), - ); - h.run_steps(2); - let tab = |modifiers| egui::Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers }; - h.input_mut().events.push(tab(Modifiers::SHIFT)); - h.run_steps(1); - assert_eq!(h.state(), " x"); - // A plain Tab still inserts spaces at the caret (the end). - h.input_mut().events.push(tab(Modifiers::NONE)); - h.run_steps(1); - assert_eq!(h.state(), " x "); - } - - /// Gives the context the deterministic font set, including the editor's monospace family. - fn install_mono(ctx: &egui::Context) { - ctx.set_fonts(theme::pinned_font_definitions()); - } - - /// Builds a 400x300 code-editor harness with pinned mono fonts, focus, two passes and an accessibility check. - fn editor_harness(text: &str) -> egui_kittest::Harness<'static, String> { - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( - |ui, text: &mut String| { - if ui.ctx().cumulative_pass_nr() == 0 { - install_mono(ui.ctx()); - return; - } - let id = egui::Id::unique("code"); - ui.memory_mut(|m| m.request_focus(id)); - code_editor(ui, id, text, Lang::Python, 5); - }, - text.to_string(), - ); - h.run_steps(2); - h.check_accessibility(); - h - } - - fn key(key: Key, modifiers: Modifiers) -> egui::Event { - egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers } - } - - #[test] - fn batched_input_applies_in_order_and_keeps_every_key() { - // Events gather between repaints (typing, key repeat). The text typed before an Enter - // comes first, and two Enters make two lines. - let mut h = editor_harness(""); - h.input_mut().events.extend([egui::Event::Text("if x:".into()), key(Key::Enter, Modifiers::NONE), key(Key::Enter, Modifiers::NONE)]); - h.run_steps(1); - assert_eq!(h.state(), "if x:\n \n "); - // Backspace and Tab in the same frame as the typing around them. - h.input_mut().events.extend([ - egui::Event::Text("y = 1".into()), - key(Key::Backspace, Modifiers::NONE), - egui::Event::Text("2".into()), - key(Key::Tab, Modifiers::NONE), - ]); - h.run_steps(1); - assert_eq!(h.state(), "if x:\n \n y = 2 "); - // An event the field acts on (an arrow) keeps the order too: the text typed before it - // is applied first, the field moves the caret, and the Enter after it waits for the - // next pass, where the editor applies it at the moved caret, with the indentation. - h.input_mut().events.extend([egui::Event::Text("a".into()), key(Key::ArrowLeft, Modifiers::NONE), key(Key::Enter, Modifiers::NONE)]); - h.run_steps(1); - assert_eq!(h.state(), "if x:\n \n y = 2 a", "the Enter waits for the arrow"); - h.run_steps(1); - assert_eq!(h.state(), "if x:\n \n y = 2 \n a"); - } - - #[test] - fn a_selection_ending_at_a_line_start_leaves_that_line_alone() { - // Selecting "a\n" in "a\nb" ends at the start of the second line, which is not selected. - let mut s = "a\nb".to_string(); - indent(&mut s, (0, 2), false); - assert_eq!(s, " a\nb"); - let mut s = " a\n b".to_string(); - indent(&mut s, (0, 6), true); - assert_eq!(s, "a\n b"); - // One character into the second line, both lines are touched. - let mut s = "a\nb".to_string(); - indent(&mut s, (0, 3), false); - assert_eq!(s, " a\n b"); - } - - #[test] - fn typed_text_and_deletions_replace_the_selection() { - let mut s = "abcd".to_string(); - assert_eq!(insert(&mut s, (3, 1), "X"), Edit { anchor: 2, caret: 2 }); - assert_eq!(s, "aXd"); - assert_eq!(delete(&mut s, (2, 2), true), Edit { anchor: 1, caret: 1 }); - assert_eq!(s, "ad"); - assert_eq!(delete(&mut s, (1, 1), false), Edit { anchor: 1, caret: 1 }); - assert_eq!(s, "a"); - assert_eq!(delete(&mut s, (0, 0), true), Edit { anchor: 0, caret: 0 }); - assert_eq!(delete(&mut s, (9, 9), false), Edit { anchor: 1, caret: 1 }); - assert_eq!(s, "a"); - let mut s = "éa".to_string(); - delete(&mut s, (2, 0), true); - assert_eq!(s, ""); - // The queue keeps what the editor does not handle, and everything after it. - let mut events = vec![ - egui::Event::Text("x".into()), - egui::Event::Text("\n".into()), - key(Key::ArrowLeft, Modifiers::NONE), - key(Key::Enter, Modifiers::NONE), - ]; - let (mut s, mut sel) = (String::new(), (0, 0)); - let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); - assert!(out.edited); - assert_eq!((s.as_str(), sel), ("x", (1, 1))); - assert_eq!(events.len(), 2, "the lone newline and the arrow stay for the field: {events:?}"); - assert_eq!(out.held, vec![key(Key::Enter, Modifiers::NONE)], "the Enter after the arrow waits for the next pass"); - let mut events = vec![egui::Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }]; - let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); - assert!(!out.edited && out.held.is_empty(), "a key release is nothing to apply"); - assert_eq!(events.len(), 1); - } - - #[test] - fn select_all_then_tab_in_one_pass_indents_every_line() { - // Cmd+A is the field's, Tab the editor's: the Tab waits for the pass after the - // selection and indents the selected lines, rather than reaching the field, which would - // replace the selection with a tab character. - let mut h = editor_harness("a\nb"); - h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::NONE)]); - h.run_steps(1); - assert_eq!(h.state(), "a\nb", "the Tab waits for the selection"); - h.run_steps(1); - assert_eq!(h.state(), " a\n b"); - h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::SHIFT)]); - h.run_steps(2); - assert_eq!(h.state(), "a\nb"); - } - - #[test] - fn a_keystroke_sequence_as_the_platform_sends_it_keeps_the_editors_enter() { - // egui-winit sends a Key event before each Text event. Those presses are nobody's to - // read, so they do not hand the Enter to the field, which knows no indentation. - let mut h = editor_harness(""); - let typed = |k, c: &str, m| [key(k, m), egui::Event::Text(c.into())]; - let mut events = Vec::new(); - events.extend(typed(Key::I, "i", Modifiers::NONE)); - events.extend(typed(Key::F, "f", Modifiers::NONE)); - events.extend(typed(Key::Space, " ", Modifiers::NONE)); - events.extend(typed(Key::X, "x", Modifiers::NONE)); - events.extend(typed(Key::Colon, ":", Modifiers::SHIFT)); - events.push(key(Key::Enter, Modifiers::NONE)); - h.input_mut().events.extend(events); - h.run_steps(1); - assert_eq!(h.state(), "if x:\n "); - // A character typed with Option (macOS) comes as a key under that modifier and its text; - // the field reads neither the key nor Option, so the Enter after it is still the editor's. - h.input_mut().events.extend([key(Key::E, Modifiers::ALT), egui::Event::Text("\u{e9}".into()), key(Key::Enter, Modifiers::NONE)]); - h.run_steps(1); - assert_eq!(h.state(), "if x:\n \u{e9}\n "); - } - - #[test] - fn tab_and_enter_under_modifiers_never_reach_the_field() { - // The field would make a tab character of Ctrl+Tab (in place of the selection) and a - // newline of Shift+Enter; Cmd+Enter is nobody's here and stays for a shortcut. - let mut events = vec![key(Key::Tab, Modifiers::CTRL), key(Key::Enter, Modifiers::SHIFT), key(Key::Enter, Modifiers::COMMAND)]; - let (mut s, mut sel) = ("if x:".to_string(), (5, 5)); - let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); - assert_eq!(s, "if x:\n "); - assert_eq!(events, vec![key(Key::Enter, Modifiers::COMMAND)]); - assert!(out.held.is_empty()); - } - - #[test] - fn events_after_a_held_key_wait_with_it_and_pointer_events_do_not() { - let mut events = vec![ - key(Key::X, Modifiers::NONE), - egui::Event::Text("x".into()), - key(Key::A, Modifiers::COMMAND), - key(Key::Tab, Modifiers::NONE), - egui::Event::PointerMoved(egui::Pos2::ZERO), - key(Key::ArrowLeft, Modifiers::NONE), - egui::Event::Text("y".into()), - ]; - let (mut s, mut sel) = (String::new(), (0, 0)); - let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); - assert_eq!((s.as_str(), sel), ("x", (1, 1)), "the typing before the select-all is applied"); - assert_eq!(events, vec![key(Key::X, Modifiers::NONE), key(Key::A, Modifiers::COMMAND), egui::Event::PointerMoved(egui::Pos2::ZERO)]); - assert_eq!(out.held, vec![key(Key::Tab, Modifiers::NONE), key(Key::ArrowLeft, Modifiers::NONE), egui::Event::Text("y".into())]); - } - - #[test] - /// Events held by the previous pass are applied once in the next pass; older held events are dropped and cleared. - fn held_back_events_land_in_the_next_pass_only() { - let mut h = editor_harness(""); - let id = egui::Id::unique("code"); - let held = |pass, c: &str| Held { pass, events: vec![egui::Event::Text(c.into())] }; - // What the pass just completed held back comes in with the next one. - let last = h.ctx.cumulative_pass_nr() - 1; - h.ctx.data_mut(|d| d.insert_temp(held_id(id), held(last, "a"))); - h.run_steps(1); - assert_eq!(h.state(), "a"); - // Anything older is dropped rather than typed out of context, and cleared. - h.ctx.data_mut(|d| d.insert_temp(held_id(id), held(last, "b"))); - h.run_steps(1); - assert_eq!(h.state(), "a"); - assert!(h.ctx.data(|d| d.get_temp::(held_id(id))).is_none()); - } - - #[test] - /// Held keyboard input does not land in the next focused text field once the code editor has lost focus. - fn held_back_events_go_nowhere_once_the_editor_lost_the_focus() { - // Typed into the editor, they must not land in whatever has the focus next. - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( - |ui, s: &mut (String, String, bool)| { - if ui.ctx().cumulative_pass_nr() == 0 { - install_mono(ui.ctx()); - return; - } - let (code, other) = (egui::Id::unique("code"), egui::Id::unique("other")); - ui.memory_mut(|m| m.request_focus(if s.2 { code } else { other })); - code_editor(ui, code, &mut s.0, Lang::Python, 5); - ui.add(TextEdit::singleline(&mut s.1).id(other)).accessible_name("Other field"); - }, - (String::new(), String::new(), true), - ); - h.run_steps(2); - h.input_mut().events.extend([key(Key::ArrowLeft, Modifiers::NONE), egui::Event::Text("x".into())]); - h.run_steps(1); - assert_eq!(h.state().0, "", "the x waits for the arrow"); - h.state_mut().2 = false; - h.run_steps(2); - assert_eq!((h.state().0.as_str(), h.state().1.as_str()), ("", "")); - } - - #[test] - /// A discard-triggered repeat pass receives held keys with the selection made by the first pass. - fn a_repeat_pass_of_the_frame_counts_as_the_next_pass() { - // When a widget asks egui to discard and repeat the pass, the repeat pass gets the - // held-back keys, with the selection the field made in the first pass. - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( - |ui, s: &mut (String, bool)| { - if ui.ctx().cumulative_pass_nr() == 0 { - install_mono(ui.ctx()); - return; - } - let id = egui::Id::unique("code"); - ui.memory_mut(|m| m.request_focus(id)); - code_editor(ui, id, &mut s.0, Lang::Python, 5); - if std::mem::take(&mut s.1) { - ui.ctx().request_discard("the test repeats this pass"); - } - }, - ("a\nb".to_string(), false), - ); - h.run_steps(2); - h.state_mut().1 = true; - h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::NONE)]); - h.run_steps(1); - assert_eq!(h.state().0, " a\n b"); - } -} +mod tests; diff --git a/crates/ui-egui/src/code_editor/tests.rs b/crates/ui-egui/src/code_editor/tests.rs new file mode 100644 index 000000000..1bfcab75c --- /dev/null +++ b/crates/ui-egui/src/code_editor/tests.rs @@ -0,0 +1,372 @@ +use super::*; +use egui::Modifiers; + +fn spans(text: &str, lang: Lang) -> Vec<(&str, Token)> { + highlight(text, lang).into_iter().map(|(r, k)| (text.get(r).unwrap_or("?"), k)).collect() +} + +#[test] +fn python_tokens() { + let s = spans("def f(x):\n return len(x) + 1.5 # done\nxl(\"A1:B2\")", Lang::Python); + assert_eq!( + s, + vec![ + ("def", Token::Keyword), + ("return", Token::Keyword), + ("len", Token::Builtin), + ("1.5", Token::Number), + ("# done", Token::Comment), + ("xl", Token::Builtin), + ("\"A1:B2\"", Token::String), + ] + ); + assert_eq!(spans("s = '''a\n'b'\nc'''", Lang::Python), vec![("'''a\n'b'\nc'''", Token::String)]); + assert_eq!(spans("x = 'it\\'s'", Lang::Python), vec![("'it\\'s'", Token::String)]); + assert_eq!(spans("hello", Lang::Plain), vec![]); +} + +#[test] +fn latex_tokens() { + let s = spans("\\frac{a^2}{3} % half\n\\{", Lang::Latex); + assert_eq!( + s, + vec![ + ("\\frac", Token::Keyword), + ("^", Token::Builtin), + ("2", Token::Number), + ("3", Token::Number), + ("% half", Token::Comment), + ("\\{", Token::Keyword), + ] + ); +} + +#[test] +fn hostile_input_never_panics_and_spans_stay_on_char_boundaries() { + let inputs = [ + "", + "'", + "\"", + "'''", + "\"\"\"x", + "#", + "\\", + "%", + "1", + ".", + "é'ü\\", + "θ = 'π' # ∑", + "\u{1F600}'\u{1F600}", + "'\\", + "\\\u{1F600}", + "\u{200B}\n\t'''\n", + "0x_é", + "a.b.c", + "____", + ]; + for text in inputs { + for lang in [Lang::Python, Lang::Latex, Lang::Plain] { + let mut last = 0; + for (r, _) in highlight(text, lang) { + assert!(r.start >= last && r.start <= r.end && r.end <= text.len(), "{text:?} {lang:?} {r:?}"); + assert!(text.is_char_boundary(r.start) && text.is_char_boundary(r.end), "{text:?} {lang:?} {r:?}"); + last = r.end; + } + let job = layout_job(text, lang, &FontId::default(), egui::Color32::BLACK, &Tokens::light().syntax, f32::INFINITY); + assert_eq!(job.text, text, "every character is laid out once"); + } + } +} + +#[test] +fn enter_keeps_indentation_and_indents_after_a_colon() { + let mut s = " x = 1".to_string(); + let e = newline(&mut s, (9, 9), Lang::Python); + assert_eq!(s, " x = 1\n "); + assert_eq!(e.caret, 14); + let mut s = "if x:".to_string(); + let e = newline(&mut s, (5, 5), Lang::Python); + assert_eq!(s, "if x:\n "); + assert_eq!(e.caret, 10); + // Plain text keeps the indentation but adds none after a colon. + let mut s = " a:".to_string(); + newline(&mut s, (4, 4), Lang::Plain); + assert_eq!(s, " a:\n "); + // A selection is replaced; out-of-range carets are clamped. + let mut s = "abcdef".to_string(); + let e = newline(&mut s, (4, 2), Lang::Plain); + assert_eq!((s.as_str(), e.caret), ("ab\nef", 3)); + let mut s = "é".to_string(); + let e = newline(&mut s, (99, 99), Lang::Plain); + assert_eq!((s.as_str(), e.caret), ("é\n", 2)); +} + +#[test] +/// Shift+Tab removes four leading spaces, while a plain Tab still inserts four spaces at the caret. +fn shift_tab_in_the_editor_dedents_rather_than_indents() { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( + |ui, text: &mut String| { + if ui.ctx().cumulative_pass_nr() == 0 { + ui.ctx().set_fonts(theme::pinned_font_definitions()); + return; + } + let id = egui::Id::unique("code"); + ui.memory_mut(|m| m.request_focus(id)); + code_editor(ui, id, text, Lang::Python, 5); + }, + " x".to_string(), + ); + h.run_steps(2); + let tab = |modifiers| egui::Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers }; + h.input_mut().events.push(tab(Modifiers::SHIFT)); + h.run_steps(1); + assert_eq!(h.state(), " x"); + // A plain Tab still inserts spaces at the caret (the end). + h.input_mut().events.push(tab(Modifiers::NONE)); + h.run_steps(1); + assert_eq!(h.state(), " x "); +} + +/// Gives the context the deterministic font set, including the editor's monospace family. +fn install_mono(ctx: &egui::Context) { + ctx.set_fonts(theme::pinned_font_definitions()); +} + +/// Builds a 400x300 code-editor harness with pinned mono fonts, focus, two passes and an accessibility check. +fn editor_harness(text: &str) -> egui_kittest::Harness<'static, String> { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( + |ui, text: &mut String| { + if ui.ctx().cumulative_pass_nr() == 0 { + install_mono(ui.ctx()); + return; + } + let id = egui::Id::unique("code"); + ui.memory_mut(|m| m.request_focus(id)); + code_editor(ui, id, text, Lang::Python, 5); + }, + text.to_string(), + ); + h.run_steps(2); + h.check_accessibility(); + h +} + +fn key(key: Key, modifiers: Modifiers) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers } +} + +#[test] +fn batched_input_applies_in_order_and_keeps_every_key() { + // Events gather between repaints (typing, key repeat). The text typed before an Enter + // comes first, and two Enters make two lines. + let mut h = editor_harness(""); + h.input_mut().events.extend([egui::Event::Text("if x:".into()), key(Key::Enter, Modifiers::NONE), key(Key::Enter, Modifiers::NONE)]); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n \n "); + // Backspace and Tab in the same frame as the typing around them. + h.input_mut().events.extend([ + egui::Event::Text("y = 1".into()), + key(Key::Backspace, Modifiers::NONE), + egui::Event::Text("2".into()), + key(Key::Tab, Modifiers::NONE), + ]); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n \n y = 2 "); + // An event the field acts on (an arrow) keeps the order too: the text typed before it + // is applied first, the field moves the caret, and the Enter after it waits for the + // next pass, where the editor applies it at the moved caret, with the indentation. + h.input_mut().events.extend([egui::Event::Text("a".into()), key(Key::ArrowLeft, Modifiers::NONE), key(Key::Enter, Modifiers::NONE)]); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n \n y = 2 a", "the Enter waits for the arrow"); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n \n y = 2 \n a"); +} + +#[test] +fn a_selection_ending_at_a_line_start_leaves_that_line_alone() { + // Selecting "a\n" in "a\nb" ends at the start of the second line, which is not selected. + let mut s = "a\nb".to_string(); + indent(&mut s, (0, 2), false); + assert_eq!(s, " a\nb"); + let mut s = " a\n b".to_string(); + indent(&mut s, (0, 6), true); + assert_eq!(s, "a\n b"); + // One character into the second line, both lines are touched. + let mut s = "a\nb".to_string(); + indent(&mut s, (0, 3), false); + assert_eq!(s, " a\n b"); +} + +#[test] +fn typed_text_and_deletions_replace_the_selection() { + let mut s = "abcd".to_string(); + assert_eq!(insert(&mut s, (3, 1), "X"), Edit { anchor: 2, caret: 2 }); + assert_eq!(s, "aXd"); + assert_eq!(delete(&mut s, (2, 2), true), Edit { anchor: 1, caret: 1 }); + assert_eq!(s, "ad"); + assert_eq!(delete(&mut s, (1, 1), false), Edit { anchor: 1, caret: 1 }); + assert_eq!(s, "a"); + assert_eq!(delete(&mut s, (0, 0), true), Edit { anchor: 0, caret: 0 }); + assert_eq!(delete(&mut s, (9, 9), false), Edit { anchor: 1, caret: 1 }); + assert_eq!(s, "a"); + let mut s = "éa".to_string(); + delete(&mut s, (2, 0), true); + assert_eq!(s, ""); + // The queue keeps what the editor does not handle, and everything after it. + let mut events = vec![ + egui::Event::Text("x".into()), + egui::Event::Text("\n".into()), + key(Key::ArrowLeft, Modifiers::NONE), + key(Key::Enter, Modifiers::NONE), + ]; + let (mut s, mut sel) = (String::new(), (0, 0)); + let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); + assert!(out.edited); + assert_eq!((s.as_str(), sel), ("x", (1, 1))); + assert_eq!(events.len(), 2, "the lone newline and the arrow stay for the field: {events:?}"); + assert_eq!(out.held, vec![key(Key::Enter, Modifiers::NONE)], "the Enter after the arrow waits for the next pass"); + let mut events = vec![egui::Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }]; + let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); + assert!(!out.edited && out.held.is_empty(), "a key release is nothing to apply"); + assert_eq!(events.len(), 1); +} + +#[test] +fn select_all_then_tab_in_one_pass_indents_every_line() { + // Cmd+A is the field's, Tab the editor's: the Tab waits for the pass after the + // selection and indents the selected lines, rather than reaching the field, which would + // replace the selection with a tab character. + let mut h = editor_harness("a\nb"); + h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::NONE)]); + h.run_steps(1); + assert_eq!(h.state(), "a\nb", "the Tab waits for the selection"); + h.run_steps(1); + assert_eq!(h.state(), " a\n b"); + h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::SHIFT)]); + h.run_steps(2); + assert_eq!(h.state(), "a\nb"); +} + +#[test] +fn a_keystroke_sequence_as_the_platform_sends_it_keeps_the_editors_enter() { + // egui-winit sends a Key event before each Text event. Those presses are nobody's to + // read, so they do not hand the Enter to the field, which knows no indentation. + let mut h = editor_harness(""); + let typed = |k, c: &str, m| [key(k, m), egui::Event::Text(c.into())]; + let mut events = Vec::new(); + events.extend(typed(Key::I, "i", Modifiers::NONE)); + events.extend(typed(Key::F, "f", Modifiers::NONE)); + events.extend(typed(Key::Space, " ", Modifiers::NONE)); + events.extend(typed(Key::X, "x", Modifiers::NONE)); + events.extend(typed(Key::Colon, ":", Modifiers::SHIFT)); + events.push(key(Key::Enter, Modifiers::NONE)); + h.input_mut().events.extend(events); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n "); + // A character typed with Option (macOS) comes as a key under that modifier and its text; + // the field reads neither the key nor Option, so the Enter after it is still the editor's. + h.input_mut().events.extend([key(Key::E, Modifiers::ALT), egui::Event::Text("\u{e9}".into()), key(Key::Enter, Modifiers::NONE)]); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n \u{e9}\n "); +} + +#[test] +fn tab_and_enter_under_modifiers_never_reach_the_field() { + // The field would make a tab character of Ctrl+Tab (in place of the selection) and a + // newline of Shift+Enter; Cmd+Enter is nobody's here and stays for a shortcut. + let mut events = vec![key(Key::Tab, Modifiers::CTRL), key(Key::Enter, Modifiers::SHIFT), key(Key::Enter, Modifiers::COMMAND)]; + let (mut s, mut sel) = ("if x:".to_string(), (5, 5)); + let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); + assert_eq!(s, "if x:\n "); + assert_eq!(events, vec![key(Key::Enter, Modifiers::COMMAND)]); + assert!(out.held.is_empty()); +} + +#[test] +fn events_after_a_held_key_wait_with_it_and_pointer_events_do_not() { + let mut events = vec![ + key(Key::X, Modifiers::NONE), + egui::Event::Text("x".into()), + key(Key::A, Modifiers::COMMAND), + key(Key::Tab, Modifiers::NONE), + egui::Event::PointerMoved(egui::Pos2::ZERO), + key(Key::ArrowLeft, Modifiers::NONE), + egui::Event::Text("y".into()), + ]; + let (mut s, mut sel) = (String::new(), (0, 0)); + let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); + assert_eq!((s.as_str(), sel), ("x", (1, 1)), "the typing before the select-all is applied"); + assert_eq!(events, vec![key(Key::X, Modifiers::NONE), key(Key::A, Modifiers::COMMAND), egui::Event::PointerMoved(egui::Pos2::ZERO)]); + assert_eq!(out.held, vec![key(Key::Tab, Modifiers::NONE), key(Key::ArrowLeft, Modifiers::NONE), egui::Event::Text("y".into())]); +} + +#[test] +/// Events held by the previous pass are applied once in the next pass; older held events are dropped and cleared. +fn held_back_events_land_in_the_next_pass_only() { + let mut h = editor_harness(""); + let id = egui::Id::unique("code"); + let held = |pass, c: &str| Held { pass, events: vec![egui::Event::Text(c.into())] }; + // What the pass just completed held back comes in with the next one. + let last = h.ctx.cumulative_pass_nr() - 1; + h.ctx.data_mut(|d| d.insert_temp(held_id(id), held(last, "a"))); + h.run_steps(1); + assert_eq!(h.state(), "a"); + // Anything older is dropped rather than typed out of context, and cleared. + h.ctx.data_mut(|d| d.insert_temp(held_id(id), held(last, "b"))); + h.run_steps(1); + assert_eq!(h.state(), "a"); + assert!(h.ctx.data(|d| d.get_temp::(held_id(id))).is_none()); +} + +#[test] +/// Held keyboard input does not land in the next focused text field once the code editor has lost focus. +fn held_back_events_go_nowhere_once_the_editor_lost_the_focus() { + // Typed into the editor, they must not land in whatever has the focus next. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( + |ui, s: &mut (String, String, bool)| { + if ui.ctx().cumulative_pass_nr() == 0 { + install_mono(ui.ctx()); + return; + } + let (code, other) = (egui::Id::unique("code"), egui::Id::unique("other")); + ui.memory_mut(|m| m.request_focus(if s.2 { code } else { other })); + code_editor(ui, code, &mut s.0, Lang::Python, 5); + ui.add(TextEdit::singleline(&mut s.1).id(other)).accessible_name("Other field"); + }, + (String::new(), String::new(), true), + ); + h.run_steps(2); + h.input_mut().events.extend([key(Key::ArrowLeft, Modifiers::NONE), egui::Event::Text("x".into())]); + h.run_steps(1); + assert_eq!(h.state().0, "", "the x waits for the arrow"); + h.state_mut().2 = false; + h.run_steps(2); + assert_eq!((h.state().0.as_str(), h.state().1.as_str()), ("", "")); +} + +#[test] +/// A discard-triggered repeat pass receives held keys with the selection made by the first pass. +fn a_repeat_pass_of_the_frame_counts_as_the_next_pass() { + // When a widget asks egui to discard and repeat the pass, the repeat pass gets the + // held-back keys, with the selection the field made in the first pass. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( + |ui, s: &mut (String, bool)| { + if ui.ctx().cumulative_pass_nr() == 0 { + install_mono(ui.ctx()); + return; + } + let id = egui::Id::unique("code"); + ui.memory_mut(|m| m.request_focus(id)); + code_editor(ui, id, &mut s.0, Lang::Python, 5); + if std::mem::take(&mut s.1) { + ui.ctx().request_discard("the test repeats this pass"); + } + }, + ("a\nb".to_string(), false), + ); + h.run_steps(2); + h.state_mut().1 = true; + h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::NONE)]); + h.run_steps(1); + assert_eq!(h.state().0, " a\n b"); +} From 27a39472edf82bdb609c4ea176d26c096332b677 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:45 +0000 Subject: [PATCH 37/76] refactor(ui-egui): move control tests to src/control/tests.rs --- crates/ui-egui/src/control.rs | 990 +--------------------------- crates/ui-egui/src/control/tests.rs | 987 +++++++++++++++++++++++++++ 2 files changed, 988 insertions(+), 989 deletions(-) create mode 100644 crates/ui-egui/src/control/tests.rs diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index a4c226b81..5e8be4945 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1411,992 +1411,4 @@ pub fn save_image(image: std::sync::Arc, path: Option<&str>) - } #[cfg(test)] -mod tests { - use super::*; - - fn sample() -> Vec { - vec![ - json!({"id": "home.borders", "label": "Borders", "menu": ["Home", "Font"]}), - json!({"id": "home.bold", "label": "Bold", "menu": ["Home", "Font"]}), - json!({"id": "ui.ribbonTab", "label": "Ribbon Tab", "enabled": true, "ui": true}), - ] - } - - fn ids(v: &[Json]) -> Vec<&str> { - v.iter().filter_map(|c| c.get("id").and_then(Json::as_str)).collect() - } - - #[test] - fn search_filters_case_insensitively() { - assert_eq!(ids(&filter_commands(sample(), Some("border"))), ["home.borders"]); - assert_eq!(filter_commands(sample(), Some("BORDER")), filter_commands(sample(), Some("border"))); - assert_eq!(ids(&filter_commands(sample(), Some("ribbon"))), ["ui.ribbonTab"]); - assert_eq!(ids(&filter_commands(sample(), Some("font"))), ["home.borders", "home.bold"]); - assert!(filter_commands(sample(), Some("catalog_no_match_7391")).is_empty()); - assert_eq!(filter_commands(sample(), None).len(), 3); - } - - fn test_app() -> SheetApp { - let mut session = gridcraft_engine::Session::new(); - session.new_workbook(); - SheetApp::new(session, crate::Services::default()) - } - - #[test] - /// Checks that oversized click/drag counts are refused without changing already queued batches, that queued input counts toward the budget, and that normal gestures still queue fully. - fn oversized_pointer_requests_are_refused_and_leave_the_queue_alone() { - let mut app = test_app(); - let p = egui::pos2(20.0, 20.0); - let m = egui::Modifiers::NONE; - let btn = egui::PointerButton::Primary; - assert!(push_click(&mut app, p, btn, 2, m).is_ok()); - assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), 5); - assert!(push_click(&mut app, p, btn, u32::MAX, m).is_err()); - assert!(push_drag(&mut app, p, p, usize::MAX, m).is_err()); - assert!(push_drag(&mut app, p, p, MAX_QUEUED_SYNTHETIC_EVENTS, m).is_err()); - assert_eq!(app.synthetic.len(), 2); - assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), 5); - // Already queued input counts toward the budget. - assert!(push_click(&mut app, p, btn, (MAX_QUEUED_SYNTHETIC_EVENTS / 2) as u32, m).is_err()); - assert_eq!(app.synthetic.len(), 2); - // Normal requests still queue in full. - assert!(push_drag(&mut app, p, p + egui::vec2(40.0, 40.0), 3, m).is_ok()); - assert_eq!(app.synthetic.len(), 8); - } - - #[test] - fn print_command_discovery_documents_settings_and_submission() { - let mut session = gridcraft_engine::Session::new(); - session.new_workbook(); - let mut app = SheetApp::new(session, crate::Services::default()); - let Outcome::Done(reply) = handle(&mut app, &egui::Context::default(), "engine.commands", &json!({"search":"print"})) else { - panic!("command discovery must return immediately"); - }; - assert_eq!(reply["ok"], true); - let commands = reply["result"].as_array().unwrap(); - for (id, expected_docs) in [ - ("ui.printSettings", &["copies", "duplex", "refresh", "discovering", "status", "detail"][..]), - ("ui.printSubmit", &["file.print", "pagination", "pending", "without opening a dialog"][..]), - ] { - let command = commands.iter().find(|command| command["id"] == id).unwrap(); - assert_eq!(command["ui"], true); - let docs = command["params"].as_str().unwrap(); - for expected in expected_docs { - assert!(docs.contains(expected), "{id}: missing {expected}"); - } - } - assert!(!app.printing.busy(), "discovery must not submit a job"); - assert!(app.backstage.is_none(), "discovery must not open settings"); - } - - #[test] - fn language_command_docs_list_every_language() { - let mut session = gridcraft_engine::Session::new(); - session.new_workbook(); - let mut app = SheetApp::new(session, crate::Services::default()); - let Outcome::Done(reply) = handle(&mut app, &egui::Context::default(), "engine.commands", &json!({"search":"language"})) else { - panic!("command discovery must return immediately"); - }; - let commands = reply["result"].as_array().unwrap(); - let docs = commands.iter().find(|c| c["id"] == "app.language.set").unwrap()["params"].as_str().unwrap(); - for l in crate::i18n::Language::ALL { - assert!(docs.contains(&format!("\"{}\"", l.code())), "app.language.set docs miss {:?}: {docs}", l.code()); - } - } - /// One headless UI pass. A test has no renderer to apply the font-atlas delta, so dropping - /// it is intentional (0.37 panics when a `TexturesDelta` is dropped unapplied). - fn ui_pass(ctx: &egui::Context, raw: egui::RawInput, f: impl FnMut(&mut egui::Ui)) -> egui::FullOutput { - let mut out = ctx.run_ui(raw, f); - out.textures_delta.clear(); - out - } - - /// Returns an egui context initialized by one blank pass at `size` with an 8192-pixel texture limit. - fn sized_context(size: egui::Vec2) -> egui::Context { - let ctx = egui::Context::default(); - let raw = egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), - max_texture_side: Some(8192), - ..Default::default() - }; - let _ = ui_pass(&ctx, raw, |_| {}); - ctx - } - - /// Returns blank raw input with a screen rect at `size` and an 8192-pixel texture limit. - fn raw_of(size: egui::Vec2) -> egui::RawInput { - egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), max_texture_side: Some(8192), ..Default::default() } - } - - /// Extracts cloned `ViewportCommand::Screenshot` callbacks from a `FullOutput` for the context's viewport. - fn screenshot_callbacks(out: &egui::FullOutput, ctx: &egui::Context) -> Vec { - out.viewport_output - .get(&ctx.viewport_id()) - .map(|output| { - output - .commands - .iter() - .filter_map(|command| match command { - egui::ViewportCommand::Screenshot(callback) => Some(callback.clone()), - _ => None, - }) - .collect::>() - }) - .unwrap_or_default() - } - - /// Run three settled passes with `raw` and return the callbacks issued in them. - fn issue_with(ctx: &egui::Context, app: &mut SheetApp, raw: egui::RawInput) -> Vec { - let mut callbacks = Vec::new(); - for _ in 0..3 { - let out = ui_pass(ctx, raw.clone(), |ui| issue_screenshots(app, ui.ctx())); - callbacks.extend(screenshot_callbacks(&out, ctx)); - } - callbacks - } - - /// Returns a per-process temporary PNG path after removing any stale file at that path. - fn temp_png(name: &str) -> std::path::PathBuf { - let path = std::env::temp_dir().join(format!("gridcraft-shot-{name}-{}.png", std::process::id())); - let _ = std::fs::remove_file(&path); - path - } - - #[test] - /// Checks rounded physical pixels, expected/retained byte counts, the texture-axis cap, the no-size case and overflow refusal. - fn capture_geometry_reports_checked_capped_pixels() { - let geometry = CaptureGeometry { - viewport_id: egui::ViewportId::ROOT, - viewport: Some(egui::vec2(800.0, 600.0)), - pixels_per_point: 2.0, - zoom: 1.0, - max_texture_side: 4096, - minimized: None, - occluded: None, - visible: None, - }; - assert_eq!(geometry.expected_size(), Some([1600, 1200])); - assert_eq!(geometry.expected_bytes(), Some(1600 * 1200 * 4)); - assert_eq!(geometry.retained_bytes(), Some(1601 * 1201 * 4), "one pixel of rounding per axis is reserved too"); - let capped = CaptureGeometry { max_texture_side: 1024, ..geometry }; - assert_eq!(capped.expected_size(), Some([1024, 1024]), "the surface limit caps both axes"); - let empty = CaptureGeometry { viewport: Some(egui::Vec2::ZERO), ..geometry }; - assert_eq!(empty.expected_size(), None, "a minimized window has no capturable size"); - assert_eq!(empty.retained_bytes(), None); - let huge = CaptureGeometry { viewport: Some(egui::vec2(f32::MAX, f32::MAX)), ..geometry }; - assert_eq!(huge.expected_size(), None, "overflow is refused, not truncated"); - } - - #[test] - /// Checks that one request beyond `SCREENSHOT_MAX_REQUESTS` is rejected with its token while earlier requests stay queued and do not reply early. - fn screenshot_admission_stops_on_the_count_budget_without_evicting() { - let ctx = sized_context(egui::vec2(800.0, 600.0)); - let mut app = test_app(); - let mut replies = Vec::new(); - for _ in 0..SCREENSHOT_MAX_REQUESTS { - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - replies.push(rx); - } - assert_eq!(app.shots.requests.len(), SCREENSHOT_MAX_REQUESTS); - assert!(replies.iter_mut().all(|r| r.try_recv().is_err()), "admitted requests do not reply early"); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - let reply = rx.try_recv().expect("the over-count request is rejected"); - assert_eq!(reply["ok"], false); - assert!(reply["error"].as_str().is_some_and(|e| e.contains("limit 4")), "{reply}"); - assert!(reply["request"].as_u64().is_some(), "a rejection carries its request token: {reply}"); - assert_eq!(app.shots.requests.len(), SCREENSHOT_MAX_REQUESTS, "an earlier request is never evicted"); - } - - #[test] - /// Three roughly 100 MB path captures hit the aggregate byte limit; the third is rejected and only two requests remain. - fn screenshot_admission_stops_on_aggregate_bytes_before_the_count() { - let ctx = sized_context(egui::vec2(5000.0, 5000.0)); - let mut app = test_app(); - let path = temp_png("aggregate").to_string_lossy().into_owned(); - let mut rejected = None; - for _ in 0..3 { - let (tx, rx) = std::sync::mpsc::channel(); - // A path capture (not base64) isolates the retained-image aggregate budget. - admit(&mut app, &ctx, Some(path.clone()), tx); - if let Ok(reply) = rx.try_recv() { - rejected = Some(reply); - } - } - let reply = rejected.expect("three 100 MB captures exceed the 256 MiB aggregate budget"); - assert!(reply["error"].as_str().is_some_and(|e| e.contains("aggregate limit")), "{reply}"); - assert_eq!(app.shots.requests.len(), 2); - } - - #[test] - /// An unknown-size request reserves the placeholder, issues nothing while settling, then replies once on timeout and releases the reservation. - fn requests_without_a_size_stay_queued_and_time_out() { - let ctx = sized_context(egui::Vec2::ZERO); - let mut app = test_app(); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - assert!(rx.try_recv().is_err(), "an unknown size is queued, not refused with an overflow error"); - assert_eq!(app.shots.requests[0].bytes, UNKNOWN_CAPTURE_BYTES, "the largest possible image is reserved meanwhile"); - // Settling passes without a size neither issue nor fail the request. - let callbacks = issue_with(&ctx, &mut app, raw_of(egui::Vec2::ZERO)); - assert!(callbacks.is_empty()); - assert!(!app.shots.requests[0].issued); - assert!(rx.try_recv().is_err()); - app.shots.requests[0].deadline = 0.0; - let _ = ui_pass(&ctx, raw_of(egui::Vec2::ZERO), |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the size-less request times out once"); - assert!(reply["error"].as_str().is_some_and(|e| e.contains("capturable size")), "{reply}"); - assert_eq!(app.shots.reserved(), 0, "the placeholder reservation is released"); - } - - /// Counts `RequestPaintWhileHidden` commands in a logic output. - fn hidden_paints(out: &egui::LogicOutput) -> usize { - out.viewport_commands.values().flatten().filter(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden)).count() - } - - #[test] - /// A queued capture requests a hidden paint; once issued, the screenshot command supplies the paint itself. - fn a_queued_capture_requests_hidden_paint_until_it_is_issued() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let (tx, _rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - let queued = ctx.run_logic(&raw_of(egui::vec2(64.0, 48.0)), |ctx| app.logic(ctx)); - assert_eq!(hidden_paints(&queued), 1, "a queued capture needs a real `ui` pass"); - - app.shots.requests[0].ready_at = 0.0; - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 1); - let issued = ctx.run_logic(&raw_of(egui::vec2(64.0, 48.0)), |ctx| app.logic(ctx)); - assert_eq!(hidden_paints(&issued), 0, "the issued Screenshot command paints the hidden window by itself"); - } - - #[test] - /// A minimized request stays queued without requesting hidden paints, because it cannot issue until restored. - fn a_minimized_capture_does_not_force_hidden_frames_while_it_waits() { - let ctx = egui::Context::default(); - let mut app = test_app(); - let mut raw = raw_of(egui::vec2(800.0, 600.0)); - if let Some(v) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { - v.inner_rect = None; - v.minimized = Some(true); - } - app.raw_input_hook(&ctx, &mut raw); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - assert!(rx.try_recv().is_err()); - let waited = ctx.run_logic(&raw, |ctx| app.logic(ctx)); - assert_eq!(hidden_paints(&waited), 0, "a minimized request cannot issue until it is restored"); - app.shots.cancel_all(); - } - - #[test] - /// The real app harness issues exactly one callback, replies once with the token and dimensions, and releases the reservation. - fn one_admitted_capture_issues_one_callback_and_replies_once() { - use egui_kittest::kittest::Queryable as _; - - let mut app = test_app(); - app.use_prepared_fonts(); - let mut h = egui_kittest::Harness::builder() - .with_size(egui::vec2(900.0, 700.0)) - .with_context_setup(|ctx| { - ctx.set_fonts(crate::theme::pinned_font_definitions()); - crate::theme::apply(ctx, false); - }) - // This test completes the callback itself. Keep the real app/AccessKit tree, but - // do not let kittest's automatic screenshot handling initialize a GPU renderer. - .renderer(egui_kittest::LazyRenderer::Uninitialized { textures_delta: Default::default(), builder: None }) - .build_ui_state( - |ui, app: &mut SheetApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - app, - ); - h.run_steps(4); - assert!(h.query_by_label("Name Box").is_some(), "the capture fixture draws the real app, not an empty tree"); - h.check_accessibility(); - - let ctx = h.ctx.clone(); - let (tx, rx) = std::sync::mpsc::channel(); - admit(h.state_mut(), &ctx, None, tx); - let token = h.state().shots.requests[0].token; - h.state_mut().shots.requests[0].ready_at = 0.0; - let mut callbacks = Vec::new(); - for _ in 0..3 { - h.step(); - callbacks.extend(screenshot_callbacks(h.output(), &ctx)); - } - assert_eq!(callbacks.len(), 1, "the settled request is issued once"); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([900, 700], egui::Color32::RED))); - h.step(); // the real logic drains the callback and the UI repaints - h.check_accessibility(); - assert!(h.query_by_label("Name Box").is_some(), "the app tree survives callback drain and repaint"); - let reply = rx.try_recv().expect("the drain replies once"); - assert_eq!(reply["ok"], true, "{reply}"); - assert_eq!(reply["result"]["width"], 900); - assert_eq!(reply["result"]["height"], 700); - assert_eq!(reply["request"].as_u64(), Some(token), "the reply names its request"); - assert!(rx.try_recv().is_err(), "a completed slot is taken exactly once"); - assert_eq!(h.state().shots.reserved(), 0, "the reservation is released after the reply"); - } - - #[test] - /// Completing callbacks in reverse order replies on each matching token/path and writes each request's own pixels. - fn out_of_order_completions_reply_on_their_own_request() { - let ctx = sized_context(egui::vec2(8.0, 8.0)); - let mut app = test_app(); - let p1 = temp_png("order-1"); - let p2 = temp_png("order-2"); - let (tx1, rx1) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(p1.to_string_lossy().into_owned()), tx1); - let token1 = app.shots.requests[0].token; - let (tx2, rx2) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(p2.to_string_lossy().into_owned()), tx2); - let token2 = app.shots.requests[1].token; - for request in &mut app.shots.requests { - request.ready_at = 0.0; - } - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 2, "one callback per admitted request"); - // Complete the second request first: slots, not callback order, carry the result. - callbacks[1].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([8, 8], egui::Color32::BLUE))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - let second = rx2.try_recv().expect("the second request gets its own reply"); - assert_eq!(second["ok"], true, "{second}"); - assert_eq!(second["request"].as_u64(), Some(token2)); - assert_eq!(second["result"]["path"].as_str(), Some(p2.to_string_lossy().as_ref())); - assert!(!p1.exists(), "the unfinished request has not written anything"); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([8, 8], egui::Color32::RED))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - let first = rx1.try_recv().expect("the first request gets its own reply"); - assert_eq!(first["request"].as_u64(), Some(token1)); - assert_eq!(first["result"]["path"].as_str(), Some(p1.to_string_lossy().as_ref())); - assert_ne!(std::fs::read(&p1).unwrap(), std::fs::read(&p2).unwrap(), "each reply carries its own pixels"); - let _ = std::fs::remove_file(&p1); - let _ = std::fs::remove_file(&p2); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// An expired request replies once with a timeout, writes no file and releases its reservation. - fn a_timed_out_capture_replies_once_and_never_writes() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let path = temp_png("timeout"); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); - app.shots.requests[0].deadline = 0.0; - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the timeout replies"); - assert_eq!(reply["ok"], false); - assert!(reply["error"].as_str().is_some_and(|e| e.contains("timeout")), "{reply}"); - assert!(!path.exists(), "a timed-out capture never writes a file"); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - assert!(rx.try_recv().is_err(), "a timeout is reported once"); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// A valid image that completes after its deadline gets one timeout reply and is not written. - fn a_capture_completed_after_its_deadline_writes_nothing() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let path = temp_png("late"); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); - let token = app.shots.requests[0].token; - app.shots.requests[0].ready_at = 0.0; - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 1); - // The deadline passes while the callback is outstanding; the image arrives afterwards. - app.shots.requests[0].deadline = 0.0; - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the late completion gets one timeout reply"); - assert_eq!(reply["ok"], false, "{reply}"); - assert!(reply["error"].as_str().is_some_and(|e| e.contains("timeout")), "{reply}"); - assert_eq!(reply["request"].as_u64(), Some(token)); - assert!(!path.exists(), "a capture that completed after its deadline never writes a file"); - assert!(rx.try_recv().is_err(), "the late image is reported once"); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// Cancelling a request does not reply; a later callback for it is ignored and writes nothing. - fn a_callback_after_the_request_is_removed_is_inert() { - let ctx = sized_context(egui::vec2(32.0, 24.0)); - let mut app = test_app(); - let path = temp_png("removed"); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); - app.shots.requests[0].ready_at = 0.0; - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 1); - app.shots.cancel_all(); - assert!(rx.try_recv().is_err(), "cancelling a request does not reply"); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::RED))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - assert!(rx.try_recv().is_err(), "a callback for a removed request is inert"); - assert!(!path.exists()); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// The first issuance adopts a new geometry, updates the epoch and reservation, spends no retry, and the capture then succeeds. - fn a_resize_between_admission_and_issuance_does_not_cost_a_retry() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - app.shots.requests[0].ready_at = 0.0; - let admitted_epoch = app.shots.requests[0].epoch; - let resized = raw_of(egui::vec2(32.0, 24.0)); - let callbacks = issue_with(&ctx, &mut app, resized.clone()); - assert_eq!(callbacks.len(), 1); - let request = &app.shots.requests[0]; - assert_eq!(request.retries, 0, "the first issuance adopts the current geometry instead of spending a retry"); - assert_eq!(request.geometry.viewport, Some(egui::vec2(32.0, 24.0))); - assert_eq!(request.expected, Some([32, 24])); - assert_eq!(request.bytes, 33 * 25 * 4); - assert!(request.epoch > admitted_epoch, "issuance records the new epoch together with the geometry"); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::RED))); - let _ = ui_pass(&ctx, resized, |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the once-issued capture replies"); - assert_eq!(reply["ok"], true, "{reply}"); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// The reservation is the largest accepted rounded image (11×9×4 bytes for a 10×8 viewport), and that image is accepted. - fn the_retained_reservation_is_the_slot_bound() { - let ctx = sized_context(egui::vec2(10.0, 8.0)); - let mut app = test_app(); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - // validate_image accepts one pixel of rounding per axis: an 11x9 image is the largest. - assert_eq!(app.shots.requests[0].bytes, 11 * 9 * 4); - app.shots.requests[0].ready_at = 0.0; - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 1); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([11, 9], egui::Color32::RED))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the rounded image is accepted"); - assert_eq!(reply["ok"], true, "{reply}"); - assert_eq!(reply["result"]["width"], 11); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// Oversized images retry within the bounded budget, never write, then report the per-image limit, while the valid peer is written. - fn an_image_beyond_the_reservation_is_released_without_writing() { - let ctx = sized_context(egui::vec2(10.0, 8.0)); - let mut app = test_app(); - let p1 = temp_png("over-1"); - let p2 = temp_png("over-2"); - let (tx1, rx1) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(p1.to_string_lossy().into_owned()), tx1); - let (tx2, rx2) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(p2.to_string_lossy().into_owned()), tx2); - for request in &mut app.shots.requests { - request.ready_at = 0.0; - } - assert_eq!(app.shots.reserved(), 2 * 11 * 9 * 4); - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 2); - // The slot refuses an image over the reservation instead of retaining bytes beyond it. - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([12, 9], egui::Color32::RED))); - // The other request completes with valid pixels. - callbacks[1].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([10, 8], egui::Color32::BLUE))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - // A larger image can be a genuine resize, so it receives the same bounded retries as - // a smaller one. Each oversized callback is released and never written while retrying. - for attempt in 1..=SCREENSHOT_MAX_RETRIES { - assert_eq!(app.shots.requests[0].retries, attempt); - assert!(rx1.try_recv().is_err(), "retry {attempt} has not replied early"); - assert!(!p1.exists(), "oversized pixels are never written on retry {attempt}"); - let callbacks = issue_with(&ctx, &mut app, raw_of(egui::vec2(10.0, 8.0))); - assert_eq!(callbacks.len(), 1, "the other valid request is already complete"); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([12, 9], egui::Color32::RED))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - } - let refused = rx1.try_recv().expect("the oversized image is reported after bounded retries"); - assert_eq!(refused["ok"], false, "{refused}"); - assert!(refused["error"].as_str().is_some_and(|e| e.contains("per-image limit")), "{refused}"); - assert!(!p1.exists()); - let accepted = rx2.try_recv().expect("the valid image is written"); - assert_eq!(accepted["ok"], true, "{accepted}"); - let _ = std::fs::remove_file(&p2); - assert_eq!(app.shots.reserved(), 0, "both reservations are released"); - } - - #[test] - /// Two `ui` calls in one pass count as one settling frame, and a pass that requests a discard is not counted; only its rerun counts. - fn settling_counts_one_real_pass_and_skips_a_discarded_one() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let (tx, _rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - // Two `ui` calls in one pass are one settling frame. - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| { - issue_screenshots(&mut app, ui.ctx()); - issue_screenshots(&mut app, ui.ctx()); - }); - assert_eq!(app.shots.requests[0].frames, 1); - // A pass that already asked to be discarded does not count, even though `run_ui` repeats it. - let mut asked = false; - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| { - if !asked { - asked = true; - ui.ctx().request_discard("settling test"); - } - issue_screenshots(&mut app, ui.ctx()); - }); - assert_eq!(app.shots.requests[0].frames, 2, "only the rerun (real) pass counted"); - } - - #[test] - /// Issuance survives a discard requested after it, the image is written once, and a duplicate callback completion is inert. - fn a_discard_requested_after_issuance_still_issues_and_writes_once() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let path = temp_png("discard-after-issue"); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); - app.shots.requests[0].ready_at = 0.0; - let mut asked = false; - let mut callbacks = Vec::new(); - for _ in 0..3 { - let out = ui_pass(&ctx, egui::RawInput::default(), |ui| { - issue_screenshots(&mut app, ui.ctx()); - if !asked && app.shots.requests.first().is_some_and(|r| r.issued) { - asked = true; - ui.ctx().request_discard("discard after screenshot issuance"); - } - }); - callbacks.extend(screenshot_callbacks(&out, &ctx)); - } - assert!(asked, "the test requested a discard after issuance"); - assert_eq!(callbacks.len(), 1, "issuance happens exactly once across the discard repeat"); - let image = std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED)); - callbacks[0].clone().complete(image.clone()); - callbacks[0].clone().complete(image); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the discarded pass still delivers one reply"); - assert_eq!(reply["ok"], true, "{reply}"); - assert!(path.exists(), "the image is written exactly once"); - assert!(rx.try_recv().is_err(), "a second completion of the same callback is inert"); - let _ = std::fs::remove_file(&path); - } - - #[test] - /// A grow-resize spends one retry, reissues at the new size and then accepts the new image. - fn an_image_larger_than_the_reservation_retries_like_a_smaller_one() { - let ctx = sized_context(egui::vec2(10.0, 8.0)); - let mut app = test_app(); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - app.shots.requests[0].ready_at = 0.0; - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 1); - - // The surface grew by two pixels while the image was outstanding. The slot's reservation - // refuses the larger image before `validate_image`; drain must retry at the new geometry - // instead of failing because the resize went in this direction. - let mut grown = raw_of(egui::vec2(20.0, 16.0)); - app.raw_input_hook(&ctx, &mut grown); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([12, 9], egui::Color32::RED))); - let _ = ctx.run_logic(&grown, |ctx| app.logic(ctx)); - let request = &app.shots.requests[0]; - assert_eq!(request.retries, 1, "the slot's per-image refusal spends one retry, not a failure"); - assert!(!request.issued); - assert_eq!(request.geometry.viewport, Some(egui::vec2(20.0, 16.0))); - assert!(rx.try_recv().is_err(), "a retried capture does not reply yet"); - - let callbacks = issue_with(&ctx, &mut app, raw_of(egui::vec2(20.0, 16.0))); - assert_eq!(callbacks.len(), 1); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([20, 16], egui::Color32::BLUE))); - let _ = ui_pass(&ctx, raw_of(egui::vec2(20.0, 16.0)), |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the retried capture replies"); - assert_eq!(reply["ok"], true, "{reply}"); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// Minimize retries reserve unknown-size bytes; only two fit the aggregate budget and stay queued (the rest are reported), and the survivors write after restore. - fn a_minimize_retry_without_a_size_still_checks_the_aggregate_budget() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let mut paths = Vec::new(); - let mut receivers = Vec::new(); - for n in 0..SCREENSHOT_MAX_REQUESTS { - let path = temp_png(&format!("minimize-retry-{n}")); - let (tx, rx) = std::sync::mpsc::channel(); - // A path capture isolates the retained-image aggregate budget. - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); - paths.push(path); - receivers.push(rx); - } - for request in &mut app.shots.requests { - request.ready_at = 0.0; - } - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), SCREENSHOT_MAX_REQUESTS, "four path captures fit every budget"); - assert_eq!(app.shots.reserved(), SCREENSHOT_MAX_REQUESTS * 65 * 49 * 4); - - // The window is minimized with every capture outstanding. The drain retries each one - // against the new surface generation, which has no capturable size, so every retry - // reserves UNKNOWN_CAPTURE_BYTES. Only two unknown-size reservations fit the aggregate - // limit; the rest must be reported instead of inflating `reserved()` over the budget. - let mut minimized = raw_of(egui::vec2(64.0, 48.0)); - if let Some(viewport) = minimized.viewports.get_mut(&egui::ViewportId::ROOT) { - viewport.inner_rect = None; - viewport.minimized = Some(true); - } - app.raw_input_hook(&ctx, &mut minimized); - let image = std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED)); - for callback in &callbacks { - callback.clone().complete(image.clone()); - } - let _ = ctx.run_logic(&minimized, |ctx| app.logic(ctx)); - - assert!( - app.shots.reserved() <= SCREENSHOT_MAX_AGGREGATE_BYTES, - "size-less retries reserved {} bytes, over the {SCREENSHOT_MAX_AGGREGATE_BYTES}-byte aggregate limit", - app.shots.reserved() - ); - let rejected: Vec = receivers.iter_mut().filter_map(|rx| rx.try_recv().ok()).collect(); - assert_eq!(rejected.len(), 2, "two unknown-size retries no longer fit the aggregate budget"); - assert!(rejected.iter().all(|reply| reply["error"].as_str().is_some_and(|e| e.contains("aggregate limit"))), "{rejected:?}"); - assert_eq!(app.shots.requests.len(), 2, "the two fitting retries stay queued"); - assert!(app.shots.requests.iter().all(|r| !r.issued && r.expected.is_none() && r.bytes == UNKNOWN_CAPTURE_BYTES)); - assert!(receivers.iter_mut().all(|rx| rx.try_recv().is_err()), "the surviving retries wait for the restore"); - - // Restoring re-issues only the surviving requests at the real size; both fit because the - // over-reserved retries were rejected rather than kept. - let mut restored = raw_of(egui::vec2(64.0, 48.0)); - if let Some(viewport) = restored.viewports.get_mut(&egui::ViewportId::ROOT) { - viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(64.0, 48.0))); - viewport.minimized = Some(false); - viewport.occluded = Some(false); - } - app.raw_input_hook(&ctx, &mut restored); - let callbacks = issue_with(&ctx, &mut app, restored.clone()); - assert_eq!(callbacks.len(), 2, "the surviving requests issue on restore"); - for callback in &callbacks { - callback.clone().complete(image.clone()); - } - let _ = ctx.run_logic(&restored, |ctx| app.logic(ctx)); - - let mut delivered = 0; - for rx in &mut receivers { - if let Ok(reply) = rx.try_recv() { - assert_eq!(reply["ok"], true, "{reply}"); - delivered += 1; - } - } - assert_eq!(delivered, 2, "the two surviving captures reply once each"); - let written = paths.iter().filter(|path| path.exists()).count(); - assert_eq!(written, 2, "only the surviving captures write their image"); - for path in &paths { - let _ = std::fs::remove_file(path); - } - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// Fresh hidden viewport geometry invalidates the old image, spends one retry, and the reissued image succeeds. - fn a_hidden_resize_rejects_an_image_issued_before_it_and_reissues() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - app.shots.requests[0].ready_at = 0.0; - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 1); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED))); - - // A hidden tick carries the resize in `RawInput::viewports`; the last UI pass still says - // 64x48, so only the fresh viewport info lets the drain see the change. - let hidden = || { - let mut raw = raw_of(egui::vec2(64.0, 48.0)); - if let Some(viewport) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { - viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(32.0, 400.0))); - viewport.native_pixels_per_point = Some(1.0); - } - raw - }; - let _ = ctx.run_logic(&hidden(), |ctx| app.logic(ctx)); - let request = &app.shots.requests[0]; - assert!(!request.issued, "the image captured before the hidden resize is not accepted"); - assert_eq!(request.retries, 1); - assert_eq!(request.geometry.viewport, Some(egui::vec2(32.0, 400.0))); - assert!(rx.try_recv().is_err(), "a retried capture does not reply yet"); - - // The next real pass issues against the new size and its image is accepted. - let resized = raw_of(egui::vec2(32.0, 24.0)); - let callbacks = issue_with(&ctx, &mut app, resized.clone()); - assert_eq!(callbacks.len(), 1, "the retry is issued once against the current geometry"); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::BLUE))); - let _ = ui_pass(&ctx, resized, |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the retried capture replies"); - assert_eq!(reply["ok"], true, "{reply}"); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// Draining four completed captures replies to all of them and permits a new admission. - fn a_drained_capture_frees_its_slot_for_the_next_admission() { - let ctx = sized_context(egui::vec2(8.0, 8.0)); - let mut app = test_app(); - let mut receivers = Vec::new(); - for _ in 0..SCREENSHOT_MAX_REQUESTS { - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - receivers.push(rx); - } - for request in &mut app.shots.requests { - request.ready_at = 0.0; - } - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - for callback in &callbacks { - callback.clone().complete(std::sync::Arc::new(egui::ColorImage::filled([8, 8], egui::Color32::GREEN))); - } - assert_eq!(app.shots.requests.len(), SCREENSHOT_MAX_REQUESTS); - // Draining before admitting is what `logic` does: completed requests release first. - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - assert_eq!(app.shots.requests.len(), 0); - assert!(receivers.iter_mut().all(|rx| rx.try_recv().is_ok())); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - assert!(rx.try_recv().is_err(), "a new request is admitted once the drained slots are free"); - assert_eq!(app.shots.requests.len(), 1); - } - - #[test] - /// Uses `screen_rect` when `inner_rect` is absent, applies native scale times zoom, and advances the epoch on a hidden resize. - fn raw_input_geometry_uses_screen_rect_when_inner_rect_is_absent() { - // Wayland always omits `inner_rect`; the fresh `RawInput::screen_rect` is the only size. - let ctx = sized_context(egui::vec2(800.0, 600.0)); - let mut app = test_app(); - let mut raw = raw_of(egui::vec2(1280.0, 720.0)); - if let Some(v) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { - v.inner_rect = None; - v.native_pixels_per_point = Some(2.0); - v.minimized = Some(false); - v.occluded = Some(false); - } - app.raw_input_hook(&ctx, &mut raw); - let geo = app.capture_geometry.expect("the raw frame records its geometry"); - assert_eq!(geo.viewport, Some(egui::vec2(1280.0, 720.0))); - assert_eq!(geo.pixels_per_point, 2.0, "native scale times zoom"); - assert_eq!(geo.expected_size(), Some([2560, 1440])); - - let epoch = app.capture_epoch; - // A hidden resize lands on a logic-only tick: no UI pass ran, but the raw frame is fresh. - let mut hidden = raw_of(egui::vec2(640.0, 480.0)); - if let Some(v) = hidden.viewports.get_mut(&egui::ViewportId::ROOT) { - v.inner_rect = None; - v.native_pixels_per_point = Some(2.0); - v.minimized = Some(false); - v.occluded = Some(true); - } - app.raw_input_hook(&ctx, &mut hidden); - let _ = ctx.run_logic(&hidden, |ctx| app.logic(ctx)); - assert!(app.capture_epoch > epoch, "a hidden resize is a new epoch"); - assert_eq!(app.capture_geometry.expect("geometry").viewport, Some(egui::vec2(640.0, 480.0))); - } - - #[test] - /// Minimized geometry has no size and reserves unknown bytes; restoring at the same size still changes the epoch. - fn minimized_geometry_has_no_size_and_restore_bumps_the_epoch() { - let ctx = egui::Context::default(); - let mut app = test_app(); - let mut minimized = raw_of(egui::vec2(800.0, 600.0)); - if let Some(v) = minimized.viewports.get_mut(&egui::ViewportId::ROOT) { - v.inner_rect = None; - v.minimized = Some(true); - } - app.raw_input_hook(&ctx, &mut minimized); - let geo = app.capture_geometry.expect("recorded"); - assert_eq!(geo.viewport, None, "a minimized window has no capturable size"); - assert_eq!(geo.visible, Some(false)); - - // Admission while minimized reserves the unknown-size maximum and waits. - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - assert_eq!(app.shots.requests[0].bytes, UNKNOWN_CAPTURE_BYTES); - assert!(rx.try_recv().is_err()); - let epoch = app.capture_epoch; - - // Restoring at the same logical size is still a new surface generation. - let mut restored = raw_of(egui::vec2(800.0, 600.0)); - if let Some(v) = restored.viewports.get_mut(&egui::ViewportId::ROOT) { - v.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); - v.minimized = Some(false); - v.occluded = Some(false); - } - app.raw_input_hook(&ctx, &mut restored); - assert!(app.capture_epoch > epoch, "minimize/restore at the same size changes the epoch"); - assert_eq!(app.capture_geometry.expect("geometry").viewport, Some(egui::vec2(800.0, 600.0))); - app.shots.cancel_all(); - } - - #[test] - /// Switching documents before issuance cancels the request once with a document-changed error and writes nothing. - fn a_document_switch_before_issuance_cancels_without_writing() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let path = temp_png("doc-queued"); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); - app.shots.requests[0].ready_at = 0.0; - let admitted = app.shots.requests[0].document; - assert!(app.run("file.new", json!({})).is_ok()); - assert_ne!(app.session.active().map(|d| d.uid), admitted, "the test switched documents"); - - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert!(callbacks.is_empty(), "a capture for another document is never issued"); - let reply = rx.try_recv().expect("the queued request is cancelled once"); - assert!(reply["error"].as_str().is_some_and(|e| e.contains("document changed")), "{reply}"); - assert!(!path.exists(), "no pixels for the old document are written to its path"); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// Switching documents after issuance rejects the completed image once and writes nothing. - fn a_document_switch_after_issuance_rejects_the_completed_image() { - let ctx = sized_context(egui::vec2(64.0, 48.0)); - let mut app = test_app(); - let path = temp_png("doc-issued"); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); - app.shots.requests[0].ready_at = 0.0; - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 1); - assert!(app.run("file.new", json!({})).is_ok()); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the switched document is reported once"); - assert!(reply["error"].as_str().is_some_and(|e| e.contains("document changed")), "{reply}"); - assert!(!path.exists(), "a completion after a document switch never writes"); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// A request bound to another viewport is rejected once with a viewport error and writes nothing. - fn a_completion_for_another_viewport_is_rejected() { - let ctx = sized_context(egui::vec2(32.0, 24.0)); - let mut app = test_app(); - let path = temp_png("viewport"); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); - app.shots.requests[0].ready_at = 0.0; - let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); - assert_eq!(callbacks.len(), 1); - // A slot/request bound to a different viewport must not fill this one. - app.shots.requests[0].viewport_id = egui::ViewportId::from_hash_of("other-window"); - callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::RED))); - let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); - let reply = rx.try_recv().expect("the viewport mismatch is reported once"); - assert!(reply["error"].as_str().is_some_and(|e| e.contains("another viewport")), "{reply}"); - assert!(!path.exists()); - assert_eq!(app.shots.reserved(), 0); - } - - #[test] - /// Accepts one-pixel rounding on a single axis, but rejects opposite-axis deviation, aspect distortion and more than one pixel of error. - fn validate_image_rejects_distortion_but_allows_one_pixel_rounding() { - let image = |w: usize, h: usize| egui::ColorImage::filled([w, h], egui::Color32::RED); - assert!(validate_image(&image(100, 100), [100, 100]).is_ok()); - assert!(validate_image(&image(101, 100), [100, 100]).is_ok(), "one pixel of rounding on one axis is legitimate"); - assert!(validate_image(&image(100, 101), [100, 100]).is_ok()); - assert!(validate_image(&image(101, 99), [100, 100]).is_err(), "independent per-axis tolerance must not accept a distorted image"); - assert!(validate_image(&image(99, 101), [100, 100]).is_err()); - assert!(validate_image(&image(102, 100), [100, 100]).is_err(), "more than one pixel per axis is a different surface"); - } - - #[test] - /// Checks the encoding working-set threshold, the base64 reply cap, and the retained-plus-encoding peak budget. - fn encoding_working_set_is_bounded_for_admitted_captures() { - assert!(encoding_working_bytes([8191, 2048], false).expect("checked") <= SCREENSHOT_MAX_ENCODING_BYTES); - assert!(encoding_working_bytes([8192, 8192], false).expect("checked") > SCREENSHOT_MAX_ENCODING_BYTES); - assert!(encoding_working_bytes([4000, 3000], true).expect("checked") <= SCREENSHOT_MAX_ENCODING_BYTES); - assert!(encoding_working_bytes([5000, 4000], true).expect("checked") > SCREENSHOT_MAX_ENCODING_BYTES); - // The base64 reply itself is capped too, before the string exists. - let png = png_worst_bytes(5000 * 4000 * 4, 4000).expect("checked"); - assert!(base64_len(png).expect("checked") > SCREENSHOT_MAX_BASE64_BYTES); - // Retained requests plus one encoding working set are held to a hard peak. - assert!(check_peak_budget(0, [64, 48], false).is_ok()); - assert!(check_peak_budget(SCREENSHOT_MAX_AGGREGATE_BYTES, [9000, 4096], false).is_err()); - } - - #[test] - /// An oversized base64 capture is refused with nothing reserved, while the same capture as a path is admitted. - fn base64_capture_over_the_encoding_budget_is_rejected_before_admission() { - let ctx = sized_context(egui::vec2(5000.0, 4000.0)); - let mut app = test_app(); - let (tx, rx) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, None, tx); - let reply = rx.try_recv().expect("an oversized base64 capture is refused at admission"); - assert_eq!(reply["ok"], false, "{reply}"); - assert!( - reply["error"].as_str().is_some_and(|e| e.contains("encoding") || e.contains("base64")), - "the error names the encoding budget: {reply}" - ); - assert!(app.shots.requests.is_empty(), "a refused request reserves nothing"); - - // The same capture as a file is admitted within the per-image and encoding budgets. - let path = temp_png("encoding-path"); - let (tx2, rx2) = std::sync::mpsc::channel(); - admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx2); - assert!(rx2.try_recv().is_err(), "the path form is admitted"); - assert_eq!(app.shots.requests.len(), 1); - app.shots.cancel_all(); - } - - #[test] - /// Oversized text, repeated maximum texts, keys and moves are refused without changing the queue, and the byte/event limits are both enforced. - fn synthetic_text_key_and_move_producers_honour_the_queue_budget() { - let ctx = egui::Context::default(); - let mut app = test_app(); - // One oversized text event is refused before it is queued. - let huge = "x".repeat(MAX_SYNTHETIC_TEXT_BYTES + 1); - let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.text", &json!({"text": huge})) else { panic!("ui.text returns immediately") }; - assert_eq!(reply["ok"], false, "{reply}"); - assert!(app.synthetic.is_empty()); - - // The byte budget stops repeated maximum-size texts; the queue is left unchanged. - let max = "x".repeat(MAX_SYNTHETIC_TEXT_BYTES); - let mut refused_at = None; - for i in 0..64 { - let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.text", &json!({"text": max.clone()})) else { - panic!("ui.text returns immediately") - }; - if reply["ok"] == false { - refused_at = Some((i, app.synthetic.len())); - break; - } - } - let (accepted, queued) = refused_at.expect("the synthetic byte budget is enforced"); - assert!(accepted > 0 && accepted < 64, "{accepted} texts fit"); - let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.text", &json!({"text": max})) else { panic!("ui.text returns immediately") }; - assert_eq!(reply["ok"], false); - assert_eq!(app.synthetic.len(), queued, "a refused text leaves the queue alone"); - - // Event count: keys and moves use the same overall budget as pointer gestures. - app.synthetic.clear(); - let pos = egui::pos2(1.0, 1.0); - app.synthetic.push_back(vec![egui::Event::PointerMoved(pos); MAX_QUEUED_SYNTHETIC_EVENTS - 1]); - let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.key", &json!({"key":"enter"})) else { panic!("ui.key returns immediately") }; - assert_eq!(reply["ok"], false, "two key events do not fit in the last slot: {reply}"); - assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), MAX_QUEUED_SYNTHETIC_EVENTS - 1); - - app.synthetic.clear(); - app.synthetic.push_back(vec![egui::Event::PointerMoved(pos); MAX_QUEUED_SYNTHETIC_EVENTS]); - let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.move", &json!({"x":2.0,"y":2.0})) else { panic!("ui.move returns immediately") }; - assert_eq!(reply["ok"], false, "{reply}"); - assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), MAX_QUEUED_SYNTHETIC_EVENTS); - } -} +mod tests; diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs new file mode 100644 index 000000000..1b0ae80e5 --- /dev/null +++ b/crates/ui-egui/src/control/tests.rs @@ -0,0 +1,987 @@ +use super::*; + +fn sample() -> Vec { + vec![ + json!({"id": "home.borders", "label": "Borders", "menu": ["Home", "Font"]}), + json!({"id": "home.bold", "label": "Bold", "menu": ["Home", "Font"]}), + json!({"id": "ui.ribbonTab", "label": "Ribbon Tab", "enabled": true, "ui": true}), + ] +} + +fn ids(v: &[Json]) -> Vec<&str> { + v.iter().filter_map(|c| c.get("id").and_then(Json::as_str)).collect() +} + +#[test] +fn search_filters_case_insensitively() { + assert_eq!(ids(&filter_commands(sample(), Some("border"))), ["home.borders"]); + assert_eq!(filter_commands(sample(), Some("BORDER")), filter_commands(sample(), Some("border"))); + assert_eq!(ids(&filter_commands(sample(), Some("ribbon"))), ["ui.ribbonTab"]); + assert_eq!(ids(&filter_commands(sample(), Some("font"))), ["home.borders", "home.bold"]); + assert!(filter_commands(sample(), Some("catalog_no_match_7391")).is_empty()); + assert_eq!(filter_commands(sample(), None).len(), 3); +} + +fn test_app() -> SheetApp { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + SheetApp::new(session, crate::Services::default()) +} + +#[test] +/// Checks that oversized click/drag counts are refused without changing already queued batches, that queued input counts toward the budget, and that normal gestures still queue fully. +fn oversized_pointer_requests_are_refused_and_leave_the_queue_alone() { + let mut app = test_app(); + let p = egui::pos2(20.0, 20.0); + let m = egui::Modifiers::NONE; + let btn = egui::PointerButton::Primary; + assert!(push_click(&mut app, p, btn, 2, m).is_ok()); + assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), 5); + assert!(push_click(&mut app, p, btn, u32::MAX, m).is_err()); + assert!(push_drag(&mut app, p, p, usize::MAX, m).is_err()); + assert!(push_drag(&mut app, p, p, MAX_QUEUED_SYNTHETIC_EVENTS, m).is_err()); + assert_eq!(app.synthetic.len(), 2); + assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), 5); + // Already queued input counts toward the budget. + assert!(push_click(&mut app, p, btn, (MAX_QUEUED_SYNTHETIC_EVENTS / 2) as u32, m).is_err()); + assert_eq!(app.synthetic.len(), 2); + // Normal requests still queue in full. + assert!(push_drag(&mut app, p, p + egui::vec2(40.0, 40.0), 3, m).is_ok()); + assert_eq!(app.synthetic.len(), 8); +} + +#[test] +fn print_command_discovery_documents_settings_and_submission() { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, crate::Services::default()); + let Outcome::Done(reply) = handle(&mut app, &egui::Context::default(), "engine.commands", &json!({"search":"print"})) else { + panic!("command discovery must return immediately"); + }; + assert_eq!(reply["ok"], true); + let commands = reply["result"].as_array().unwrap(); + for (id, expected_docs) in [ + ("ui.printSettings", &["copies", "duplex", "refresh", "discovering", "status", "detail"][..]), + ("ui.printSubmit", &["file.print", "pagination", "pending", "without opening a dialog"][..]), + ] { + let command = commands.iter().find(|command| command["id"] == id).unwrap(); + assert_eq!(command["ui"], true); + let docs = command["params"].as_str().unwrap(); + for expected in expected_docs { + assert!(docs.contains(expected), "{id}: missing {expected}"); + } + } + assert!(!app.printing.busy(), "discovery must not submit a job"); + assert!(app.backstage.is_none(), "discovery must not open settings"); +} + +#[test] +fn language_command_docs_list_every_language() { + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, crate::Services::default()); + let Outcome::Done(reply) = handle(&mut app, &egui::Context::default(), "engine.commands", &json!({"search":"language"})) else { + panic!("command discovery must return immediately"); + }; + let commands = reply["result"].as_array().unwrap(); + let docs = commands.iter().find(|c| c["id"] == "app.language.set").unwrap()["params"].as_str().unwrap(); + for l in crate::i18n::Language::ALL { + assert!(docs.contains(&format!("\"{}\"", l.code())), "app.language.set docs miss {:?}: {docs}", l.code()); + } +} +/// One headless UI pass. A test has no renderer to apply the font-atlas delta, so dropping +/// it is intentional (0.37 panics when a `TexturesDelta` is dropped unapplied). +fn ui_pass(ctx: &egui::Context, raw: egui::RawInput, f: impl FnMut(&mut egui::Ui)) -> egui::FullOutput { + let mut out = ctx.run_ui(raw, f); + out.textures_delta.clear(); + out +} + +/// Returns an egui context initialized by one blank pass at `size` with an 8192-pixel texture limit. +fn sized_context(size: egui::Vec2) -> egui::Context { + let ctx = egui::Context::default(); + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), + max_texture_side: Some(8192), + ..Default::default() + }; + let _ = ui_pass(&ctx, raw, |_| {}); + ctx +} + +/// Returns blank raw input with a screen rect at `size` and an 8192-pixel texture limit. +fn raw_of(size: egui::Vec2) -> egui::RawInput { + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), max_texture_side: Some(8192), ..Default::default() } +} + +/// Extracts cloned `ViewportCommand::Screenshot` callbacks from a `FullOutput` for the context's viewport. +fn screenshot_callbacks(out: &egui::FullOutput, ctx: &egui::Context) -> Vec { + out.viewport_output + .get(&ctx.viewport_id()) + .map(|output| { + output + .commands + .iter() + .filter_map(|command| match command { + egui::ViewportCommand::Screenshot(callback) => Some(callback.clone()), + _ => None, + }) + .collect::>() + }) + .unwrap_or_default() +} + +/// Run three settled passes with `raw` and return the callbacks issued in them. +fn issue_with(ctx: &egui::Context, app: &mut SheetApp, raw: egui::RawInput) -> Vec { + let mut callbacks = Vec::new(); + for _ in 0..3 { + let out = ui_pass(ctx, raw.clone(), |ui| issue_screenshots(app, ui.ctx())); + callbacks.extend(screenshot_callbacks(&out, ctx)); + } + callbacks +} + +/// Returns a per-process temporary PNG path after removing any stale file at that path. +fn temp_png(name: &str) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!("gridcraft-shot-{name}-{}.png", std::process::id())); + let _ = std::fs::remove_file(&path); + path +} + +#[test] +/// Checks rounded physical pixels, expected/retained byte counts, the texture-axis cap, the no-size case and overflow refusal. +fn capture_geometry_reports_checked_capped_pixels() { + let geometry = CaptureGeometry { + viewport_id: egui::ViewportId::ROOT, + viewport: Some(egui::vec2(800.0, 600.0)), + pixels_per_point: 2.0, + zoom: 1.0, + max_texture_side: 4096, + minimized: None, + occluded: None, + visible: None, + }; + assert_eq!(geometry.expected_size(), Some([1600, 1200])); + assert_eq!(geometry.expected_bytes(), Some(1600 * 1200 * 4)); + assert_eq!(geometry.retained_bytes(), Some(1601 * 1201 * 4), "one pixel of rounding per axis is reserved too"); + let capped = CaptureGeometry { max_texture_side: 1024, ..geometry }; + assert_eq!(capped.expected_size(), Some([1024, 1024]), "the surface limit caps both axes"); + let empty = CaptureGeometry { viewport: Some(egui::Vec2::ZERO), ..geometry }; + assert_eq!(empty.expected_size(), None, "a minimized window has no capturable size"); + assert_eq!(empty.retained_bytes(), None); + let huge = CaptureGeometry { viewport: Some(egui::vec2(f32::MAX, f32::MAX)), ..geometry }; + assert_eq!(huge.expected_size(), None, "overflow is refused, not truncated"); +} + +#[test] +/// Checks that one request beyond `SCREENSHOT_MAX_REQUESTS` is rejected with its token while earlier requests stay queued and do not reply early. +fn screenshot_admission_stops_on_the_count_budget_without_evicting() { + let ctx = sized_context(egui::vec2(800.0, 600.0)); + let mut app = test_app(); + let mut replies = Vec::new(); + for _ in 0..SCREENSHOT_MAX_REQUESTS { + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + replies.push(rx); + } + assert_eq!(app.shots.requests.len(), SCREENSHOT_MAX_REQUESTS); + assert!(replies.iter_mut().all(|r| r.try_recv().is_err()), "admitted requests do not reply early"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + let reply = rx.try_recv().expect("the over-count request is rejected"); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("limit 4")), "{reply}"); + assert!(reply["request"].as_u64().is_some(), "a rejection carries its request token: {reply}"); + assert_eq!(app.shots.requests.len(), SCREENSHOT_MAX_REQUESTS, "an earlier request is never evicted"); +} + +#[test] +/// Three roughly 100 MB path captures hit the aggregate byte limit; the third is rejected and only two requests remain. +fn screenshot_admission_stops_on_aggregate_bytes_before_the_count() { + let ctx = sized_context(egui::vec2(5000.0, 5000.0)); + let mut app = test_app(); + let path = temp_png("aggregate").to_string_lossy().into_owned(); + let mut rejected = None; + for _ in 0..3 { + let (tx, rx) = std::sync::mpsc::channel(); + // A path capture (not base64) isolates the retained-image aggregate budget. + admit(&mut app, &ctx, Some(path.clone()), tx); + if let Ok(reply) = rx.try_recv() { + rejected = Some(reply); + } + } + let reply = rejected.expect("three 100 MB captures exceed the 256 MiB aggregate budget"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("aggregate limit")), "{reply}"); + assert_eq!(app.shots.requests.len(), 2); +} + +#[test] +/// An unknown-size request reserves the placeholder, issues nothing while settling, then replies once on timeout and releases the reservation. +fn requests_without_a_size_stay_queued_and_time_out() { + let ctx = sized_context(egui::Vec2::ZERO); + let mut app = test_app(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + assert!(rx.try_recv().is_err(), "an unknown size is queued, not refused with an overflow error"); + assert_eq!(app.shots.requests[0].bytes, UNKNOWN_CAPTURE_BYTES, "the largest possible image is reserved meanwhile"); + // Settling passes without a size neither issue nor fail the request. + let callbacks = issue_with(&ctx, &mut app, raw_of(egui::Vec2::ZERO)); + assert!(callbacks.is_empty()); + assert!(!app.shots.requests[0].issued); + assert!(rx.try_recv().is_err()); + app.shots.requests[0].deadline = 0.0; + let _ = ui_pass(&ctx, raw_of(egui::Vec2::ZERO), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the size-less request times out once"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("capturable size")), "{reply}"); + assert_eq!(app.shots.reserved(), 0, "the placeholder reservation is released"); +} + +/// Counts `RequestPaintWhileHidden` commands in a logic output. +fn hidden_paints(out: &egui::LogicOutput) -> usize { + out.viewport_commands.values().flatten().filter(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden)).count() +} + +#[test] +/// A queued capture requests a hidden paint; once issued, the screenshot command supplies the paint itself. +fn a_queued_capture_requests_hidden_paint_until_it_is_issued() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let (tx, _rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + let queued = ctx.run_logic(&raw_of(egui::vec2(64.0, 48.0)), |ctx| app.logic(ctx)); + assert_eq!(hidden_paints(&queued), 1, "a queued capture needs a real `ui` pass"); + + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + let issued = ctx.run_logic(&raw_of(egui::vec2(64.0, 48.0)), |ctx| app.logic(ctx)); + assert_eq!(hidden_paints(&issued), 0, "the issued Screenshot command paints the hidden window by itself"); +} + +#[test] +/// A minimized request stays queued without requesting hidden paints, because it cannot issue until restored. +fn a_minimized_capture_does_not_force_hidden_frames_while_it_waits() { + let ctx = egui::Context::default(); + let mut app = test_app(); + let mut raw = raw_of(egui::vec2(800.0, 600.0)); + if let Some(v) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { + v.inner_rect = None; + v.minimized = Some(true); + } + app.raw_input_hook(&ctx, &mut raw); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + assert!(rx.try_recv().is_err()); + let waited = ctx.run_logic(&raw, |ctx| app.logic(ctx)); + assert_eq!(hidden_paints(&waited), 0, "a minimized request cannot issue until it is restored"); + app.shots.cancel_all(); +} + +#[test] +/// The real app harness issues exactly one callback, replies once with the token and dimensions, and releases the reservation. +fn one_admitted_capture_issues_one_callback_and_replies_once() { + use egui_kittest::kittest::Queryable as _; + + let mut app = test_app(); + app.use_prepared_fonts(); + let mut h = egui_kittest::Harness::builder() + .with_size(egui::vec2(900.0, 700.0)) + .with_context_setup(|ctx| { + ctx.set_fonts(crate::theme::pinned_font_definitions()); + crate::theme::apply(ctx, false); + }) + // This test completes the callback itself. Keep the real app/AccessKit tree, but + // do not let kittest's automatic screenshot handling initialize a GPU renderer. + .renderer(egui_kittest::LazyRenderer::Uninitialized { textures_delta: Default::default(), builder: None }) + .build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + assert!(h.query_by_label("Name Box").is_some(), "the capture fixture draws the real app, not an empty tree"); + h.check_accessibility(); + + let ctx = h.ctx.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(h.state_mut(), &ctx, None, tx); + let token = h.state().shots.requests[0].token; + h.state_mut().shots.requests[0].ready_at = 0.0; + let mut callbacks = Vec::new(); + for _ in 0..3 { + h.step(); + callbacks.extend(screenshot_callbacks(h.output(), &ctx)); + } + assert_eq!(callbacks.len(), 1, "the settled request is issued once"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([900, 700], egui::Color32::RED))); + h.step(); // the real logic drains the callback and the UI repaints + h.check_accessibility(); + assert!(h.query_by_label("Name Box").is_some(), "the app tree survives callback drain and repaint"); + let reply = rx.try_recv().expect("the drain replies once"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(reply["result"]["width"], 900); + assert_eq!(reply["result"]["height"], 700); + assert_eq!(reply["request"].as_u64(), Some(token), "the reply names its request"); + assert!(rx.try_recv().is_err(), "a completed slot is taken exactly once"); + assert_eq!(h.state().shots.reserved(), 0, "the reservation is released after the reply"); +} + +#[test] +/// Completing callbacks in reverse order replies on each matching token/path and writes each request's own pixels. +fn out_of_order_completions_reply_on_their_own_request() { + let ctx = sized_context(egui::vec2(8.0, 8.0)); + let mut app = test_app(); + let p1 = temp_png("order-1"); + let p2 = temp_png("order-2"); + let (tx1, rx1) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(p1.to_string_lossy().into_owned()), tx1); + let token1 = app.shots.requests[0].token; + let (tx2, rx2) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(p2.to_string_lossy().into_owned()), tx2); + let token2 = app.shots.requests[1].token; + for request in &mut app.shots.requests { + request.ready_at = 0.0; + } + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 2, "one callback per admitted request"); + // Complete the second request first: slots, not callback order, carry the result. + callbacks[1].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([8, 8], egui::Color32::BLUE))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let second = rx2.try_recv().expect("the second request gets its own reply"); + assert_eq!(second["ok"], true, "{second}"); + assert_eq!(second["request"].as_u64(), Some(token2)); + assert_eq!(second["result"]["path"].as_str(), Some(p2.to_string_lossy().as_ref())); + assert!(!p1.exists(), "the unfinished request has not written anything"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([8, 8], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let first = rx1.try_recv().expect("the first request gets its own reply"); + assert_eq!(first["request"].as_u64(), Some(token1)); + assert_eq!(first["result"]["path"].as_str(), Some(p1.to_string_lossy().as_ref())); + assert_ne!(std::fs::read(&p1).unwrap(), std::fs::read(&p2).unwrap(), "each reply carries its own pixels"); + let _ = std::fs::remove_file(&p1); + let _ = std::fs::remove_file(&p2); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// An expired request replies once with a timeout, writes no file and releases its reservation. +fn a_timed_out_capture_replies_once_and_never_writes() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let path = temp_png("timeout"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + app.shots.requests[0].deadline = 0.0; + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the timeout replies"); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("timeout")), "{reply}"); + assert!(!path.exists(), "a timed-out capture never writes a file"); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + assert!(rx.try_recv().is_err(), "a timeout is reported once"); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// A valid image that completes after its deadline gets one timeout reply and is not written. +fn a_capture_completed_after_its_deadline_writes_nothing() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let path = temp_png("late"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + let token = app.shots.requests[0].token; + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + // The deadline passes while the callback is outstanding; the image arrives afterwards. + app.shots.requests[0].deadline = 0.0; + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the late completion gets one timeout reply"); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("timeout")), "{reply}"); + assert_eq!(reply["request"].as_u64(), Some(token)); + assert!(!path.exists(), "a capture that completed after its deadline never writes a file"); + assert!(rx.try_recv().is_err(), "the late image is reported once"); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// Cancelling a request does not reply; a later callback for it is ignored and writes nothing. +fn a_callback_after_the_request_is_removed_is_inert() { + let ctx = sized_context(egui::vec2(32.0, 24.0)); + let mut app = test_app(); + let path = temp_png("removed"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + app.shots.cancel_all(); + assert!(rx.try_recv().is_err(), "cancelling a request does not reply"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + assert!(rx.try_recv().is_err(), "a callback for a removed request is inert"); + assert!(!path.exists()); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// The first issuance adopts a new geometry, updates the epoch and reservation, spends no retry, and the capture then succeeds. +fn a_resize_between_admission_and_issuance_does_not_cost_a_retry() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + app.shots.requests[0].ready_at = 0.0; + let admitted_epoch = app.shots.requests[0].epoch; + let resized = raw_of(egui::vec2(32.0, 24.0)); + let callbacks = issue_with(&ctx, &mut app, resized.clone()); + assert_eq!(callbacks.len(), 1); + let request = &app.shots.requests[0]; + assert_eq!(request.retries, 0, "the first issuance adopts the current geometry instead of spending a retry"); + assert_eq!(request.geometry.viewport, Some(egui::vec2(32.0, 24.0))); + assert_eq!(request.expected, Some([32, 24])); + assert_eq!(request.bytes, 33 * 25 * 4); + assert!(request.epoch > admitted_epoch, "issuance records the new epoch together with the geometry"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::RED))); + let _ = ui_pass(&ctx, resized, |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the once-issued capture replies"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// The reservation is the largest accepted rounded image (11×9×4 bytes for a 10×8 viewport), and that image is accepted. +fn the_retained_reservation_is_the_slot_bound() { + let ctx = sized_context(egui::vec2(10.0, 8.0)); + let mut app = test_app(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + // validate_image accepts one pixel of rounding per axis: an 11x9 image is the largest. + assert_eq!(app.shots.requests[0].bytes, 11 * 9 * 4); + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([11, 9], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the rounded image is accepted"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(reply["result"]["width"], 11); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// Oversized images retry within the bounded budget, never write, then report the per-image limit, while the valid peer is written. +fn an_image_beyond_the_reservation_is_released_without_writing() { + let ctx = sized_context(egui::vec2(10.0, 8.0)); + let mut app = test_app(); + let p1 = temp_png("over-1"); + let p2 = temp_png("over-2"); + let (tx1, rx1) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(p1.to_string_lossy().into_owned()), tx1); + let (tx2, rx2) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(p2.to_string_lossy().into_owned()), tx2); + for request in &mut app.shots.requests { + request.ready_at = 0.0; + } + assert_eq!(app.shots.reserved(), 2 * 11 * 9 * 4); + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 2); + // The slot refuses an image over the reservation instead of retaining bytes beyond it. + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([12, 9], egui::Color32::RED))); + // The other request completes with valid pixels. + callbacks[1].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([10, 8], egui::Color32::BLUE))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + // A larger image can be a genuine resize, so it receives the same bounded retries as + // a smaller one. Each oversized callback is released and never written while retrying. + for attempt in 1..=SCREENSHOT_MAX_RETRIES { + assert_eq!(app.shots.requests[0].retries, attempt); + assert!(rx1.try_recv().is_err(), "retry {attempt} has not replied early"); + assert!(!p1.exists(), "oversized pixels are never written on retry {attempt}"); + let callbacks = issue_with(&ctx, &mut app, raw_of(egui::vec2(10.0, 8.0))); + assert_eq!(callbacks.len(), 1, "the other valid request is already complete"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([12, 9], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + } + let refused = rx1.try_recv().expect("the oversized image is reported after bounded retries"); + assert_eq!(refused["ok"], false, "{refused}"); + assert!(refused["error"].as_str().is_some_and(|e| e.contains("per-image limit")), "{refused}"); + assert!(!p1.exists()); + let accepted = rx2.try_recv().expect("the valid image is written"); + assert_eq!(accepted["ok"], true, "{accepted}"); + let _ = std::fs::remove_file(&p2); + assert_eq!(app.shots.reserved(), 0, "both reservations are released"); +} + +#[test] +/// Two `ui` calls in one pass count as one settling frame, and a pass that requests a discard is not counted; only its rerun counts. +fn settling_counts_one_real_pass_and_skips_a_discarded_one() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let (tx, _rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + // Two `ui` calls in one pass are one settling frame. + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| { + issue_screenshots(&mut app, ui.ctx()); + issue_screenshots(&mut app, ui.ctx()); + }); + assert_eq!(app.shots.requests[0].frames, 1); + // A pass that already asked to be discarded does not count, even though `run_ui` repeats it. + let mut asked = false; + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| { + if !asked { + asked = true; + ui.ctx().request_discard("settling test"); + } + issue_screenshots(&mut app, ui.ctx()); + }); + assert_eq!(app.shots.requests[0].frames, 2, "only the rerun (real) pass counted"); +} + +#[test] +/// Issuance survives a discard requested after it, the image is written once, and a duplicate callback completion is inert. +fn a_discard_requested_after_issuance_still_issues_and_writes_once() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let path = temp_png("discard-after-issue"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + app.shots.requests[0].ready_at = 0.0; + let mut asked = false; + let mut callbacks = Vec::new(); + for _ in 0..3 { + let out = ui_pass(&ctx, egui::RawInput::default(), |ui| { + issue_screenshots(&mut app, ui.ctx()); + if !asked && app.shots.requests.first().is_some_and(|r| r.issued) { + asked = true; + ui.ctx().request_discard("discard after screenshot issuance"); + } + }); + callbacks.extend(screenshot_callbacks(&out, &ctx)); + } + assert!(asked, "the test requested a discard after issuance"); + assert_eq!(callbacks.len(), 1, "issuance happens exactly once across the discard repeat"); + let image = std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED)); + callbacks[0].clone().complete(image.clone()); + callbacks[0].clone().complete(image); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the discarded pass still delivers one reply"); + assert_eq!(reply["ok"], true, "{reply}"); + assert!(path.exists(), "the image is written exactly once"); + assert!(rx.try_recv().is_err(), "a second completion of the same callback is inert"); + let _ = std::fs::remove_file(&path); +} + +#[test] +/// A grow-resize spends one retry, reissues at the new size and then accepts the new image. +fn an_image_larger_than_the_reservation_retries_like_a_smaller_one() { + let ctx = sized_context(egui::vec2(10.0, 8.0)); + let mut app = test_app(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + + // The surface grew by two pixels while the image was outstanding. The slot's reservation + // refuses the larger image before `validate_image`; drain must retry at the new geometry + // instead of failing because the resize went in this direction. + let mut grown = raw_of(egui::vec2(20.0, 16.0)); + app.raw_input_hook(&ctx, &mut grown); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([12, 9], egui::Color32::RED))); + let _ = ctx.run_logic(&grown, |ctx| app.logic(ctx)); + let request = &app.shots.requests[0]; + assert_eq!(request.retries, 1, "the slot's per-image refusal spends one retry, not a failure"); + assert!(!request.issued); + assert_eq!(request.geometry.viewport, Some(egui::vec2(20.0, 16.0))); + assert!(rx.try_recv().is_err(), "a retried capture does not reply yet"); + + let callbacks = issue_with(&ctx, &mut app, raw_of(egui::vec2(20.0, 16.0))); + assert_eq!(callbacks.len(), 1); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([20, 16], egui::Color32::BLUE))); + let _ = ui_pass(&ctx, raw_of(egui::vec2(20.0, 16.0)), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the retried capture replies"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// Minimize retries reserve unknown-size bytes; only two fit the aggregate budget and stay queued (the rest are reported), and the survivors write after restore. +fn a_minimize_retry_without_a_size_still_checks_the_aggregate_budget() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let mut paths = Vec::new(); + let mut receivers = Vec::new(); + for n in 0..SCREENSHOT_MAX_REQUESTS { + let path = temp_png(&format!("minimize-retry-{n}")); + let (tx, rx) = std::sync::mpsc::channel(); + // A path capture isolates the retained-image aggregate budget. + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + paths.push(path); + receivers.push(rx); + } + for request in &mut app.shots.requests { + request.ready_at = 0.0; + } + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), SCREENSHOT_MAX_REQUESTS, "four path captures fit every budget"); + assert_eq!(app.shots.reserved(), SCREENSHOT_MAX_REQUESTS * 65 * 49 * 4); + + // The window is minimized with every capture outstanding. The drain retries each one + // against the new surface generation, which has no capturable size, so every retry + // reserves UNKNOWN_CAPTURE_BYTES. Only two unknown-size reservations fit the aggregate + // limit; the rest must be reported instead of inflating `reserved()` over the budget. + let mut minimized = raw_of(egui::vec2(64.0, 48.0)); + if let Some(viewport) = minimized.viewports.get_mut(&egui::ViewportId::ROOT) { + viewport.inner_rect = None; + viewport.minimized = Some(true); + } + app.raw_input_hook(&ctx, &mut minimized); + let image = std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED)); + for callback in &callbacks { + callback.clone().complete(image.clone()); + } + let _ = ctx.run_logic(&minimized, |ctx| app.logic(ctx)); + + assert!( + app.shots.reserved() <= SCREENSHOT_MAX_AGGREGATE_BYTES, + "size-less retries reserved {} bytes, over the {SCREENSHOT_MAX_AGGREGATE_BYTES}-byte aggregate limit", + app.shots.reserved() + ); + let rejected: Vec = receivers.iter_mut().filter_map(|rx| rx.try_recv().ok()).collect(); + assert_eq!(rejected.len(), 2, "two unknown-size retries no longer fit the aggregate budget"); + assert!(rejected.iter().all(|reply| reply["error"].as_str().is_some_and(|e| e.contains("aggregate limit"))), "{rejected:?}"); + assert_eq!(app.shots.requests.len(), 2, "the two fitting retries stay queued"); + assert!(app.shots.requests.iter().all(|r| !r.issued && r.expected.is_none() && r.bytes == UNKNOWN_CAPTURE_BYTES)); + assert!(receivers.iter_mut().all(|rx| rx.try_recv().is_err()), "the surviving retries wait for the restore"); + + // Restoring re-issues only the surviving requests at the real size; both fit because the + // over-reserved retries were rejected rather than kept. + let mut restored = raw_of(egui::vec2(64.0, 48.0)); + if let Some(viewport) = restored.viewports.get_mut(&egui::ViewportId::ROOT) { + viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(64.0, 48.0))); + viewport.minimized = Some(false); + viewport.occluded = Some(false); + } + app.raw_input_hook(&ctx, &mut restored); + let callbacks = issue_with(&ctx, &mut app, restored.clone()); + assert_eq!(callbacks.len(), 2, "the surviving requests issue on restore"); + for callback in &callbacks { + callback.clone().complete(image.clone()); + } + let _ = ctx.run_logic(&restored, |ctx| app.logic(ctx)); + + let mut delivered = 0; + for rx in &mut receivers { + if let Ok(reply) = rx.try_recv() { + assert_eq!(reply["ok"], true, "{reply}"); + delivered += 1; + } + } + assert_eq!(delivered, 2, "the two surviving captures reply once each"); + let written = paths.iter().filter(|path| path.exists()).count(); + assert_eq!(written, 2, "only the surviving captures write their image"); + for path in &paths { + let _ = std::fs::remove_file(path); + } + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// Fresh hidden viewport geometry invalidates the old image, spends one retry, and the reissued image succeeds. +fn a_hidden_resize_rejects_an_image_issued_before_it_and_reissues() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED))); + + // A hidden tick carries the resize in `RawInput::viewports`; the last UI pass still says + // 64x48, so only the fresh viewport info lets the drain see the change. + let hidden = || { + let mut raw = raw_of(egui::vec2(64.0, 48.0)); + if let Some(viewport) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { + viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(32.0, 400.0))); + viewport.native_pixels_per_point = Some(1.0); + } + raw + }; + let _ = ctx.run_logic(&hidden(), |ctx| app.logic(ctx)); + let request = &app.shots.requests[0]; + assert!(!request.issued, "the image captured before the hidden resize is not accepted"); + assert_eq!(request.retries, 1); + assert_eq!(request.geometry.viewport, Some(egui::vec2(32.0, 400.0))); + assert!(rx.try_recv().is_err(), "a retried capture does not reply yet"); + + // The next real pass issues against the new size and its image is accepted. + let resized = raw_of(egui::vec2(32.0, 24.0)); + let callbacks = issue_with(&ctx, &mut app, resized.clone()); + assert_eq!(callbacks.len(), 1, "the retry is issued once against the current geometry"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::BLUE))); + let _ = ui_pass(&ctx, resized, |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the retried capture replies"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// Draining four completed captures replies to all of them and permits a new admission. +fn a_drained_capture_frees_its_slot_for_the_next_admission() { + let ctx = sized_context(egui::vec2(8.0, 8.0)); + let mut app = test_app(); + let mut receivers = Vec::new(); + for _ in 0..SCREENSHOT_MAX_REQUESTS { + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + receivers.push(rx); + } + for request in &mut app.shots.requests { + request.ready_at = 0.0; + } + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + for callback in &callbacks { + callback.clone().complete(std::sync::Arc::new(egui::ColorImage::filled([8, 8], egui::Color32::GREEN))); + } + assert_eq!(app.shots.requests.len(), SCREENSHOT_MAX_REQUESTS); + // Draining before admitting is what `logic` does: completed requests release first. + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + assert_eq!(app.shots.requests.len(), 0); + assert!(receivers.iter_mut().all(|rx| rx.try_recv().is_ok())); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + assert!(rx.try_recv().is_err(), "a new request is admitted once the drained slots are free"); + assert_eq!(app.shots.requests.len(), 1); +} + +#[test] +/// Uses `screen_rect` when `inner_rect` is absent, applies native scale times zoom, and advances the epoch on a hidden resize. +fn raw_input_geometry_uses_screen_rect_when_inner_rect_is_absent() { + // Wayland always omits `inner_rect`; the fresh `RawInput::screen_rect` is the only size. + let ctx = sized_context(egui::vec2(800.0, 600.0)); + let mut app = test_app(); + let mut raw = raw_of(egui::vec2(1280.0, 720.0)); + if let Some(v) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { + v.inner_rect = None; + v.native_pixels_per_point = Some(2.0); + v.minimized = Some(false); + v.occluded = Some(false); + } + app.raw_input_hook(&ctx, &mut raw); + let geo = app.capture_geometry.expect("the raw frame records its geometry"); + assert_eq!(geo.viewport, Some(egui::vec2(1280.0, 720.0))); + assert_eq!(geo.pixels_per_point, 2.0, "native scale times zoom"); + assert_eq!(geo.expected_size(), Some([2560, 1440])); + + let epoch = app.capture_epoch; + // A hidden resize lands on a logic-only tick: no UI pass ran, but the raw frame is fresh. + let mut hidden = raw_of(egui::vec2(640.0, 480.0)); + if let Some(v) = hidden.viewports.get_mut(&egui::ViewportId::ROOT) { + v.inner_rect = None; + v.native_pixels_per_point = Some(2.0); + v.minimized = Some(false); + v.occluded = Some(true); + } + app.raw_input_hook(&ctx, &mut hidden); + let _ = ctx.run_logic(&hidden, |ctx| app.logic(ctx)); + assert!(app.capture_epoch > epoch, "a hidden resize is a new epoch"); + assert_eq!(app.capture_geometry.expect("geometry").viewport, Some(egui::vec2(640.0, 480.0))); +} + +#[test] +/// Minimized geometry has no size and reserves unknown bytes; restoring at the same size still changes the epoch. +fn minimized_geometry_has_no_size_and_restore_bumps_the_epoch() { + let ctx = egui::Context::default(); + let mut app = test_app(); + let mut minimized = raw_of(egui::vec2(800.0, 600.0)); + if let Some(v) = minimized.viewports.get_mut(&egui::ViewportId::ROOT) { + v.inner_rect = None; + v.minimized = Some(true); + } + app.raw_input_hook(&ctx, &mut minimized); + let geo = app.capture_geometry.expect("recorded"); + assert_eq!(geo.viewport, None, "a minimized window has no capturable size"); + assert_eq!(geo.visible, Some(false)); + + // Admission while minimized reserves the unknown-size maximum and waits. + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + assert_eq!(app.shots.requests[0].bytes, UNKNOWN_CAPTURE_BYTES); + assert!(rx.try_recv().is_err()); + let epoch = app.capture_epoch; + + // Restoring at the same logical size is still a new surface generation. + let mut restored = raw_of(egui::vec2(800.0, 600.0)); + if let Some(v) = restored.viewports.get_mut(&egui::ViewportId::ROOT) { + v.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + v.minimized = Some(false); + v.occluded = Some(false); + } + app.raw_input_hook(&ctx, &mut restored); + assert!(app.capture_epoch > epoch, "minimize/restore at the same size changes the epoch"); + assert_eq!(app.capture_geometry.expect("geometry").viewport, Some(egui::vec2(800.0, 600.0))); + app.shots.cancel_all(); +} + +#[test] +/// Switching documents before issuance cancels the request once with a document-changed error and writes nothing. +fn a_document_switch_before_issuance_cancels_without_writing() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let path = temp_png("doc-queued"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + app.shots.requests[0].ready_at = 0.0; + let admitted = app.shots.requests[0].document; + assert!(app.run("file.new", json!({})).is_ok()); + assert_ne!(app.session.active().map(|d| d.uid), admitted, "the test switched documents"); + + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert!(callbacks.is_empty(), "a capture for another document is never issued"); + let reply = rx.try_recv().expect("the queued request is cancelled once"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("document changed")), "{reply}"); + assert!(!path.exists(), "no pixels for the old document are written to its path"); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// Switching documents after issuance rejects the completed image once and writes nothing. +fn a_document_switch_after_issuance_rejects_the_completed_image() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let path = temp_png("doc-issued"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + assert!(app.run("file.new", json!({})).is_ok()); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the switched document is reported once"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("document changed")), "{reply}"); + assert!(!path.exists(), "a completion after a document switch never writes"); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// A request bound to another viewport is rejected once with a viewport error and writes nothing. +fn a_completion_for_another_viewport_is_rejected() { + let ctx = sized_context(egui::vec2(32.0, 24.0)); + let mut app = test_app(); + let path = temp_png("viewport"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + // A slot/request bound to a different viewport must not fill this one. + app.shots.requests[0].viewport_id = egui::ViewportId::from_hash_of("other-window"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the viewport mismatch is reported once"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("another viewport")), "{reply}"); + assert!(!path.exists()); + assert_eq!(app.shots.reserved(), 0); +} + +#[test] +/// Accepts one-pixel rounding on a single axis, but rejects opposite-axis deviation, aspect distortion and more than one pixel of error. +fn validate_image_rejects_distortion_but_allows_one_pixel_rounding() { + let image = |w: usize, h: usize| egui::ColorImage::filled([w, h], egui::Color32::RED); + assert!(validate_image(&image(100, 100), [100, 100]).is_ok()); + assert!(validate_image(&image(101, 100), [100, 100]).is_ok(), "one pixel of rounding on one axis is legitimate"); + assert!(validate_image(&image(100, 101), [100, 100]).is_ok()); + assert!(validate_image(&image(101, 99), [100, 100]).is_err(), "independent per-axis tolerance must not accept a distorted image"); + assert!(validate_image(&image(99, 101), [100, 100]).is_err()); + assert!(validate_image(&image(102, 100), [100, 100]).is_err(), "more than one pixel per axis is a different surface"); +} + +#[test] +/// Checks the encoding working-set threshold, the base64 reply cap, and the retained-plus-encoding peak budget. +fn encoding_working_set_is_bounded_for_admitted_captures() { + assert!(encoding_working_bytes([8191, 2048], false).expect("checked") <= SCREENSHOT_MAX_ENCODING_BYTES); + assert!(encoding_working_bytes([8192, 8192], false).expect("checked") > SCREENSHOT_MAX_ENCODING_BYTES); + assert!(encoding_working_bytes([4000, 3000], true).expect("checked") <= SCREENSHOT_MAX_ENCODING_BYTES); + assert!(encoding_working_bytes([5000, 4000], true).expect("checked") > SCREENSHOT_MAX_ENCODING_BYTES); + // The base64 reply itself is capped too, before the string exists. + let png = png_worst_bytes(5000 * 4000 * 4, 4000).expect("checked"); + assert!(base64_len(png).expect("checked") > SCREENSHOT_MAX_BASE64_BYTES); + // Retained requests plus one encoding working set are held to a hard peak. + assert!(check_peak_budget(0, [64, 48], false).is_ok()); + assert!(check_peak_budget(SCREENSHOT_MAX_AGGREGATE_BYTES, [9000, 4096], false).is_err()); +} + +#[test] +/// An oversized base64 capture is refused with nothing reserved, while the same capture as a path is admitted. +fn base64_capture_over_the_encoding_budget_is_rejected_before_admission() { + let ctx = sized_context(egui::vec2(5000.0, 4000.0)); + let mut app = test_app(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + let reply = rx.try_recv().expect("an oversized base64 capture is refused at admission"); + assert_eq!(reply["ok"], false, "{reply}"); + assert!( + reply["error"].as_str().is_some_and(|e| e.contains("encoding") || e.contains("base64")), + "the error names the encoding budget: {reply}" + ); + assert!(app.shots.requests.is_empty(), "a refused request reserves nothing"); + + // The same capture as a file is admitted within the per-image and encoding budgets. + let path = temp_png("encoding-path"); + let (tx2, rx2) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx2); + assert!(rx2.try_recv().is_err(), "the path form is admitted"); + assert_eq!(app.shots.requests.len(), 1); + app.shots.cancel_all(); +} + +#[test] +/// Oversized text, repeated maximum texts, keys and moves are refused without changing the queue, and the byte/event limits are both enforced. +fn synthetic_text_key_and_move_producers_honour_the_queue_budget() { + let ctx = egui::Context::default(); + let mut app = test_app(); + // One oversized text event is refused before it is queued. + let huge = "x".repeat(MAX_SYNTHETIC_TEXT_BYTES + 1); + let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.text", &json!({"text": huge})) else { panic!("ui.text returns immediately") }; + assert_eq!(reply["ok"], false, "{reply}"); + assert!(app.synthetic.is_empty()); + + // The byte budget stops repeated maximum-size texts; the queue is left unchanged. + let max = "x".repeat(MAX_SYNTHETIC_TEXT_BYTES); + let mut refused_at = None; + for i in 0..64 { + let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.text", &json!({"text": max.clone()})) else { + panic!("ui.text returns immediately") + }; + if reply["ok"] == false { + refused_at = Some((i, app.synthetic.len())); + break; + } + } + let (accepted, queued) = refused_at.expect("the synthetic byte budget is enforced"); + assert!(accepted > 0 && accepted < 64, "{accepted} texts fit"); + let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.text", &json!({"text": max})) else { panic!("ui.text returns immediately") }; + assert_eq!(reply["ok"], false); + assert_eq!(app.synthetic.len(), queued, "a refused text leaves the queue alone"); + + // Event count: keys and moves use the same overall budget as pointer gestures. + app.synthetic.clear(); + let pos = egui::pos2(1.0, 1.0); + app.synthetic.push_back(vec![egui::Event::PointerMoved(pos); MAX_QUEUED_SYNTHETIC_EVENTS - 1]); + let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.key", &json!({"key":"enter"})) else { panic!("ui.key returns immediately") }; + assert_eq!(reply["ok"], false, "two key events do not fit in the last slot: {reply}"); + assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), MAX_QUEUED_SYNTHETIC_EVENTS - 1); + + app.synthetic.clear(); + app.synthetic.push_back(vec![egui::Event::PointerMoved(pos); MAX_QUEUED_SYNTHETIC_EVENTS]); + let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.move", &json!({"x":2.0,"y":2.0})) else { panic!("ui.move returns immediately") }; + assert_eq!(reply["ok"], false, "{reply}"); + assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), MAX_QUEUED_SYNTHETIC_EVENTS); +} From 46a406c034db7a07c2d08fa66b6abc5992198b64 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:45 +0000 Subject: [PATCH 38/76] refactor(ui-egui): move grid tests to src/grid/tests.rs --- crates/ui-egui/src/grid.rs | 310 +------------------------------ crates/ui-egui/src/grid/tests.rs | 306 ++++++++++++++++++++++++++++++ 2 files changed, 307 insertions(+), 309 deletions(-) create mode 100644 crates/ui-egui/src/grid/tests.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 2f9a1ba6e..970ab61dd 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -4201,315 +4201,7 @@ pub(crate) fn heading_font_size(zoom: f32) -> f32 { } #[cfg(test)] -mod tests { - - #[test] - fn editor_matches_the_cell_while_the_text_fits() { - use super::editor_width; - assert_eq!(editor_width(10.0, 80.0, 7.0), 80.0); - assert_eq!(editor_width(66.0, 80.0, 7.0), 80.0); - assert_eq!(editor_width(67.0, 80.0, 7.0), 88.0); - } - - #[test] - fn editor_slack_fits_one_more_glyph_at_high_zoom() { - use super::editor_slack; - // A glyph is at most about one em wide; at 400% an em is ~60 px, far over the old fixed 14 px. - for size in [14.7_f32, 25.0, 60.0] { - assert!(editor_slack(size) - 4.0 >= size, "slack {} too small for a {size} px glyph", editor_slack(size)); - } - } - - #[test] - fn explicit_left_and_right_keep_their_side_on_a_right_to_left_sheet() { - use super::{HAlign, painted_halign}; - // Painted before the mirror: swapped, so they land where the user put them. - assert_eq!(painted_halign(HAlign::Right, true), HAlign::Left); - assert_eq!(painted_halign(HAlign::Left, true), HAlign::Right); - // Left-to-right sheets and the other alignments are painted as they are. - assert_eq!(painted_halign(HAlign::Right, false), HAlign::Right); - assert_eq!(painted_halign(HAlign::Left, false), HAlign::Left); - for h in [HAlign::Center, HAlign::Justify, HAlign::Fill] { - assert_eq!(painted_halign(h, true), h); - } - } - - #[test] - fn heading_labels_scale_with_the_zoom_without_a_cap() { - use super::{DISPLAY_SCALE, heading_font_size}; - let at = |percent: f32| heading_font_size(percent / 100.0 * DISPLAY_SCALE); - assert!((at(400.0) / at(100.0) - 4.0).abs() < 1e-4, "four times as large at 400%"); - assert!(at(200.0) > at(100.0) && at(400.0) > at(200.0)); - assert_eq!(at(10.0), 8.5, "never below a readable size"); - } - use super::*; - - #[test] - fn list_picker_fits_up_to_eight_rows_then_scrolls() { - assert_eq!(list_picker_height(5, 18.0, 3.0), 5.0 * 18.0 + 4.0 * 3.0); - assert_eq!(list_picker_height(8, 18.0, 3.0), 8.0 * 18.0 + 7.0 * 3.0); - assert_eq!(list_picker_height(30, 18.0, 3.0), list_picker_height(8, 18.0, 3.0)); - assert_eq!(list_picker_height(0, 18.0, 3.0), 18.0); - } - - #[test] - fn number_format_colors_resolve_named_and_indexed_rgb() { - use gridcraft_engine::numfmt::FormatColor as F; - assert_eq!(numfmt_color(F::Green), Color32::from_rgb(0, 255, 0)); - assert_eq!(numfmt_color(F::Indexed(1)), Color32::BLACK); - assert_eq!(numfmt_color(F::Indexed(10)), Color32::from_rgb(0, 128, 0)); - assert_eq!(numfmt_color(F::Indexed(46)), Color32::from_rgb(255, 102, 0)); - assert_eq!(numfmt_color(F::Indexed(56)), Color32::from_rgb(51, 51, 51)); - for index in [0, 57, 255] { - assert_eq!(numfmt_color(F::Indexed(index)), Color32::BLACK); - } - } - - #[test] - fn wheel_zoom_steps_along_the_ladder_and_stops_at_100() { - for (from, zoom_in, to) in [ - (100, true, 120), - (120, false, 100), - (100, false, 85), - // Between steps, the next one each way (Excel: 112% goes to 115% or 100%). - (112, true, 120), - (112, false, 100), - // A step less than 5% away is skipped… - (121, false, 100), - (86, false, 70), - (139, true, 170), - // …but never 100% or a limit. - (99, true, 100), - (101, false, 100), - (96, true, 100), - (385, true, 400), - (11, false, 10), - (400, true, 400), - (10, false, 10), - // Out-of-range zooms (from a file) come into range first. - (5, true, 12), - (1000, false, 340), - (0, false, 10), - (u16::MAX, true, 400), - ] { - assert_eq!(wheel_zoom_step(from, zoom_in), to, "{from}% {}", if zoom_in { "in" } else { "out" }); - } - // Every step on the ladder is 15–25% from the next, and a notch in then out returns. - // (The glide between steps is `zoom_glide_moves_by_ratio_and_always_arrives`.) - for w in WHEEL_ZOOM_LADDER.windows(2) { - let ratio = f32::from(w[1]) / f32::from(w[0]); - assert!((1.15..=1.25).contains(&ratio), "{w:?}"); - assert_eq!(wheel_zoom_step(w[0], true), w[1]); - assert_eq!(wheel_zoom_step(w[1], false), w[0]); - } - } - - #[test] - fn zoom_glide_moves_by_ratio_and_always_arrives() { - // A third of the way per frame, as at 60 frames a second. - for (from, to) in [(100, 120), (120, 100), (10, 12), (340, 400), (400, 10), (85, 100)] { - let (mut z, mut frames) = (from, 0); - while z != to { - let next = zoom_glide_step(z, to, 0.32); - assert!(next != z && next.abs_diff(to) < z.abs_diff(to), "{from} → {to}: {z} then {next}"); - z = next; - frames += 1; - assert!(frames < 40, "{from} → {to} arrives"); - } - assert!(frames >= 2, "{from} → {to} glides over more than one frame ({frames})"); - } - assert_eq!(zoom_glide_step(100, 120, 0.32), 106, "a third of the way by ratio"); - assert_eq!(zoom_glide_step(120, 100, 0.32), 113); - // No progress asked or nonsense: still a percent closer, or straight there. - assert_eq!(zoom_glide_step(100, 120, 0.0), 101); - assert_eq!(zoom_glide_step(120, 100, f32::NAN), 100); - assert_eq!(zoom_glide_step(100, 120, 5.0), 120); - assert_eq!(zoom_glide_step(120, 120, 0.5), 120); - assert_eq!(zoom_glide_step(0, 10, 0.5), 3); - } - - #[test] - fn center_across_run_stops_at_first_non_continuing_column() { - assert_eq!(center_across_end(0, 20, |c| c <= 2), 2); - assert_eq!(center_across_end(0, 20, |_| false), 0); - assert_eq!(center_across_end(0, 1, |_| true), 1); - } - - #[test] - fn conditional_number_format_reaches_the_cell_text() { - // #1163: a rule's number format replaces the cell's own; unmatched cells keep theirs. - let mut session = gridcraft_engine::Session::new(); - session.new_workbook(); - session.execute("range.setValues", json!({"range": "A1", "values": [[1], [3], [4]]})).unwrap(); - session.execute("home.numberFormat", json!({"range": "A3", "code": "0.0"})).unwrap(); - let rule = json!({"type": "cellIs", "operator": "greater", "value": "2", "style": {"num_fmt": "0.00"}}); - session.execute("home.conditionalFormat", json!({"range": "A1:A3", "rule": rule})).unwrap(); - let wb = session.active().unwrap().wb.clone(); - let sh = wb.sheet(0).unwrap(); - let mut cf = CfCache::new(); - let text = |row: u32, cf: &mut CfCache| { - let c = CellRef::new(row, 0); - let (lk, _) = look(&wb, 0, sh, c, cf); - display_text(&wb, sh, c, &sh.value(c), &lk.style, 200.0, 7.0).0 - }; - assert_eq!(text(0, &mut cf), "1"); - assert_eq!(text(1, &mut cf), "3.00"); - assert_eq!(text(2, &mut cf), "4.00"); - } - - #[test] - fn filter_menu_uses_the_table_whose_header_was_clicked() { - use gridcraft_engine::model::Table; - let table = |id: u32, r0: u32, r1: u32| Table { - id, - name: format!("T{id}"), - range: RangeRef::new(CellRef::new(r0, 0), CellRef::new(r1, 0)), - header_row: true, - totals_row: false, - columns: vec![], - style: String::new(), - banded_rows: true, - banded_cols: false, - first_col: false, - last_col: false, - filter_button: true, - filter: vec![], - table_type: None, - connection_id: None, - }; - let mut sh = Sheet::new("S"); - sh.tables.push(table(1, 0, 2)); - sh.tables.push(table(2, 4, 7)); - assert_eq!(filter_table_range(&sh, 0, 4), Some(sh.tables[1].range)); - assert_eq!(filter_table_range(&sh, 0, 0), Some(sh.tables[0].range)); - assert_eq!(filter_table_range(&sh, 1, 4), None); - } - - #[test] - fn cell_text_y_moves_monotonically_in_standard_and_tall_rows() { - let rect_std = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0)); - let gh = 18.5; - let z = 1.0; - - let y_top_std = cell_text_y(VAlign::Top, rect_std, gh, z); - let y_center_std = cell_text_y(VAlign::Center, rect_std, gh, z); - let y_bottom_std = cell_text_y(VAlign::Bottom, rect_std, gh, z); - - assert!(y_top_std < y_center_std, "Top ({y_top_std}) must be higher than Center ({y_center_std})"); - assert!(y_center_std < y_bottom_std, "Center ({y_center_std}) must be higher than Bottom ({y_bottom_std})"); - - // Verify tall row (e.g. 40px) - let rect_tall = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 40.0)); - let y_top_tall = cell_text_y(VAlign::Top, rect_tall, gh, z); - let y_center_tall = cell_text_y(VAlign::Center, rect_tall, gh, z); - let y_bottom_tall = cell_text_y(VAlign::Bottom, rect_tall, gh, z); - - assert!(y_top_tall < y_center_tall, "Top ({y_top_tall}) must be higher than Center ({y_center_tall})"); - assert!(y_center_tall < y_bottom_tall, "Center ({y_center_tall}) must be higher than Bottom ({y_bottom_tall})"); - } - - #[test] - fn wrapped_cell_text_y_positions_correctly_and_safeguards_overflow() { - let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 40.0)); - let gh = 30.0; - let z = 1.0; - - let y_top = wrapped_cell_text_y(VAlign::Top, rect, gh, z); - let y_center = wrapped_cell_text_y(VAlign::Center, rect, gh, z); - let y_bottom = wrapped_cell_text_y(VAlign::Bottom, rect, gh, z); - - assert!(y_top < y_center); - assert!(y_center < y_bottom); - - // Overflow case: gh exceeds rect height; Top still starts inside the cell. - let gh_overflow = 60.0; - let y_top_ovf = wrapped_cell_text_y(VAlign::Top, rect, gh_overflow, z); - assert!(y_top_ovf >= rect.top()); - } - - #[test] - fn wrapped_cell_text_y_keeps_vertical_alignment_when_text_is_taller_than_the_row() { - // A default-height row holding three wrapped lines. - let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0)); - let (gh, z) = (60.0, 1.0); - let y_top = wrapped_cell_text_y(VAlign::Top, rect, gh, z); - let y_center = wrapped_cell_text_y(VAlign::Center, rect, gh, z); - let y_bottom = wrapped_cell_text_y(VAlign::Bottom, rect, gh, z); - // Taller text overflows upward for Bottom and both ways for Center (the cell clips it). - assert!((y_top - 1.5).abs() < 0.01, "Top starts at the top edge, got {y_top}"); - assert!((y_center + gh / 2.0 - rect.center().y).abs() < 0.01, "Center is centred, got {y_center}"); - assert!((y_bottom + gh - (rect.bottom() - 1.5)).abs() < 0.01, "Bottom ends at the bottom edge, got {y_bottom}"); - } - - /// The screen box of a galley of `size` drawn at `pos` turned by `angle` about `pos`, as egui - /// draws a rotated `TextShape`. - fn rotated_box(pos: Pos2, size: egui::Vec2, angle: f32) -> Rect { - let rot = egui::emath::Rot2::from_angle(angle); - let pts = [vec2(0.0, 0.0), vec2(size.x, 0.0), vec2(0.0, size.y), size].map(|v| pos + rot * v); - Rect::from_points(&pts) - } - - #[test] - fn rotated_text_box_follows_the_cell_alignment() { - // A tall, narrow merged block (one 3-character column over four 60pt rows). - let rect = Rect::from_min_max(pos2(10.0, 20.0), pos2(40.0, 340.0)); - let size = vec2(90.0, 15.0); - let pad = 2.5; - let near = |a: f32, b: f32| (a - b).abs() < 0.01; - for deg in [90i16, -90, 45, -45] { - let angle = -(deg as f32).to_radians(); - for v in [VAlign::Top, VAlign::Center, VAlign::Bottom] { - for h in [HAlign::Left, HAlign::Center, HAlign::Right] { - let bb = rotated_box(rotated_text_pos(rect, size, angle, h, v, pad, 0.0), size, angle); - let what = format!("{deg}° {h:?}/{v:?}: {bb:?} in {rect:?}"); - assert!(near(bb.height(), rotated_box(Pos2::ZERO, size, angle).height()), "{what}"); - assert!(bb.top() >= rect.top() && bb.bottom() <= rect.bottom(), "{what}"); - // A narrow column can't hold a 45° line across: only check where it fits. - let fits_x = bb.width() <= rect.width() - 2.0 * pad; - match h { - HAlign::Left => assert!(near(bb.left(), rect.left() + pad), "{what}"), - HAlign::Right => assert!(near(bb.right(), rect.right() - pad), "{what}"), - _ => assert!(near(bb.center().x, rect.center().x), "{what}"), - } - if fits_x { - assert!(bb.left() >= rect.left() && bb.right() <= rect.right(), "{what}"); - } - match v { - VAlign::Top => assert!(near(bb.top(), rect.top() + pad), "{what}"), - VAlign::Center => assert!(near(bb.center().y, rect.center().y), "{what}"), - // Bottom is the default: downward text keeps starting at the top, as before. - _ if deg < 0 => assert!(near(bb.top(), rect.top() + pad), "{what}"), - _ => assert!(near(bb.bottom(), rect.bottom() - pad), "{what}"), - } - } - } - } - } - - #[test] - fn upright_text_with_default_alignment_keeps_its_place() { - let rect = Rect::from_min_max(pos2(10.0, 20.0), pos2(40.0, 340.0)); - let size = vec2(90.0, 15.0); - let pad = 2.5; - let near = |a: Pos2, b: Pos2| (a - b).length() < 0.01; - // Upward text starts at the bottom-left corner, downward text one block-height in from the top-left. - let up = rotated_text_pos(rect, size, -std::f32::consts::FRAC_PI_2, HAlign::General, VAlign::Bottom, pad, 0.0); - assert!(near(up, pos2(rect.left() + pad, rect.bottom() - pad)), "{up:?}"); - let down = rotated_text_pos(rect, size, std::f32::consts::FRAC_PI_2, HAlign::General, VAlign::Bottom, pad, 0.0); - assert!(near(down, pos2(rect.left() + pad + size.y, rect.top() + pad)), "{down:?}"); - } - - #[test] - fn upward_text_with_middle_align_is_centred_in_a_merged_block() { - // Issue #435: "Licht Strom (?)" turned up in A1:A4 with Middle Align was drawn at the bottom. - let rect = Rect::from_min_max(pos2(10.0, 20.0), pos2(40.0, 340.0)); - let size = vec2(90.0, 15.0); - let angle = -std::f32::consts::FRAC_PI_2; - let bb = rotated_box(rotated_text_pos(rect, size, angle, HAlign::Center, VAlign::Center, 2.5, 0.0), size, angle); - assert!((bb.center().y - rect.center().y).abs() < 0.01, "{bb:?} not centred in {rect:?}"); - assert!((bb.center().x - rect.center().x).abs() < 0.01, "{bb:?} not centred in {rect:?}"); - } -} +mod tests; #[cfg(test)] mod pane_tests { diff --git a/crates/ui-egui/src/grid/tests.rs b/crates/ui-egui/src/grid/tests.rs new file mode 100644 index 000000000..74abfc861 --- /dev/null +++ b/crates/ui-egui/src/grid/tests.rs @@ -0,0 +1,306 @@ +#[test] +fn editor_matches_the_cell_while_the_text_fits() { + use super::editor_width; + assert_eq!(editor_width(10.0, 80.0, 7.0), 80.0); + assert_eq!(editor_width(66.0, 80.0, 7.0), 80.0); + assert_eq!(editor_width(67.0, 80.0, 7.0), 88.0); +} + +#[test] +fn editor_slack_fits_one_more_glyph_at_high_zoom() { + use super::editor_slack; + // A glyph is at most about one em wide; at 400% an em is ~60 px, far over the old fixed 14 px. + for size in [14.7_f32, 25.0, 60.0] { + assert!(editor_slack(size) - 4.0 >= size, "slack {} too small for a {size} px glyph", editor_slack(size)); + } +} + +#[test] +fn explicit_left_and_right_keep_their_side_on_a_right_to_left_sheet() { + use super::{HAlign, painted_halign}; + // Painted before the mirror: swapped, so they land where the user put them. + assert_eq!(painted_halign(HAlign::Right, true), HAlign::Left); + assert_eq!(painted_halign(HAlign::Left, true), HAlign::Right); + // Left-to-right sheets and the other alignments are painted as they are. + assert_eq!(painted_halign(HAlign::Right, false), HAlign::Right); + assert_eq!(painted_halign(HAlign::Left, false), HAlign::Left); + for h in [HAlign::Center, HAlign::Justify, HAlign::Fill] { + assert_eq!(painted_halign(h, true), h); + } +} + +#[test] +fn heading_labels_scale_with_the_zoom_without_a_cap() { + use super::{DISPLAY_SCALE, heading_font_size}; + let at = |percent: f32| heading_font_size(percent / 100.0 * DISPLAY_SCALE); + assert!((at(400.0) / at(100.0) - 4.0).abs() < 1e-4, "four times as large at 400%"); + assert!(at(200.0) > at(100.0) && at(400.0) > at(200.0)); + assert_eq!(at(10.0), 8.5, "never below a readable size"); +} +use super::*; + +#[test] +fn list_picker_fits_up_to_eight_rows_then_scrolls() { + assert_eq!(list_picker_height(5, 18.0, 3.0), 5.0 * 18.0 + 4.0 * 3.0); + assert_eq!(list_picker_height(8, 18.0, 3.0), 8.0 * 18.0 + 7.0 * 3.0); + assert_eq!(list_picker_height(30, 18.0, 3.0), list_picker_height(8, 18.0, 3.0)); + assert_eq!(list_picker_height(0, 18.0, 3.0), 18.0); +} + +#[test] +fn number_format_colors_resolve_named_and_indexed_rgb() { + use gridcraft_engine::numfmt::FormatColor as F; + assert_eq!(numfmt_color(F::Green), Color32::from_rgb(0, 255, 0)); + assert_eq!(numfmt_color(F::Indexed(1)), Color32::BLACK); + assert_eq!(numfmt_color(F::Indexed(10)), Color32::from_rgb(0, 128, 0)); + assert_eq!(numfmt_color(F::Indexed(46)), Color32::from_rgb(255, 102, 0)); + assert_eq!(numfmt_color(F::Indexed(56)), Color32::from_rgb(51, 51, 51)); + for index in [0, 57, 255] { + assert_eq!(numfmt_color(F::Indexed(index)), Color32::BLACK); + } +} + +#[test] +fn wheel_zoom_steps_along_the_ladder_and_stops_at_100() { + for (from, zoom_in, to) in [ + (100, true, 120), + (120, false, 100), + (100, false, 85), + // Between steps, the next one each way (Excel: 112% goes to 115% or 100%). + (112, true, 120), + (112, false, 100), + // A step less than 5% away is skipped… + (121, false, 100), + (86, false, 70), + (139, true, 170), + // …but never 100% or a limit. + (99, true, 100), + (101, false, 100), + (96, true, 100), + (385, true, 400), + (11, false, 10), + (400, true, 400), + (10, false, 10), + // Out-of-range zooms (from a file) come into range first. + (5, true, 12), + (1000, false, 340), + (0, false, 10), + (u16::MAX, true, 400), + ] { + assert_eq!(wheel_zoom_step(from, zoom_in), to, "{from}% {}", if zoom_in { "in" } else { "out" }); + } + // Every step on the ladder is 15–25% from the next, and a notch in then out returns. + // (The glide between steps is `zoom_glide_moves_by_ratio_and_always_arrives`.) + for w in WHEEL_ZOOM_LADDER.windows(2) { + let ratio = f32::from(w[1]) / f32::from(w[0]); + assert!((1.15..=1.25).contains(&ratio), "{w:?}"); + assert_eq!(wheel_zoom_step(w[0], true), w[1]); + assert_eq!(wheel_zoom_step(w[1], false), w[0]); + } +} + +#[test] +fn zoom_glide_moves_by_ratio_and_always_arrives() { + // A third of the way per frame, as at 60 frames a second. + for (from, to) in [(100, 120), (120, 100), (10, 12), (340, 400), (400, 10), (85, 100)] { + let (mut z, mut frames) = (from, 0); + while z != to { + let next = zoom_glide_step(z, to, 0.32); + assert!(next != z && next.abs_diff(to) < z.abs_diff(to), "{from} → {to}: {z} then {next}"); + z = next; + frames += 1; + assert!(frames < 40, "{from} → {to} arrives"); + } + assert!(frames >= 2, "{from} → {to} glides over more than one frame ({frames})"); + } + assert_eq!(zoom_glide_step(100, 120, 0.32), 106, "a third of the way by ratio"); + assert_eq!(zoom_glide_step(120, 100, 0.32), 113); + // No progress asked or nonsense: still a percent closer, or straight there. + assert_eq!(zoom_glide_step(100, 120, 0.0), 101); + assert_eq!(zoom_glide_step(120, 100, f32::NAN), 100); + assert_eq!(zoom_glide_step(100, 120, 5.0), 120); + assert_eq!(zoom_glide_step(120, 120, 0.5), 120); + assert_eq!(zoom_glide_step(0, 10, 0.5), 3); +} + +#[test] +fn center_across_run_stops_at_first_non_continuing_column() { + assert_eq!(center_across_end(0, 20, |c| c <= 2), 2); + assert_eq!(center_across_end(0, 20, |_| false), 0); + assert_eq!(center_across_end(0, 1, |_| true), 1); +} + +#[test] +fn conditional_number_format_reaches_the_cell_text() { + // #1163: a rule's number format replaces the cell's own; unmatched cells keep theirs. + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [[1], [3], [4]]})).unwrap(); + session.execute("home.numberFormat", json!({"range": "A3", "code": "0.0"})).unwrap(); + let rule = json!({"type": "cellIs", "operator": "greater", "value": "2", "style": {"num_fmt": "0.00"}}); + session.execute("home.conditionalFormat", json!({"range": "A1:A3", "rule": rule})).unwrap(); + let wb = session.active().unwrap().wb.clone(); + let sh = wb.sheet(0).unwrap(); + let mut cf = CfCache::new(); + let text = |row: u32, cf: &mut CfCache| { + let c = CellRef::new(row, 0); + let (lk, _) = look(&wb, 0, sh, c, cf); + display_text(&wb, sh, c, &sh.value(c), &lk.style, 200.0, 7.0).0 + }; + assert_eq!(text(0, &mut cf), "1"); + assert_eq!(text(1, &mut cf), "3.00"); + assert_eq!(text(2, &mut cf), "4.00"); +} + +#[test] +fn filter_menu_uses_the_table_whose_header_was_clicked() { + use gridcraft_engine::model::Table; + let table = |id: u32, r0: u32, r1: u32| Table { + id, + name: format!("T{id}"), + range: RangeRef::new(CellRef::new(r0, 0), CellRef::new(r1, 0)), + header_row: true, + totals_row: false, + columns: vec![], + style: String::new(), + banded_rows: true, + banded_cols: false, + first_col: false, + last_col: false, + filter_button: true, + filter: vec![], + table_type: None, + connection_id: None, + }; + let mut sh = Sheet::new("S"); + sh.tables.push(table(1, 0, 2)); + sh.tables.push(table(2, 4, 7)); + assert_eq!(filter_table_range(&sh, 0, 4), Some(sh.tables[1].range)); + assert_eq!(filter_table_range(&sh, 0, 0), Some(sh.tables[0].range)); + assert_eq!(filter_table_range(&sh, 1, 4), None); +} + +#[test] +fn cell_text_y_moves_monotonically_in_standard_and_tall_rows() { + let rect_std = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0)); + let gh = 18.5; + let z = 1.0; + + let y_top_std = cell_text_y(VAlign::Top, rect_std, gh, z); + let y_center_std = cell_text_y(VAlign::Center, rect_std, gh, z); + let y_bottom_std = cell_text_y(VAlign::Bottom, rect_std, gh, z); + + assert!(y_top_std < y_center_std, "Top ({y_top_std}) must be higher than Center ({y_center_std})"); + assert!(y_center_std < y_bottom_std, "Center ({y_center_std}) must be higher than Bottom ({y_bottom_std})"); + + // Verify tall row (e.g. 40px) + let rect_tall = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 40.0)); + let y_top_tall = cell_text_y(VAlign::Top, rect_tall, gh, z); + let y_center_tall = cell_text_y(VAlign::Center, rect_tall, gh, z); + let y_bottom_tall = cell_text_y(VAlign::Bottom, rect_tall, gh, z); + + assert!(y_top_tall < y_center_tall, "Top ({y_top_tall}) must be higher than Center ({y_center_tall})"); + assert!(y_center_tall < y_bottom_tall, "Center ({y_center_tall}) must be higher than Bottom ({y_bottom_tall})"); +} + +#[test] +fn wrapped_cell_text_y_positions_correctly_and_safeguards_overflow() { + let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 40.0)); + let gh = 30.0; + let z = 1.0; + + let y_top = wrapped_cell_text_y(VAlign::Top, rect, gh, z); + let y_center = wrapped_cell_text_y(VAlign::Center, rect, gh, z); + let y_bottom = wrapped_cell_text_y(VAlign::Bottom, rect, gh, z); + + assert!(y_top < y_center); + assert!(y_center < y_bottom); + + // Overflow case: gh exceeds rect height; Top still starts inside the cell. + let gh_overflow = 60.0; + let y_top_ovf = wrapped_cell_text_y(VAlign::Top, rect, gh_overflow, z); + assert!(y_top_ovf >= rect.top()); +} + +#[test] +fn wrapped_cell_text_y_keeps_vertical_alignment_when_text_is_taller_than_the_row() { + // A default-height row holding three wrapped lines. + let rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 20.0)); + let (gh, z) = (60.0, 1.0); + let y_top = wrapped_cell_text_y(VAlign::Top, rect, gh, z); + let y_center = wrapped_cell_text_y(VAlign::Center, rect, gh, z); + let y_bottom = wrapped_cell_text_y(VAlign::Bottom, rect, gh, z); + // Taller text overflows upward for Bottom and both ways for Center (the cell clips it). + assert!((y_top - 1.5).abs() < 0.01, "Top starts at the top edge, got {y_top}"); + assert!((y_center + gh / 2.0 - rect.center().y).abs() < 0.01, "Center is centred, got {y_center}"); + assert!((y_bottom + gh - (rect.bottom() - 1.5)).abs() < 0.01, "Bottom ends at the bottom edge, got {y_bottom}"); +} + +/// The screen box of a galley of `size` drawn at `pos` turned by `angle` about `pos`, as egui +/// draws a rotated `TextShape`. +fn rotated_box(pos: Pos2, size: egui::Vec2, angle: f32) -> Rect { + let rot = egui::emath::Rot2::from_angle(angle); + let pts = [vec2(0.0, 0.0), vec2(size.x, 0.0), vec2(0.0, size.y), size].map(|v| pos + rot * v); + Rect::from_points(&pts) +} + +#[test] +fn rotated_text_box_follows_the_cell_alignment() { + // A tall, narrow merged block (one 3-character column over four 60pt rows). + let rect = Rect::from_min_max(pos2(10.0, 20.0), pos2(40.0, 340.0)); + let size = vec2(90.0, 15.0); + let pad = 2.5; + let near = |a: f32, b: f32| (a - b).abs() < 0.01; + for deg in [90i16, -90, 45, -45] { + let angle = -(deg as f32).to_radians(); + for v in [VAlign::Top, VAlign::Center, VAlign::Bottom] { + for h in [HAlign::Left, HAlign::Center, HAlign::Right] { + let bb = rotated_box(rotated_text_pos(rect, size, angle, h, v, pad, 0.0), size, angle); + let what = format!("{deg}° {h:?}/{v:?}: {bb:?} in {rect:?}"); + assert!(near(bb.height(), rotated_box(Pos2::ZERO, size, angle).height()), "{what}"); + assert!(bb.top() >= rect.top() && bb.bottom() <= rect.bottom(), "{what}"); + // A narrow column can't hold a 45° line across: only check where it fits. + let fits_x = bb.width() <= rect.width() - 2.0 * pad; + match h { + HAlign::Left => assert!(near(bb.left(), rect.left() + pad), "{what}"), + HAlign::Right => assert!(near(bb.right(), rect.right() - pad), "{what}"), + _ => assert!(near(bb.center().x, rect.center().x), "{what}"), + } + if fits_x { + assert!(bb.left() >= rect.left() && bb.right() <= rect.right(), "{what}"); + } + match v { + VAlign::Top => assert!(near(bb.top(), rect.top() + pad), "{what}"), + VAlign::Center => assert!(near(bb.center().y, rect.center().y), "{what}"), + // Bottom is the default: downward text keeps starting at the top, as before. + _ if deg < 0 => assert!(near(bb.top(), rect.top() + pad), "{what}"), + _ => assert!(near(bb.bottom(), rect.bottom() - pad), "{what}"), + } + } + } + } +} + +#[test] +fn upright_text_with_default_alignment_keeps_its_place() { + let rect = Rect::from_min_max(pos2(10.0, 20.0), pos2(40.0, 340.0)); + let size = vec2(90.0, 15.0); + let pad = 2.5; + let near = |a: Pos2, b: Pos2| (a - b).length() < 0.01; + // Upward text starts at the bottom-left corner, downward text one block-height in from the top-left. + let up = rotated_text_pos(rect, size, -std::f32::consts::FRAC_PI_2, HAlign::General, VAlign::Bottom, pad, 0.0); + assert!(near(up, pos2(rect.left() + pad, rect.bottom() - pad)), "{up:?}"); + let down = rotated_text_pos(rect, size, std::f32::consts::FRAC_PI_2, HAlign::General, VAlign::Bottom, pad, 0.0); + assert!(near(down, pos2(rect.left() + pad + size.y, rect.top() + pad)), "{down:?}"); +} + +#[test] +fn upward_text_with_middle_align_is_centred_in_a_merged_block() { + // Issue #435: "Licht Strom (?)" turned up in A1:A4 with Middle Align was drawn at the bottom. + let rect = Rect::from_min_max(pos2(10.0, 20.0), pos2(40.0, 340.0)); + let size = vec2(90.0, 15.0); + let angle = -std::f32::consts::FRAC_PI_2; + let bb = rotated_box(rotated_text_pos(rect, size, angle, HAlign::Center, VAlign::Center, 2.5, 0.0), size, angle); + assert!((bb.center().y - rect.center().y).abs() < 0.01, "{bb:?} not centred in {rect:?}"); + assert!((bb.center().x - rect.center().x).abs() < 0.01, "{bb:?} not centred in {rect:?}"); +} From cb661c00517b032cee6be71edfe7992960f18bfd Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:46 +0000 Subject: [PATCH 39/76] refactor(ui-egui): move theme tests to src/theme/tests.rs --- crates/ui-egui/src/theme.rs | 361 +----------------------------- crates/ui-egui/src/theme/tests.rs | 358 +++++++++++++++++++++++++++++ 2 files changed, 359 insertions(+), 360 deletions(-) create mode 100644 crates/ui-egui/src/theme/tests.rs diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 478b8b86b..28dc465e2 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -1267,363 +1267,4 @@ mod contrast_tests { } #[cfg(all(test, not(target_arch = "wasm32")))] -mod tests { - use super::*; - use crate::i18n::Language; - - #[test] - /// Loaded script-fallback faces end each role chain just before the craft fonts, in the language's order. - fn fallback_faces_are_appended_to_every_family() { - // Structural: whatever loaded, the non-Latin fallback keys must sit at the end of each chain - // (after the Latin/default fonts) so Latin still wins for Latin text, in the language's order. - for han in [HanOrder::Chinese, HanOrder::Japanese] { - let language = if han == HanOrder::Japanese { Language::Ja } else { Language::Zh }; - let fonts = font_definitions_for_language(language); - let fb_keys: Vec = - fallback_faces(han).into_iter().map(|(k, _)| k.to_string()).filter(|k| fonts.font_data.contains_key(k)).collect(); - if fb_keys.is_empty() { - return; // no fallback face on this machine (minimal Linux) - } - for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { - let chain = fonts.families.get(&FontFamily::Name(role.into())).map(Vec::as_slice).unwrap_or_default(); - // The optional craft-fonts faces follow the system fallbacks at the very end. - let end = chain.iter().rposition(|k| !k.starts_with("craft-")).map_or(0, |i| i + 1); - assert!(end >= fb_keys.len(), "{role} chain too short for its fallback"); - assert_eq!(&chain[end - fb_keys.len()..end], fb_keys.as_slice(), "{role} must end its non-craft chain with the fallback faces"); - } - } - } - - #[test] - /// Every font family ends with exactly the permitted craft faces in manifest order. - fn craft_fonts_are_the_last_fallback_in_every_family() { - // Ordering: app/bundled faces, Hack/emoji, system script fallbacks, permitted craft faces. - // A craft face must never displace a system face that already covers a glyph. - let fonts = font_definitions_for_language(Language::En); - let expected: Vec = craft_font_faces().into_iter().map(|(key, _)| key).collect(); - if expected.is_empty() { - return; // optional input absent - } - for (family, chain) in &fonts.families { - let first = chain.iter().position(|k| k.starts_with("craft-")).unwrap_or(chain.len()); - assert_eq!(&chain[first..], expected.as_slice(), "{family:?} must end with exactly the permitted craft faces in manifest order"); - } - } - - #[test] - /// Skips on wasm or when required mode is off; a required native build must embed LXGW with `Hani`/`Hans`. - fn a_required_native_build_embeds_the_chinese_face() { - if cfg!(target_arch = "wasm32") || !craft_fonts_required() { - return; - } - assert!( - chinese_font_loaded(), - "CRAFT_FONTS_REQUIRED=1 native builds must embed the LXGW Chinese face from fonts/simplified-chinese-manifest.txt" - ); - let face = craft_font_scripts().find(|(f, s, _)| *f == "LXGW WenKai Lite" && *s == "Regular"); - assert_eq!(face.map(|(_, _, scripts)| scripts), Some(["Hani", "Hans"].as_slice()), "the Chinese manifest's script tags are asserted"); - } - - #[test] - /// No embedded craft face belongs to the barred Noto CJK, Adobe or Source families. - fn barred_faces_never_enter_the_font_set() { - for (family, _, _) in craft_font_scripts() { - assert!(!family.contains("CJK"), "the barred Noto CJK collection must never be embedded: {family}"); - assert!(!family.starts_with("Source ") && !family.starts_with("Adobe "), "a barred Adobe/Source face was embedded: {family}"); - } - } - - #[test] - /// Skips when craft fonts are absent unless required mode is set; otherwise the required faces and their exact script tags are embedded. - fn the_required_craft_faces_keep_their_exact_scripts() { - if !craft_fonts_loaded() { - assert!(!craft_fonts_required(), "CRAFT_FONTS_REQUIRED=1 but no craft-fonts faces were embedded"); - return; - } - let faces: Vec<_> = craft_font_scripts().collect(); - for (family, style, scripts) in [ - ("BIZ UDPGothic", "Regular", ["Jpan", "Latn"].as_slice()), - ("BIZ UDPGothic", "Bold", ["Jpan", "Latn"].as_slice()), - ("NanumGothic", "Regular", ["Hang", "Kore"].as_slice()), - ("Noto Sans Symbols", "Regular", ["Zsym"].as_slice()), - ("Noto Sans Symbols 2", "Regular", ["Zsym"].as_slice()), - ] { - assert!( - faces.iter().any(|(f, s, declared)| *f == family && *s == style && *declared == scripts), - "{family} {style} with {scripts:?} must be embedded; got {faces:?}" - ); - } - } - - #[test] - /// Interface text maps unbundled `⌃`, `✓` and `✕` to readable chrome fallbacks; document text is not rewritten. - fn available_text_maps_unbundled_chrome_symbols() { - // No bundled face has ⌃ ✓ ✕; chrome text must show ^ ✔ × instead of tofu. Document text is - // never rewritten: this helper is only used for interface tooltips. - let ctx = egui::Context::default(); - ctx.set_fonts(FontDefinitions::default()); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - assert_eq!(available_text(ui, "Show Formulas (⌃`) ✓ ✕"), "Show Formulas (^`) ✔ ×"); - }); - out.textures_delta.clear(); - } - - #[test] - fn interface_families_fall_back_to_the_cell_font() { - // Structural (#349): the formula bar, name box and dialogs draw every script the cells can, - // so each interface chain carries the cell face, ahead of the script fallback faces. - let fonts = font_definitions_for_language(Language::En); - let cell = format!("sys-{CELL}"); - if !fonts.font_data.contains_key(&cell) { - return; // no system cell font on this machine - } - let fb: Vec = fallback_faces(HanOrder::Chinese).into_iter().map(|(k, _)| k.to_string()).collect(); - for family in [FontFamily::Proportional, FontFamily::Name(UI.into()), FontFamily::Name(UI_BOLD.into()), FontFamily::Name(MONO.into())] { - let chain = fonts.families.get(&family).cloned().unwrap_or_default(); - let at = chain.iter().position(|k| *k == cell); - assert!(at.is_some(), "{family:?} lacks the cell face: {chain:?}"); - let first_fb = chain.iter().position(|k| fb.contains(k)).unwrap_or(chain.len()); - assert!(at.unwrap_or(usize::MAX) < first_fb, "{family:?}: cell face must precede the script faces"); - } - } - - #[test] - fn language_reorders_only_the_han_faces() { - let keys = |han| fallback_faces(han).into_iter().map(|(k, _)| k).collect::>(); - assert_eq!( - keys(HanOrder::Japanese), - ["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"] - ); - assert_eq!( - keys(HanOrder::Chinese), - ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"] - ); - assert_eq!(HanOrder::of(Language::Ja), HanOrder::Japanese); - for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr, Language::De, Language::Cs, Language::Fr, Language::Tr] { - assert_eq!(HanOrder::of(l), HanOrder::Chinese, "{l:?} shares the default order (no font rebuild)"); - } - } - - #[test] - fn cjk_fallback_probes_flatpak_fonts_with_the_correct_locale_faces() { - // Exercise the candidates the fallback loader actually consumes, not just the directory - // constant. This must work on CI without an installed CJK font or a Flatpak runtime. - for (candidates, face) in [(cjk_han_japanese(), 0), (cjk_han_simplified(), 2), (cjk_hangul(), 1)] { - for directory in [ - "/run/host/fonts/opentype/noto", - "/run/host/fonts/google-noto-cjk", - "/run/host/fonts/noto-cjk", - "/run/host/fonts/truetype/noto", - "/run/host/fonts/noto", - "/run/host/fonts/truetype", - "/run/host/fonts/TTF", - "/run/host/fonts", - "/run/host/local-fonts", - "/run/host/local-fonts/noto-cjk", - "/run/host/user-fonts", - ] { - let path = format!("{directory}/NotoSansCJK-Regular.ttc"); - assert!(candidates.contains(&(path.clone(), face)), "missing Flatpak CJK candidate {path}, face {face}"); - } - } - let chinese = cjk_han_simplified(); - assert!(chinese.contains(&("/run/host/fonts/truetype/wqy/wqy-microhei.ttc".into(), 0))); - assert!(chinese.contains(&("/run/host/fonts/wenquanyi/wqy-microhei.ttc".into(), 0))); - } - - #[test] - /// A font file shared by several fallback scripts is loaded once, with one shared font blob rather than a copy per script. - fn fallback_faces_load_each_file_once() { - // The region-merged Noto Sans CJK .ttc serves Japanese, Chinese and Hangul; it must be loaded - // once, not once per script (tens of MB each). - for language in [Language::En, Language::Ja] { - let fonts = font_definitions_for_language(language); - let present: Vec<&Arc> = - fallback_faces(HanOrder::of(language)).into_iter().filter_map(|(k, _)| fonts.font_data.get(k)).collect(); - for (i, a) in present.iter().enumerate() { - for b in present.iter().skip(i + 1) { - assert!(!std::sync::Arc::ptr_eq(&a.font, &b.font), "the same font file is loaded for two fallback scripts"); - } - } - } - } - - #[test] - /// Checks that the proportional and cell families have glyphs for every present script sample; samples with no system or craft face are printed as skipped. - fn fallback_covers_its_script() { - // Real glyph check, per script: the proportional family (what cells and the UI use) must - // have a glyph for every non-space character in the sample. A script runs when a system - // face or the optional craft-fonts input provides one; the printed skip is a recorded - // gate, and a present face that is not wired in fails. - let cases: [(&str, Vec<(String, u32)>, &str); 10] = [ - ("sys-cjk-han-ja", cjk_han_japanese(), "カテゴリ"), - // Chinese uses a system Han face or the native-only permitted LXGW craft face; the - // barred Noto CJK collection never supplies it. - ("sys-cjk-han-zh", cjk_han_simplified(), "页面布局 客户端配置"), - ("sys-cjk-hangul", cjk_hangul(), "한글"), - ("sys-thai", thai(), "ทดสอบ"), - ("sys-lao", lao(), "ພາສາລາວ"), - ("sys-hebrew", hebrew(), "עברית"), - ("craft-noto-sans-arabic", Vec::new(), "مرحبا"), - ("craft-noto-sans-georgian", Vec::new(), "ქართული"), - ("ui", candidates(UI), "Русский"), - // Czech interface labels: every Czech accented letter, in both cases. - ("ui", candidates(UI), "Příliš žluťoučký kůň úpěl ďábelské ódy ŘŠČŽÝÁÍÉÚŮĚŤĎŇÓ"), - ]; - for language in [Language::En, Language::Ja] { - let ctx = egui::Context::default(); - ctx.set_fonts(font_definitions_for_language(language)); - let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {}); - out.textures_delta.clear(); // nothing consumes the atlas in a headless test - for family in [FontFamily::Proportional, FontFamily::Name(CELL.into())] { - let font = FontId::new(14.0, family.clone()); - for (key, cands, sample) in &cases { - // A script runs when a system face or the optional craft-fonts input provides - // one. The skip is printed: a missing fixture is a recorded gate, not a pass. - let system = cands.iter().any(|(p, _)| std::path::Path::new(p).exists()); - if !system && !craft_fonts_cover(sample) { - eprintln!("skipped {key}: no system face and no craft-fonts face for {sample:?}"); - continue; - } - let missing: String = - sample.chars().filter(|c| !c.is_whitespace()).filter(|&c| !ctx.fonts_mut(|f| f.has_glyph(&font, c))).collect(); - assert!(missing.is_empty(), "{key} is present but {sample:?} has no glyph for {missing:?} ({language:?}, {family:?})"); - } - } - } - } - - #[test] - fn chromeos_font_layout_is_probed() { - // #1762: Crostini mounts the host's fonts under /usr/share/fonts/chromeos, one folder per - // family set; a script whose list misses it renders as tofu with the font installed. - let probes = |cands: Vec<(String, u32)>, path: &str| cands.iter().any(|(p, _)| p == path); - let cros = "/usr/share/fonts/chromeos"; - assert!(probes(thai(), &format!("{cros}/noto/NotoSansThai-Regular.ttf"))); - assert!(probes(hebrew(), &format!("{cros}/noto/NotoSansHebrew-Regular.ttf"))); - assert!(probes(cjk_han_japanese(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); - assert!(probes(cjk_han_simplified(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); - assert!(probes(cjk_hangul(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); - assert!(probes(symbols_more(), &format!("{cros}/noto/NotoSansSymbols2-Regular.ttf"))); - assert!(probes(candidates(UI), &format!("{cros}/noto/NotoSans-Regular.ttf"))); - assert!(probes(candidates(CELL), &format!("{cros}/crosextra/Carlito-Regular.ttf"))); - } - - #[test] - /// Empty, garbage, truncated and bad-index fonts are refused; a bad face is dropped from the definitions and `set_fonts` does not panic. - fn unparseable_font_files_are_rejected_not_fatal() { - // #226: epaint panics in `set_fonts` on a face it can't parse ("An offset was out of - // bounds"). Garbage, a truncated real font and a bad collection index must all be refused. - let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.bytes().to_vec()).unwrap_or_default(); - assert!(font_parses(&real, 0), "egui's own font must parse"); - assert!(!font_parses(b"", 0)); - assert!(!font_parses(&[0xAB; 64], 0)); - assert!(!font_parses(real.get(..20).unwrap_or_default(), 0), "a truncated font must be refused"); - assert!(!font_parses(&real, 3), "a single-face file has no face 3"); - - // A bad face that slipped into the definitions is dropped (key and family entries), and the - // context builds its fonts without panicking. - let mut fonts = font_definitions(); - fonts.font_data.insert("sys-hebrew".into(), Arc::new(FontData::from_owned(vec![0, 1, 0, 0, 0, 0xFF, 0, 0]))); - for chain in fonts.families.values_mut() { - chain.push("sys-hebrew".into()); - } - drop_unparseable_fonts(&mut fonts); - assert!(!fonts.font_data.contains_key("sys-hebrew")); - assert!(fonts.families.values().all(|c| !c.iter().any(|k| k == "sys-hebrew"))); - let ctx = egui::Context::default(); - ctx.set_fonts(fonts); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - ui.label("Hebrew עברית"); - }); - out.textures_delta.clear(); // nothing consumes the atlas in a headless test - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - /// A broken existing font candidate is skipped in favor of a later readable candidate. - fn try_load_skips_a_broken_candidate() { - // A candidate that exists but is not a readable font must not win over a good one behind it. - let dir = std::env::temp_dir().join(format!("gridcraft-font-test-{}", std::process::id())); - let _ = std::fs::create_dir_all(&dir); - let bad = dir.join("broken.ttf"); - let good = dir.join("good.ttf"); - let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.bytes().to_vec()).unwrap_or_default(); - let _ = std::fs::write(&bad, real.get(..40).unwrap_or_default()); - let _ = std::fs::write(&good, &real); - let (bad_s, good_s) = (bad.to_string_lossy().into_owned(), good.to_string_lossy().into_owned()); - assert!(try_load(&[(bad_s.as_str(), 0)]).is_none()); - let loaded = try_load(&[(bad_s.as_str(), 0), (good_s.as_str(), 0)]); - assert_eq!(loaded.map(|fd| fd.bytes().len()), Some(real.len())); - let _ = std::fs::remove_dir_all(&dir); - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - /// Repeated loads reuse one shared font blob, while a face the kept file does not contain is still refused. - fn interface_fonts_are_read_once_and_shared_with_egui() { - // egui copies owned font bytes into its text engine and keeps the original too; borrowed - // bytes it shares. A rebuild of the font set reuses the file read the first time. - let path = std::env::temp_dir().join(format!("gridcraft-font-shared-{}.ttf", std::process::id())); - std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).unwrap(); - let p = path.to_string_lossy().into_owned(); - let first = try_load(&[(p.as_str(), 0)]).unwrap(); - std::fs::remove_file(&path).unwrap(); - let second = try_load(&[(p.as_str(), 0)]).expect("kept from the first read"); - assert!( - std::sync::Arc::ptr_eq(&first.font, &second.font), - "interface font bytes must be one shared blob, so egui shares them instead of copying" - ); - assert_eq!(first.bytes(), epaint_default_fonts::UBUNTU_LIGHT); - assert!(try_load(&[(p.as_str(), 3)]).is_none(), "a face the kept file doesn't have is still refused"); - } - - #[test] - fn macos_bold_interface_face_is_drawn_unhinted() { - // #1184: Helvetica Neue Bold's own TrueType hints close Cyrillic е into ө at 13 pt on a - // Retina screen. Core Text never runs them, so on macOS the face goes unhinted like every - // other app draws it; the regular face and the other platforms keep egui's default. - let fonts = font_definitions_for_language(Language::En); - let hinting = |key: &str| fonts.font_data.get(key).map(|fd| fd.tweak.hinting); - if let Some(bold) = hinting(&format!("sys-{UI_BOLD}")) { - assert_eq!(bold, if cfg!(target_os = "macos") { Some(false) } else { None }); - } - if let Some(regular) = hinting(&format!("sys-{UI}")) { - assert_eq!(regular, None, "only the bold face is affected"); - } - } - - #[test] - fn fedora_package_folders_are_searched_after_the_other_linux_layouts() { - // #1694: Fedora keeps each font family in its own package folder, so without these entries - // no cell face is found there and bold and italic cells draw as regular text. Each comes - // after the existing Linux folders for the same font, so wherever one of those matched, - // nothing changes. - let paths = |cands: Vec<(String, u32)>| cands.into_iter().map(|(p, _)| p).collect::>(); - let cases: [(&str, Vec, &str, &str); 9] = [ - (CELL, paths(candidates(CELL)), "google-carlito-fonts/Carlito-Regular.ttf", "/carlito/Carlito-Regular.ttf"), - (CELL_BOLD, paths(candidates(CELL_BOLD)), "google-carlito-fonts/Carlito-Bold.ttf", "/carlito/Carlito-Bold.ttf"), - (CELL_ITALIC, paths(candidates(CELL_ITALIC)), "google-carlito-fonts/Carlito-Italic.ttf", "/carlito/Carlito-Italic.ttf"), - (CELL_BOLD_ITALIC, paths(candidates(CELL_BOLD_ITALIC)), "google-carlito-fonts/Carlito-BoldItalic.ttf", "/carlito/Carlito-BoldItalic.ttf"), - (CELL_BOLD, paths(candidates(CELL_BOLD)), "liberation-sans-fonts/LiberationSans-Bold.ttf", "/liberation/LiberationSans-Bold.ttf"), - (SERIF, paths(candidates(SERIF)), "liberation-serif-fonts/LiberationSerif-Regular.ttf", "/liberation/LiberationSerif-Regular.ttf"), - (MONO, paths(candidates(MONO)), "liberation-mono-fonts/LiberationMono-Regular.ttf", "/dejavu/DejaVuSansMono.ttf"), - ("thai", paths(thai()), "google-noto-vf/NotoSansThai[wght].ttf", "/NotoSansThai-Regular.ttf"), - ("hebrew", paths(hebrew()), "google-noto-vf/NotoSansHebrew[wght].ttf", "/NotoSansHebrew-Regular.ttf"), - ]; - for (role, list, fedora_file, earlier) in &cases { - let at = list.iter().position(|p| *p == format!("/usr/share/fonts/{fedora_file}")); - let last_earlier = list.iter().rposition(|p| p.ends_with(earlier)); - assert!(at.is_some(), "{role}: Fedora's {fedora_file} is not a candidate"); - assert!(last_earlier.is_some(), "{role}: no existing {earlier} candidate to compare with"); - assert!(at > last_earlier, "{role}: {fedora_file} must come after every existing {earlier}"); - } - // Liberation Mono only stands in for DejaVu Sans Mono, wherever that is installed. - let mono = paths(candidates(MONO)); - let first_liberation = mono.iter().position(|p| p.ends_with("LiberationMono-Regular.ttf")); - let last_dejavu = mono.iter().rposition(|p| p.ends_with("DejaVuSansMono.ttf")); - assert!(first_liberation.is_some() && first_liberation > last_dejavu, "Liberation Mono must follow DejaVu Sans Mono: {mono:?}"); - let noto = mono.iter().position(|p| p == "/usr/share/fonts/google-noto-vf/NotoSansMono[wght].ttf"); - assert!(noto.is_some() && noto.unwrap_or(0) + 1 == mono.len(), "Fedora's default Noto Sans Mono must be the last resort: {mono:?}"); - } -} +mod tests; diff --git a/crates/ui-egui/src/theme/tests.rs b/crates/ui-egui/src/theme/tests.rs new file mode 100644 index 000000000..15ee29090 --- /dev/null +++ b/crates/ui-egui/src/theme/tests.rs @@ -0,0 +1,358 @@ +use super::*; +use crate::i18n::Language; + +#[test] +/// Loaded script-fallback faces end each role chain just before the craft fonts, in the language's order. +fn fallback_faces_are_appended_to_every_family() { + // Structural: whatever loaded, the non-Latin fallback keys must sit at the end of each chain + // (after the Latin/default fonts) so Latin still wins for Latin text, in the language's order. + for han in [HanOrder::Chinese, HanOrder::Japanese] { + let language = if han == HanOrder::Japanese { Language::Ja } else { Language::Zh }; + let fonts = font_definitions_for_language(language); + let fb_keys: Vec = + fallback_faces(han).into_iter().map(|(k, _)| k.to_string()).filter(|k| fonts.font_data.contains_key(k)).collect(); + if fb_keys.is_empty() { + return; // no fallback face on this machine (minimal Linux) + } + for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { + let chain = fonts.families.get(&FontFamily::Name(role.into())).map(Vec::as_slice).unwrap_or_default(); + // The optional craft-fonts faces follow the system fallbacks at the very end. + let end = chain.iter().rposition(|k| !k.starts_with("craft-")).map_or(0, |i| i + 1); + assert!(end >= fb_keys.len(), "{role} chain too short for its fallback"); + assert_eq!(&chain[end - fb_keys.len()..end], fb_keys.as_slice(), "{role} must end its non-craft chain with the fallback faces"); + } + } +} + +#[test] +/// Every font family ends with exactly the permitted craft faces in manifest order. +fn craft_fonts_are_the_last_fallback_in_every_family() { + // Ordering: app/bundled faces, Hack/emoji, system script fallbacks, permitted craft faces. + // A craft face must never displace a system face that already covers a glyph. + let fonts = font_definitions_for_language(Language::En); + let expected: Vec = craft_font_faces().into_iter().map(|(key, _)| key).collect(); + if expected.is_empty() { + return; // optional input absent + } + for (family, chain) in &fonts.families { + let first = chain.iter().position(|k| k.starts_with("craft-")).unwrap_or(chain.len()); + assert_eq!(&chain[first..], expected.as_slice(), "{family:?} must end with exactly the permitted craft faces in manifest order"); + } +} + +#[test] +/// Skips on wasm or when required mode is off; a required native build must embed LXGW with `Hani`/`Hans`. +fn a_required_native_build_embeds_the_chinese_face() { + if cfg!(target_arch = "wasm32") || !craft_fonts_required() { + return; + } + assert!( + chinese_font_loaded(), + "CRAFT_FONTS_REQUIRED=1 native builds must embed the LXGW Chinese face from fonts/simplified-chinese-manifest.txt" + ); + let face = craft_font_scripts().find(|(f, s, _)| *f == "LXGW WenKai Lite" && *s == "Regular"); + assert_eq!(face.map(|(_, _, scripts)| scripts), Some(["Hani", "Hans"].as_slice()), "the Chinese manifest's script tags are asserted"); +} + +#[test] +/// No embedded craft face belongs to the barred Noto CJK, Adobe or Source families. +fn barred_faces_never_enter_the_font_set() { + for (family, _, _) in craft_font_scripts() { + assert!(!family.contains("CJK"), "the barred Noto CJK collection must never be embedded: {family}"); + assert!(!family.starts_with("Source ") && !family.starts_with("Adobe "), "a barred Adobe/Source face was embedded: {family}"); + } +} + +#[test] +/// Skips when craft fonts are absent unless required mode is set; otherwise the required faces and their exact script tags are embedded. +fn the_required_craft_faces_keep_their_exact_scripts() { + if !craft_fonts_loaded() { + assert!(!craft_fonts_required(), "CRAFT_FONTS_REQUIRED=1 but no craft-fonts faces were embedded"); + return; + } + let faces: Vec<_> = craft_font_scripts().collect(); + for (family, style, scripts) in [ + ("BIZ UDPGothic", "Regular", ["Jpan", "Latn"].as_slice()), + ("BIZ UDPGothic", "Bold", ["Jpan", "Latn"].as_slice()), + ("NanumGothic", "Regular", ["Hang", "Kore"].as_slice()), + ("Noto Sans Symbols", "Regular", ["Zsym"].as_slice()), + ("Noto Sans Symbols 2", "Regular", ["Zsym"].as_slice()), + ] { + assert!( + faces.iter().any(|(f, s, declared)| *f == family && *s == style && *declared == scripts), + "{family} {style} with {scripts:?} must be embedded; got {faces:?}" + ); + } +} + +#[test] +/// Interface text maps unbundled `⌃`, `✓` and `✕` to readable chrome fallbacks; document text is not rewritten. +fn available_text_maps_unbundled_chrome_symbols() { + // No bundled face has ⌃ ✓ ✕; chrome text must show ^ ✔ × instead of tofu. Document text is + // never rewritten: this helper is only used for interface tooltips. + let ctx = egui::Context::default(); + ctx.set_fonts(FontDefinitions::default()); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + assert_eq!(available_text(ui, "Show Formulas (⌃`) ✓ ✕"), "Show Formulas (^`) ✔ ×"); + }); + out.textures_delta.clear(); +} + +#[test] +fn interface_families_fall_back_to_the_cell_font() { + // Structural (#349): the formula bar, name box and dialogs draw every script the cells can, + // so each interface chain carries the cell face, ahead of the script fallback faces. + let fonts = font_definitions_for_language(Language::En); + let cell = format!("sys-{CELL}"); + if !fonts.font_data.contains_key(&cell) { + return; // no system cell font on this machine + } + let fb: Vec = fallback_faces(HanOrder::Chinese).into_iter().map(|(k, _)| k.to_string()).collect(); + for family in [FontFamily::Proportional, FontFamily::Name(UI.into()), FontFamily::Name(UI_BOLD.into()), FontFamily::Name(MONO.into())] { + let chain = fonts.families.get(&family).cloned().unwrap_or_default(); + let at = chain.iter().position(|k| *k == cell); + assert!(at.is_some(), "{family:?} lacks the cell face: {chain:?}"); + let first_fb = chain.iter().position(|k| fb.contains(k)).unwrap_or(chain.len()); + assert!(at.unwrap_or(usize::MAX) < first_fb, "{family:?}: cell face must precede the script faces"); + } +} + +#[test] +fn language_reorders_only_the_han_faces() { + let keys = |han| fallback_faces(han).into_iter().map(|(k, _)| k).collect::>(); + assert_eq!( + keys(HanOrder::Japanese), + ["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"] + ); + assert_eq!( + keys(HanOrder::Chinese), + ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"] + ); + assert_eq!(HanOrder::of(Language::Ja), HanOrder::Japanese); + for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr, Language::De, Language::Cs, Language::Fr, Language::Tr] { + assert_eq!(HanOrder::of(l), HanOrder::Chinese, "{l:?} shares the default order (no font rebuild)"); + } +} + +#[test] +fn cjk_fallback_probes_flatpak_fonts_with_the_correct_locale_faces() { + // Exercise the candidates the fallback loader actually consumes, not just the directory + // constant. This must work on CI without an installed CJK font or a Flatpak runtime. + for (candidates, face) in [(cjk_han_japanese(), 0), (cjk_han_simplified(), 2), (cjk_hangul(), 1)] { + for directory in [ + "/run/host/fonts/opentype/noto", + "/run/host/fonts/google-noto-cjk", + "/run/host/fonts/noto-cjk", + "/run/host/fonts/truetype/noto", + "/run/host/fonts/noto", + "/run/host/fonts/truetype", + "/run/host/fonts/TTF", + "/run/host/fonts", + "/run/host/local-fonts", + "/run/host/local-fonts/noto-cjk", + "/run/host/user-fonts", + ] { + let path = format!("{directory}/NotoSansCJK-Regular.ttc"); + assert!(candidates.contains(&(path.clone(), face)), "missing Flatpak CJK candidate {path}, face {face}"); + } + } + let chinese = cjk_han_simplified(); + assert!(chinese.contains(&("/run/host/fonts/truetype/wqy/wqy-microhei.ttc".into(), 0))); + assert!(chinese.contains(&("/run/host/fonts/wenquanyi/wqy-microhei.ttc".into(), 0))); +} + +#[test] +/// A font file shared by several fallback scripts is loaded once, with one shared font blob rather than a copy per script. +fn fallback_faces_load_each_file_once() { + // The region-merged Noto Sans CJK .ttc serves Japanese, Chinese and Hangul; it must be loaded + // once, not once per script (tens of MB each). + for language in [Language::En, Language::Ja] { + let fonts = font_definitions_for_language(language); + let present: Vec<&Arc> = + fallback_faces(HanOrder::of(language)).into_iter().filter_map(|(k, _)| fonts.font_data.get(k)).collect(); + for (i, a) in present.iter().enumerate() { + for b in present.iter().skip(i + 1) { + assert!(!std::sync::Arc::ptr_eq(&a.font, &b.font), "the same font file is loaded for two fallback scripts"); + } + } + } +} + +#[test] +/// Checks that the proportional and cell families have glyphs for every present script sample; samples with no system or craft face are printed as skipped. +fn fallback_covers_its_script() { + // Real glyph check, per script: the proportional family (what cells and the UI use) must + // have a glyph for every non-space character in the sample. A script runs when a system + // face or the optional craft-fonts input provides one; the printed skip is a recorded + // gate, and a present face that is not wired in fails. + let cases: [(&str, Vec<(String, u32)>, &str); 10] = [ + ("sys-cjk-han-ja", cjk_han_japanese(), "カテゴリ"), + // Chinese uses a system Han face or the native-only permitted LXGW craft face; the + // barred Noto CJK collection never supplies it. + ("sys-cjk-han-zh", cjk_han_simplified(), "页面布局 客户端配置"), + ("sys-cjk-hangul", cjk_hangul(), "한글"), + ("sys-thai", thai(), "ทดสอบ"), + ("sys-lao", lao(), "ພາສາລາວ"), + ("sys-hebrew", hebrew(), "עברית"), + ("craft-noto-sans-arabic", Vec::new(), "مرحبا"), + ("craft-noto-sans-georgian", Vec::new(), "ქართული"), + ("ui", candidates(UI), "Русский"), + // Czech interface labels: every Czech accented letter, in both cases. + ("ui", candidates(UI), "Příliš žluťoučký kůň úpěl ďábelské ódy ŘŠČŽÝÁÍÉÚŮĚŤĎŇÓ"), + ]; + for language in [Language::En, Language::Ja] { + let ctx = egui::Context::default(); + ctx.set_fonts(font_definitions_for_language(language)); + let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {}); + out.textures_delta.clear(); // nothing consumes the atlas in a headless test + for family in [FontFamily::Proportional, FontFamily::Name(CELL.into())] { + let font = FontId::new(14.0, family.clone()); + for (key, cands, sample) in &cases { + // A script runs when a system face or the optional craft-fonts input provides + // one. The skip is printed: a missing fixture is a recorded gate, not a pass. + let system = cands.iter().any(|(p, _)| std::path::Path::new(p).exists()); + if !system && !craft_fonts_cover(sample) { + eprintln!("skipped {key}: no system face and no craft-fonts face for {sample:?}"); + continue; + } + let missing: String = + sample.chars().filter(|c| !c.is_whitespace()).filter(|&c| !ctx.fonts_mut(|f| f.has_glyph(&font, c))).collect(); + assert!(missing.is_empty(), "{key} is present but {sample:?} has no glyph for {missing:?} ({language:?}, {family:?})"); + } + } + } +} + +#[test] +fn chromeos_font_layout_is_probed() { + // #1762: Crostini mounts the host's fonts under /usr/share/fonts/chromeos, one folder per + // family set; a script whose list misses it renders as tofu with the font installed. + let probes = |cands: Vec<(String, u32)>, path: &str| cands.iter().any(|(p, _)| p == path); + let cros = "/usr/share/fonts/chromeos"; + assert!(probes(thai(), &format!("{cros}/noto/NotoSansThai-Regular.ttf"))); + assert!(probes(hebrew(), &format!("{cros}/noto/NotoSansHebrew-Regular.ttf"))); + assert!(probes(cjk_han_japanese(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); + assert!(probes(cjk_han_simplified(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); + assert!(probes(cjk_hangul(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); + assert!(probes(symbols_more(), &format!("{cros}/noto/NotoSansSymbols2-Regular.ttf"))); + assert!(probes(candidates(UI), &format!("{cros}/noto/NotoSans-Regular.ttf"))); + assert!(probes(candidates(CELL), &format!("{cros}/crosextra/Carlito-Regular.ttf"))); +} + +#[test] +/// Empty, garbage, truncated and bad-index fonts are refused; a bad face is dropped from the definitions and `set_fonts` does not panic. +fn unparseable_font_files_are_rejected_not_fatal() { + // #226: epaint panics in `set_fonts` on a face it can't parse ("An offset was out of + // bounds"). Garbage, a truncated real font and a bad collection index must all be refused. + let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.bytes().to_vec()).unwrap_or_default(); + assert!(font_parses(&real, 0), "egui's own font must parse"); + assert!(!font_parses(b"", 0)); + assert!(!font_parses(&[0xAB; 64], 0)); + assert!(!font_parses(real.get(..20).unwrap_or_default(), 0), "a truncated font must be refused"); + assert!(!font_parses(&real, 3), "a single-face file has no face 3"); + + // A bad face that slipped into the definitions is dropped (key and family entries), and the + // context builds its fonts without panicking. + let mut fonts = font_definitions(); + fonts.font_data.insert("sys-hebrew".into(), Arc::new(FontData::from_owned(vec![0, 1, 0, 0, 0, 0xFF, 0, 0]))); + for chain in fonts.families.values_mut() { + chain.push("sys-hebrew".into()); + } + drop_unparseable_fonts(&mut fonts); + assert!(!fonts.font_data.contains_key("sys-hebrew")); + assert!(fonts.families.values().all(|c| !c.iter().any(|k| k == "sys-hebrew"))); + let ctx = egui::Context::default(); + ctx.set_fonts(fonts); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + ui.label("Hebrew עברית"); + }); + out.textures_delta.clear(); // nothing consumes the atlas in a headless test +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +/// A broken existing font candidate is skipped in favor of a later readable candidate. +fn try_load_skips_a_broken_candidate() { + // A candidate that exists but is not a readable font must not win over a good one behind it. + let dir = std::env::temp_dir().join(format!("gridcraft-font-test-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let bad = dir.join("broken.ttf"); + let good = dir.join("good.ttf"); + let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.bytes().to_vec()).unwrap_or_default(); + let _ = std::fs::write(&bad, real.get(..40).unwrap_or_default()); + let _ = std::fs::write(&good, &real); + let (bad_s, good_s) = (bad.to_string_lossy().into_owned(), good.to_string_lossy().into_owned()); + assert!(try_load(&[(bad_s.as_str(), 0)]).is_none()); + let loaded = try_load(&[(bad_s.as_str(), 0), (good_s.as_str(), 0)]); + assert_eq!(loaded.map(|fd| fd.bytes().len()), Some(real.len())); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +/// Repeated loads reuse one shared font blob, while a face the kept file does not contain is still refused. +fn interface_fonts_are_read_once_and_shared_with_egui() { + // egui copies owned font bytes into its text engine and keeps the original too; borrowed + // bytes it shares. A rebuild of the font set reuses the file read the first time. + let path = std::env::temp_dir().join(format!("gridcraft-font-shared-{}.ttf", std::process::id())); + std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).unwrap(); + let p = path.to_string_lossy().into_owned(); + let first = try_load(&[(p.as_str(), 0)]).unwrap(); + std::fs::remove_file(&path).unwrap(); + let second = try_load(&[(p.as_str(), 0)]).expect("kept from the first read"); + assert!( + std::sync::Arc::ptr_eq(&first.font, &second.font), + "interface font bytes must be one shared blob, so egui shares them instead of copying" + ); + assert_eq!(first.bytes(), epaint_default_fonts::UBUNTU_LIGHT); + assert!(try_load(&[(p.as_str(), 3)]).is_none(), "a face the kept file doesn't have is still refused"); +} + +#[test] +fn macos_bold_interface_face_is_drawn_unhinted() { + // #1184: Helvetica Neue Bold's own TrueType hints close Cyrillic е into ө at 13 pt on a + // Retina screen. Core Text never runs them, so on macOS the face goes unhinted like every + // other app draws it; the regular face and the other platforms keep egui's default. + let fonts = font_definitions_for_language(Language::En); + let hinting = |key: &str| fonts.font_data.get(key).map(|fd| fd.tweak.hinting); + if let Some(bold) = hinting(&format!("sys-{UI_BOLD}")) { + assert_eq!(bold, if cfg!(target_os = "macos") { Some(false) } else { None }); + } + if let Some(regular) = hinting(&format!("sys-{UI}")) { + assert_eq!(regular, None, "only the bold face is affected"); + } +} + +#[test] +fn fedora_package_folders_are_searched_after_the_other_linux_layouts() { + // #1694: Fedora keeps each font family in its own package folder, so without these entries + // no cell face is found there and bold and italic cells draw as regular text. Each comes + // after the existing Linux folders for the same font, so wherever one of those matched, + // nothing changes. + let paths = |cands: Vec<(String, u32)>| cands.into_iter().map(|(p, _)| p).collect::>(); + let cases: [(&str, Vec, &str, &str); 9] = [ + (CELL, paths(candidates(CELL)), "google-carlito-fonts/Carlito-Regular.ttf", "/carlito/Carlito-Regular.ttf"), + (CELL_BOLD, paths(candidates(CELL_BOLD)), "google-carlito-fonts/Carlito-Bold.ttf", "/carlito/Carlito-Bold.ttf"), + (CELL_ITALIC, paths(candidates(CELL_ITALIC)), "google-carlito-fonts/Carlito-Italic.ttf", "/carlito/Carlito-Italic.ttf"), + (CELL_BOLD_ITALIC, paths(candidates(CELL_BOLD_ITALIC)), "google-carlito-fonts/Carlito-BoldItalic.ttf", "/carlito/Carlito-BoldItalic.ttf"), + (CELL_BOLD, paths(candidates(CELL_BOLD)), "liberation-sans-fonts/LiberationSans-Bold.ttf", "/liberation/LiberationSans-Bold.ttf"), + (SERIF, paths(candidates(SERIF)), "liberation-serif-fonts/LiberationSerif-Regular.ttf", "/liberation/LiberationSerif-Regular.ttf"), + (MONO, paths(candidates(MONO)), "liberation-mono-fonts/LiberationMono-Regular.ttf", "/dejavu/DejaVuSansMono.ttf"), + ("thai", paths(thai()), "google-noto-vf/NotoSansThai[wght].ttf", "/NotoSansThai-Regular.ttf"), + ("hebrew", paths(hebrew()), "google-noto-vf/NotoSansHebrew[wght].ttf", "/NotoSansHebrew-Regular.ttf"), + ]; + for (role, list, fedora_file, earlier) in &cases { + let at = list.iter().position(|p| *p == format!("/usr/share/fonts/{fedora_file}")); + let last_earlier = list.iter().rposition(|p| p.ends_with(earlier)); + assert!(at.is_some(), "{role}: Fedora's {fedora_file} is not a candidate"); + assert!(last_earlier.is_some(), "{role}: no existing {earlier} candidate to compare with"); + assert!(at > last_earlier, "{role}: {fedora_file} must come after every existing {earlier}"); + } + // Liberation Mono only stands in for DejaVu Sans Mono, wherever that is installed. + let mono = paths(candidates(MONO)); + let first_liberation = mono.iter().position(|p| p.ends_with("LiberationMono-Regular.ttf")); + let last_dejavu = mono.iter().rposition(|p| p.ends_with("DejaVuSansMono.ttf")); + assert!(first_liberation.is_some() && first_liberation > last_dejavu, "Liberation Mono must follow DejaVu Sans Mono: {mono:?}"); + let noto = mono.iter().position(|p| p == "/usr/share/fonts/google-noto-vf/NotoSansMono[wght].ttf"); + assert!(noto.is_some() && noto.unwrap_or(0) + 1 == mono.len(), "Fedora's default Noto Sans Mono must be the last resort: {mono:?}"); +} From a8db625e6b955e47d1b24a56d71f6c039f34789f Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:59 +0000 Subject: [PATCH 40/76] style: cargo fmt on the moved test files --- apps/gridcraft-cli/src/check/tests.rs | 9 ++++----- crates/engine/src/cmd/data/tests.rs | 5 ++--- crates/engine/src/cmd/pivot/tests.rs | 9 +++------ crates/engine/src/cmd/print/tests.rs | 7 ++----- crates/engine/src/cmd/solver/tests.rs | 21 +++++++-------------- crates/engine/src/io/tests.rs | 9 ++------- crates/functions/src/financial/tests.rs | 5 +---- crates/ui-egui/src/code_editor/tests.rs | 8 ++------ crates/ui-egui/src/control/tests.rs | 16 ++++------------ crates/ui-egui/src/theme/tests.rs | 9 +++------ 10 files changed, 30 insertions(+), 68 deletions(-) diff --git a/apps/gridcraft-cli/src/check/tests.rs b/apps/gridcraft-cli/src/check/tests.rs index f1df04670..68c6f8fb5 100644 --- a/apps/gridcraft-cli/src/check/tests.rs +++ b/apps/gridcraft-cli/src/check/tests.rs @@ -223,8 +223,7 @@ fn diff_detects_changed_cond_format_rule() { wb.sheet_mut(0).unwrap().cond_formats.push(cf); wb }; - let diffs = - |a: &Workbook, b: &Workbook| diff_workbooks(a, b, &Options::default()).into_iter().find(|c| c.name == "condFormats").unwrap().diffs; + let diffs = |a: &Workbook, b: &Workbook| diff_workbooks(a, b, &Options::default()).into_iter().find(|c| c.name == "condFormats").unwrap().diffs; assert_eq!(diffs(&with(bar(false, 1)), &with(bar(true, 1))), 1); assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 1))), 0); assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 5))), 0); @@ -315,7 +314,8 @@ fn plain() -> Vec { fn package_reports_relationship_kinds_the_output_lost() { // A kind GridCraft doesn't know is dropped on save; a calculation chain is never written // (Excel rebuilds it), so its absence isn't a loss. - let calc_chain = r#""#; + let calc_chain = + r#""#; let source = with_rels(&plain(), &[(WB_RELS, &format!("{MADE_UP}{calc_chain}"))], &[MADE_UP_PART, ("xl/calcChain.xml", "")]); assert_eq!(package_of(&source), ["lost relationship madeUp"]); assert!(check_bytes("t.xlsx", &plain(), &Options::default()).round_trip.is_clean()); @@ -347,8 +347,7 @@ fn comments_without_a_comment_are_not_a_loss() { #[test] fn empty_drawings_and_person_lists_are_not_a_loss() { let drawing = r#""#; - let person = - r#""#; + let person = r#""#; let source = with_rels( &plain(), &[(WB_RELS, &format!("{MADE_UP}{person}")), (SHEET_RELS, drawing)], diff --git a/crates/engine/src/cmd/data/tests.rs b/crates/engine/src/cmd/data/tests.rs index 8708fbf75..5b848e5c0 100644 --- a/crates/engine/src/cmd/data/tests.rs +++ b/crates/engine/src/cmd/data/tests.rs @@ -128,9 +128,8 @@ fn partial_validation_edits_keep_the_rest_of_the_old_rule() { let mut s = Session::new(); s.new_workbook(); let rule = |range: &str, lo: &str, hi: &str| json!({"range": range, "type": "whole", "operator": "between", "formula1": lo, "formula2": hi}); - let ok = |s: &mut Session, cell: &str, input: &str| { - s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == json!(true) - }; + let ok = + |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == json!(true); s.execute("data.validation", rule("B1:B3", "1", "10")).unwrap(); s.execute("data.validation", json!({"range": "B2", "clear": true})).unwrap(); assert!(ok(&mut s, "B2", "999")); diff --git a/crates/engine/src/cmd/pivot/tests.rs b/crates/engine/src/cmd/pivot/tests.rs index 49aca34d8..639f9b63d 100644 --- a/crates/engine/src/cmd/pivot/tests.rs +++ b/crates/engine/src/cmd/pivot/tests.rs @@ -569,12 +569,10 @@ fn replace_protection() { s.execute("pivot.addField", json!({"field": "Region", "area": "rows", "replace": true})).unwrap(); assert_eq!(text(&s, "H5"), "North"); // A second pivot can't overlap the first. - let e = - s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "destination": "Sheet1!H6", "replace": true})).unwrap_err().to_string(); + let e = s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "destination": "Sheet1!H6", "replace": true})).unwrap_err().to_string(); assert!(e.contains("overlap"), "{e}"); // Nor its own source data. - let e = - s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "destination": "Sheet1!B2", "replace": true})).unwrap_err().to_string(); + let e = s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "destination": "Sheet1!B2", "replace": true})).unwrap_err().to_string(); assert!(e.contains("source data"), "{e}"); // Delete clears the report. s.execute("pivot.delete", json!({})).unwrap(); @@ -761,8 +759,7 @@ fn collapsed_column_items_count_as_one_column_group() { #[test] fn inserting_or_deleting_lines_moves_the_pivot_with_its_cells() { let mut s = sales(); - s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "destination": "Sheet1!H10", "rows": ["Region"], "values": ["Sales"]})) - .unwrap(); + s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "destination": "Sheet1!H10", "rows": ["Region"], "values": ["Sales"]})).unwrap(); assert_eq!(pt(&s).last_range.unwrap().a1(), "H10:I14"); // Rows above the report (and below the source) move the report only. s.execute("home.insertRows", json!({"rows": "16:17"})).unwrap(); diff --git a/crates/engine/src/cmd/print/tests.rs b/crates/engine/src/cmd/print/tests.rs index 89b7b62a7..21b13338f 100644 --- a/crates/engine/src/cmd/print/tests.rs +++ b/crates/engine/src/cmd/print/tests.rs @@ -223,11 +223,8 @@ fn pdf_contains_text_and_header_codes_expand() { let mut s = s(); s.execute("range.setValues", json!({"range": "A1", "values": [["Quarterly Report", null], ["Total", 1234.5]]})).unwrap(); s.execute("home.bold", json!({"range": "A1"})).unwrap(); - s.execute( - "pageLayout.pageSetup", - json!({"header": "&LLeft &A&CMiddle&RPage &P of &N", "footer": "&C&F", "gridlines": true, "headings": true}), - ) - .unwrap(); + s.execute("pageLayout.pageSetup", json!({"header": "&LLeft &A&CMiddle&RPage &P of &N", "footer": "&C&F", "gridlines": true, "headings": true})) + .unwrap(); let (pdf, pages) = pdf_of(&mut s, json!({})); assert_eq!(pages, 1); assert!(contains(&pdf, "(Quarterly Report) Tj")); diff --git a/crates/engine/src/cmd/solver/tests.rs b/crates/engine/src/cmd/solver/tests.rs index bac1328b7..3f570fc6e 100644 --- a/crates/engine/src/cmd/solver/tests.rs +++ b/crates/engine/src/cmd/solver/tests.rs @@ -103,8 +103,7 @@ fn evolutionary_finds_the_global_minimum_of_non_smooth_and_multimodal_models() { let mut s = s(); // |x − 3| + |y + 2| has a kink at its minimum (3, −2). set(&mut s, &[("A1", "0"), ("A2", "0"), ("B1", "=ABS(A1-3)+ABS(A2+2)")]); - let p = - json!({"objective": "B1", "goal": "min", "variables": "A1:A2", "constraints": ["A1:A2 >= -10", "A1:A2 <= 10"], "method": "evolutionary"}); + let p = json!({"objective": "B1", "goal": "min", "variables": "A1:A2", "constraints": ["A1:A2 >= -10", "A1:A2 <= 10"], "method": "evolutionary"}); let r = s.execute("data.solver", p.clone()).unwrap(); assert!(r["status"] == "converged" || r["status"] == "cannotImprove", "{r}"); assert!((n(&s, "A1") - 3.0).abs() < 1e-6 && (n(&s, "A2") + 2.0).abs() < 1e-6, "{r}"); @@ -247,8 +246,7 @@ fn answer_and_limits_reports_describe_the_wyndor_glass_solution() { assert_eq!(row(r), [2.0, 0.0, 30.0, 2.0, 36.0]); assert_eq!(row(r + 1), [6.0, 0.0, 6.0, 6.0, 36.0]); // data.solverReport writes reports from the saved model and a given solution. - let out = - s.execute("data.solverReport", json!({"reports": ["answer"], "values": {"B2": 2, "C2": 6}, "original": {"B2": 1, "C2": 1}})).unwrap(); + let out = s.execute("data.solverReport", json!({"reports": ["answer"], "values": {"B2": 2, "C2": 6}, "original": {"B2": 1, "C2": 1}})).unwrap(); assert_eq!(out["sheets"], json!(["Answer Report 2"])); let r = row_of(&s, "Answer Report 2", "$D$6") + 1; assert_eq!(at(&s, "Answer Report 2", &format!("D{r}")), Value::number(8.0)); @@ -289,11 +287,8 @@ fn refused_solver_reports_preserve_the_document_and_history() { for (keep, save) in [(true, true), (false, true), (true, false), (false, false)] { let mut s = s(); set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]); - s.execute( - "data.solver", - json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 2"], "method": "simplex", "keep": false}), - ) - .unwrap(); + s.execute("data.solver", json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 2"], "method": "simplex", "keep": false})) + .unwrap(); set(&mut s, &[("C1", "redo")]); s.execute("edit.undo", json!({})).unwrap(); let d = s.doc_mut().unwrap(); @@ -411,8 +406,7 @@ fn limits_report_of_a_nonlinear_model() { // width ranges over 0..5, the area over 0..25. let mut s = s(); set(&mut s, &[("A1", "1"), ("A2", "1"), ("B1", "=A1*A2"), ("B2", "=2*A1+2*A2")]); - let r = - s.execute("data.solver", json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 20"], "reports": ["limits"]})).unwrap(); + let r = s.execute("data.solver", json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 20"], "reports": ["limits"]})).unwrap(); assert_eq!(r["reports"], json!(["Limits Report 1"]), "{r}"); let l = "Limits Report 1"; let r = row_of(&s, l, "$A$1") + 1; @@ -427,9 +421,8 @@ fn infeasible_unbounded_and_nonlinear_models_are_reported() { let mut s = s(); set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]); for method in ["simplex", "grg"] { - let r = s - .execute("data.solver", json!({"objective": "B1", "variables": "A1", "constraints": ["A1 >= 5", "B1 <= 6"], "method": method})) - .unwrap(); + let r = + s.execute("data.solver", json!({"objective": "B1", "variables": "A1", "constraints": ["A1 >= 5", "B1 <= 6"], "method": method})).unwrap(); assert_eq!(r["status"], "infeasible", "{method}: {r}"); assert_eq!(r["changed"], false); assert_eq!(n(&s, "A1"), 1.0, "an infeasible model leaves the cells alone"); diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs index 36e5cad89..48c8e130e 100644 --- a/crates/engine/src/io/tests.rs +++ b/crates/engine/src/io/tests.rs @@ -176,10 +176,7 @@ fn html_export_marks_right_to_left_sheets() { let html = to_html(&wb, 0).unwrap(); assert!(html.contains("
"), HAlign::General), + ("B2", Value::Number(3.0), HAlign::General), + ("A3", Value::text("Total"), HAlign::Right), + ], + ); + wb.sheet_mut(0).unwrap().merges.push(RangeRef::parse("A3:B3").unwrap()); + assert_eq!( + to_html(&wb, 0).unwrap(), + "Sheet1\ + \ + \ + \ +
NameQty
Apples & <pears>3
Total
" + ); + let cell = "border:1px solid #d4d4d4;padding:2px 4px;white-space:pre-wrap;"; + assert_eq!( + copied(&wb, "A1:B2"), + format!( + "\ + \ +
NameQty
Apples & <pears>3
" + ) + ); +} + +#[test] +fn html_export_fails_instead_of_dropping_cells_past_the_row_limit() { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::text("near")); + sh.set_value(CellRef::new(149_999, 0), gridcraft_core::Value::text("far")); + assert!(save_bytes(&wb, "book.html").is_err()); + let sh = wb.sheet_mut(0).unwrap(); + sh.remove_cell(CellRef::new(149_999, 0)); + sh.set_value(CellRef::new(100_000, 0), gridcraft_core::Value::text("far")); + let html = String::from_utf8(save_bytes(&wb, "book.html").unwrap()).unwrap(); + assert!(html.contains(">near
"), "{html}"); assert!(html.contains(""), "Latin text keeps its own direction: {html}"); - assert!( - html.contains(""), - "numbers mirror to the left and keep their sign in front: {html}" - ); + assert!(html.contains(""), "numbers mirror to the left and keep their sign in front: {html}"); let clip = copied(&wb, "A1:B1"); assert!(clip.starts_with("
Hello!-5-5
egui::Context { let ctx = egui::Context::default(); - let raw = egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), - max_texture_side: Some(8192), - ..Default::default() - }; + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), max_texture_side: Some(8192), ..Default::default() }; let _ = ui_pass(&ctx, raw, |_| {}); ctx } @@ -927,10 +924,7 @@ fn base64_capture_over_the_encoding_budget_is_rejected_before_admission() { admit(&mut app, &ctx, None, tx); let reply = rx.try_recv().expect("an oversized base64 capture is refused at admission"); assert_eq!(reply["ok"], false, "{reply}"); - assert!( - reply["error"].as_str().is_some_and(|e| e.contains("encoding") || e.contains("base64")), - "the error names the encoding budget: {reply}" - ); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("encoding") || e.contains("base64")), "the error names the encoding budget: {reply}"); assert!(app.shots.requests.is_empty(), "a refused request reserves nothing"); // The same capture as a file is admitted within the per-image and encoding budgets. @@ -957,9 +951,7 @@ fn synthetic_text_key_and_move_producers_honour_the_queue_budget() { let max = "x".repeat(MAX_SYNTHETIC_TEXT_BYTES); let mut refused_at = None; for i in 0..64 { - let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.text", &json!({"text": max.clone()})) else { - panic!("ui.text returns immediately") - }; + let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.text", &json!({"text": max.clone()})) else { panic!("ui.text returns immediately") }; if reply["ok"] == false { refused_at = Some((i, app.synthetic.len())); break; diff --git a/crates/ui-egui/src/theme/tests.rs b/crates/ui-egui/src/theme/tests.rs index 15ee29090..b8ad4416e 100644 --- a/crates/ui-egui/src/theme/tests.rs +++ b/crates/ui-egui/src/theme/tests.rs @@ -9,8 +9,7 @@ fn fallback_faces_are_appended_to_every_family() { for han in [HanOrder::Chinese, HanOrder::Japanese] { let language = if han == HanOrder::Japanese { Language::Ja } else { Language::Zh }; let fonts = font_definitions_for_language(language); - let fb_keys: Vec = - fallback_faces(han).into_iter().map(|(k, _)| k.to_string()).filter(|k| fonts.font_data.contains_key(k)).collect(); + let fb_keys: Vec = fallback_faces(han).into_iter().map(|(k, _)| k.to_string()).filter(|k| fonts.font_data.contains_key(k)).collect(); if fb_keys.is_empty() { return; // no fallback face on this machine (minimal Linux) } @@ -168,8 +167,7 @@ fn fallback_faces_load_each_file_once() { // once, not once per script (tens of MB each). for language in [Language::En, Language::Ja] { let fonts = font_definitions_for_language(language); - let present: Vec<&Arc> = - fallback_faces(HanOrder::of(language)).into_iter().filter_map(|(k, _)| fonts.font_data.get(k)).collect(); + let present: Vec<&Arc> = fallback_faces(HanOrder::of(language)).into_iter().filter_map(|(k, _)| fonts.font_data.get(k)).collect(); for (i, a) in present.iter().enumerate() { for b in present.iter().skip(i + 1) { assert!(!std::sync::Arc::ptr_eq(&a.font, &b.font), "the same font file is loaded for two fallback scripts"); @@ -215,8 +213,7 @@ fn fallback_covers_its_script() { eprintln!("skipped {key}: no system face and no craft-fonts face for {sample:?}"); continue; } - let missing: String = - sample.chars().filter(|c| !c.is_whitespace()).filter(|&c| !ctx.fonts_mut(|f| f.has_glyph(&font, c))).collect(); + let missing: String = sample.chars().filter(|c| !c.is_whitespace()).filter(|&c| !ctx.fonts_mut(|f| f.has_glyph(&font, c))).collect(); assert!(missing.is_empty(), "{key} is present but {sample:?} has no glyph for {missing:?} ({language:?}, {family:?})"); } } From 58465af95adebd22bdbc6c01b215c3f7e81c7f9a Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:50:33 +0000 Subject: [PATCH 41/76] refactor(engine): move cmd/solver.rs items to cmd/solver/ --- crates/engine/src/cmd/solver.rs | 1863 +----------------- crates/engine/src/cmd/solver/evolutionary.rs | 255 +++ crates/engine/src/cmd/solver/lp.rs | 447 +++++ crates/engine/src/cmd/solver/model.rs | 459 +++++ crates/engine/src/cmd/solver/nlp.rs | 197 ++ crates/engine/src/cmd/solver/problem.rs | 144 ++ crates/engine/src/cmd/solver/reports.rs | 373 ++++ 7 files changed, 1890 insertions(+), 1848 deletions(-) create mode 100644 crates/engine/src/cmd/solver/evolutionary.rs create mode 100644 crates/engine/src/cmd/solver/lp.rs create mode 100644 crates/engine/src/cmd/solver/model.rs create mode 100644 crates/engine/src/cmd/solver/nlp.rs create mode 100644 crates/engine/src/cmd/solver/problem.rs create mode 100644 crates/engine/src/cmd/solver/reports.rs diff --git a/crates/engine/src/cmd/solver.rs b/crates/engine/src/cmd/solver.rs index 191493841..d42b75961 100644 --- a/crates/engine/src/cmd/solver.rs +++ b/crates/engine/src/cmd/solver.rs @@ -33,6 +33,21 @@ use super::whatif::Trial; use super::{CommandSpec, Ctx, DocState, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, str_param}; use gridcraft_core::split_sheet; +mod evolutionary; +mod lp; +mod model; +mod nlp; +mod problem; +mod reports; + +use evolutionary::{EvoEnd, evolutionary}; +use lp::{Outcome, Sense, branch_and_bound, linearize, solve_lp}; +pub use model::saved_model; +use model::{Goal, Method, Model, model_params, parse_model, save_model}; +use nlp::{nlp, project}; +use problem::{Evaluator, as_num, build_problem}; +use reports::{ReportInput, insert_report_selections, prepare_reports, report, report_kinds, write_reports}; + const CMD: &str = "data.solver"; const REPORT_CMD: &str = "data.solverReport"; /// Excel's limit on decision variables. @@ -75,1485 +90,6 @@ pub fn specs() -> Vec { ] } -// ---------------------------------------------------------------- model - -#[derive(Clone, Copy, Debug, PartialEq)] -enum Rel { - Le, - Eq, - Ge, - Int, - Bin, -} - -impl Rel { - fn parse(s: &str) -> Option { - match s.trim().to_ascii_lowercase().as_str() { - "<=" | "≤" | "=<" | "le" => Some(Rel::Le), - ">=" | "≥" | "=>" | "ge" => Some(Rel::Ge), - "=" | "==" | "eq" => Some(Rel::Eq), - "int" | "integer" => Some(Rel::Int), - "bin" | "binary" => Some(Rel::Bin), - _ => None, - } - } - fn text(self) -> &'static str { - match self { - Rel::Le => "<=", - Rel::Eq => "=", - Rel::Ge => ">=", - Rel::Int => "int", - Rel::Bin => "bin", - } - } - /// The relation code of the saved model (`solver_relN`), as in Solver's VBA `SolverAdd`. - fn code(self) -> u8 { - match self { - Rel::Le => 1, - Rel::Eq => 2, - Rel::Ge => 3, - Rel::Int => 4, - Rel::Bin => 5, - } - } - fn from_code(c: &str) -> Option { - match c.trim() { - "1" => Some(Rel::Le), - "2" => Some(Rel::Eq), - "3" => Some(Rel::Ge), - "4" => Some(Rel::Int), - "5" => Some(Rel::Bin), - _ => None, - } - } -} - -#[derive(Clone, Debug)] -enum Rhs { - None, - Const(f64), - Cells(Vec), -} - -#[derive(Clone, Debug)] -struct Constraint { - lhs: Vec, - lhs_text: String, - rel: Rel, - rhs: Rhs, - rhs_text: String, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -enum Goal { - Max, - Min, - Value(f64), -} - -#[derive(Clone, Copy, Debug, PartialEq)] -enum Method { - Simplex, - Grg, - Evolutionary, -} - -/// The Evolutionary method's options (Solver Options › Evolutionary). The search keeps no -/// clock, so the time without improvement is counted in generations. -#[derive(Clone, Copy, Debug, PartialEq)] -struct EvoOptions { - population: usize, - mutation: f64, - seed: u64, - convergence: f64, - stall: u64, - require_bounds: bool, -} - -impl Default for EvoOptions { - fn default() -> EvoOptions { - EvoOptions { population: 100, mutation: 0.075, seed: 0, convergence: 1e-4, stall: 50, require_bounds: true } - } -} - -fn evo_options(p: &Json) -> EvoOptions { - let d = EvoOptions::default(); - let num = |k: &str| p.get(k).and_then(Json::as_f64).or_else(|| str_param(p, k).and_then(num_text)).filter(|v| v.is_finite()); - EvoOptions { - population: num("populationSize").map_or(d.population, |v| v.clamp(MIN_POPULATION as f64, MAX_POPULATION as f64) as usize), - mutation: num("mutationRate").map_or(d.mutation, |v| v.clamp(0.0, 1.0)), - seed: num("randomSeed").map_or(d.seed, |v| v.clamp(0.0, f64::from(u32::MAX)) as u64), - convergence: num("convergence").map_or(d.convergence, |v| v.clamp(0.0, 1.0)), - stall: num("generationsWithoutImprovement").map_or(d.stall, |v| v.clamp(1.0, 100_000.0) as u64), - require_bounds: bool_param(p, "requireBounds").unwrap_or(d.require_bounds), - } -} - -#[derive(Clone, Debug)] -struct Model { - sheet: usize, - objective: CellRef, - goal: Goal, - vars: Vec, - vars_text: String, - cons: Vec, - non_negative: bool, - method: Method, - evo: EvoOptions, -} - -/// Cells of `"B2:B4,D1"` on one sheet (row-major per area, in order), at most `cap`. -fn cells_of(wb: &Workbook, t: &str, default_sheet: usize, cap: usize) -> Result<(usize, Vec)> { - let mut sheet = None; - let mut out = Vec::new(); - for part in t.split(',').map(str::trim).filter(|x| !x.is_empty()) { - let (sh, body) = split_sheet(part); - let si = match sh { - Some(n) => wb.sheet_index(&n).ok_or_else(|| bad(CMD, format!("no sheet named `{n}`")))?, - None => default_sheet, - }; - if *sheet.get_or_insert(si) != si { - return Err(bad(CMD, "all cells of a reference must be on one sheet")); - } - let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(CMD, format!("not a reference: `{part}`")))?; - if r.count() > cap as u64 || out.len() + r.count() as usize > cap { - return Err(bad(CMD, format!("too many cells (at most {cap})"))); - } - out.extend(r.iter()); - } - if out.is_empty() { - return Err(bad(CMD, format!("not a reference: `{t}`"))); - } - Ok((sheet.unwrap_or(default_sheet), out)) -} - -fn num_text(t: &str) -> Option { - gridcraft_core::parse::parse_number_text(t.trim()).filter(|v| v.is_finite()) -} - -/// Splits `"B5 <= 10"` / `"B2 int"` into (lhs, relation, rhs). -fn split_constraint(t: &str) -> Option<(String, Rel, String)> { - for op in ["<=", ">=", "=<", "=>", "≤", "≥", "=="] { - if let Some((a, b)) = t.split_once(op) { - return Some((a.trim().into(), Rel::parse(op)?, b.trim().into())); - } - } - if let Some((a, b)) = t.split_once('=') { - return Some((a.trim().into(), Rel::Eq, b.trim().into())); - } - let lower = t.trim().to_ascii_lowercase(); - for (suffix, rel) in [("integer", Rel::Int), ("binary", Rel::Bin), ("int", Rel::Int), ("bin", Rel::Bin)] { - if let Some(a) = lower.strip_suffix(suffix) { - let a = a.trim(); - return Some((t.trim().get(..a.len())?.trim().to_string(), rel, String::new())); - } - } - None -} - -fn parse_constraint(wb: &Workbook, sheet: usize, c: &Json) -> Result { - let (lhs_text, rel, rhs_json) = match c { - Json::String(t) => { - let (l, r, rhs) = split_constraint(t).ok_or_else(|| bad(CMD, format!("not a constraint: `{t}`")))?; - (l, r, Json::String(rhs)) - } - Json::Object(_) => { - let lhs = str_param(c, "lhs").or_else(|| str_param(c, "cell")).or_else(|| str_param(c, "range")).unwrap_or("").to_string(); - let op = str_param(c, "op").or_else(|| str_param(c, "relation")).unwrap_or(""); - let rel = Rel::parse(op).ok_or_else(|| bad(CMD, format!("unknown constraint relation `{op}`")))?; - (lhs, rel, c.get("rhs").or_else(|| c.get("value")).cloned().unwrap_or(Json::Null)) - } - _ => return Err(bad(CMD, "a constraint is a string like \"B5 <= 10\" or {lhs, op, rhs}")), - }; - let (lsi, lhs) = cells_of(wb, &lhs_text, sheet, MAX_CONSTRAINTS)?; - if lsi != sheet { - return Err(EngineError::Other("Constraint cells must be on the objective cell's sheet.".into())); - } - let (rhs, rhs_text) = match rel { - Rel::Int | Rel::Bin => (Rhs::None, String::new()), - _ => match &rhs_json { - Json::Number(n) => { - let v = n.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "the constraint's right side must be a number"))?; - (Rhs::Const(v), v.to_string()) - } - Json::String(t) => { - let t = t.trim().trim_start_matches('=').trim(); - if let Some(v) = num_text(t) { - (Rhs::Const(v), t.to_string()) - } else { - let (rsi, cells) = cells_of(wb, t, sheet, MAX_CONSTRAINTS)?; - if rsi != sheet { - return Err(EngineError::Other("Constraint cells must be on the objective cell's sheet.".into())); - } - if cells.len() != 1 && cells.len() != lhs.len() { - return Err(EngineError::Other("The constraint's two sides must be the same size (or the right side one cell).".into())); - } - (Rhs::Cells(cells), t.to_string()) - } - } - _ => return Err(bad(CMD, "missing the constraint's right side")), - }, - }; - Ok(Constraint { lhs, lhs_text, rel, rhs, rhs_text }) -} - -fn parse_model(s: &Session, p: &Json) -> Result { - let d = s.doc()?; - let wb = &d.wb; - let obj_text = str_param(p, "objective").or_else(|| str_param(p, "setObjective")).or_else(|| str_param(p, "set")).unwrap_or(""); - let (sheet, obj) = cells_of(wb, obj_text, wb.active_sheet(), 1).map_err(|_| EngineError::Other("The objective must be a single cell.".into()))?; - let objective = *obj.first().ok_or_else(|| EngineError::Other("The objective must be a single cell.".into()))?; - let sh = wb.sheet(sheet).ok_or(EngineError::NoDocument)?; - if sh.cell(objective).is_none_or(|c| c.formula.is_none()) { - return Err(EngineError::Other("The objective cell must contain a formula.".into())); - } - let goal = match str_param(p, "goal").or_else(|| str_param(p, "to")).unwrap_or("max").to_ascii_lowercase().as_str() { - "max" | "maximize" | "maximum" => Goal::Max, - "min" | "minimize" | "minimum" => Goal::Min, - "value" | "valueof" | "value of" => { - let v = f64_param(p, "value") - .or_else(|| str_param(p, "value").and_then(num_text)) - .filter(|v| v.is_finite()) - .ok_or_else(|| bad(CMD, "goal `value` needs a number in `value`"))?; - Goal::Value(v) - } - g => return Err(bad(CMD, format!("unknown goal `{g}` (max, min or value)"))), - }; - let vars_text = str_param(p, "variables").or_else(|| str_param(p, "changing")).or_else(|| str_param(p, "byChanging")).unwrap_or("").to_string(); - let (vsi, mut vars) = cells_of(wb, &vars_text, sheet, MAX_VARS).map_err(|e| match e { - EngineError::BadParams { msg, .. } if msg.starts_with("too many") => { - EngineError::Other(format!("Too many variable cells (at most {MAX_VARS}).")) - } - _ => EngineError::Other("The variable cells must be a reference like B2:B4.".into()), - })?; - if vsi != sheet { - return Err(EngineError::Other("The variable cells must be on the objective cell's sheet.".into())); - } - let mut seen = std::collections::HashSet::new(); - vars.retain(|c| seen.insert(*c)); - if vars.iter().any(|c| sh.cell(*c).is_some_and(|c| c.formula.is_some())) { - return Err(EngineError::Other("Variable cells must hold constants, not formulas.".into())); - } - let mut cons = Vec::new(); - let mut scalar = 0usize; - if let Some(list) = p.get("constraints") { - let list = list.as_array().ok_or_else(|| bad(CMD, "`constraints` must be an array"))?; - if list.len() > MAX_CONSTRAINTS { - return Err(EngineError::Other(format!("Too many constraints (at most {MAX_CONSTRAINTS})."))); - } - for c in list { - let c = parse_constraint(wb, sheet, c)?; - scalar = scalar.saturating_add(c.lhs.len()); - if scalar > MAX_CONSTRAINTS { - return Err(EngineError::Other(format!("Too many constraints (at most {MAX_CONSTRAINTS} cells)."))); - } - if matches!(c.rel, Rel::Int | Rel::Bin) && c.lhs.iter().any(|x| !vars.contains(x)) { - return Err(EngineError::Other("Integer and binary constraints must be on variable cells.".into())); - } - cons.push(c); - } - } - let non_negative = bool_param(p, "nonNegative").unwrap_or(true); - let method = match str_param(p, "method").unwrap_or("grg").to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { - "grg" | "grgnonlinear" | "nonlinear" => Method::Grg, - "simplex" | "simplexlp" | "lp" | "linear" => Method::Simplex, - "evolutionary" | "evo" | "genetic" => Method::Evolutionary, - m => return Err(bad(CMD, format!("unknown method `{m}` (grg, simplex or evolutionary)"))), - }; - Ok(Model { sheet, objective, goal, vars, vars_text, cons, non_negative, method, evo: evo_options(p) }) -} - -// ---------------------------------------------------------------- saved model - -fn ref_text(sheet_name: &str, cells: &str) -> String { - let q = crate::pivot::quote_sheet(sheet_name); - cells - .split(',') - .map(str::trim) - .filter(|x| !x.is_empty()) - .filter_map(|part| { - let r = RangeRef::parse(split_sheet(part).1.trim())?; - let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row as u64 + 1); - Some(if r.is_single() { format!("{q}!{}", abs(r.start)) } else { format!("{q}!{}:{}", abs(r.start), abs(r.end)) }) - }) - .collect::>() - .join(",") -} - -/// The model as hidden names scoped to its sheet (replacing any saved before). -fn save_model(wb: &mut Workbook, m: &Model) { - let Some(sheet_name) = wb.sheet(m.sheet).map(|s| s.name.clone()) else { return }; - wb.names.retain(|n| !(n.scope == Some(m.sheet) && n.name.to_ascii_lowercase().starts_with("solver_"))); - let mut put = |name: String, formula: String| { - wb.names.push(DefinedName { name, scope: Some(m.sheet), formula, comment: String::new(), hidden: true }); - }; - put("solver_opt".into(), ref_text(&sheet_name, &m.objective.a1())); - let (typ, val) = match m.goal { - Goal::Max => (1, 0.0), - Goal::Min => (2, 0.0), - Goal::Value(v) => (3, v), - }; - put("solver_typ".into(), typ.to_string()); - put("solver_val".into(), val.to_string()); - put("solver_adj".into(), ref_text(&sheet_name, &m.vars_text)); - for (i, c) in m.cons.iter().enumerate() { - let k = i + 1; - put(format!("solver_lhs{k}"), ref_text(&sheet_name, &c.lhs_text)); - put(format!("solver_rel{k}"), c.rel.code().to_string()); - let rhs = match &c.rhs { - Rhs::None => { - if c.rel == Rel::Bin { - "\"binary\"".to_string() - } else { - "\"integer\"".to_string() - } - } - Rhs::Const(v) => v.to_string(), - Rhs::Cells(_) => ref_text(&sheet_name, &c.rhs_text), - }; - put(format!("solver_rhs{k}"), rhs); - } - put("solver_num".into(), m.cons.len().to_string()); - put("solver_neg".into(), if m.non_negative { "1" } else { "2" }.into()); - let eng = match m.method { - Method::Grg => "1", - Method::Simplex => "2", - Method::Evolutionary => "3", - }; - put("solver_eng".into(), eng.into()); - if m.method == Method::Evolutionary { - put("solver_ssz".into(), m.evo.population.to_string()); - put("solver_mrt".into(), m.evo.mutation.to_string()); - put("solver_rsd".into(), m.evo.seed.to_string()); - put("solver_rbv".into(), if m.evo.require_bounds { "1" } else { "2" }.into()); - } - put("solver_ver".into(), "3".into()); -} - -/// The saved model of a sheet as `data.solver` params, if any. -pub fn saved_model(wb: &Workbook, sheet: usize) -> Option { - let get = |n: &str| { - wb.names - .iter() - .find(|d| d.name.eq_ignore_ascii_case(n) && (d.scope == Some(sheet) || d.scope.is_none())) - .map(|d| d.formula.trim().trim_start_matches('=').to_string()) - }; - let local = |t: String| -> String { t.split(',').map(|p| split_sheet(p.trim()).1.replace('$', "")).collect::>().join(",") }; - let objective = local(get("solver_opt")?); - if objective.is_empty() { - return None; - } - let goal = match get("solver_typ").as_deref().map(str::trim) { - Some("2") => "min", - Some("3") => "value", - _ => "max", - }; - let value = get("solver_val").and_then(|v| num_text(&v)).unwrap_or(0.0); - let count = get("solver_num").and_then(|v| num_text(&v)).unwrap_or(0.0).clamp(0.0, MAX_CONSTRAINTS as f64) as usize; - let mut constraints = Vec::new(); - for k in 1..=count { - let (Some(lhs), Some(rel)) = (get(&format!("solver_lhs{k}")), get(&format!("solver_rel{k}")).and_then(|r| Rel::from_code(&r))) else { - continue; - }; - let lhs = local(lhs); - constraints.push(match rel { - Rel::Int | Rel::Bin => json!(format!("{lhs} {}", rel.text())), - _ => { - let rhs = get(&format!("solver_rhs{k}")).unwrap_or_default(); - let rhs = if num_text(&rhs).is_some() { rhs } else { local(rhs) }; - json!(format!("{lhs} {} {rhs}", rel.text())) - } - }); - } - let method = match get("solver_eng").as_deref().map(str::trim) { - Some("2") => "simplex", - Some("3") => "evolutionary", - _ => "grg", - }; - let mut model = json!({ - "objective": objective, - "goal": goal, - "value": value, - "variables": get("solver_adj").map(local).unwrap_or_default(), - "constraints": constraints, - "nonNegative": get("solver_neg").as_deref().map(str::trim) != Some("2"), - "method": method, - }); - for (name, key) in [("solver_ssz", "populationSize"), ("solver_mrt", "mutationRate"), ("solver_rsd", "randomSeed")] { - if let Some(v) = get(name).and_then(|v| num_text(&v)) { - model[key] = json!(v); - } - } - if let Some(v) = get("solver_rbv") { - model["requireBounds"] = json!(v.trim() != "2"); - } - Some(model) -} - -/// The model as `data.solver` params, in the form [`saved_model`] returns (the objective -/// qualified by its sheet). -fn model_params(wb: &Workbook, m: &Model) -> Json { - let sheet = wb.sheet(m.sheet).map(|s| crate::pivot::quote_sheet(&s.name)).unwrap_or_default(); - let (goal, value) = match m.goal { - Goal::Max => ("max", 0.0), - Goal::Min => ("min", 0.0), - Goal::Value(v) => ("value", v), - }; - let constraints: Vec = m - .cons - .iter() - .map(|c| match c.rel { - Rel::Int | Rel::Bin => json!(format!("{} {}", c.lhs_text, c.rel.text())), - _ => json!(format!("{} {} {}", c.lhs_text, c.rel.text(), c.rhs_text)), - }) - .collect(); - let method = match m.method { - Method::Simplex => "simplex", - Method::Grg => "grg", - Method::Evolutionary => "evolutionary", - }; - let mut out = json!({ - "objective": format!("{sheet}!{}", m.objective.a1()), - "goal": goal, - "value": value, - "variables": m.vars_text, - "constraints": constraints, - "nonNegative": m.non_negative, - "method": method, - }); - if m.method == Method::Evolutionary { - let e = &m.evo; - out["populationSize"] = json!(e.population); - out["mutationRate"] = json!(e.mutation); - out["randomSeed"] = json!(e.seed); - out["convergence"] = json!(e.convergence); - out["generationsWithoutImprovement"] = json!(e.stall); - out["requireBounds"] = json!(e.require_bounds); - } - out -} - -// ---------------------------------------------------------------- evaluation - -#[derive(Clone, Copy, Debug, PartialEq)] -enum Sense { - Le, - Ge, - Eq, -} - -/// A scalar constraint `lhs (sense) rhs`. -#[derive(Clone, Debug)] -struct Row { - lhs: CellRef, - sense: Sense, - rhs: Option, - rhs_const: f64, -} - -/// The values the solvers see at a point: the objective and `lhs − rhs` per row. -#[derive(Clone, Debug)] -struct Point { - f: f64, - g: Vec, -} - -struct Evaluator { - trial: Trial, - sheet: usize, - vars: Vec, - objective: CellRef, - rows: Vec, - evals: u64, - budget: u64, - /// The Limits report: the objective is this variable itself. - target: Option, -} - -fn as_num(v: &Value) -> f64 { - match v { - Value::Number(n) => *n, - Value::Empty => 0.0, - Value::Bool(b) => f64::from(u8::from(*b)), - _ => f64::NAN, - } -} - -impl Evaluator { - fn exhausted(&self) -> bool { - self.evals >= self.budget - } - - /// Evaluates the model at `x`; `None` once the budget is spent. - fn eval(&mut self, x: &[f64]) -> Option { - if self.exhausted() { - return None; - } - self.evals += 1; - let sets: Vec<(usize, CellRef, Value)> = self.vars.iter().zip(x).map(|(c, v)| (self.sheet, *c, Value::number(*v))).collect(); - let mut reads = Vec::with_capacity(1 + 2 * self.rows.len()); - reads.push((self.sheet, self.objective)); - for r in &self.rows { - reads.push((self.sheet, r.lhs)); - if let Some(c) = r.rhs { - reads.push((self.sheet, c)); - } - } - let vals = self.trial.run(&sets, &reads); - let mut it = vals.iter().map(as_num); - let f = it.next().unwrap_or(f64::NAN); - let f = match self.target { - Some(j) => x.get(j).copied().unwrap_or(f64::NAN), - None => f, - }; - let mut g = Vec::with_capacity(self.rows.len()); - for r in &self.rows { - let l = it.next().unwrap_or(f64::NAN); - let rv = if r.rhs.is_some() { it.next().unwrap_or(f64::NAN) } else { r.rhs_const }; - g.push(l - rv); - } - Some(Point { f, g }) - } -} - -// ---------------------------------------------------------------- problem - -struct Problem { - lo: Vec, - hi: Vec, - integer: Vec, - rows: Vec, -} - -/// Splits the constraints into variable bounds and general rows. -fn build_problem(m: &Model) -> Problem { - let n = m.vars.len(); - let mut lo = vec![f64::NEG_INFINITY; n]; - let mut hi = vec![f64::INFINITY; n]; - let mut has_lower = vec![false; n]; - let mut integer = vec![false; n]; - let mut rows = Vec::new(); - let var_index = |c: &CellRef| m.vars.iter().position(|v| v == c); - for c in &m.cons { - for (k, cell) in c.lhs.iter().enumerate() { - let j = var_index(cell); - match c.rel { - Rel::Int | Rel::Bin => { - if let Some(j) = j { - if let Some(b) = integer.get_mut(j) { - *b = true; - } - if c.rel == Rel::Bin - && let (Some(l), Some(h), Some(hl)) = (lo.get_mut(j), hi.get_mut(j), has_lower.get_mut(j)) - { - *l = l.max(0.0); - *h = h.min(1.0); - *hl = true; - } - } - } - rel => { - let sense = match rel { - Rel::Le => Sense::Le, - Rel::Ge => Sense::Ge, - _ => Sense::Eq, - }; - let (rhs, rhs_const) = match &c.rhs { - Rhs::Const(v) => (None, *v), - Rhs::Cells(cells) => (cells.get(k).or_else(|| cells.first()).copied(), 0.0), - Rhs::None => (None, 0.0), - }; - match (j, rhs) { - // A constant bound on a variable cell is a box bound. - (Some(j), None) => { - if let (Some(l), Some(h), Some(hl)) = (lo.get_mut(j), hi.get_mut(j), has_lower.get_mut(j)) { - if sense != Sense::Ge { - *h = h.min(rhs_const); - } - if sense != Sense::Le { - *l = l.max(rhs_const); - *hl = true; - } - } - } - _ => rows.push(Row { lhs: *cell, sense, rhs, rhs_const }), - } - } - } - } - } - if m.non_negative { - for (l, hl) in lo.iter_mut().zip(&has_lower) { - if !hl { - *l = l.max(0.0); - } - } - } - Problem { lo, hi, integer, rows } -} - -/// What a relaxation (or the whole solve) ended with. -#[derive(Clone, Debug)] -enum Outcome { - /// x, objective to minimise, and whether the solver converged (vs. ran out of budget). - Solved(Vec, f64, bool), - Infeasible, - Unbounded, - Limit, -} - -/// Depth-first branch and bound over a relaxation solver; returns the best integer point. -fn branch_and_bound( - lo: &[f64], - hi: &[f64], - integer: &[bool], - max_nodes: usize, - gap: f64, - relax: &mut dyn FnMut(&[f64], &[f64]) -> Outcome, -) -> (Outcome, usize) { - let mut stack: Vec<(Vec, Vec)> = vec![(lo.to_vec(), hi.to_vec())]; - let mut best: Option<(Vec, f64)> = None; - let mut nodes = 0usize; - let mut limited = false; - let mut root = true; - while let Some((l, h)) = stack.pop() { - if nodes >= max_nodes { - limited = true; - break; - } - nodes += 1; - let out = relax(&l, &h); - let was_root = std::mem::replace(&mut root, false); - let (x, obj, converged) = match out { - Outcome::Solved(x, obj, c) => (x, obj, c), - Outcome::Unbounded if was_root => return (Outcome::Unbounded, nodes), - Outcome::Limit => { - limited = true; - break; - } - _ => continue, - }; - if !converged { - limited = true; - } - if let Some((_, b)) = &best - && obj >= b - gap * b.abs().max(1e-12) - 1e-9 * (1.0 + b.abs()) - { - continue; - } - // The most fractional integer variable. - let frac = x - .iter() - .zip(integer) - .enumerate() - .filter(|(_, (_, int))| **int) - .map(|(j, (v, _))| (j, (v - v.round()).abs())) - .filter(|(_, f)| *f > 1e-6) - .max_by(|a, b| a.1.total_cmp(&b.1)); - match frac { - None => { - let snapped: Vec = x.iter().zip(integer).map(|(v, int)| if *int { v.round() } else { *v }).collect(); - best = Some((snapped, obj)); - } - Some((j, _)) => { - let v = x.get(j).copied().unwrap_or(0.0); - let (mut down_h, mut up_l) = (h.clone(), l.clone()); - if let Some(e) = down_h.get_mut(j) { - *e = e.min(v.floor()); - } - if let Some(e) = up_l.get_mut(j) { - *e = e.max(v.ceil()); - } - // Explore the nearer side first (pushed last). - if v - v.floor() < 0.5 { - stack.push((up_l, h)); - stack.push((l, down_h)); - } else { - stack.push((l, down_h)); - stack.push((up_l, h)); - } - } - } - } - match best { - Some((x, obj)) => (Outcome::Solved(x, obj, !limited), nodes), - None if limited => (Outcome::Limit, nodes), - None => (Outcome::Infeasible, nodes), - } -} - -// ---------------------------------------------------------------- simplex - -/// Pivots the tableau on (r, j), updating the objective row. -fn pivot(t: &mut [Vec], obj: &mut [f64], r: usize, j: usize) { - let Some(prow) = t.get_mut(r) else { return }; - let pv = prow.get(j).copied().unwrap_or(0.0); - if pv == 0.0 { - return; - } - for v in prow.iter_mut() { - *v /= pv; - } - let prow = prow.clone(); - for (i, row) in t.iter_mut().enumerate() { - if i == r { - continue; - } - let f = row.get(j).copied().unwrap_or(0.0); - if f != 0.0 { - for (v, p) in row.iter_mut().zip(&prow) { - *v -= f * p; - } - } - } - let f = obj.get(j).copied().unwrap_or(0.0); - if f != 0.0 { - for (v, p) in obj.iter_mut().zip(&prow) { - *v -= f * p; - } - } -} - -enum Step { - Optimal, - Unbounded, - Limit, -} - -/// Minimises with the reduced costs in `obj` (last entry: −objective). Columns not `allowed` -/// never enter. -fn simplex(t: &mut [Vec], basis: &mut [usize], obj: &mut [f64], allowed: &[bool], budget: &mut u64) -> Step { - let n = obj.len().saturating_sub(1); - let cost = (t.len() as u64 + 1).saturating_mul(n as u64 + 1).max(1); - let mut stalled = 0u32; - loop { - if *budget < cost { - return Step::Limit; - } - *budget -= cost; - let bland = stalled > 50; - let mut enter = None; - let mut best = -EPS; - for (j, d) in obj.iter().take(n).enumerate() { - if !allowed.get(j).copied().unwrap_or(false) || *d >= -EPS { - continue; - } - if bland { - enter = Some(j); - break; - } - if *d < best { - best = *d; - enter = Some(j); - } - } - let Some(j) = enter else { return Step::Optimal }; - let mut leave: Option<(usize, f64)> = None; - for (i, row) in t.iter().enumerate() { - let a = row.get(j).copied().unwrap_or(0.0); - if a <= EPS { - continue; - } - let ratio = row.get(n).copied().unwrap_or(0.0) / a; - let better = match leave { - None => true, - Some((li, lr)) => ratio < lr - 1e-12 || (ratio <= lr + 1e-12 && basis.get(i) < basis.get(li)), - }; - if better { - leave = Some((i, ratio)); - } - } - let Some((r, ratio)) = leave else { return Step::Unbounded }; - stalled = if ratio <= EPS { stalled + 1 } else { 0 }; - pivot(t, obj, r, j); - if let Some(b) = basis.get_mut(r) { - *b = j; - } - } -} - -/// Minimises `c·x` subject to `rows` (coefficients, sense, rhs) and `lo ≤ x ≤ hi`. -fn solve_lp(c: &[f64], rows: &[(Vec, 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. - struct Map { - var: usize, - sign: f64, - } - let mut cols: Vec = 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 mut yrows: Vec<(Vec, 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, 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::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 = 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; - } - } - } - } - // 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() { - if let Some(v) = cost.get_mut(k) { - *v = c.get(mp.var).copied().unwrap_or(0.0) * mp.sign; - } - } - let mut obj = cost.clone(); - for (i, row) in t.iter().enumerate() { - let cb = basis.get(i).and_then(|b| cost.get(*b)).copied().unwrap_or(0.0); - if cb != 0.0 { - for (v, x) in obj.iter_mut().zip(row) { - *v -= cb * x; - } - } - } - let allowed: Vec = (0..width).map(|j| !is_art(j)).collect(); - match simplex(&mut t, &mut basis, &mut obj, &allowed, budget) { - Step::Limit => return Outcome::Limit, - Step::Unbounded => return Outcome::Unbounded, - Step::Optimal => {} - } - let mut y = vec![0.0; width]; - for (i, row) in t.iter().enumerate() { - if let (Some(b), Some(v)) = (basis.get(i), row.get(width)) - && let Some(e) = y.get_mut(*b) - { - *e = *v; - } - } - let mut x = offset; - for (k, mp) in cols.iter().enumerate() { - if let Some(e) = x.get_mut(mp.var) { - *e += mp.sign * y.get(k).copied().unwrap_or(0.0); - } - } - let val: f64 = c.iter().zip(&x).map(|(a, b)| a * b).sum(); - Outcome::Solved(x, val, true) -} - -/// 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`. -#[allow(clippy::type_complexity)] -fn linearize(ev: &mut Evaluator, x0: &[f64]) -> std::result::Result<((Vec, f64), Vec<(Vec, 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()) { - return Err("error"); - } - let mut cf = vec![0.0; n]; - let mut ga = vec![vec![0.0; n]; p0.g.len()]; - for j in 0..n { - let mut x = x0.to_vec(); - if let Some(v) = x.get_mut(j) { - *v += 1.0; - } - let p = ev.eval(&x).ok_or("budget")?; - if let Some(v) = cf.get_mut(j) { - *v = p.f - p0.f; - } - for (row, (gi, g0)) in ga.iter_mut().zip(p.g.iter().zip(&p0.g)) { - if let Some(v) = row.get_mut(j) { - *v = gi - g0; - } - } - } - let dot = |a: &[f64], x: &[f64]| a.iter().zip(x).map(|(p, q)| p * q).sum::(); - let f_const = p0.f - dot(&cf, x0); - let rows: Vec<(Vec, f64)> = ga.into_iter().zip(&p0.g).map(|(a, g0)| (a.clone(), g0 - dot(&a, x0))).collect(); - // Linearity check at a spread-out point. - let xt: Vec = - x0.iter().enumerate().map(|(j, v)| v + if j % 2 == 0 { 1.7 + 0.3 * (j % 5) as f64 } else { -0.6 - 0.2 * (j % 3) as f64 }).collect(); - let pt = ev.eval(&xt).ok_or("budget")?; - let close = |actual: f64, pred: f64, terms: f64| actual.is_finite() && (actual - pred).abs() <= 1e-6 * (1.0 + actual.abs() + terms); - let terms = |a: &[f64]| a.iter().zip(&xt).map(|(p, q)| (p * q).abs()).sum::(); - if !close(pt.f, f_const + dot(&cf, &xt), terms(&cf)) { - return Err("nonlinear"); - } - for ((a, k), g) in rows.iter().zip(&pt.g) { - if !close(*g, k + dot(a, &xt), terms(a)) { - return Err("nonlinear"); - } - } - Ok(((cf, f_const), rows)) -} - -// ---------------------------------------------------------------- nonlinear - -/// Augmented-Lagrangian value of a point: objective `phi` plus the constraint terms. -fn aug(phi: f64, cons: &[f64], senses: &[Sense], lam: &[f64], mu: f64) -> f64 { - let mut v = phi; - for ((c, s), l) in cons.iter().zip(senses).zip(lam) { - v += match s { - Sense::Eq => l * c + 0.5 * mu * c * c, - _ => { - let t = (l + mu * c).max(0.0); - (t * t - l * l) / (2.0 * mu) - } - }; - } - v -} - -/// Constraint values in the `≤ 0` / `= 0` form, and the objective to minimise. -fn shape(p: &Point, rows: &[Row], goal: Goal) -> (f64, Vec) { - let phi = match goal { - Goal::Max => -p.f, - Goal::Min => p.f, - Goal::Value(v) => (p.f - v) * (p.f - v), - }; - let cons = p.g.iter().zip(rows).map(|(g, r)| if r.sense == Sense::Ge { -g } else { *g }).collect(); - (phi, cons) -} - -fn violation(cons: &[f64], senses: &[Sense]) -> f64 { - cons.iter().zip(senses).map(|(c, s)| if *s == Sense::Eq { c.abs() } else { c.max(0.0) }).fold(0.0, f64::max) -} - -fn project(x: &mut [f64], lo: &[f64], hi: &[f64]) { - for ((v, l), h) in x.iter_mut().zip(lo).zip(hi) { - if *v < *l { - *v = *l; - } - if *v > *h { - *v = *h; - } - } -} - -/// Local minimisation of the model within bounds `lo..hi` from `x0`. -fn nlp(ev: &mut Evaluator, x0: &[f64], lo: &[f64], hi: &[f64], goal: Goal) -> Outcome { - let n = x0.len(); - let rows = ev.rows.clone(); - let senses: Vec = rows.iter().map(|r| r.sense).collect(); - let mut x = x0.to_vec(); - project(&mut x, lo, hi); - let mut lam = vec![0.0; rows.len()]; - let mut mu = 10.0; - let Some(p) = ev.eval(&x) else { return Outcome::Limit }; - let (mut phi, mut cons) = shape(&p, &rows, goal); - if !phi.is_finite() { - return Outcome::Infeasible; - } - let phi0 = phi; - let mut prev_viol = violation(&cons, &senses); - let mut converged = false; - let mut out_of_budget = false; - let mut carried: Option = None; - for _outer in 0..40 { - // Inner: projected gradient on the augmented Lagrangian. - let lval = |phi: f64, cons: &[f64], lam: &[f64], mu: f64| aug(phi, cons, &senses, lam, mu); - let grad = |ev: &mut Evaluator, x: &[f64], lam: &[f64], mu: f64| -> Option> { - let mut gr = vec![0.0; n]; - for j in 0..n { - let xj = x.get(j).copied().unwrap_or(0.0); - let h = 1e-6 * xj.abs().max(1.0); - let (l, u) = (lo.get(j).copied().unwrap_or(f64::NEG_INFINITY), hi.get(j).copied().unwrap_or(f64::INFINITY)); - let (a, b) = (if xj - h >= l { xj - h } else { xj }, if xj + h <= u { xj + h } else { xj }); - if b <= a { - continue; - } - let mut xa = x.to_vec(); - let mut xb = x.to_vec(); - if let Some(v) = xa.get_mut(j) { - *v = a; - } - if let Some(v) = xb.get_mut(j) { - *v = b; - } - let (pa, ca) = shape(&ev.eval(&xa)?, &rows, goal); - let (pb, cb) = shape(&ev.eval(&xb)?, &rows, goal); - let d = (lval(pb, &cb, lam, mu) - lval(pa, &ca, lam, mu)) / (b - a); - if let Some(v) = gr.get_mut(j) { - *v = if d.is_finite() { d } else { 0.0 }; - } - } - Some(gr) - }; - let mut l_cur = lval(phi, &cons, &lam, mu); - let Some(mut g) = grad(ev, &x, &lam, mu) else { - out_of_budget = true; - break; - }; - // The step length carries over between outer iterations once known. - let gnorm = g.iter().map(|v| v.abs()).fold(0.0, f64::max); - let mut alpha = carried.unwrap_or(if gnorm > 0.0 { 1.0 / gnorm } else { 1.0 }); - let mut inner_done = false; - let mut quiet = 0; - for _inner in 0..500 { - let mut accepted = None; - let mut a = alpha; - for _ in 0..40 { - let mut xn: Vec = x.iter().zip(&g).map(|(v, d)| v - a * d).collect(); - project(&mut xn, lo, hi); - let Some(pn) = ev.eval(&xn) else { break }; - let (phn, cn) = shape(&pn, &rows, goal); - let ln = lval(phn, &cn, &lam, mu); - let decrease: f64 = g.iter().zip(x.iter().zip(&xn)).map(|(d, (u, v))| d * (u - v)).sum(); - if ln.is_finite() && ln <= l_cur - 1e-4 * decrease { - accepted = Some((xn, phn, cn, ln)); - break; - } - a *= 0.25; - } - if ev.exhausted() { - out_of_budget = true; - } - let Some((xn, phn, cn, ln)) = accepted else { - inner_done = true; - break; - }; - // Variables or objective running off towards infinity: unbounded. - if phn.abs() > 1e15 * (1.0 + phi0.abs()) || xn.iter().zip(x0).any(|(v, s)| v.abs() > 1e15 * (1.0 + s.abs())) { - return Outcome::Unbounded; - } - let s: Vec = xn.iter().zip(&x).map(|(a, b)| a - b).collect(); - let step = s.iter().map(|v| v.abs()).fold(0.0, f64::max); - let xmax = xn.iter().map(|v| v.abs()).fold(1.0, f64::max); - let small = step <= 1e-10 * xmax || (l_cur - ln).abs() <= 1e-13 * (1.0 + l_cur.abs()); - quiet = if small { quiet + 1 } else { 0 }; - x = xn; - phi = phn; - cons = cn; - l_cur = ln; - if quiet >= 3 { - inner_done = true; - break; - } - let Some(gn) = grad(ev, &x, &lam, mu) else { - out_of_budget = true; - break; - }; - // Barzilai–Borwein step. - let yv: Vec = gn.iter().zip(&g).map(|(a, b)| a - b).collect(); - let sy: f64 = s.iter().zip(&yv).map(|(a, b)| a * b).sum(); - let ss: f64 = s.iter().map(|v| v * v).sum(); - alpha = if sy > 0.0 { (ss / sy).clamp(1e-12, 1e12) } else { (a * 4.0).min(1e12) }; - g = gn; - if g.iter().all(|v| *v == 0.0) { - inner_done = true; - break; - } - } - carried = Some(alpha); - if out_of_budget { - break; - } - let viol = violation(&cons, &senses); - let scale = rows.iter().map(|r| r.rhs_const.abs()).fold(1.0, f64::max); - if viol <= PRECISION * scale && inner_done { - converged = true; - if rows.is_empty() { - break; - } - } - // Multiplier and penalty updates. - for ((l, c), s) in lam.iter_mut().zip(&cons).zip(&senses) { - *l = match s { - Sense::Eq => *l + mu * c, - _ => (*l + mu * c).max(0.0), - }; - } - if viol > 0.25 * prev_viol { - mu = (mu * 10.0).min(1e10); - } - if converged && viol <= PRECISION * scale * 1e-2 { - break; - } - prev_viol = viol; - converged = false; - } - let viol = violation(&cons, &senses); - let scale = rows.iter().map(|r| r.rhs_const.abs()).fold(1.0, f64::max); - if viol > PRECISION * scale * 10.0 { - return if out_of_budget { Outcome::Limit } else { Outcome::Infeasible }; - } - Outcome::Solved(x, phi, (converged || viol <= PRECISION * scale) && !out_of_budget) -} - -// ---------------------------------------------------------------- evolutionary - -/// A small deterministic generator (SplitMix64), so a seed reproduces a run on every platform. -struct Rng(u64); - -impl Rng { - fn new(seed: u64) -> Rng { - Rng(seed ^ 0x2545_F491_4F6C_DD1D) - } - fn next(&mut self) -> u64 { - self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = self.0; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) - } - /// Uniform in [0, 1). - fn uniform(&mut self) -> f64 { - (self.next() >> 11) as f64 / (1u64 << 53) as f64 - } - fn between(&mut self, a: f64, b: f64) -> f64 { - a + (b - a) * self.uniform() - } - fn below(&mut self, n: usize) -> usize { - if n == 0 { 0 } else { usize::try_from(self.next() % n as u64).unwrap_or(0) } - } - /// Standard normal (Box–Muller). - fn normal(&mut self) -> f64 { - let u = self.uniform().max(f64::MIN_POSITIVE); - (-2.0 * u.ln()).sqrt() * (std::f64::consts::TAU * self.uniform()).cos() - } -} - -/// A candidate: its point, the objective to minimise and its constraint violation. -#[derive(Clone, Debug)] -struct Member { - x: Vec, - phi: f64, - viol: f64, -} - -impl Member { - /// Feasible beats infeasible; then the lower objective, or the smaller violation. - fn better(&self, o: &Member, tol: f64) -> bool { - match (self.viol <= tol, o.viol <= tol) { - (true, false) => true, - (false, true) => false, - (true, true) => self.phi < o.phi, - (false, false) => self.viol < o.viol, - } - } -} - -/// Why the Evolutionary search stopped. -#[derive(Clone, Copy, Debug, PartialEq)] -enum EvoEnd { - /// Almost all of the population is within Convergence of the best member. - Converged, - /// The best member didn't improve for the set number of generations. - Stalled, - /// The evaluation budget ran out. - Budget, -} - -/// The search box, integer flags and scoring shared by the genetic and pattern phases. -struct EvoSearch<'a> { - ev: &'a mut Evaluator, - rows: Vec, - senses: Vec, - goal: Goal, - lo: Vec, - hi: Vec, - integer: &'a [bool], - tol: f64, -} - -impl EvoSearch<'_> { - /// Clamps into the box and rounds integer variables. - fn repair(&self, x: &mut [f64]) { - for (j, v) in x.iter_mut().enumerate() { - let (l, h) = (self.lo.get(j).copied().unwrap_or(0.0), self.hi.get(j).copied().unwrap_or(0.0)); - *v = if v.is_finite() { v.clamp(l, h) } else { l }; - if self.integer.get(j).copied().unwrap_or(false) { - *v = v.round().clamp(l.ceil(), h.floor()); - } - } - } - - /// Scores `x`; `None` once the budget is spent. - fn score(&mut self, x: Vec) -> Option { - let p = self.ev.eval(&x)?; - let (phi, cons) = shape(&p, &self.rows, self.goal); - let viol = violation(&cons, &self.senses); - let phi = if phi.is_finite() { phi } else { f64::INFINITY }; - let viol = if viol.is_finite() && cons.iter().all(|c| c.is_finite()) { viol } else { f64::INFINITY }; - Some(Member { x, phi, viol }) - } -} - -/// The Evolutionary method within `lo..hi` from `x0`. -fn evolutionary(ev: &mut Evaluator, x0: &[f64], lo: &[f64], hi: &[f64], integer: &[bool], goal: Goal, o: &EvoOptions) -> (Outcome, EvoEnd) { - let rows = ev.rows.clone(); - let senses: Vec = rows.iter().map(|r| r.sense).collect(); - let scale = rows.iter().map(|r| r.rhs_const.abs()).fold(1.0, f64::max); - let mut start = x0.to_vec(); - project(&mut start, lo, hi); - // Without a bound, search within 100 of the start. - let blo: Vec = lo.iter().zip(&start).map(|(l, s)| if l.is_finite() { *l } else { s - 100.0 }).collect(); - let bhi: Vec = hi.iter().zip(&start).map(|(h, s)| if h.is_finite() { *h } else { s + 100.0 }).collect(); - let no_integer = blo.iter().zip(&bhi).zip(integer).any(|((l, h), int)| *int && l.ceil() > h.floor()); - if blo.iter().zip(&bhi).any(|(l, h)| l > h) || no_integer { - return (Outcome::Infeasible, EvoEnd::Converged); - } - let span: Vec = blo.iter().zip(&bhi).map(|(l, h)| (h - l).max(1e-9)).collect(); - let mut se = EvoSearch { ev, rows, senses, goal, lo: blo, hi: bhi, integer, tol: PRECISION * scale }; - let mut rng = Rng::new(o.seed); - let size = o.population.clamp(MIN_POPULATION, MAX_POPULATION); - let tol = se.tol; - let mut end = EvoEnd::Budget; - // The first generation: the start and random points in the box. - let mut pop: Vec = Vec::with_capacity(size); - se.repair(&mut start); - let mut next_x = Some(start); - while pop.len() < size { - let x = next_x.take().unwrap_or_else(|| { - let mut x: Vec = se.lo.iter().zip(&se.hi).map(|(l, h)| rng.between(*l, *h)).collect(); - se.repair(&mut x); - x - }); - match se.score(x) { - Some(m) => pop.push(m), - None => break, - } - } - let mut best: Option = None; - for m in &pop { - if best.as_ref().is_none_or(|b| m.better(b, tol)) { - best = Some(m.clone()); - } - } - let mut last_improved = 0u64; - let mut generation = 0u64; - 'search: while pop.len() == size { - let Some(b) = best.clone() else { break }; - // Converged: 99% of the population is feasible and within Convergence of the best. - let close = pop.iter().filter(|m| m.viol <= tol && (m.phi - b.phi).abs() <= o.convergence * b.phi.abs().max(1.0)).count(); - if b.viol <= tol && generation >= 5 && close * 100 >= size * 99 { - end = EvoEnd::Converged; - break; - } - if generation.saturating_sub(last_improved) >= o.stall { - end = EvoEnd::Stalled; - break; - } - // The next generation: the best survives, the rest are children of two tournaments. - let mut next = Vec::with_capacity(size); - next.push(b.clone()); - while next.len() < size { - let mut parent = || { - let (i, k) = (rng.below(size), rng.below(size)); - match (pop.get(i), pop.get(k)) { - (Some(p), Some(q)) => Some(if q.better(p, tol) { q.x.clone() } else { p.x.clone() }), - _ => None, - } - }; - let (Some(pa), Some(pb)) = (parent(), parent()) else { break 'search }; - let mut x: Vec = pa - .iter() - .zip(&pb) - .zip(&span) - .map(|((a, c), w)| { - let mut v = a + rng.between(-0.25, 1.25) * (c - a); - if rng.uniform() < o.mutation { - v += 0.1 * w * rng.normal(); - } - v - }) - .collect(); - se.repair(&mut x); - match se.score(x) { - Some(m) => next.push(m), - None => break 'search, - } - } - generation += 1; - for m in &next { - let Some(cur) = &best else { continue }; - if m.better(cur, tol) { - let improved = cur.viol > tol || cur.phi - m.phi > 1e-9 * cur.phi.abs().max(1.0); - best = Some(m.clone()); - if improved { - last_improved = generation; - } - } - } - pop = next; - } - // Polish: a compass search on the continuous variables around the best member. - if let Some(mut b) = best.clone() { - let mut step: Vec = span.iter().map(|w| 0.01 * w).collect(); - let n = b.x.len(); - 'polish: for _ in 0..10_000 { - if step.iter().zip(&span).zip(integer).all(|((s, w), int)| *int || *s <= 1e-9 * w.max(1.0)) { - break; - } - let mut moved = false; - for j in 0..n { - if integer.get(j).copied().unwrap_or(false) { - continue; - } - for dir in [1.0, -1.0] { - let mut x = b.x.clone(); - if let (Some(v), Some(s)) = (x.get_mut(j), step.get(j)) { - *v += dir * s; - } - se.repair(&mut x); - if x == b.x { - continue; - } - let Some(m) = se.score(x) else { break 'polish }; - if m.better(&b, tol) { - b = m; - moved = true; - break; - } - } - } - if !moved { - for s in &mut step { - *s *= 0.5; - } - } - } - best = Some(b); - } - match best { - Some(b) if b.viol <= tol => (Outcome::Solved(b.x, b.phi, end != EvoEnd::Budget), end), - _ if end == EvoEnd::Budget => (Outcome::Limit, end), - _ => (Outcome::Infeasible, end), - } -} - /// What a solve ended with, and why Simplex LP or Evolutionary stopped. struct Solve { outcome: Outcome, @@ -1615,375 +151,6 @@ fn optimise(ev: &mut Evaluator, m: &Model, goal: Goal, x0: &[f64], lo: &[f64], h Solve { outcome, not_linear, evo_end } } -// ---------------------------------------------------------------- reports - -#[derive(Clone, Copy, Debug, PartialEq)] -enum ReportKind { - Answer, - Limits, -} - -impl ReportKind { - fn title(self) -> &'static str { - match self { - ReportKind::Answer => "Answer", - ReportKind::Limits => "Limits", - } - } -} - -/// The `reports` list (`None` when absent). -fn report_kinds(cmd: &'static str, p: &Json) -> Result>> { - let Some(list) = p.get("reports") else { return Ok(None) }; - let list = list.as_array().ok_or_else(|| bad(cmd, "`reports` must be an array of answer and limits"))?; - let mut kinds = Vec::new(); - for k in list { - let kind = match k.as_str().map(|t| t.trim().to_ascii_lowercase()).as_deref() { - Some("answer") => ReportKind::Answer, - Some("limits") => ReportKind::Limits, - _ => return Err(bad(cmd, format!("unknown report {k} (answer or limits)"))), - }; - if !kinds.contains(&kind) { - kinds.push(kind); - } - } - Ok(Some(kinds)) -} - -/// A report's cells: (row, column, value, bold). -type Lines = Vec<(u32, u32, Value, bool)>; - -fn abs_ref(c: CellRef) -> String { - format!("${}${}", gridcraft_core::col_to_letters(c.col), u64::from(c.row) + 1) -} - -/// A cell's name in the reports: the nearest text to its left and the nearest text above it. -fn cell_name(sh: &Sheet, c: CellRef) -> String { - let text = |c: CellRef| match sh.value(c) { - Value::Text(t) if !t.trim().is_empty() => Some(t.to_string()), - _ => None, - }; - let left = (0..c.col).rev().find_map(|col| text(CellRef::new(c.row, col))); - let top = (0..c.row).rev().find_map(|row| text(CellRef::new(row, c.col))); - [left, top].into_iter().flatten().collect::>().join(" ") -} - -fn method_name(m: Method) -> &'static str { - match m { - Method::Simplex => "Simplex LP", - Method::Grg => "GRG Nonlinear", - Method::Evolutionary => "Evolutionary", - } -} - -/// Header labels in bold from column 1, then the rows below them; returns the next free row. -fn table(out: &mut Lines, row: u32, headers: &[&str], rows: Vec>) -> u32 { - for (k, h) in headers.iter().enumerate() { - out.push((row, 1 + k as u32, Value::text(*h), true)); - } - let mut r = row; - for cells in rows { - r = r.saturating_add(1); - for (k, v) in cells.into_iter().enumerate() { - out.push((r, 1 + k as u32, v, false)); - } - } - r.saturating_add(2) -} - -/// Everything a report describes: the model, its starting and final values, and the result. -struct ReportInput<'a> { - model: &'a Model, - prob: &'a Problem, - original: Vec, - solution: Vec, - message: String, - budget: u64, -} - -fn title(out: &mut Lines, kind: ReportKind, sheet: &str) -> u32 { - out.push((0, 0, Value::text(format!("GridCraft {} Report", kind.title())), true)); - out.push((1, 0, Value::text(format!("Worksheet: {sheet}")), false)); - 3 -} - -fn format_constraint_row(sh: &Sheet, c: &Constraint, cell: CellRef, v: f64, rhs: &[f64], k: usize) -> Vec { - let (formula, status, slack) = match (c.rel, &c.rhs) { - (Rel::Int, _) => (format!("{}=Integer", abs_ref(cell)), "Binding", 0.0), - (Rel::Bin, _) => (format!("{}=Binary", abs_ref(cell)), "Binding", 0.0), - (rel, rhs_cells) => { - let b = rhs.get(k).or_else(|| rhs.first()).copied().unwrap_or(f64::NAN); - let rhs_text = match rhs_cells { - Rhs::Cells(cells) => cells.get(k).or_else(|| cells.first()).map(|c| abs_ref(*c)).unwrap_or_default(), - _ => gridcraft_core::number_to_text(b), - }; - let slack = (v - b).abs(); - let binding = rel == Rel::Eq || slack <= PRECISION * (1.0 + b.abs()); - let slack = if binding { 0.0 } else { slack }; - (format!("{}{}{rhs_text}", abs_ref(cell), rel.text()), if binding { "Binding" } else { "Not Binding" }, slack) - } - }; - let num = |v: f64| if v.is_finite() { Value::number(v) } else { Value::Error(gridcraft_core::CellError::Value) }; - vec![Value::text(abs_ref(cell)), Value::text(cell_name(sh, cell)), num(v), Value::text(formula), Value::text(status), num(slack)] -} - -fn answer_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Lines { - let m = inp.model; - let mut trial = Trial::new(d); - let read = |trial: &mut Trial, x: &[f64], cells: &[CellRef]| -> Vec { - let sets: Vec<(usize, CellRef, Value)> = m.vars.iter().zip(x).map(|(c, v)| (m.sheet, *c, Value::number(*v))).collect(); - let reads: Vec<(usize, CellRef)> = cells.iter().map(|c| (m.sheet, *c)).collect(); - trial.run(&sets, &reads).iter().map(as_num).collect() - }; - let f0 = read(&mut trial, &inp.original, &[m.objective]).first().copied().unwrap_or(f64::NAN); - // Every cell the constraints read, at the solution. - let mut cells = vec![m.objective]; - for c in &m.cons { - cells.extend(&c.lhs); - if let Rhs::Cells(r) = &c.rhs { - cells.extend(r); - } - } - let vals = read(&mut trial, &inp.solution, &cells); - let mut at = vals.iter().copied(); - let f1 = at.next().unwrap_or(f64::NAN); - let num = |v: f64| if v.is_finite() { Value::number(v) } else { Value::Error(gridcraft_core::CellError::Value) }; - let mut out = Lines::new(); - let mut r = title(&mut out, ReportKind::Answer, &sh.name); - out.push((r, 0, Value::text(format!("Result: {}", inp.message)), false)); - out.push((r + 1, 0, Value::text(format!("Engine: {}", method_name(m.method))), false)); - r += 3; - let goal = match m.goal { - Goal::Max => "Max".to_string(), - Goal::Min => "Min".to_string(), - Goal::Value(v) => format!("Value Of {v}"), - }; - out.push((r, 0, Value::text(format!("Objective Cell ({goal})")), true)); - let obj = vec![vec![Value::text(abs_ref(m.objective)), Value::text(cell_name(sh, m.objective)), num(f0), num(f1)]]; - r = table(&mut out, r + 1, &["Cell", "Name", "Original Value", "Final Value"], obj); - out.push((r, 0, Value::text("Variable Cells"), true)); - let kind = |j: usize| { - let bin = m.cons.iter().any(|c| c.rel == Rel::Bin && m.vars.get(j).is_some_and(|v| c.lhs.contains(v))); - match (bin, inp.prob.integer.get(j).copied().unwrap_or(false)) { - (true, _) => "Binary", - (false, true) => "Integer", - _ => "Contin", - } - }; - let vars = m - .vars - .iter() - .enumerate() - .map(|(j, c)| { - let (a, b) = (inp.original.get(j).copied().unwrap_or(f64::NAN), inp.solution.get(j).copied().unwrap_or(f64::NAN)); - vec![Value::text(abs_ref(*c)), Value::text(cell_name(sh, *c)), num(a), num(b), Value::text(kind(j))] - }) - .collect(); - r = table(&mut out, r + 1, &["Cell", "Name", "Original Value", "Final Value", "Integer"], vars); - out.push((r, 0, Value::text("Constraints"), true)); - let mut rows = Vec::new(); - for c in &m.cons { - let lhs: Vec = c.lhs.iter().map(|_| at.next().unwrap_or(f64::NAN)).collect(); - let rhs: Vec = match &c.rhs { - Rhs::Cells(cells) => cells.iter().map(|_| at.next().unwrap_or(f64::NAN)).collect(), - Rhs::Const(v) => vec![*v], - Rhs::None => vec![], - }; - for (k, (cell, v)) in c.lhs.iter().zip(&lhs).enumerate() { - rows.push(format_constraint_row(sh, c, *cell, *v, &rhs, k)); - } - } - table(&mut out, r + 1, &["Cell", "Name", "Cell Value", "Formula", "Status", "Slack"], rows); - out -} - -fn limits_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Result { - let m = inp.model; - if inp.prob.integer.iter().any(|b| *b) { - return Err(EngineError::Other("The Limits report doesn't apply to models with integer or binary constraints.".into())); - } - if m.method == Method::Evolutionary { - return Err(EngineError::Other("The Limits report is available for Simplex LP and GRG Nonlinear.".into())); - } - let n = m.vars.len(); - let x = &inp.solution; - // Each variable is minimised and maximised with the others held at the solution. - let per = (inp.budget / (2 * n as u64).max(1)).max(n as u64 + 3); - let mut ev = Evaluator { - trial: Trial::new(d), - sheet: m.sheet, - vars: m.vars.clone(), - objective: m.objective, - rows: inp.prob.rows.clone(), - evals: 0, - budget: 0, - target: None, - }; - let objective_at = |ev: &mut Evaluator, x: &[f64]| { - ev.target = None; - ev.budget = ev.evals.saturating_add(1); - ev.eval(x).map(|p| p.f).unwrap_or(f64::NAN) - }; - let num = |v: f64| if v.is_finite() { Value::number(v) } else { Value::Error(gridcraft_core::CellError::Value) }; - let f = objective_at(&mut ev, x); - let mut rows = Vec::new(); - for (j, c) in m.vars.iter().enumerate() { - let (mut lo, mut hi) = (x.clone(), x.clone()); - if let (Some(l), Some(h), Some(pl), Some(ph)) = (lo.get_mut(j), hi.get_mut(j), inp.prob.lo.get(j), inp.prob.hi.get(j)) { - *l = *pl; - *h = *ph; - } - let mut ends = Vec::new(); - for goal in [Goal::Min, Goal::Max] { - ev.target = Some(j); - ev.evals = 0; - ev.budget = per; - 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); - (num(v), num(objective_at(&mut ev, &p))) - } - Outcome::Unbounded => (Value::text("Unbounded"), Value::Empty), - _ => (Value::Error(gridcraft_core::CellError::NA), Value::Empty), - }); - } - let mut row = vec![Value::text(abs_ref(*c)), Value::text(cell_name(sh, *c)), num(x.get(j).copied().unwrap_or(f64::NAN))]; - for (v, o) in ends { - row.push(v); - row.push(o); - } - rows.push(row); - } - let mut out = Lines::new(); - let r = title(&mut out, ReportKind::Limits, &sh.name); - let obj = vec![vec![Value::text(abs_ref(m.objective)), Value::text(cell_name(sh, m.objective)), num(f)]]; - let r = table(&mut out, r, &["Objective Cell", "Name", "Value"], obj); - table(&mut out, r, &["Variable Cell", "Name", "Value", "Lower Limit", "Objective Result", "Upper Limit", "Objective Result"], rows); - Ok(out) -} - -/// Builds every report before the solution or saved model can change. -fn prepare_reports(d: &DocState, kinds: &[ReportKind], inp: &ReportInput) -> Result> { - if kinds.is_empty() { - return Ok(Vec::new()); - } - if d.wb.protected_structure { - return Err(EngineError::Other("The workbook is protected and cannot be changed.".into())); - } - let sh = d.wb.sheet(inp.model.sheet).ok_or(EngineError::NoDocument)?; - let mut built = Vec::new(); - for k in kinds { - let lines = match k { - ReportKind::Answer => answer_report(d, sh, inp), - ReportKind::Limits => limits_report(d, sh, inp)?, - }; - let name = (1..=10_000u32) - .map(|i| format!("{} Report {i}", k.title())) - .find(|n| d.wb.sheet_index(n).is_none() && !built.iter().any(|(b, _): &(String, Lines)| b == n)) - .ok_or_else(|| EngineError::Other("Too many report sheets.".into()))?; - built.push((name, lines)); - } - Ok(built) -} - -/// Inserts prepared reports before the model's sheet, which stays active. -fn write_reports(cx: &mut Ctx, at: usize, built: Vec<(String, Lines)>) -> Vec { - let names: Vec = built.iter().map(|(n, _)| n.clone()).collect(); - let count = built.len(); - if count == 0 { - return names; - } - let bold = cx.wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.font.bold = true); - for (i, (name, lines)) in built.into_iter().enumerate() { - let mut sheet = Sheet::new(name); - for (r, c, v, b) in lines { - let style = if b { bold } else { gridcraft_model::StyleId::DEFAULT }; - sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style }); - } - for (c, w) in [(0u32, 14.0f32), (1, 110.0), (2, 140.0), (3, 120.0), (4, 120.0), (5, 120.0), (6, 120.0), (7, 120.0)] { - sheet.cols.insert(c, gridcraft_model::LineInfo { size: Some(w), ..Default::default() }); - } - cx.wb.sheets.insert(at + i, std::sync::Arc::new(sheet)); - } - for nm in cx.wb.names.iter_mut() { - if let Some(sc) = nm.scope.as_mut() - && *sc >= at - { - *sc += count; - } - } - if cx.wb.active_sheet() >= at { - cx.wb.set_active_sheet(cx.wb.active_sheet().saturating_add(count)); - } - cx.structural = true; - names -} - -fn insert_report_selections(s: &mut Session, at: usize, count: usize) { - if let Ok(d) = s.doc_mut() { - for i in 0..count { - d.sheet_selections.insert((at + i).min(d.sheet_selections.len()), crate::Selection::default()); - } - } -} - -/// `data.solverReport`: reports for a solution of the sheet's saved model. -fn report(s: &mut Session, p: &Json) -> Result { - let kinds = report_kinds(REPORT_CMD, p)?.ok_or_else(|| bad(REPORT_CMD, "missing `reports` (answer, limits)"))?; - if kinds.is_empty() { - return Err(bad(REPORT_CMD, "`reports` is empty (answer, limits)")); - } - let d = s.doc()?; - // The given model as is, else the saved model with any params given here over it. - let params = match p.get("model") { - Some(m @ Json::Object(_)) => m.clone(), - Some(Json::Null) | None => { - let mut params = saved_model(&d.wb, d.wb.active_sheet()).unwrap_or_else(|| json!({})); - if let (Some(base), Some(over)) = (params.as_object_mut(), p.as_object()) { - for (k, v) in over { - base.insert(k.clone(), v.clone()); - } - } - params - } - Some(_) => return Err(bad(REPORT_CMD, "`model` must be data.solver params")), - }; - let model = parse_model(s, ¶ms)?; - let prob = build_problem(&model); - let d = s.doc()?; - let sh = d.wb.sheet(model.sheet).ok_or(EngineError::NoDocument)?; - let current: Vec = model.vars.iter().map(|c| as_num(&sh.value(*c))).collect(); - // `{cell: number}` maps; cells they leave out keep their current values. - let point = |key: &str| -> Result> { - let mut x = current.clone(); - let Some(map) = p.get(key) else { return Ok(x) }; - let map = map.as_object().ok_or_else(|| bad(REPORT_CMD, format!("`{key}` must map cells to numbers")))?; - for (cell, v) in map { - let c = CellRef::parse(&cell.replace('$', "")).ok_or_else(|| bad(REPORT_CMD, format!("not a cell: `{cell}`")))?; - let j = model.vars.iter().position(|v| *v == c).ok_or_else(|| bad(REPORT_CMD, format!("`{cell}` is not a variable cell")))?; - let v = v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(REPORT_CMD, format!("`{key}.{cell}` must be a number")))?; - if let Some(e) = x.get_mut(j) { - *e = v; - } - } - Ok(x) - }; - let solution = point("values")?; - let original = if p.get("original").is_some() { point("original")? } else { solution.clone() }; - let budget = params - .get("maxEvaluations") - .and_then(Json::as_f64) - .filter(|v| v.is_finite() && *v >= 1.0) - .map_or(DEFAULT_EVALS, |v| (v as u64).min(MAX_EVALS)); - let message = str_param(p, "message").unwrap_or("").chars().take(500).collect(); - let inp = ReportInput { model: &model, prob: &prob, original, solution, message, budget }; - let built = prepare_reports(s.doc()?, &kinds, &inp)?; - let sheets = edit(s, |cx| Ok(write_reports(cx, model.sheet, built)))?; - insert_report_selections(s, model.sheet, sheets.len()); - Ok(json!({"sheets": sheets})) -} - // ---------------------------------------------------------------- command fn run(s: &mut Session, p: &Json) -> Result { diff --git a/crates/engine/src/cmd/solver/evolutionary.rs b/crates/engine/src/cmd/solver/evolutionary.rs new file mode 100644 index 000000000..391d4e8ed --- /dev/null +++ b/crates/engine/src/cmd/solver/evolutionary.rs @@ -0,0 +1,255 @@ +use super::lp::{Outcome, Row, Sense}; +use super::model::{EvoOptions, Goal}; +use super::nlp::{project, shape, violation}; +use super::problem::Evaluator; +use super::*; + +// ---------------------------------------------------------------- evolutionary + +/// A small deterministic generator (SplitMix64), so a seed reproduces a run on every platform. +struct Rng(u64); + +impl Rng { + fn new(seed: u64) -> Rng { + Rng(seed ^ 0x2545_F491_4F6C_DD1D) + } + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + /// Uniform in [0, 1). + fn uniform(&mut self) -> f64 { + (self.next() >> 11) as f64 / (1u64 << 53) as f64 + } + fn between(&mut self, a: f64, b: f64) -> f64 { + a + (b - a) * self.uniform() + } + fn below(&mut self, n: usize) -> usize { + if n == 0 { 0 } else { usize::try_from(self.next() % n as u64).unwrap_or(0) } + } + /// Standard normal (Box–Muller). + fn normal(&mut self) -> f64 { + let u = self.uniform().max(f64::MIN_POSITIVE); + (-2.0 * u.ln()).sqrt() * (std::f64::consts::TAU * self.uniform()).cos() + } +} + +/// A candidate: its point, the objective to minimise and its constraint violation. +#[derive(Clone, Debug)] +struct Member { + x: Vec, + phi: f64, + viol: f64, +} + +impl Member { + /// Feasible beats infeasible; then the lower objective, or the smaller violation. + fn better(&self, o: &Member, tol: f64) -> bool { + match (self.viol <= tol, o.viol <= tol) { + (true, false) => true, + (false, true) => false, + (true, true) => self.phi < o.phi, + (false, false) => self.viol < o.viol, + } + } +} + +/// Why the Evolutionary search stopped. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) enum EvoEnd { + /// Almost all of the population is within Convergence of the best member. + Converged, + /// The best member didn't improve for the set number of generations. + Stalled, + /// The evaluation budget ran out. + Budget, +} + +/// The search box, integer flags and scoring shared by the genetic and pattern phases. +struct EvoSearch<'a> { + ev: &'a mut Evaluator, + rows: Vec, + senses: Vec, + goal: Goal, + lo: Vec, + hi: Vec, + integer: &'a [bool], + tol: f64, +} + +impl EvoSearch<'_> { + /// Clamps into the box and rounds integer variables. + fn repair(&self, x: &mut [f64]) { + for (j, v) in x.iter_mut().enumerate() { + let (l, h) = (self.lo.get(j).copied().unwrap_or(0.0), self.hi.get(j).copied().unwrap_or(0.0)); + *v = if v.is_finite() { v.clamp(l, h) } else { l }; + if self.integer.get(j).copied().unwrap_or(false) { + *v = v.round().clamp(l.ceil(), h.floor()); + } + } + } + + /// Scores `x`; `None` once the budget is spent. + fn score(&mut self, x: Vec) -> Option { + let p = self.ev.eval(&x)?; + let (phi, cons) = shape(&p, &self.rows, self.goal); + let viol = violation(&cons, &self.senses); + let phi = if phi.is_finite() { phi } else { f64::INFINITY }; + let viol = if viol.is_finite() && cons.iter().all(|c| c.is_finite()) { viol } else { f64::INFINITY }; + Some(Member { x, phi, viol }) + } +} + +/// The Evolutionary method within `lo..hi` from `x0`. +pub(super) fn evolutionary( + ev: &mut Evaluator, + x0: &[f64], + lo: &[f64], + hi: &[f64], + integer: &[bool], + goal: Goal, + o: &EvoOptions, +) -> (Outcome, EvoEnd) { + let rows = ev.rows.clone(); + let senses: Vec = rows.iter().map(|r| r.sense).collect(); + let scale = rows.iter().map(|r| r.rhs_const.abs()).fold(1.0, f64::max); + let mut start = x0.to_vec(); + project(&mut start, lo, hi); + // Without a bound, search within 100 of the start. + let blo: Vec = lo.iter().zip(&start).map(|(l, s)| if l.is_finite() { *l } else { s - 100.0 }).collect(); + let bhi: Vec = hi.iter().zip(&start).map(|(h, s)| if h.is_finite() { *h } else { s + 100.0 }).collect(); + let no_integer = blo.iter().zip(&bhi).zip(integer).any(|((l, h), int)| *int && l.ceil() > h.floor()); + if blo.iter().zip(&bhi).any(|(l, h)| l > h) || no_integer { + return (Outcome::Infeasible, EvoEnd::Converged); + } + let span: Vec = blo.iter().zip(&bhi).map(|(l, h)| (h - l).max(1e-9)).collect(); + let mut se = EvoSearch { ev, rows, senses, goal, lo: blo, hi: bhi, integer, tol: PRECISION * scale }; + let mut rng = Rng::new(o.seed); + let size = o.population.clamp(MIN_POPULATION, MAX_POPULATION); + let tol = se.tol; + let mut end = EvoEnd::Budget; + // The first generation: the start and random points in the box. + let mut pop: Vec = Vec::with_capacity(size); + se.repair(&mut start); + let mut next_x = Some(start); + while pop.len() < size { + let x = next_x.take().unwrap_or_else(|| { + let mut x: Vec = se.lo.iter().zip(&se.hi).map(|(l, h)| rng.between(*l, *h)).collect(); + se.repair(&mut x); + x + }); + match se.score(x) { + Some(m) => pop.push(m), + None => break, + } + } + let mut best: Option = None; + for m in &pop { + if best.as_ref().is_none_or(|b| m.better(b, tol)) { + best = Some(m.clone()); + } + } + let mut last_improved = 0u64; + let mut generation = 0u64; + 'search: while pop.len() == size { + let Some(b) = best.clone() else { break }; + // Converged: 99% of the population is feasible and within Convergence of the best. + let close = pop.iter().filter(|m| m.viol <= tol && (m.phi - b.phi).abs() <= o.convergence * b.phi.abs().max(1.0)).count(); + if b.viol <= tol && generation >= 5 && close * 100 >= size * 99 { + end = EvoEnd::Converged; + break; + } + if generation.saturating_sub(last_improved) >= o.stall { + end = EvoEnd::Stalled; + break; + } + // The next generation: the best survives, the rest are children of two tournaments. + let mut next = Vec::with_capacity(size); + next.push(b.clone()); + while next.len() < size { + let mut parent = || { + let (i, k) = (rng.below(size), rng.below(size)); + match (pop.get(i), pop.get(k)) { + (Some(p), Some(q)) => Some(if q.better(p, tol) { q.x.clone() } else { p.x.clone() }), + _ => None, + } + }; + let (Some(pa), Some(pb)) = (parent(), parent()) else { break 'search }; + let mut x: Vec = pa + .iter() + .zip(&pb) + .zip(&span) + .map(|((a, c), w)| { + let mut v = a + rng.between(-0.25, 1.25) * (c - a); + if rng.uniform() < o.mutation { + v += 0.1 * w * rng.normal(); + } + v + }) + .collect(); + se.repair(&mut x); + match se.score(x) { + Some(m) => next.push(m), + None => break 'search, + } + } + generation += 1; + for m in &next { + let Some(cur) = &best else { continue }; + if m.better(cur, tol) { + let improved = cur.viol > tol || cur.phi - m.phi > 1e-9 * cur.phi.abs().max(1.0); + best = Some(m.clone()); + if improved { + last_improved = generation; + } + } + } + pop = next; + } + // Polish: a compass search on the continuous variables around the best member. + if let Some(mut b) = best.clone() { + let mut step: Vec = span.iter().map(|w| 0.01 * w).collect(); + let n = b.x.len(); + 'polish: for _ in 0..10_000 { + if step.iter().zip(&span).zip(integer).all(|((s, w), int)| *int || *s <= 1e-9 * w.max(1.0)) { + break; + } + let mut moved = false; + for j in 0..n { + if integer.get(j).copied().unwrap_or(false) { + continue; + } + for dir in [1.0, -1.0] { + let mut x = b.x.clone(); + if let (Some(v), Some(s)) = (x.get_mut(j), step.get(j)) { + *v += dir * s; + } + se.repair(&mut x); + if x == b.x { + continue; + } + let Some(m) = se.score(x) else { break 'polish }; + if m.better(&b, tol) { + b = m; + moved = true; + break; + } + } + } + if !moved { + for s in &mut step { + *s *= 0.5; + } + } + } + best = Some(b); + } + match best { + Some(b) if b.viol <= tol => (Outcome::Solved(b.x, b.phi, end != EvoEnd::Budget), end), + _ if end == EvoEnd::Budget => (Outcome::Limit, end), + _ => (Outcome::Infeasible, end), + } +} diff --git a/crates/engine/src/cmd/solver/lp.rs b/crates/engine/src/cmd/solver/lp.rs new file mode 100644 index 000000000..29dad10db --- /dev/null +++ b/crates/engine/src/cmd/solver/lp.rs @@ -0,0 +1,447 @@ +use super::problem::Evaluator; +use super::*; + +// ---------------------------------------------------------------- evaluation + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) enum Sense { + Le, + Ge, + Eq, +} + +/// A scalar constraint `lhs (sense) rhs`. +#[derive(Clone, Debug)] +pub(super) struct Row { + pub(super) lhs: CellRef, + pub(super) sense: Sense, + pub(super) rhs: Option, + pub(super) rhs_const: f64, +} + +/// What a relaxation (or the whole solve) ended with. +#[derive(Clone, Debug)] +pub(super) enum Outcome { + /// x, objective to minimise, and whether the solver converged (vs. ran out of budget). + Solved(Vec, f64, bool), + Infeasible, + Unbounded, + Limit, +} + +/// Depth-first branch and bound over a relaxation solver; returns the best integer point. +pub(super) fn branch_and_bound( + lo: &[f64], + hi: &[f64], + integer: &[bool], + max_nodes: usize, + gap: f64, + relax: &mut dyn FnMut(&[f64], &[f64]) -> Outcome, +) -> (Outcome, usize) { + let mut stack: Vec<(Vec, Vec)> = vec![(lo.to_vec(), hi.to_vec())]; + let mut best: Option<(Vec, f64)> = None; + let mut nodes = 0usize; + let mut limited = false; + let mut root = true; + while let Some((l, h)) = stack.pop() { + if nodes >= max_nodes { + limited = true; + break; + } + nodes += 1; + let out = relax(&l, &h); + let was_root = std::mem::replace(&mut root, false); + let (x, obj, converged) = match out { + Outcome::Solved(x, obj, c) => (x, obj, c), + Outcome::Unbounded if was_root => return (Outcome::Unbounded, nodes), + Outcome::Limit => { + limited = true; + break; + } + _ => continue, + }; + if !converged { + limited = true; + } + if let Some((_, b)) = &best + && obj >= b - gap * b.abs().max(1e-12) - 1e-9 * (1.0 + b.abs()) + { + continue; + } + // The most fractional integer variable. + let frac = x + .iter() + .zip(integer) + .enumerate() + .filter(|(_, (_, int))| **int) + .map(|(j, (v, _))| (j, (v - v.round()).abs())) + .filter(|(_, f)| *f > 1e-6) + .max_by(|a, b| a.1.total_cmp(&b.1)); + match frac { + None => { + let snapped: Vec = x.iter().zip(integer).map(|(v, int)| if *int { v.round() } else { *v }).collect(); + best = Some((snapped, obj)); + } + Some((j, _)) => { + let v = x.get(j).copied().unwrap_or(0.0); + let (mut down_h, mut up_l) = (h.clone(), l.clone()); + if let Some(e) = down_h.get_mut(j) { + *e = e.min(v.floor()); + } + if let Some(e) = up_l.get_mut(j) { + *e = e.max(v.ceil()); + } + // Explore the nearer side first (pushed last). + if v - v.floor() < 0.5 { + stack.push((up_l, h)); + stack.push((l, down_h)); + } else { + stack.push((l, down_h)); + stack.push((up_l, h)); + } + } + } + } + match best { + Some((x, obj)) => (Outcome::Solved(x, obj, !limited), nodes), + None if limited => (Outcome::Limit, nodes), + None => (Outcome::Infeasible, nodes), + } +} + +// ---------------------------------------------------------------- simplex + +/// Pivots the tableau on (r, j), updating the objective row. +fn pivot(t: &mut [Vec], obj: &mut [f64], r: usize, j: usize) { + let Some(prow) = t.get_mut(r) else { return }; + let pv = prow.get(j).copied().unwrap_or(0.0); + if pv == 0.0 { + return; + } + for v in prow.iter_mut() { + *v /= pv; + } + let prow = prow.clone(); + for (i, row) in t.iter_mut().enumerate() { + if i == r { + continue; + } + let f = row.get(j).copied().unwrap_or(0.0); + if f != 0.0 { + for (v, p) in row.iter_mut().zip(&prow) { + *v -= f * p; + } + } + } + let f = obj.get(j).copied().unwrap_or(0.0); + if f != 0.0 { + for (v, p) in obj.iter_mut().zip(&prow) { + *v -= f * p; + } + } +} + +enum Step { + Optimal, + Unbounded, + Limit, +} + +/// Minimises with the reduced costs in `obj` (last entry: −objective). Columns not `allowed` +/// never enter. +fn simplex(t: &mut [Vec], basis: &mut [usize], obj: &mut [f64], allowed: &[bool], budget: &mut u64) -> Step { + let n = obj.len().saturating_sub(1); + let cost = (t.len() as u64 + 1).saturating_mul(n as u64 + 1).max(1); + let mut stalled = 0u32; + loop { + if *budget < cost { + return Step::Limit; + } + *budget -= cost; + let bland = stalled > 50; + let mut enter = None; + let mut best = -EPS; + for (j, d) in obj.iter().take(n).enumerate() { + if !allowed.get(j).copied().unwrap_or(false) || *d >= -EPS { + continue; + } + if bland { + enter = Some(j); + break; + } + if *d < best { + best = *d; + enter = Some(j); + } + } + let Some(j) = enter else { return Step::Optimal }; + let mut leave: Option<(usize, f64)> = None; + for (i, row) in t.iter().enumerate() { + let a = row.get(j).copied().unwrap_or(0.0); + if a <= EPS { + continue; + } + let ratio = row.get(n).copied().unwrap_or(0.0) / a; + let better = match leave { + None => true, + Some((li, lr)) => ratio < lr - 1e-12 || (ratio <= lr + 1e-12 && basis.get(i) < basis.get(li)), + }; + if better { + leave = Some((i, ratio)); + } + } + let Some((r, ratio)) = leave else { return Step::Unbounded }; + stalled = if ratio <= EPS { stalled + 1 } else { 0 }; + pivot(t, obj, r, j); + if let Some(b) = basis.get_mut(r) { + *b = j; + } + } +} + +/// Minimises `c·x` subject to `rows` (coefficients, sense, rhs) and `lo ≤ x ≤ hi`. +pub(super) fn solve_lp(c: &[f64], rows: &[(Vec, 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. + struct Map { + var: usize, + sign: f64, + } + let mut cols: Vec = 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 mut yrows: Vec<(Vec, 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, 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::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 = 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; + } + } + } + } + // 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() { + if let Some(v) = cost.get_mut(k) { + *v = c.get(mp.var).copied().unwrap_or(0.0) * mp.sign; + } + } + let mut obj = cost.clone(); + for (i, row) in t.iter().enumerate() { + let cb = basis.get(i).and_then(|b| cost.get(*b)).copied().unwrap_or(0.0); + if cb != 0.0 { + for (v, x) in obj.iter_mut().zip(row) { + *v -= cb * x; + } + } + } + let allowed: Vec = (0..width).map(|j| !is_art(j)).collect(); + match simplex(&mut t, &mut basis, &mut obj, &allowed, budget) { + Step::Limit => return Outcome::Limit, + Step::Unbounded => return Outcome::Unbounded, + Step::Optimal => {} + } + let mut y = vec![0.0; width]; + for (i, row) in t.iter().enumerate() { + if let (Some(b), Some(v)) = (basis.get(i), row.get(width)) + && let Some(e) = y.get_mut(*b) + { + *e = *v; + } + } + let mut x = offset; + for (k, mp) in cols.iter().enumerate() { + if let Some(e) = x.get_mut(mp.var) { + *e += mp.sign * y.get(k).copied().unwrap_or(0.0); + } + } + let val: f64 = c.iter().zip(&x).map(|(a, b)| a * b).sum(); + Outcome::Solved(x, val, true) +} + +/// 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`. +#[allow(clippy::type_complexity)] +pub(super) fn linearize(ev: &mut Evaluator, x0: &[f64]) -> std::result::Result<((Vec, f64), Vec<(Vec, 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()) { + return Err("error"); + } + let mut cf = vec![0.0; n]; + let mut ga = vec![vec![0.0; n]; p0.g.len()]; + for j in 0..n { + let mut x = x0.to_vec(); + if let Some(v) = x.get_mut(j) { + *v += 1.0; + } + let p = ev.eval(&x).ok_or("budget")?; + if let Some(v) = cf.get_mut(j) { + *v = p.f - p0.f; + } + for (row, (gi, g0)) in ga.iter_mut().zip(p.g.iter().zip(&p0.g)) { + if let Some(v) = row.get_mut(j) { + *v = gi - g0; + } + } + } + let dot = |a: &[f64], x: &[f64]| a.iter().zip(x).map(|(p, q)| p * q).sum::(); + let f_const = p0.f - dot(&cf, x0); + let rows: Vec<(Vec, f64)> = ga.into_iter().zip(&p0.g).map(|(a, g0)| (a.clone(), g0 - dot(&a, x0))).collect(); + // Linearity check at a spread-out point. + let xt: Vec = + x0.iter().enumerate().map(|(j, v)| v + if j % 2 == 0 { 1.7 + 0.3 * (j % 5) as f64 } else { -0.6 - 0.2 * (j % 3) as f64 }).collect(); + let pt = ev.eval(&xt).ok_or("budget")?; + let close = |actual: f64, pred: f64, terms: f64| actual.is_finite() && (actual - pred).abs() <= 1e-6 * (1.0 + actual.abs() + terms); + let terms = |a: &[f64]| a.iter().zip(&xt).map(|(p, q)| (p * q).abs()).sum::(); + if !close(pt.f, f_const + dot(&cf, &xt), terms(&cf)) { + return Err("nonlinear"); + } + for ((a, k), g) in rows.iter().zip(&pt.g) { + if !close(*g, k + dot(a, &xt), terms(a)) { + return Err("nonlinear"); + } + } + Ok(((cf, f_const), rows)) +} diff --git a/crates/engine/src/cmd/solver/model.rs b/crates/engine/src/cmd/solver/model.rs new file mode 100644 index 000000000..b97eccd78 --- /dev/null +++ b/crates/engine/src/cmd/solver/model.rs @@ -0,0 +1,459 @@ +use super::*; + +// ---------------------------------------------------------------- model + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) enum Rel { + Le, + Eq, + Ge, + Int, + Bin, +} + +impl Rel { + fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "<=" | "≤" | "=<" | "le" => Some(Rel::Le), + ">=" | "≥" | "=>" | "ge" => Some(Rel::Ge), + "=" | "==" | "eq" => Some(Rel::Eq), + "int" | "integer" => Some(Rel::Int), + "bin" | "binary" => Some(Rel::Bin), + _ => None, + } + } + pub(super) fn text(self) -> &'static str { + match self { + Rel::Le => "<=", + Rel::Eq => "=", + Rel::Ge => ">=", + Rel::Int => "int", + Rel::Bin => "bin", + } + } + /// The relation code of the saved model (`solver_relN`), as in Solver's VBA `SolverAdd`. + fn code(self) -> u8 { + match self { + Rel::Le => 1, + Rel::Eq => 2, + Rel::Ge => 3, + Rel::Int => 4, + Rel::Bin => 5, + } + } + fn from_code(c: &str) -> Option { + match c.trim() { + "1" => Some(Rel::Le), + "2" => Some(Rel::Eq), + "3" => Some(Rel::Ge), + "4" => Some(Rel::Int), + "5" => Some(Rel::Bin), + _ => None, + } + } +} + +#[derive(Clone, Debug)] +pub(super) enum Rhs { + None, + Const(f64), + Cells(Vec), +} + +#[derive(Clone, Debug)] +pub(super) struct Constraint { + pub(super) lhs: Vec, + lhs_text: String, + pub(super) rel: Rel, + pub(super) rhs: Rhs, + rhs_text: String, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) enum Goal { + Max, + Min, + Value(f64), +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) enum Method { + Simplex, + Grg, + Evolutionary, +} + +/// The Evolutionary method's options (Solver Options › Evolutionary). The search keeps no +/// clock, so the time without improvement is counted in generations. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct EvoOptions { + pub(super) population: usize, + pub(super) mutation: f64, + pub(super) seed: u64, + pub(super) convergence: f64, + pub(super) stall: u64, + pub(super) require_bounds: bool, +} + +impl Default for EvoOptions { + fn default() -> EvoOptions { + EvoOptions { population: 100, mutation: 0.075, seed: 0, convergence: 1e-4, stall: 50, require_bounds: true } + } +} + +fn evo_options(p: &Json) -> EvoOptions { + let d = EvoOptions::default(); + let num = |k: &str| p.get(k).and_then(Json::as_f64).or_else(|| str_param(p, k).and_then(num_text)).filter(|v| v.is_finite()); + EvoOptions { + population: num("populationSize").map_or(d.population, |v| v.clamp(MIN_POPULATION as f64, MAX_POPULATION as f64) as usize), + mutation: num("mutationRate").map_or(d.mutation, |v| v.clamp(0.0, 1.0)), + seed: num("randomSeed").map_or(d.seed, |v| v.clamp(0.0, f64::from(u32::MAX)) as u64), + convergence: num("convergence").map_or(d.convergence, |v| v.clamp(0.0, 1.0)), + stall: num("generationsWithoutImprovement").map_or(d.stall, |v| v.clamp(1.0, 100_000.0) as u64), + require_bounds: bool_param(p, "requireBounds").unwrap_or(d.require_bounds), + } +} + +#[derive(Clone, Debug)] +pub(super) struct Model { + pub(super) sheet: usize, + pub(super) objective: CellRef, + pub(super) goal: Goal, + pub(super) vars: Vec, + vars_text: String, + pub(super) cons: Vec, + pub(super) non_negative: bool, + pub(super) method: Method, + pub(super) evo: EvoOptions, +} + +/// Cells of `"B2:B4,D1"` on one sheet (row-major per area, in order), at most `cap`. +fn cells_of(wb: &Workbook, t: &str, default_sheet: usize, cap: usize) -> Result<(usize, Vec)> { + let mut sheet = None; + let mut out = Vec::new(); + for part in t.split(',').map(str::trim).filter(|x| !x.is_empty()) { + let (sh, body) = split_sheet(part); + let si = match sh { + Some(n) => wb.sheet_index(&n).ok_or_else(|| bad(CMD, format!("no sheet named `{n}`")))?, + None => default_sheet, + }; + if *sheet.get_or_insert(si) != si { + return Err(bad(CMD, "all cells of a reference must be on one sheet")); + } + let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(CMD, format!("not a reference: `{part}`")))?; + if r.count() > cap as u64 || out.len() + r.count() as usize > cap { + return Err(bad(CMD, format!("too many cells (at most {cap})"))); + } + out.extend(r.iter()); + } + if out.is_empty() { + return Err(bad(CMD, format!("not a reference: `{t}`"))); + } + Ok((sheet.unwrap_or(default_sheet), out)) +} + +fn num_text(t: &str) -> Option { + gridcraft_core::parse::parse_number_text(t.trim()).filter(|v| v.is_finite()) +} + +/// Splits `"B5 <= 10"` / `"B2 int"` into (lhs, relation, rhs). +fn split_constraint(t: &str) -> Option<(String, Rel, String)> { + for op in ["<=", ">=", "=<", "=>", "≤", "≥", "=="] { + if let Some((a, b)) = t.split_once(op) { + return Some((a.trim().into(), Rel::parse(op)?, b.trim().into())); + } + } + if let Some((a, b)) = t.split_once('=') { + return Some((a.trim().into(), Rel::Eq, b.trim().into())); + } + let lower = t.trim().to_ascii_lowercase(); + for (suffix, rel) in [("integer", Rel::Int), ("binary", Rel::Bin), ("int", Rel::Int), ("bin", Rel::Bin)] { + if let Some(a) = lower.strip_suffix(suffix) { + let a = a.trim(); + return Some((t.trim().get(..a.len())?.trim().to_string(), rel, String::new())); + } + } + None +} + +fn parse_constraint(wb: &Workbook, sheet: usize, c: &Json) -> Result { + let (lhs_text, rel, rhs_json) = match c { + Json::String(t) => { + let (l, r, rhs) = split_constraint(t).ok_or_else(|| bad(CMD, format!("not a constraint: `{t}`")))?; + (l, r, Json::String(rhs)) + } + Json::Object(_) => { + let lhs = str_param(c, "lhs").or_else(|| str_param(c, "cell")).or_else(|| str_param(c, "range")).unwrap_or("").to_string(); + let op = str_param(c, "op").or_else(|| str_param(c, "relation")).unwrap_or(""); + let rel = Rel::parse(op).ok_or_else(|| bad(CMD, format!("unknown constraint relation `{op}`")))?; + (lhs, rel, c.get("rhs").or_else(|| c.get("value")).cloned().unwrap_or(Json::Null)) + } + _ => return Err(bad(CMD, "a constraint is a string like \"B5 <= 10\" or {lhs, op, rhs}")), + }; + let (lsi, lhs) = cells_of(wb, &lhs_text, sheet, MAX_CONSTRAINTS)?; + if lsi != sheet { + return Err(EngineError::Other("Constraint cells must be on the objective cell's sheet.".into())); + } + let (rhs, rhs_text) = match rel { + Rel::Int | Rel::Bin => (Rhs::None, String::new()), + _ => match &rhs_json { + Json::Number(n) => { + let v = n.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "the constraint's right side must be a number"))?; + (Rhs::Const(v), v.to_string()) + } + Json::String(t) => { + let t = t.trim().trim_start_matches('=').trim(); + if let Some(v) = num_text(t) { + (Rhs::Const(v), t.to_string()) + } else { + let (rsi, cells) = cells_of(wb, t, sheet, MAX_CONSTRAINTS)?; + if rsi != sheet { + return Err(EngineError::Other("Constraint cells must be on the objective cell's sheet.".into())); + } + if cells.len() != 1 && cells.len() != lhs.len() { + return Err(EngineError::Other("The constraint's two sides must be the same size (or the right side one cell).".into())); + } + (Rhs::Cells(cells), t.to_string()) + } + } + _ => return Err(bad(CMD, "missing the constraint's right side")), + }, + }; + Ok(Constraint { lhs, lhs_text, rel, rhs, rhs_text }) +} + +pub(super) fn parse_model(s: &Session, p: &Json) -> Result { + let d = s.doc()?; + let wb = &d.wb; + let obj_text = str_param(p, "objective").or_else(|| str_param(p, "setObjective")).or_else(|| str_param(p, "set")).unwrap_or(""); + let (sheet, obj) = cells_of(wb, obj_text, wb.active_sheet(), 1).map_err(|_| EngineError::Other("The objective must be a single cell.".into()))?; + let objective = *obj.first().ok_or_else(|| EngineError::Other("The objective must be a single cell.".into()))?; + let sh = wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + if sh.cell(objective).is_none_or(|c| c.formula.is_none()) { + return Err(EngineError::Other("The objective cell must contain a formula.".into())); + } + let goal = match str_param(p, "goal").or_else(|| str_param(p, "to")).unwrap_or("max").to_ascii_lowercase().as_str() { + "max" | "maximize" | "maximum" => Goal::Max, + "min" | "minimize" | "minimum" => Goal::Min, + "value" | "valueof" | "value of" => { + let v = f64_param(p, "value") + .or_else(|| str_param(p, "value").and_then(num_text)) + .filter(|v| v.is_finite()) + .ok_or_else(|| bad(CMD, "goal `value` needs a number in `value`"))?; + Goal::Value(v) + } + g => return Err(bad(CMD, format!("unknown goal `{g}` (max, min or value)"))), + }; + let vars_text = str_param(p, "variables").or_else(|| str_param(p, "changing")).or_else(|| str_param(p, "byChanging")).unwrap_or("").to_string(); + let (vsi, mut vars) = cells_of(wb, &vars_text, sheet, MAX_VARS).map_err(|e| match e { + EngineError::BadParams { msg, .. } if msg.starts_with("too many") => { + EngineError::Other(format!("Too many variable cells (at most {MAX_VARS}).")) + } + _ => EngineError::Other("The variable cells must be a reference like B2:B4.".into()), + })?; + if vsi != sheet { + return Err(EngineError::Other("The variable cells must be on the objective cell's sheet.".into())); + } + let mut seen = std::collections::HashSet::new(); + vars.retain(|c| seen.insert(*c)); + if vars.iter().any(|c| sh.cell(*c).is_some_and(|c| c.formula.is_some())) { + return Err(EngineError::Other("Variable cells must hold constants, not formulas.".into())); + } + let mut cons = Vec::new(); + let mut scalar = 0usize; + if let Some(list) = p.get("constraints") { + let list = list.as_array().ok_or_else(|| bad(CMD, "`constraints` must be an array"))?; + if list.len() > MAX_CONSTRAINTS { + return Err(EngineError::Other(format!("Too many constraints (at most {MAX_CONSTRAINTS})."))); + } + for c in list { + let c = parse_constraint(wb, sheet, c)?; + scalar = scalar.saturating_add(c.lhs.len()); + if scalar > MAX_CONSTRAINTS { + return Err(EngineError::Other(format!("Too many constraints (at most {MAX_CONSTRAINTS} cells)."))); + } + if matches!(c.rel, Rel::Int | Rel::Bin) && c.lhs.iter().any(|x| !vars.contains(x)) { + return Err(EngineError::Other("Integer and binary constraints must be on variable cells.".into())); + } + cons.push(c); + } + } + let non_negative = bool_param(p, "nonNegative").unwrap_or(true); + let method = match str_param(p, "method").unwrap_or("grg").to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { + "grg" | "grgnonlinear" | "nonlinear" => Method::Grg, + "simplex" | "simplexlp" | "lp" | "linear" => Method::Simplex, + "evolutionary" | "evo" | "genetic" => Method::Evolutionary, + m => return Err(bad(CMD, format!("unknown method `{m}` (grg, simplex or evolutionary)"))), + }; + Ok(Model { sheet, objective, goal, vars, vars_text, cons, non_negative, method, evo: evo_options(p) }) +} + +// ---------------------------------------------------------------- saved model + +fn ref_text(sheet_name: &str, cells: &str) -> String { + let q = crate::pivot::quote_sheet(sheet_name); + cells + .split(',') + .map(str::trim) + .filter(|x| !x.is_empty()) + .filter_map(|part| { + let r = RangeRef::parse(split_sheet(part).1.trim())?; + let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row as u64 + 1); + Some(if r.is_single() { format!("{q}!{}", abs(r.start)) } else { format!("{q}!{}:{}", abs(r.start), abs(r.end)) }) + }) + .collect::>() + .join(",") +} + +/// The model as hidden names scoped to its sheet (replacing any saved before). +pub(super) fn save_model(wb: &mut Workbook, m: &Model) { + let Some(sheet_name) = wb.sheet(m.sheet).map(|s| s.name.clone()) else { return }; + wb.names.retain(|n| !(n.scope == Some(m.sheet) && n.name.to_ascii_lowercase().starts_with("solver_"))); + let mut put = |name: String, formula: String| { + wb.names.push(DefinedName { name, scope: Some(m.sheet), formula, comment: String::new(), hidden: true }); + }; + put("solver_opt".into(), ref_text(&sheet_name, &m.objective.a1())); + let (typ, val) = match m.goal { + Goal::Max => (1, 0.0), + Goal::Min => (2, 0.0), + Goal::Value(v) => (3, v), + }; + put("solver_typ".into(), typ.to_string()); + put("solver_val".into(), val.to_string()); + put("solver_adj".into(), ref_text(&sheet_name, &m.vars_text)); + for (i, c) in m.cons.iter().enumerate() { + let k = i + 1; + put(format!("solver_lhs{k}"), ref_text(&sheet_name, &c.lhs_text)); + put(format!("solver_rel{k}"), c.rel.code().to_string()); + let rhs = match &c.rhs { + Rhs::None => { + if c.rel == Rel::Bin { + "\"binary\"".to_string() + } else { + "\"integer\"".to_string() + } + } + Rhs::Const(v) => v.to_string(), + Rhs::Cells(_) => ref_text(&sheet_name, &c.rhs_text), + }; + put(format!("solver_rhs{k}"), rhs); + } + put("solver_num".into(), m.cons.len().to_string()); + put("solver_neg".into(), if m.non_negative { "1" } else { "2" }.into()); + let eng = match m.method { + Method::Grg => "1", + Method::Simplex => "2", + Method::Evolutionary => "3", + }; + put("solver_eng".into(), eng.into()); + if m.method == Method::Evolutionary { + put("solver_ssz".into(), m.evo.population.to_string()); + put("solver_mrt".into(), m.evo.mutation.to_string()); + put("solver_rsd".into(), m.evo.seed.to_string()); + put("solver_rbv".into(), if m.evo.require_bounds { "1" } else { "2" }.into()); + } + put("solver_ver".into(), "3".into()); +} + +/// The saved model of a sheet as `data.solver` params, if any. +pub fn saved_model(wb: &Workbook, sheet: usize) -> Option { + let get = |n: &str| { + wb.names + .iter() + .find(|d| d.name.eq_ignore_ascii_case(n) && (d.scope == Some(sheet) || d.scope.is_none())) + .map(|d| d.formula.trim().trim_start_matches('=').to_string()) + }; + let local = |t: String| -> String { t.split(',').map(|p| split_sheet(p.trim()).1.replace('$', "")).collect::>().join(",") }; + let objective = local(get("solver_opt")?); + if objective.is_empty() { + return None; + } + let goal = match get("solver_typ").as_deref().map(str::trim) { + Some("2") => "min", + Some("3") => "value", + _ => "max", + }; + let value = get("solver_val").and_then(|v| num_text(&v)).unwrap_or(0.0); + let count = get("solver_num").and_then(|v| num_text(&v)).unwrap_or(0.0).clamp(0.0, MAX_CONSTRAINTS as f64) as usize; + let mut constraints = Vec::new(); + for k in 1..=count { + let (Some(lhs), Some(rel)) = (get(&format!("solver_lhs{k}")), get(&format!("solver_rel{k}")).and_then(|r| Rel::from_code(&r))) else { + continue; + }; + let lhs = local(lhs); + constraints.push(match rel { + Rel::Int | Rel::Bin => json!(format!("{lhs} {}", rel.text())), + _ => { + let rhs = get(&format!("solver_rhs{k}")).unwrap_or_default(); + let rhs = if num_text(&rhs).is_some() { rhs } else { local(rhs) }; + json!(format!("{lhs} {} {rhs}", rel.text())) + } + }); + } + let method = match get("solver_eng").as_deref().map(str::trim) { + Some("2") => "simplex", + Some("3") => "evolutionary", + _ => "grg", + }; + let mut model = json!({ + "objective": objective, + "goal": goal, + "value": value, + "variables": get("solver_adj").map(local).unwrap_or_default(), + "constraints": constraints, + "nonNegative": get("solver_neg").as_deref().map(str::trim) != Some("2"), + "method": method, + }); + for (name, key) in [("solver_ssz", "populationSize"), ("solver_mrt", "mutationRate"), ("solver_rsd", "randomSeed")] { + if let Some(v) = get(name).and_then(|v| num_text(&v)) { + model[key] = json!(v); + } + } + if let Some(v) = get("solver_rbv") { + model["requireBounds"] = json!(v.trim() != "2"); + } + Some(model) +} + +/// The model as `data.solver` params, in the form [`saved_model`] returns (the objective +/// qualified by its sheet). +pub(super) fn model_params(wb: &Workbook, m: &Model) -> Json { + let sheet = wb.sheet(m.sheet).map(|s| crate::pivot::quote_sheet(&s.name)).unwrap_or_default(); + let (goal, value) = match m.goal { + Goal::Max => ("max", 0.0), + Goal::Min => ("min", 0.0), + Goal::Value(v) => ("value", v), + }; + let constraints: Vec = m + .cons + .iter() + .map(|c| match c.rel { + Rel::Int | Rel::Bin => json!(format!("{} {}", c.lhs_text, c.rel.text())), + _ => json!(format!("{} {} {}", c.lhs_text, c.rel.text(), c.rhs_text)), + }) + .collect(); + let method = match m.method { + Method::Simplex => "simplex", + Method::Grg => "grg", + Method::Evolutionary => "evolutionary", + }; + let mut out = json!({ + "objective": format!("{sheet}!{}", m.objective.a1()), + "goal": goal, + "value": value, + "variables": m.vars_text, + "constraints": constraints, + "nonNegative": m.non_negative, + "method": method, + }); + if m.method == Method::Evolutionary { + let e = &m.evo; + out["populationSize"] = json!(e.population); + out["mutationRate"] = json!(e.mutation); + out["randomSeed"] = json!(e.seed); + out["convergence"] = json!(e.convergence); + out["generationsWithoutImprovement"] = json!(e.stall); + out["requireBounds"] = json!(e.require_bounds); + } + out +} diff --git a/crates/engine/src/cmd/solver/nlp.rs b/crates/engine/src/cmd/solver/nlp.rs new file mode 100644 index 000000000..6219e9b23 --- /dev/null +++ b/crates/engine/src/cmd/solver/nlp.rs @@ -0,0 +1,197 @@ +use super::lp::{Outcome, Row, Sense}; +use super::model::Goal; +use super::problem::{Evaluator, Point}; +use super::*; + +// ---------------------------------------------------------------- nonlinear + +/// Augmented-Lagrangian value of a point: objective `phi` plus the constraint terms. +fn aug(phi: f64, cons: &[f64], senses: &[Sense], lam: &[f64], mu: f64) -> f64 { + let mut v = phi; + for ((c, s), l) in cons.iter().zip(senses).zip(lam) { + v += match s { + Sense::Eq => l * c + 0.5 * mu * c * c, + _ => { + let t = (l + mu * c).max(0.0); + (t * t - l * l) / (2.0 * mu) + } + }; + } + v +} + +/// Constraint values in the `≤ 0` / `= 0` form, and the objective to minimise. +pub(super) fn shape(p: &Point, rows: &[Row], goal: Goal) -> (f64, Vec) { + let phi = match goal { + Goal::Max => -p.f, + Goal::Min => p.f, + Goal::Value(v) => (p.f - v) * (p.f - v), + }; + let cons = p.g.iter().zip(rows).map(|(g, r)| if r.sense == Sense::Ge { -g } else { *g }).collect(); + (phi, cons) +} + +pub(super) fn violation(cons: &[f64], senses: &[Sense]) -> f64 { + cons.iter().zip(senses).map(|(c, s)| if *s == Sense::Eq { c.abs() } else { c.max(0.0) }).fold(0.0, f64::max) +} + +pub(super) fn project(x: &mut [f64], lo: &[f64], hi: &[f64]) { + for ((v, l), h) in x.iter_mut().zip(lo).zip(hi) { + if *v < *l { + *v = *l; + } + if *v > *h { + *v = *h; + } + } +} + +/// Local minimisation of the model within bounds `lo..hi` from `x0`. +pub(super) fn nlp(ev: &mut Evaluator, x0: &[f64], lo: &[f64], hi: &[f64], goal: Goal) -> Outcome { + let n = x0.len(); + let rows = ev.rows.clone(); + let senses: Vec = rows.iter().map(|r| r.sense).collect(); + let mut x = x0.to_vec(); + project(&mut x, lo, hi); + let mut lam = vec![0.0; rows.len()]; + let mut mu = 10.0; + let Some(p) = ev.eval(&x) else { return Outcome::Limit }; + let (mut phi, mut cons) = shape(&p, &rows, goal); + if !phi.is_finite() { + return Outcome::Infeasible; + } + let phi0 = phi; + let mut prev_viol = violation(&cons, &senses); + let mut converged = false; + let mut out_of_budget = false; + let mut carried: Option = None; + for _outer in 0..40 { + // Inner: projected gradient on the augmented Lagrangian. + let lval = |phi: f64, cons: &[f64], lam: &[f64], mu: f64| aug(phi, cons, &senses, lam, mu); + let grad = |ev: &mut Evaluator, x: &[f64], lam: &[f64], mu: f64| -> Option> { + let mut gr = vec![0.0; n]; + for j in 0..n { + let xj = x.get(j).copied().unwrap_or(0.0); + let h = 1e-6 * xj.abs().max(1.0); + let (l, u) = (lo.get(j).copied().unwrap_or(f64::NEG_INFINITY), hi.get(j).copied().unwrap_or(f64::INFINITY)); + let (a, b) = (if xj - h >= l { xj - h } else { xj }, if xj + h <= u { xj + h } else { xj }); + if b <= a { + continue; + } + let mut xa = x.to_vec(); + let mut xb = x.to_vec(); + if let Some(v) = xa.get_mut(j) { + *v = a; + } + if let Some(v) = xb.get_mut(j) { + *v = b; + } + let (pa, ca) = shape(&ev.eval(&xa)?, &rows, goal); + let (pb, cb) = shape(&ev.eval(&xb)?, &rows, goal); + let d = (lval(pb, &cb, lam, mu) - lval(pa, &ca, lam, mu)) / (b - a); + if let Some(v) = gr.get_mut(j) { + *v = if d.is_finite() { d } else { 0.0 }; + } + } + Some(gr) + }; + let mut l_cur = lval(phi, &cons, &lam, mu); + let Some(mut g) = grad(ev, &x, &lam, mu) else { + out_of_budget = true; + break; + }; + // The step length carries over between outer iterations once known. + let gnorm = g.iter().map(|v| v.abs()).fold(0.0, f64::max); + let mut alpha = carried.unwrap_or(if gnorm > 0.0 { 1.0 / gnorm } else { 1.0 }); + let mut inner_done = false; + let mut quiet = 0; + for _inner in 0..500 { + let mut accepted = None; + let mut a = alpha; + for _ in 0..40 { + let mut xn: Vec = x.iter().zip(&g).map(|(v, d)| v - a * d).collect(); + project(&mut xn, lo, hi); + let Some(pn) = ev.eval(&xn) else { break }; + let (phn, cn) = shape(&pn, &rows, goal); + let ln = lval(phn, &cn, &lam, mu); + let decrease: f64 = g.iter().zip(x.iter().zip(&xn)).map(|(d, (u, v))| d * (u - v)).sum(); + if ln.is_finite() && ln <= l_cur - 1e-4 * decrease { + accepted = Some((xn, phn, cn, ln)); + break; + } + a *= 0.25; + } + if ev.exhausted() { + out_of_budget = true; + } + let Some((xn, phn, cn, ln)) = accepted else { + inner_done = true; + break; + }; + // Variables or objective running off towards infinity: unbounded. + if phn.abs() > 1e15 * (1.0 + phi0.abs()) || xn.iter().zip(x0).any(|(v, s)| v.abs() > 1e15 * (1.0 + s.abs())) { + return Outcome::Unbounded; + } + let s: Vec = xn.iter().zip(&x).map(|(a, b)| a - b).collect(); + let step = s.iter().map(|v| v.abs()).fold(0.0, f64::max); + let xmax = xn.iter().map(|v| v.abs()).fold(1.0, f64::max); + let small = step <= 1e-10 * xmax || (l_cur - ln).abs() <= 1e-13 * (1.0 + l_cur.abs()); + quiet = if small { quiet + 1 } else { 0 }; + x = xn; + phi = phn; + cons = cn; + l_cur = ln; + if quiet >= 3 { + inner_done = true; + break; + } + let Some(gn) = grad(ev, &x, &lam, mu) else { + out_of_budget = true; + break; + }; + // Barzilai–Borwein step. + let yv: Vec = gn.iter().zip(&g).map(|(a, b)| a - b).collect(); + let sy: f64 = s.iter().zip(&yv).map(|(a, b)| a * b).sum(); + let ss: f64 = s.iter().map(|v| v * v).sum(); + alpha = if sy > 0.0 { (ss / sy).clamp(1e-12, 1e12) } else { (a * 4.0).min(1e12) }; + g = gn; + if g.iter().all(|v| *v == 0.0) { + inner_done = true; + break; + } + } + carried = Some(alpha); + if out_of_budget { + break; + } + let viol = violation(&cons, &senses); + let scale = rows.iter().map(|r| r.rhs_const.abs()).fold(1.0, f64::max); + if viol <= PRECISION * scale && inner_done { + converged = true; + if rows.is_empty() { + break; + } + } + // Multiplier and penalty updates. + for ((l, c), s) in lam.iter_mut().zip(&cons).zip(&senses) { + *l = match s { + Sense::Eq => *l + mu * c, + _ => (*l + mu * c).max(0.0), + }; + } + if viol > 0.25 * prev_viol { + mu = (mu * 10.0).min(1e10); + } + if converged && viol <= PRECISION * scale * 1e-2 { + break; + } + prev_viol = viol; + converged = false; + } + let viol = violation(&cons, &senses); + let scale = rows.iter().map(|r| r.rhs_const.abs()).fold(1.0, f64::max); + if viol > PRECISION * scale * 10.0 { + return if out_of_budget { Outcome::Limit } else { Outcome::Infeasible }; + } + Outcome::Solved(x, phi, (converged || viol <= PRECISION * scale) && !out_of_budget) +} diff --git a/crates/engine/src/cmd/solver/problem.rs b/crates/engine/src/cmd/solver/problem.rs new file mode 100644 index 000000000..be74a81af --- /dev/null +++ b/crates/engine/src/cmd/solver/problem.rs @@ -0,0 +1,144 @@ +use super::lp::{Row, Sense}; +use super::model::{Model, Rel, Rhs}; +use super::*; + +/// The values the solvers see at a point: the objective and `lhs − rhs` per row. +#[derive(Clone, Debug)] +pub(super) struct Point { + pub(super) f: f64, + pub(super) g: Vec, +} + +pub(super) struct Evaluator { + pub(super) trial: Trial, + pub(super) sheet: usize, + pub(super) vars: Vec, + pub(super) objective: CellRef, + pub(super) rows: Vec, + pub(super) evals: u64, + pub(super) budget: u64, + /// The Limits report: the objective is this variable itself. + pub(super) target: Option, +} + +pub(super) fn as_num(v: &Value) -> f64 { + match v { + Value::Number(n) => *n, + Value::Empty => 0.0, + Value::Bool(b) => f64::from(u8::from(*b)), + _ => f64::NAN, + } +} + +impl Evaluator { + pub(super) fn exhausted(&self) -> bool { + self.evals >= self.budget + } + + /// Evaluates the model at `x`; `None` once the budget is spent. + pub(super) fn eval(&mut self, x: &[f64]) -> Option { + if self.exhausted() { + return None; + } + self.evals += 1; + let sets: Vec<(usize, CellRef, Value)> = self.vars.iter().zip(x).map(|(c, v)| (self.sheet, *c, Value::number(*v))).collect(); + let mut reads = Vec::with_capacity(1 + 2 * self.rows.len()); + reads.push((self.sheet, self.objective)); + for r in &self.rows { + reads.push((self.sheet, r.lhs)); + if let Some(c) = r.rhs { + reads.push((self.sheet, c)); + } + } + let vals = self.trial.run(&sets, &reads); + let mut it = vals.iter().map(as_num); + let f = it.next().unwrap_or(f64::NAN); + let f = match self.target { + Some(j) => x.get(j).copied().unwrap_or(f64::NAN), + None => f, + }; + let mut g = Vec::with_capacity(self.rows.len()); + for r in &self.rows { + let l = it.next().unwrap_or(f64::NAN); + let rv = if r.rhs.is_some() { it.next().unwrap_or(f64::NAN) } else { r.rhs_const }; + g.push(l - rv); + } + Some(Point { f, g }) + } +} + +// ---------------------------------------------------------------- problem + +pub(super) struct Problem { + pub(super) lo: Vec, + pub(super) hi: Vec, + pub(super) integer: Vec, + pub(super) rows: Vec, +} + +/// Splits the constraints into variable bounds and general rows. +pub(super) fn build_problem(m: &Model) -> Problem { + let n = m.vars.len(); + let mut lo = vec![f64::NEG_INFINITY; n]; + let mut hi = vec![f64::INFINITY; n]; + let mut has_lower = vec![false; n]; + let mut integer = vec![false; n]; + let mut rows = Vec::new(); + let var_index = |c: &CellRef| m.vars.iter().position(|v| v == c); + for c in &m.cons { + for (k, cell) in c.lhs.iter().enumerate() { + let j = var_index(cell); + match c.rel { + Rel::Int | Rel::Bin => { + if let Some(j) = j { + if let Some(b) = integer.get_mut(j) { + *b = true; + } + if c.rel == Rel::Bin + && let (Some(l), Some(h), Some(hl)) = (lo.get_mut(j), hi.get_mut(j), has_lower.get_mut(j)) + { + *l = l.max(0.0); + *h = h.min(1.0); + *hl = true; + } + } + } + rel => { + let sense = match rel { + Rel::Le => Sense::Le, + Rel::Ge => Sense::Ge, + _ => Sense::Eq, + }; + let (rhs, rhs_const) = match &c.rhs { + Rhs::Const(v) => (None, *v), + Rhs::Cells(cells) => (cells.get(k).or_else(|| cells.first()).copied(), 0.0), + Rhs::None => (None, 0.0), + }; + match (j, rhs) { + // A constant bound on a variable cell is a box bound. + (Some(j), None) => { + if let (Some(l), Some(h), Some(hl)) = (lo.get_mut(j), hi.get_mut(j), has_lower.get_mut(j)) { + if sense != Sense::Ge { + *h = h.min(rhs_const); + } + if sense != Sense::Le { + *l = l.max(rhs_const); + *hl = true; + } + } + } + _ => rows.push(Row { lhs: *cell, sense, rhs, rhs_const }), + } + } + } + } + } + if m.non_negative { + for (l, hl) in lo.iter_mut().zip(&has_lower) { + if !hl { + *l = l.max(0.0); + } + } + } + Problem { lo, hi, integer, rows } +} diff --git a/crates/engine/src/cmd/solver/reports.rs b/crates/engine/src/cmd/solver/reports.rs new file mode 100644 index 000000000..9f3b82866 --- /dev/null +++ b/crates/engine/src/cmd/solver/reports.rs @@ -0,0 +1,373 @@ +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::*; + +// ---------------------------------------------------------------- reports + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) enum ReportKind { + Answer, + Limits, +} + +impl ReportKind { + fn title(self) -> &'static str { + match self { + ReportKind::Answer => "Answer", + ReportKind::Limits => "Limits", + } + } +} + +/// The `reports` list (`None` when absent). +pub(super) fn report_kinds(cmd: &'static str, p: &Json) -> Result>> { + let Some(list) = p.get("reports") else { return Ok(None) }; + let list = list.as_array().ok_or_else(|| bad(cmd, "`reports` must be an array of answer and limits"))?; + let mut kinds = Vec::new(); + for k in list { + let kind = match k.as_str().map(|t| t.trim().to_ascii_lowercase()).as_deref() { + Some("answer") => ReportKind::Answer, + Some("limits") => ReportKind::Limits, + _ => return Err(bad(cmd, format!("unknown report {k} (answer or limits)"))), + }; + if !kinds.contains(&kind) { + kinds.push(kind); + } + } + Ok(Some(kinds)) +} + +/// A report's cells: (row, column, value, bold). +type Lines = Vec<(u32, u32, Value, bool)>; + +fn abs_ref(c: CellRef) -> String { + format!("${}${}", gridcraft_core::col_to_letters(c.col), u64::from(c.row) + 1) +} + +/// A cell's name in the reports: the nearest text to its left and the nearest text above it. +fn cell_name(sh: &Sheet, c: CellRef) -> String { + let text = |c: CellRef| match sh.value(c) { + Value::Text(t) if !t.trim().is_empty() => Some(t.to_string()), + _ => None, + }; + let left = (0..c.col).rev().find_map(|col| text(CellRef::new(c.row, col))); + let top = (0..c.row).rev().find_map(|row| text(CellRef::new(row, c.col))); + [left, top].into_iter().flatten().collect::>().join(" ") +} + +fn method_name(m: Method) -> &'static str { + match m { + Method::Simplex => "Simplex LP", + Method::Grg => "GRG Nonlinear", + Method::Evolutionary => "Evolutionary", + } +} + +/// Header labels in bold from column 1, then the rows below them; returns the next free row. +fn table(out: &mut Lines, row: u32, headers: &[&str], rows: Vec>) -> u32 { + for (k, h) in headers.iter().enumerate() { + out.push((row, 1 + k as u32, Value::text(*h), true)); + } + let mut r = row; + for cells in rows { + r = r.saturating_add(1); + for (k, v) in cells.into_iter().enumerate() { + out.push((r, 1 + k as u32, v, false)); + } + } + r.saturating_add(2) +} + +/// Everything a report describes: the model, its starting and final values, and the result. +pub(super) struct ReportInput<'a> { + pub(super) model: &'a Model, + pub(super) prob: &'a Problem, + pub(super) original: Vec, + pub(super) solution: Vec, + pub(super) message: String, + pub(super) budget: u64, +} + +fn title(out: &mut Lines, kind: ReportKind, sheet: &str) -> u32 { + out.push((0, 0, Value::text(format!("GridCraft {} Report", kind.title())), true)); + out.push((1, 0, Value::text(format!("Worksheet: {sheet}")), false)); + 3 +} + +fn format_constraint_row(sh: &Sheet, c: &Constraint, cell: CellRef, v: f64, rhs: &[f64], k: usize) -> Vec { + let (formula, status, slack) = match (c.rel, &c.rhs) { + (Rel::Int, _) => (format!("{}=Integer", abs_ref(cell)), "Binding", 0.0), + (Rel::Bin, _) => (format!("{}=Binary", abs_ref(cell)), "Binding", 0.0), + (rel, rhs_cells) => { + let b = rhs.get(k).or_else(|| rhs.first()).copied().unwrap_or(f64::NAN); + let rhs_text = match rhs_cells { + Rhs::Cells(cells) => cells.get(k).or_else(|| cells.first()).map(|c| abs_ref(*c)).unwrap_or_default(), + _ => gridcraft_core::number_to_text(b), + }; + let slack = (v - b).abs(); + let binding = rel == Rel::Eq || slack <= PRECISION * (1.0 + b.abs()); + let slack = if binding { 0.0 } else { slack }; + (format!("{}{}{rhs_text}", abs_ref(cell), rel.text()), if binding { "Binding" } else { "Not Binding" }, slack) + } + }; + let num = |v: f64| if v.is_finite() { Value::number(v) } else { Value::Error(gridcraft_core::CellError::Value) }; + vec![Value::text(abs_ref(cell)), Value::text(cell_name(sh, cell)), num(v), Value::text(formula), Value::text(status), num(slack)] +} + +fn answer_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Lines { + let m = inp.model; + let mut trial = Trial::new(d); + let read = |trial: &mut Trial, x: &[f64], cells: &[CellRef]| -> Vec { + let sets: Vec<(usize, CellRef, Value)> = m.vars.iter().zip(x).map(|(c, v)| (m.sheet, *c, Value::number(*v))).collect(); + let reads: Vec<(usize, CellRef)> = cells.iter().map(|c| (m.sheet, *c)).collect(); + trial.run(&sets, &reads).iter().map(as_num).collect() + }; + let f0 = read(&mut trial, &inp.original, &[m.objective]).first().copied().unwrap_or(f64::NAN); + // Every cell the constraints read, at the solution. + let mut cells = vec![m.objective]; + for c in &m.cons { + cells.extend(&c.lhs); + if let Rhs::Cells(r) = &c.rhs { + cells.extend(r); + } + } + let vals = read(&mut trial, &inp.solution, &cells); + let mut at = vals.iter().copied(); + let f1 = at.next().unwrap_or(f64::NAN); + let num = |v: f64| if v.is_finite() { Value::number(v) } else { Value::Error(gridcraft_core::CellError::Value) }; + let mut out = Lines::new(); + let mut r = title(&mut out, ReportKind::Answer, &sh.name); + out.push((r, 0, Value::text(format!("Result: {}", inp.message)), false)); + out.push((r + 1, 0, Value::text(format!("Engine: {}", method_name(m.method))), false)); + r += 3; + let goal = match m.goal { + Goal::Max => "Max".to_string(), + Goal::Min => "Min".to_string(), + Goal::Value(v) => format!("Value Of {v}"), + }; + out.push((r, 0, Value::text(format!("Objective Cell ({goal})")), true)); + let obj = vec![vec![Value::text(abs_ref(m.objective)), Value::text(cell_name(sh, m.objective)), num(f0), num(f1)]]; + r = table(&mut out, r + 1, &["Cell", "Name", "Original Value", "Final Value"], obj); + out.push((r, 0, Value::text("Variable Cells"), true)); + let kind = |j: usize| { + let bin = m.cons.iter().any(|c| c.rel == Rel::Bin && m.vars.get(j).is_some_and(|v| c.lhs.contains(v))); + match (bin, inp.prob.integer.get(j).copied().unwrap_or(false)) { + (true, _) => "Binary", + (false, true) => "Integer", + _ => "Contin", + } + }; + let vars = m + .vars + .iter() + .enumerate() + .map(|(j, c)| { + let (a, b) = (inp.original.get(j).copied().unwrap_or(f64::NAN), inp.solution.get(j).copied().unwrap_or(f64::NAN)); + vec![Value::text(abs_ref(*c)), Value::text(cell_name(sh, *c)), num(a), num(b), Value::text(kind(j))] + }) + .collect(); + r = table(&mut out, r + 1, &["Cell", "Name", "Original Value", "Final Value", "Integer"], vars); + out.push((r, 0, Value::text("Constraints"), true)); + let mut rows = Vec::new(); + for c in &m.cons { + let lhs: Vec = c.lhs.iter().map(|_| at.next().unwrap_or(f64::NAN)).collect(); + let rhs: Vec = match &c.rhs { + Rhs::Cells(cells) => cells.iter().map(|_| at.next().unwrap_or(f64::NAN)).collect(), + Rhs::Const(v) => vec![*v], + Rhs::None => vec![], + }; + for (k, (cell, v)) in c.lhs.iter().zip(&lhs).enumerate() { + rows.push(format_constraint_row(sh, c, *cell, *v, &rhs, k)); + } + } + table(&mut out, r + 1, &["Cell", "Name", "Cell Value", "Formula", "Status", "Slack"], rows); + out +} + +fn limits_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Result { + let m = inp.model; + if inp.prob.integer.iter().any(|b| *b) { + return Err(EngineError::Other("The Limits report doesn't apply to models with integer or binary constraints.".into())); + } + if m.method == Method::Evolutionary { + return Err(EngineError::Other("The Limits report is available for Simplex LP and GRG Nonlinear.".into())); + } + let n = m.vars.len(); + let x = &inp.solution; + // Each variable is minimised and maximised with the others held at the solution. + let per = (inp.budget / (2 * n as u64).max(1)).max(n as u64 + 3); + let mut ev = Evaluator { + trial: Trial::new(d), + sheet: m.sheet, + vars: m.vars.clone(), + objective: m.objective, + rows: inp.prob.rows.clone(), + evals: 0, + budget: 0, + target: None, + }; + let objective_at = |ev: &mut Evaluator, x: &[f64]| { + ev.target = None; + ev.budget = ev.evals.saturating_add(1); + ev.eval(x).map(|p| p.f).unwrap_or(f64::NAN) + }; + let num = |v: f64| if v.is_finite() { Value::number(v) } else { Value::Error(gridcraft_core::CellError::Value) }; + let f = objective_at(&mut ev, x); + let mut rows = Vec::new(); + for (j, c) in m.vars.iter().enumerate() { + let (mut lo, mut hi) = (x.clone(), x.clone()); + if let (Some(l), Some(h), Some(pl), Some(ph)) = (lo.get_mut(j), hi.get_mut(j), inp.prob.lo.get(j), inp.prob.hi.get(j)) { + *l = *pl; + *h = *ph; + } + let mut ends = Vec::new(); + for goal in [Goal::Min, Goal::Max] { + ev.target = Some(j); + ev.evals = 0; + ev.budget = per; + 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); + (num(v), num(objective_at(&mut ev, &p))) + } + Outcome::Unbounded => (Value::text("Unbounded"), Value::Empty), + _ => (Value::Error(gridcraft_core::CellError::NA), Value::Empty), + }); + } + let mut row = vec![Value::text(abs_ref(*c)), Value::text(cell_name(sh, *c)), num(x.get(j).copied().unwrap_or(f64::NAN))]; + for (v, o) in ends { + row.push(v); + row.push(o); + } + rows.push(row); + } + let mut out = Lines::new(); + let r = title(&mut out, ReportKind::Limits, &sh.name); + let obj = vec![vec![Value::text(abs_ref(m.objective)), Value::text(cell_name(sh, m.objective)), num(f)]]; + let r = table(&mut out, r, &["Objective Cell", "Name", "Value"], obj); + table(&mut out, r, &["Variable Cell", "Name", "Value", "Lower Limit", "Objective Result", "Upper Limit", "Objective Result"], rows); + Ok(out) +} + +/// Builds every report before the solution or saved model can change. +pub(super) fn prepare_reports(d: &DocState, kinds: &[ReportKind], inp: &ReportInput) -> Result> { + if kinds.is_empty() { + return Ok(Vec::new()); + } + if d.wb.protected_structure { + return Err(EngineError::Other("The workbook is protected and cannot be changed.".into())); + } + let sh = d.wb.sheet(inp.model.sheet).ok_or(EngineError::NoDocument)?; + let mut built = Vec::new(); + for k in kinds { + let lines = match k { + ReportKind::Answer => answer_report(d, sh, inp), + ReportKind::Limits => limits_report(d, sh, inp)?, + }; + let name = (1..=10_000u32) + .map(|i| format!("{} Report {i}", k.title())) + .find(|n| d.wb.sheet_index(n).is_none() && !built.iter().any(|(b, _): &(String, Lines)| b == n)) + .ok_or_else(|| EngineError::Other("Too many report sheets.".into()))?; + built.push((name, lines)); + } + Ok(built) +} + +/// Inserts prepared reports before the model's sheet, which stays active. +pub(super) fn write_reports(cx: &mut Ctx, at: usize, built: Vec<(String, Lines)>) -> Vec { + let names: Vec = built.iter().map(|(n, _)| n.clone()).collect(); + let count = built.len(); + if count == 0 { + return names; + } + let bold = cx.wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.font.bold = true); + for (i, (name, lines)) in built.into_iter().enumerate() { + let mut sheet = Sheet::new(name); + for (r, c, v, b) in lines { + let style = if b { bold } else { gridcraft_model::StyleId::DEFAULT }; + sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style }); + } + for (c, w) in [(0u32, 14.0f32), (1, 110.0), (2, 140.0), (3, 120.0), (4, 120.0), (5, 120.0), (6, 120.0), (7, 120.0)] { + sheet.cols.insert(c, gridcraft_model::LineInfo { size: Some(w), ..Default::default() }); + } + cx.wb.sheets.insert(at + i, std::sync::Arc::new(sheet)); + } + for nm in cx.wb.names.iter_mut() { + if let Some(sc) = nm.scope.as_mut() + && *sc >= at + { + *sc += count; + } + } + if cx.wb.active_sheet() >= at { + cx.wb.set_active_sheet(cx.wb.active_sheet().saturating_add(count)); + } + cx.structural = true; + names +} + +pub(super) fn insert_report_selections(s: &mut Session, at: usize, count: usize) { + if let Ok(d) = s.doc_mut() { + for i in 0..count { + d.sheet_selections.insert((at + i).min(d.sheet_selections.len()), crate::Selection::default()); + } + } +} + +/// `data.solverReport`: reports for a solution of the sheet's saved model. +pub(super) fn report(s: &mut Session, p: &Json) -> Result { + let kinds = report_kinds(REPORT_CMD, p)?.ok_or_else(|| bad(REPORT_CMD, "missing `reports` (answer, limits)"))?; + if kinds.is_empty() { + return Err(bad(REPORT_CMD, "`reports` is empty (answer, limits)")); + } + let d = s.doc()?; + // The given model as is, else the saved model with any params given here over it. + let params = match p.get("model") { + Some(m @ Json::Object(_)) => m.clone(), + Some(Json::Null) | None => { + let mut params = saved_model(&d.wb, d.wb.active_sheet()).unwrap_or_else(|| json!({})); + if let (Some(base), Some(over)) = (params.as_object_mut(), p.as_object()) { + for (k, v) in over { + base.insert(k.clone(), v.clone()); + } + } + params + } + Some(_) => return Err(bad(REPORT_CMD, "`model` must be data.solver params")), + }; + let model = parse_model(s, ¶ms)?; + let prob = build_problem(&model); + let d = s.doc()?; + let sh = d.wb.sheet(model.sheet).ok_or(EngineError::NoDocument)?; + let current: Vec = model.vars.iter().map(|c| as_num(&sh.value(*c))).collect(); + // `{cell: number}` maps; cells they leave out keep their current values. + let point = |key: &str| -> Result> { + let mut x = current.clone(); + let Some(map) = p.get(key) else { return Ok(x) }; + let map = map.as_object().ok_or_else(|| bad(REPORT_CMD, format!("`{key}` must map cells to numbers")))?; + for (cell, v) in map { + let c = CellRef::parse(&cell.replace('$', "")).ok_or_else(|| bad(REPORT_CMD, format!("not a cell: `{cell}`")))?; + let j = model.vars.iter().position(|v| *v == c).ok_or_else(|| bad(REPORT_CMD, format!("`{cell}` is not a variable cell")))?; + let v = v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(REPORT_CMD, format!("`{key}.{cell}` must be a number")))?; + if let Some(e) = x.get_mut(j) { + *e = v; + } + } + Ok(x) + }; + let solution = point("values")?; + let original = if p.get("original").is_some() { point("original")? } else { solution.clone() }; + let budget = params + .get("maxEvaluations") + .and_then(Json::as_f64) + .filter(|v| v.is_finite() && *v >= 1.0) + .map_or(DEFAULT_EVALS, |v| (v as u64).min(MAX_EVALS)); + let message = str_param(p, "message").unwrap_or("").chars().take(500).collect(); + let inp = ReportInput { model: &model, prob: &prob, original, solution, message, budget }; + let built = prepare_reports(s.doc()?, &kinds, &inp)?; + let sheets = edit(s, |cx| Ok(write_reports(cx, model.sheet, built)))?; + insert_report_selections(s, model.sheet, sheets.len()); + Ok(json!({"sheets": sheets})) +} From 5a7d2f259e0b993acb864537addc94b17cb67331 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:04:00 +0000 Subject: [PATCH 42/76] refactor(engine): move cmd/insert.rs items to cmd/insert/ --- crates/engine/src/cmd/insert.rs | 1516 +------------------ crates/engine/src/cmd/insert/chart.rs | 420 +++++ crates/engine/src/cmd/insert/cond_format.rs | 339 +++++ crates/engine/src/cmd/insert/links.rs | 157 ++ crates/engine/src/cmd/insert/objects.rs | 264 ++++ crates/engine/src/cmd/insert/table.rs | 331 ++++ 6 files changed, 1524 insertions(+), 1503 deletions(-) create mode 100644 crates/engine/src/cmd/insert/chart.rs create mode 100644 crates/engine/src/cmd/insert/cond_format.rs create mode 100644 crates/engine/src/cmd/insert/links.rs create mode 100644 crates/engine/src/cmd/insert/objects.rs create mode 100644 crates/engine/src/cmd/insert/table.rs diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 8274dfeed..6dc2de5fe 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -10,6 +10,19 @@ use super::sheet::{rewrite, shift_cell_features}; use super::*; use crate::selection::current_region; +mod chart; +mod cond_format; +mod links; +mod objects; +mod table; + +pub use chart::{build_chart, chart_kind, recommend_charts, scatter_x_from_first, series_from_range}; +use chart::{chart_delete, chart_set, chart_switch, insert_chart}; +use cond_format::{add_cf, clear_cf, insert_icon, manage_cf}; +use links::{comment, delete_comment, insert_link, open_link, reply_comment, resolve_comment, show_note}; +use objects::{delete_object, insert_picture, insert_shape, insert_sparkline, move_object, set_anchor_mode, shape_set_text}; +use table::{format_as_table, insert_table, table_convert, table_flag, table_rename, table_style, table_total_fn}; + pub fn specs() -> Vec { vec![ cmd!( @@ -226,1171 +239,6 @@ fn block(s: &Session, p: &Json) -> Result { Ok(current_region(sh, d.selection.active)) } -fn chart_areas(s: &Session, p: &Json) -> Result> { - if p.get("range").is_some() { - return target_ranges(s, p); - } - let d = s.doc()?; - if d.selection.ranges.len() > 1 { - return Ok(d.selection.ranges.clone()); - } - Ok(vec![block(s, p)?]) -} - -// ---------------------------------------------------------------- tables - -fn insert_table(s: &mut Session, p: &Json) -> Result { - let r = block(s, p)?; - let sheet = target_sheet(s, p)?; - let d = s.doc()?; - if d.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.range.intersects(&r))) { - return Err(EngineError::Other("A table can't overlap another table.".into())); - } - let arrays = |sh: &gridcraft_model::Sheet| { - sh.cells.iter().any(|(_, x)| x.formula.as_ref().and_then(|f| f.array).is_some_and(|a| !a.is_single() && a.intersects(&r))) - }; - if d.wb.sheet(sheet).is_some_and(arrays) { - return Err(EngineError::Other("Multi-cell array formulas aren't allowed in tables.".into())); - } - if r.count() > 50_000_000 { - return Err(bad("insert.table", "range too large")); - } - let header = bool_param(p, "header").unwrap_or_else(|| { - let sh = d.wb.sheet(sheet); - sh.is_some_and(|sh| (r.start.col..=r.end.col).all(|c| sh.value(CellRef::new(r.start.row, c)).is_text()) && r.height() > 1) - }); - let name = str_param(p, "name") - .map(str::to_string) - .unwrap_or_else(|| (1..).map(|n| format!("Table{n}")).find(|n| d.wb.table(n).is_none()).unwrap_or_else(|| "Table".into())); - if !valid_table_name(&name) { - return Err(EngineError::Other("The name that you entered is not valid.".into())); - } - if d.wb.table(&name).is_some() || d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) { - return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into())); - } - // Without a header row, the data moves down a row to make room for one: never a table with it. - let moves_a_table = |sh: &gridcraft_model::Sheet| { - sh.tables.iter().any(|t| t.range.end.row >= r.start.row && t.range.start.col <= r.end.col && t.range.end.col >= r.start.col) - }; - if !header && d.wb.sheet(sheet).is_some_and(moves_a_table) { - return Err(EngineError::Other("This operation isn't allowed: it would shift cells in a table on your worksheet.".into())); - } - let style = str_param(p, "style").unwrap_or("TableStyleMedium2").to_string(); - let id = d.wb.next_object_id(); - edit(s, |cx| { - // Without a header row, insert one with generic names. - let r = if header { - r - } else { - let sh = cx.sheet_mut(sheet)?; - // The header row pushes the data down one row: never off the end of the sheet. - let last = RangeRef::new(CellRef::new(MAX_ROWS - 1, r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col)); - if sh.cells.iter_range(last).any(|(_, x)| x.formula.is_some() || !x.value.is_empty()) { - return Err(EngineError::Other("To prevent possible loss of data, cells can't be pushed off the end of the worksheet.".into())); - } - sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, 1); - // Like Insert Cells > Shift down: move merges, conditional formats, notes, links and pictures, and re-point every formula - // reference (on any sheet) at the cells that moved. - let e = Edit::InsertCells { axis: Axis::Rows, range: RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) }; - shift_cell_features(sh, &e); - let name = sh.name.clone(); - rewrite(&mut cx.wb, &name, &e); - let sh = cx.sheet_mut(sheet)?; - for (i, c) in (r.start.col..=r.end.col).enumerate() { - sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1))); - } - cx.structural = true; - RangeRef::new(r.start, CellRef::new((r.end.row + 1).min(MAX_ROWS - 1), r.end.col)) - }; - let sh = cx.sheet_mut(sheet)?; - let mut names: Vec = Vec::new(); - // Lower-case names taken, and the next suffix for each header, so that thousands of equal - // headers don't take cubic time. - let mut taken = std::collections::HashSet::new(); - let mut next_suffix = std::collections::HashMap::new(); - for c in r.start.col..=r.end.col { - let mut n = sh.value(CellRef::new(r.start.row, c)).display(); - if n.is_empty() { - n = format!("Column{}", c - r.start.col + 1); - } - let base = n.clone(); - let key = base.to_ascii_lowercase(); - let mut k = next_suffix.get(&key).copied().unwrap_or(2); - while taken.contains(&n.to_ascii_lowercase()) { - n = format!("{base}{k}"); - k += 1; - } - next_suffix.insert(key, k); - taken.insert(n.to_ascii_lowercase()); - // Headers are text. - sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(n.as_str())); - names.push(n); - } - sh.tables.push(Table { - id, - name: name.clone(), - range: r, - header_row: true, - totals_row: false, - columns: names - .into_iter() - .map(|n| TableColumn { name: n, totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None }) - .collect(), - style, - banded_rows: true, - banded_cols: false, - first_col: false, - last_col: false, - filter_button: true, - filter: vec![], - table_type: None, - connection_id: None, - }); - cx.structural = true; - Ok(json!({"table": name, "range": r.a1()})) - }) -} - -/// Format as Table inside a table restyles that table, as in Excel; elsewhere it makes a new one. -fn format_as_table(s: &mut Session, p: &Json) -> Result { - // Without a range, the active area decides (as in insert_table), not the first one. - let r = if p.get("range").is_some() { target_range(s, p)? } else { s.doc()?.selection.current() }; - let sheet = target_sheet(s, p)?; - let inside = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.tables.iter().find(|t| t.range.contains_range(&r))).map(|t| (t.name.clone(), t.range)); - match inside { - Some((table, range)) => { - table_style(s, &with(p, "table", json!(table)))?; - Ok(json!({"table": table, "range": range.a1()})) - } - None => insert_table(s, p), - } -} - -fn find_table(s: &Session, p: &Json) -> Result<(usize, usize)> { - let d = s.doc()?; - if let Some(n) = str_param(p, "table") { - return d.wb.table(n).ok_or_else(|| bad("table", format!("no table named `{n}`"))); - } - let sheet = d.wb.active_sheet(); - let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; - let ti = sh - .tables - .iter() - .position(|t| t.range.contains(d.selection.active)) - .ok_or_else(|| EngineError::Disabled("table".into(), "select a cell in a table".into()))?; - Ok((sheet, ti)) -} - -fn table_flag(s: &mut Session, p: &Json, which: &str) -> Result { - let (si, ti) = find_table(s, p)?; - let on = bool_param(p, "on"); - edit(s, |cx| { - let sh = cx.sheet_mut(si)?; - let Some(t) = sh.tables.get_mut(ti) else { return Ok(Json::Null) }; - match which { - "totals" => { - let v = on.unwrap_or(!t.totals_row); - if v != t.totals_row { - if v && t.range.end.row.saturating_add(1) >= MAX_ROWS { - return Err(EngineError::Other("There's no room below the table for the total row.".into())); - } - t.totals_row = v; - if v { - t.range.end.row += 1; - if let Some(first) = t.columns.first_mut() { - first.totals_label = Some("Total".into()); - } - if t.columns.len() > 1 - && let Some(last) = t.columns.last_mut() - && last.totals == TotalsFn::None - { - last.totals = TotalsFn::Sum; - } - } else { - let row = t.range.end.row; - t.range.end.row = t.range.end.row.saturating_sub(1); - let (c0, c1) = (t.range.start.col, t.range.end.col); - for c in c0..=c1 { - sh.remove_cell(CellRef::new(row, c)); - } - } - write_totals(sh, ti); - cx.structural = true; - } - } - "bandedRows" => t.banded_rows = on.unwrap_or(!t.banded_rows), - "bandedCols" => t.banded_cols = on.unwrap_or(!t.banded_cols), - "firstCol" => t.first_col = on.unwrap_or(!t.first_col), - "lastCol" => t.last_col = on.unwrap_or(!t.last_col), - "header" => { - t.filter_button = on.unwrap_or(!t.filter_button); - if !t.filter_button { - t.filter.clear(); - } - } - _ => {} - } - Ok(Json::Null) - }) -} - -/// Writes the totals row cells (labels and SUBTOTAL formulas). -fn write_totals(sh: &mut Sheet, ti: usize) { - let Some(t) = sh.tables.get(ti).cloned() else { return }; - if !t.totals_row { - return; - } - let row = t.range.end.row; - for (i, col) in t.columns.iter().enumerate() { - let c = CellRef::new(row, t.range.start.col + i as u32); - if let Some(code) = col.totals.subtotal_code() { - let f = format!("=SUBTOTAL({code},{}[{}])", t.name, col.name); - sh.set_cell(c, Cell { formula: Some(std::sync::Arc::new(Formula::new(&f))), value: gridcraft_core::Value::Empty, style: sh.style_id(c) }); - } else if let Some(l) = &col.totals_label { - sh.set_value(c, gridcraft_core::Value::text(l.as_str())); - } else { - sh.remove_cell(c); - } - } -} - -fn table_total_fn(s: &mut Session, p: &Json) -> Result { - let (si, ti) = find_table(s, p)?; - let col = str_param(p, "column").ok_or_else(|| bad("table.totalFunction", "missing `column`"))?.to_string(); - let f = match str_param(p, "function").unwrap_or("sum") { - "average" => TotalsFn::Average, - "count" => TotalsFn::Count, - "countNums" => TotalsFn::CountNums, - "max" => TotalsFn::Max, - "min" => TotalsFn::Min, - "stdDev" => TotalsFn::StdDev, - "var" => TotalsFn::Var, - "none" => TotalsFn::None, - _ => TotalsFn::Sum, - }; - edit(s, |cx| { - let sh = cx.sheet_mut(si)?; - if let Some(t) = sh.tables.get_mut(ti) - && let Some(i) = t.column_index(&col) - && let Some(c) = t.columns.get_mut(i) - { - c.totals = f; - } - write_totals(sh, ti); - cx.structural = true; - Ok(Json::Null) - }) -} - -fn table_style(s: &mut Session, p: &Json) -> Result { - let (si, ti) = find_table(s, p)?; - let style = str_param(p, "style").unwrap_or("TableStyleMedium2").to_string(); - edit(s, |cx| { - if let Some(t) = cx.sheet_mut(si)?.tables.get_mut(ti) { - t.style = style.clone(); - } - Ok(Json::Null) - }) -} - -/// Table names follow the Microsoft name rules, as the xlsx writer does: letters, digits, `_` and `.`, -/// starting with a letter, `_` or `\`, and at most 255 characters. Anything else (a hyphen, say) would -/// read as an operator inside a structured reference, and the writer would rename the table on save. -fn valid_table_name(name: &str) -> bool { - name.chars().count() <= 255 - && name.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\') - && CellRef::parse(name).is_none() - && !super::formulas::is_r1c1(name) - && name.chars().enumerate().all(|(i, c)| c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\')) -} - -fn table_rename(s: &mut Session, p: &Json) -> Result { - let (si, ti) = find_table(s, p)?; - let name = str_param(p, "name").ok_or_else(|| bad("table.rename", "missing `name`"))?.to_string(); - if !valid_table_name(&name) { - return Err(EngineError::Other("The name that you entered is not valid.".into())); - } - let wb = &s.doc()?.wb; - if wb.table(&name).is_some_and(|x| x != (si, ti)) || wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) { - return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into())); - } - edit(s, |cx| { - let old = cx.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.name.clone()).unwrap_or_default(); - if let Some(t) = cx.sheet_mut(si)?.tables.get_mut(ti) { - t.name = name.clone(); - } - // Structured references follow the rename. - for i in 0..cx.wb.sheets.len() { - let keys: Vec<(CellRef, std::sync::Arc)> = - cx.wb.sheets.get(i).map(|s| s.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default(); - // A bare name that is also a defined name resolves to the defined name, so it is not the table. - let shadowed = cx.wb.name(&old, i).is_some(); - let Some(sh) = cx.wb.sheet_mut(i) else { continue }; - for (c, f) in keys { - let Some(e) = f.expr() else { continue }; - let ne = e.map(&mut |x| match x { - gridcraft_formula::Expr::Struct(mut st) if st.table.eq_ignore_ascii_case(&old) => { - st.table = name.clone(); - gridcraft_formula::Expr::Struct(st) - } - gridcraft_formula::Expr::Name(n) if !shadowed && n.eq_ignore_ascii_case(&old) => gridcraft_formula::Expr::Name(name.clone()), - o => o, - }); - if let Some(cell) = sh.cells.get_mut(c) { - cell.formula = Some(std::sync::Arc::new(Formula::from_expr(ne))); - } - } - } - cx.structural = true; - Ok(Json::Null) - }) -} - -fn table_convert(s: &mut Session, p: &Json) -> Result { - let (si, ti) = find_table(s, p)?; - edit(s, |cx| { - // Apply the table look as plain formatting, then drop the table. - let wb = &mut cx.wb; - let Some(t) = wb.sheet(si).and_then(|sh| sh.tables.get(ti)).cloned() else { return Ok(Json::Null) }; - let looks: Vec<(CellRef, Style)> = t.range.iter().map(|c| (c, crate::tables::table_cell_style(wb, si, &t, c))).collect(); - for (c, st) in looks { - let id = wb.styles.intern(st); - if let Some(sh) = wb.sheet_mut(si) { - sh.set_style(c, id); - } - } - if let Some(sh) = wb.sheet_mut(si) { - sh.tables.remove(ti); - } - cx.structural = true; - Ok(Json::Null) - }) -} - -// ---------------------------------------------------------------- charts - -pub fn chart_kind(t: &str, sub: &str) -> ChartKind { - match (t, sub) { - ("column", "stacked") => ChartKind::ColumnStacked, - ("column", "stacked100") => ChartKind::ColumnStacked100, - ("bar", "stacked") => ChartKind::BarStacked, - ("bar", "stacked100") => ChartKind::BarStacked100, - ("bar", _) => ChartKind::BarClustered, - ("line", "markers") => ChartKind::LineMarkers, - ("line", "stacked") => ChartKind::LineStacked, - ("line", _) => ChartKind::Line, - ("pie", _) => ChartKind::Pie, - ("doughnut", _) => ChartKind::Doughnut, - ("area", "stacked") => ChartKind::AreaStacked, - ("area", "stacked100") => ChartKind::AreaStacked100, - ("area", _) => ChartKind::Area, - ("scatter", "lines") => ChartKind::ScatterLines, - ("scatter", _) => ChartKind::Scatter, - ("bubble", _) => ChartKind::Bubble, - ("radar", _) => ChartKind::Radar, - ("histogram", _) => ChartKind::Histogram, - ("waterfall", _) => ChartKind::Waterfall, - ("funnel", _) => ChartKind::Funnel, - ("treemap", _) => ChartKind::Treemap, - ("sunburst", _) => ChartKind::Sunburst, - ("boxWhisker", _) => ChartKind::BoxWhisker, - ("stock", _) => ChartKind::Stock, - ("combo", _) => ChartKind::Combo, - _ => ChartKind::ColumnClustered, - } -} - -/// Builds series from a block: first row/column as names/categories when they're text. -pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec { - let q = gridcraft_formula::quote_sheet(&sh.name); - let abs = |r: RangeRef| { - format!( - "{q}!${}${}:${}${}", - gridcraft_core::col_to_letters(r.start.col), - r.start.row + 1, - gridcraft_core::col_to_letters(r.end.col), - r.end.row + 1 - ) - }; - let abs1 = |c: CellRef| format!("{q}!${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1); - let is_text = |c: CellRef| sh.value(c).is_text() || sh.value(c).is_empty(); - let header_row = r.height() > 1 - && (r.start.col..=r.end.col).any(|c| sh.value(CellRef::new(r.start.row, c)).is_text()) - && (r.start.col..=r.end.col).filter(|c| *c != r.start.col).all(|c| is_text(CellRef::new(r.start.row, c))); - // A blank top-left cell above a header row marks the first column as the category labels even - // when it holds numbers (years, X values), the usual spreadsheet convention, so it isn't - // plotted as a series of its own (#287). - let blank_corner = header_row && sh.value(r.start).is_empty(); - let label_col = - r.width() > 1 && (blank_corner || (r.start.row + header_row as u32..=r.end.row).all(|row| is_text(CellRef::new(row, r.start.col)))); - let data = RangeRef::new(CellRef::new(r.start.row + header_row as u32, r.start.col + label_col as u32), r.end); - let mut out = Vec::new(); - if !by_rows { - for c in data.start.col..=data.end.col { - out.push(Series { - name: header_row.then(|| abs1(CellRef::new(r.start.row, c))), - categories: label_col.then(|| abs(RangeRef::new(CellRef::new(data.start.row, r.start.col), CellRef::new(data.end.row, r.start.col)))), - values: abs(RangeRef::new(CellRef::new(data.start.row, c), CellRef::new(data.end.row, c))), - bubble_sizes: None, - color: None, - secondary: false, - kind: None, - quartiles: Default::default(), - smooth: false, - trendlines: Vec::new(), - }); - } - } else { - for row in data.start.row..=data.end.row { - out.push(Series { - name: label_col.then(|| abs1(CellRef::new(row, r.start.col))), - categories: header_row - .then(|| abs(RangeRef::new(CellRef::new(r.start.row, data.start.col), CellRef::new(r.start.row, data.end.col)))), - values: abs(RangeRef::new(CellRef::new(row, data.start.col), CellRef::new(row, data.end.col))), - bubble_sizes: None, - color: None, - secondary: false, - kind: None, - quartiles: Default::default(), - smooth: false, - trendlines: Vec::new(), - }); - } - } - out -} - -fn series_from_areas(sh: &Sheet, areas: &[RangeRef]) -> Result<(RangeRef, bool, Vec)> { - let Some(first) = areas.first().copied() else { return Err(EngineError::Other("Select data for the chart first.".into())) }; - let bounds = areas.iter().skip(1).fold(first, |r, area| r.union(area)); - if areas.len() == 1 { - let by_rows = first.width() > first.height() + 1; - return Ok((bounds, by_rows, series_from_range(sh, first, by_rows))); - } - let valid_shape = first.width() == 1 - && first.height() > 1 - && areas.iter().skip(1).all(|area| area.width() == 1 && area.height() == first.height()) - && first.iter().all(|cell| sh.value(cell).is_text() || sh.value(cell).is_empty()); - if !valid_shape { - return Err(bad("insert.chart", "multi-area charts require a text category column followed by equal-height series columns")); - } - let category = series_from_range(sh, first, false).into_iter().next().map(|series| series.values); - let mut series = Vec::with_capacity(areas.len() - 1); - for area in &areas[1..] { - let mut area_series = series_from_range(sh, *area, false); - let Some(mut item) = area_series.pop() else { - return Err(EngineError::Other("Select data for the chart first.".into())); - }; - if item.name.is_none() { - return Err(bad("insert.chart", "multi-area series columns require headers")); - } - item.categories = category.clone(); - series.push(item); - } - Ok((bounds, false, series)) -} - -/// A scatter plot of a block without category labels takes its first column (or row) as the X -/// values of the others, as Excel does: two plain columns make one series of points, not two (#428). -pub fn scatter_x_from_first(kind: ChartKind, series: &mut Vec) { - if matches!(kind, ChartKind::Scatter | ChartKind::ScatterLines) && series.len() > 1 && series[0].categories.is_none() { - let x = series.remove(0).values; - for se in series.iter_mut() { - se.categories = Some(x.clone()); - } - } -} - -/// Most cells a chart's source block may span (bounds the series it builds). -const MAX_CHART_CELLS: u64 = 1_000_000; - -/// Chart types that suit the shape of the data, best first, as `(type, subtype, label)` for -/// `insert.chart`. Our own heuristic from the shape of the block (series count, points per -/// series, text categories, negative values), not a copy of Excel's ranking: two numeric columns -/// without labels → scatter; one short series → column, pie, bar; long series → line; several -/// series → clustered and stacked columns and bars, line. -pub fn recommend_charts(s: &Session, p: &Json) -> Result> { - let areas = chart_areas(s, p)?; - if areas.iter().fold(0_u64, |count, area| count.saturating_add(area.count())) > MAX_CHART_CELLS { - return Err(bad("insert.chart", "range too large for a chart")); - } - let sheet = target_sheet(s, p)?; - let d = s.doc()?; - let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; - let (_, by_rows, series) = series_from_areas(sh, &areas)?; - let ranges: Vec = series - .iter() - .filter_map(|x| match gridcraft_formula::parse(&x.values) { - Ok(gridcraft_formula::Expr::Ref(rf)) => Some(rf.range()), - _ => None, - }) - .collect(); - let points = ranges.first().map_or(0, RangeRef::count); - let (mut any_number, mut non_negative) = (false, true); - for n in ranges.iter().flat_map(|rr| rr.iter()).filter_map(|c| sh.value(c).as_f64()) { - any_number = true; - non_negative &= n >= 0.0; - } - if series.is_empty() || !any_number { - return Err(EngineError::Other("Select data for the chart first.".into())); - } - let labelled = series.first().is_some_and(|x| x.categories.is_some()); - const COLUMN: (&str, &str, &str) = ("column", "", "Clustered Column"); - const BAR: (&str, &str, &str) = ("bar", "", "Clustered Bar"); - const LINE: (&str, &str, &str) = ("line", "", "Line"); - const LINE_MARKERS: (&str, &str, &str) = ("line", "markers", "Line with Markers"); - const PIE: (&str, &str, &str) = ("pie", "", "Pie"); - const DOUGHNUT: (&str, &str, &str) = ("doughnut", "", "Doughnut"); - const AREA: (&str, &str, &str) = ("area", "", "Area"); - const SCATTER: (&str, &str, &str) = ("scatter", "", "Scatter"); - const STACKED_COLUMN: (&str, &str, &str) = ("column", "stacked", "Stacked Column"); - const STACKED_BAR: (&str, &str, &str) = ("bar", "stacked", "Stacked Bar"); - let mut out = Vec::new(); - if series.len() == 2 && !labelled && !by_rows { - out.extend([SCATTER, LINE_MARKERS, COLUMN]); - } else if series.len() == 1 { - if points > 12 { - out.extend([LINE, COLUMN, AREA]); - } else { - out.extend([COLUMN, BAR]); - if non_negative && points >= 2 { - out.extend([PIE, DOUGHNUT]); - } - out.push(LINE_MARKERS); - } - } else if points > 12 { - out.extend([LINE, COLUMN, AREA, STACKED_COLUMN]); - } else { - out.extend([COLUMN, BAR, STACKED_COLUMN, LINE_MARKERS, STACKED_BAR]); - } - Ok(out) -} - -/// The chart `insert.chart` would insert for these params, without inserting it: the sheet it -/// goes on and the chart (previews in the Recommended Charts dialog). -pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> { - let areas = chart_areas(s, p)?; - if areas.iter().fold(0_u64, |count, area| count.saturating_add(area.count())) > MAX_CHART_CELLS { - return Err(bad("insert.chart", "range too large for a chart")); - } - let mut t = str_param(p, "type").unwrap_or("column").to_string(); - let mut sub = str_param(p, "subtype").unwrap_or("").to_string(); - if t == "auto" { - let first = recommend_charts(s, p)?.into_iter().next().unwrap_or(("column", "", "")); - t = first.0.into(); - sub = first.1.into(); - } - let sheet = target_sheet(s, p)?; - let d = s.doc()?; - let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; - let kind = chart_kind(&t, &sub); - let (bounds, by_rows, mut series) = series_from_areas(sh, &areas)?; - if series.is_empty() { - return Err(EngineError::Other("Select data for the chart first.".into())); - } - scatter_x_from_first(kind, &mut series); - let id = d.wb.next_object_id(); - let at = cell_param_on(s, p, "at", sheet)?.unwrap_or_else(|| CellRef::new(bounds.start.row, bounds.end.col + 2)); - let title = str_param(p, "title").map(str::to_string).or_else(|| { - if series.len() == 1 { - series[0].name.as_ref().map(|n| gridcraft_calc::evaluate(&d.wb, sheet, at, n).display()) - } else { - Some("Chart Title".into()) - } - }); - let chart = Chart { - id, - kind, - anchor: Anchor { - cell: at, - dx: 0.0, - dy: 0.0, - width: f64_param(p, "width").unwrap_or(480.0) as f32, - height: f64_param(p, "height").unwrap_or(288.0) as f32, - mode: anchor_mode_param(p).unwrap_or_default(), - }, - title, - series, - legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom }, - data_labels: false, - gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel), - style: 1, - x_title: None, - y_title: None, - source: (areas.len() == 1).then(|| format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), bounds.a1())), - by_rows, - pivot: None, - }; - Ok((sheet, chart)) -} - -fn insert_chart(s: &mut Session, p: &Json) -> Result { - let (sheet, chart) = build_chart(s, p)?; - let id = chart.id; - edit(s, |cx| { - cx.sheet_mut(sheet)?.charts.push(chart); - Ok(json!({"chart": id})) - }) -} - -fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> { - let d = s.doc()?; - let sheet = d.wb.active_sheet(); - let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; - let idx = match u32_param(p, "chart") { - Some(id) => sh.charts.iter().position(|c| c.id == id), - None => sh.charts.len().checked_sub(1), - }; - idx.map(|i| (sheet, i)).ok_or_else(|| bad("chart", "no such chart")) -} - -fn chart_set(s: &mut Session, p: &Json) -> Result { - let (si, ci) = find_chart(s, p)?; - let at = cell_param_on(s, p, "at", si)?; - let quartiles = p - .get("quartiles") - .map(|v| match v.as_str() { - Some("exclusive") => Ok(gridcraft_model::QuartileMethod::Exclusive), - Some("inclusive") => Ok(gridcraft_model::QuartileMethod::Inclusive), - _ => Err(bad("quartiles", "expected exclusive or inclusive")), - }) - .transpose()?; - edit(s, |cx| { - let sh = cx.sheet_mut(si)?; - let Some(c) = sh.charts.get_mut(ci) else { return Ok(Json::Null) }; - if let Some(t) = str_param(p, "type") { - c.kind = chart_kind(t, str_param(p, "subtype").unwrap_or("")); - } - if let Some(method) = quartiles { - for series in &mut c.series { - series.quartiles = method; - } - } - if let Some(t) = p.get("title") { - c.title = t.as_str().map(str::to_string); - } - if let Some(l) = str_param(p, "legend") { - c.legend = match l { - "none" => LegendPos::None, - "top" => LegendPos::Top, - "left" => LegendPos::Left, - "right" => LegendPos::Right, - _ => LegendPos::Bottom, - }; - } - if let Some(v) = bool_param(p, "dataLabels") { - c.data_labels = v; - } - if let Some(v) = bool_param(p, "gridlines") { - c.gridlines = v; - } - if let Some(v) = u32_param(p, "style") { - c.style = v.clamp(1, 16); - } - if let Some(t) = p.get("xTitle") { - c.x_title = t.as_str().map(str::to_string); - } - if let Some(t) = p.get("yTitle") { - c.y_title = t.as_str().map(str::to_string); - } - if let Some(at) = at { - c.anchor.cell = at; - } - if let Some(w) = f64_param(p, "width") { - c.anchor.width = w.clamp(20.0, 10000.0) as f32; - } - if let Some(h) = f64_param(p, "height") { - c.anchor.height = h.clamp(20.0, 10000.0) as f32; - } - if let Some(v) = f64_param(p, "dx") { - c.anchor.dx = v as f32; - } - if let Some(v) = f64_param(p, "dy") { - c.anchor.dy = v as f32; - } - Ok(Json::Null) - }) -} - -fn chart_switch(s: &mut Session, p: &Json) -> Result { - let (si, ci) = find_chart(s, p)?; - edit(s, |cx| { - let Some(sh) = cx.wb.sheet(si) else { return Ok(Json::Null) }; - let Some(c) = sh.charts.get(ci) else { return Ok(Json::Null) }; - // Charts read from a file have no source range: take the block their series span. - let Some((src, by_rows)) = c.source.clone().map(|s| (s, c.by_rows)).or_else(|| series_block(&c.series)) else { return Ok(Json::Null) }; - let (sheet_name, body) = split_sheet(&src); - let src_sheet = sheet_name.and_then(|n| cx.wb.sheet_index(&n)).unwrap_or(si); - let Some(mut r) = RangeRef::parse(body) else { return Ok(Json::Null) }; - let Some(ssh) = cx.wb.sheet(src_sheet) else { return Ok(Json::Null) }; - // Whole-column/row references (common in files) span a million cells: clip the block's - // far edges to the cells in use, then refuse anything still too large to chart. - if let Some(used) = ssh.used_range() { - r.end.row = r.end.row.min(used.end.row).max(r.start.row); - r.end.col = r.end.col.min(used.end.col).max(r.start.col); - } - if r.count() > MAX_CHART_CELLS { - return Err(bad("chart.switchRowColumn", "range too large for a chart")); - } - let by_rows = !by_rows; - let series = series_from_range(ssh, r, by_rows); - if let Some(c) = cx.sheet_mut(si)?.charts.get_mut(ci) { - c.source.get_or_insert(src); - c.by_rows = by_rows; - c.series = series; - scatter_x_from_first(c.kind, &mut c.series); - } - Ok(Json::Null) - }) -} - -/// The block a chart's series come from, and whether each series is a row of it: the bounding -/// range of the series names, categories and values, when they're all references to one sheet. -fn series_block(series: &[Series]) -> Option<(String, bool)> { - let mut sheet: Option = None; - let mut bounds: Option = None; - let mut rows = 0; - for s in series { - for (i, f) in [Some(&s.values), s.categories.as_ref(), s.name.as_ref()].into_iter().flatten().enumerate() { - let Ok(gridcraft_formula::Expr::Ref(r)) = gridcraft_formula::parse(f) else { return None }; - let gridcraft_formula::SheetSel::Named(n) = &r.sheet else { return None }; - if !sheet.get_or_insert_with(|| n.clone()).eq_ignore_ascii_case(n) { - return None; - } - let range = r.range(); - if i == 0 && range.height() == 1 && range.width() > 1 { - rows += 1; - } - bounds = Some(bounds.map_or(range, |b| b.union(&range))); - } - } - Some((format!("{}!{}", gridcraft_formula::quote_sheet(&sheet?), bounds?.a1()), rows > 0 && rows == series.len())) -} - -fn chart_delete(s: &mut Session, p: &Json) -> Result { - let (si, ci) = find_chart(s, p)?; - edit(s, |cx| { - cx.sheet_mut(si)?.charts.remove(ci); - Ok(Json::Null) - }) -} - -fn insert_sparkline(s: &mut Session, p: &Json) -> Result { - let src = str_param(p, "range").ok_or_else(|| bad("insert.sparkline", "missing `range`"))?.to_string(); - let loc = cell_param(s, p, "location")?.ok_or_else(|| bad("insert.sparkline", "missing `location`"))?; - let kind = match str_param(p, "type") { - Some("column") => SparklineKind::Column, - Some("winLoss") => SparklineKind::WinLoss, - _ => SparklineKind::Line, - }; - let markers = bool_param(p, "markers").unwrap_or(false); - let sheet = s.doc()?.wb.active_sheet(); - edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - sh.sparklines.retain(|x| x.cell != loc); - sh.sparklines.push(Sparkline { cell: loc, source: src.clone(), kind, color: Color::Theme(4, 0), markers }); - Ok(Json::Null) - }) -} - -fn insert_picture(s: &mut Session, p: &Json) -> Result { - let placement = str_param(p, "placement").unwrap_or("overCells"); - if !matches!(placement, "cell" | "overCells") { - return Err(bad("insert.picture", "placement must be cell or overCells")); - } - let data = if let Some(b) = str_param(p, "base64") { - crate::io::base64_decode(b).ok_or_else(|| bad("insert.picture", "invalid base64"))? - } else if let Some(path) = str_param(p, "path") { - crate::io::read_file(path)? - } else { - s.ui_requests.push(crate::UiRequest::Dialog("insertPicture".into(), json!({"placement": placement}))); - return ok(); - }; - if placement == "cell" { - let mime = validate_cell_picture(&data)?; - let d = s.doc()?; - let sheet = d.wb.active_sheet(); - let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - let at = match str_param(p, "at") { - Some(a) => CellRef::parse(a).ok_or_else(|| bad("insert.picture", "invalid cell address"))?, - None => d.selection.active, - }; - let at = sh.merge_at(at).map(|r| r.start).unwrap_or(at); - let style = sh.style_id(at); - if sh.is_protected() && d.wb.styles.get(style).protection.locked && locked_cell_error(sh, at, &d.unlocked_ranges).is_some() { - return Err(EngineError::Other("The cell you're trying to change is on a protected sheet.".into())); - } - let picture = CellPicture { data, mime: mime.into(), alt: str_param(p, "alt").unwrap_or("").into() }; - return edit(s, |cx| { - cx.sheet_mut(sheet)?.set_picture(at, std::sync::Arc::new(picture)); - cx.touch(sheet, at); - Ok(json!({"cell": at.a1(), "placement": "cell"})) - }); - } - if data.len() > 64 * 1024 * 1024 { - return Err(bad("insert.picture", "image larger than 64 MB")); - } - let mime = str_param(p, "mime") - .map(str::to_string) - .unwrap_or_else(|| if data.starts_with(&[0x89, b'P', b'N', b'G']) { "image/png".into() } else { "image/jpeg".into() }); - let (w, h) = crate::io::image_size(&data).unwrap_or((320, 240)); - let d = s.doc()?; - let id = d.wb.next_object_id(); - let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); - let scale = (640.0 / w.max(1) as f32).min(1.0); - let img = Image { - id, - anchor: Anchor { - cell: at, - dx: 0.0, - dy: 0.0, - width: f64_param(p, "width").map(|v| v as f32).unwrap_or(w as f32 * scale), - height: f64_param(p, "height").map(|v| v as f32).unwrap_or(h as f32 * scale), - // Excel inserts pictures as "Move but don't size with cells" (`editAs="oneCell"`). - mode: anchor_mode_param(p).unwrap_or(gridcraft_model::AnchorMode::MoveOnly), - }, - data: data.into(), - mime, - alt: str_param(p, "alt").unwrap_or("").into(), - }; - let sheet = d.wb.active_sheet(); - edit(s, |cx| { - cx.sheet_mut(sheet)?.images.push(img); - Ok(json!({"image": id})) - }) -} - -/// Validate before mutating the workbook; header dimensions alone do not prove the pixels decode. -fn validate_cell_picture(data: &[u8]) -> Result<&'static str> { - if data.len() > 16 * 1024 * 1024 { - return Err(bad("insert.picture", "cell pictures must be at most 16 MiB")); - } - let format = image::guess_format(data).map_err(|_| bad("insert.picture", "choose a valid PNG or JPEG picture"))?; - let mime = match format { - image::ImageFormat::Png => "image/png", - image::ImageFormat::Jpeg => "image/jpeg", - _ => return Err(bad("insert.picture", "cell pictures support PNG and JPEG")), - }; - let (width, height) = image::ImageReader::with_format(std::io::Cursor::new(data), format) - .into_dimensions() - .map_err(|_| bad("insert.picture", "invalid image dimensions"))?; - if width == 0 || height == 0 || width > 8192 || height > 8192 || u64::from(width) * u64::from(height) > 16_000_000 { - return Err(bad("insert.picture", "cell pictures are limited to 8192 pixels per side and 16 million pixels")); - } - let mut reader = image::ImageReader::with_format(std::io::Cursor::new(data), format); - let mut limits = image::Limits::default(); - limits.max_image_width = Some(8192); - limits.max_image_height = Some(8192); - limits.max_alloc = Some(64 * 1024 * 1024); - reader.limits(limits); - reader.decode().map_err(|_| bad("insert.picture", "picture cannot be decoded within the 64 MiB limit"))?; - Ok(mime) -} - -fn insert_shape(s: &mut Session, p: &Json) -> Result { - let kind = match str_param(p, "kind").unwrap_or("rectangle") { - "roundedRectangle" => ShapeKind::RoundedRectangle, - "ellipse" | "oval" => ShapeKind::Ellipse, - "triangle" => ShapeKind::Triangle, - "line" => ShapeKind::Line, - "arrow" => ShapeKind::Arrow, - "textBox" => ShapeKind::TextBox, - _ => ShapeKind::Rectangle, - }; - let d = s.doc()?; - let id = d.wb.next_object_id(); - let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); - let fill = - super::format::color_param(p.get("fill")).unwrap_or(if kind == ShapeKind::TextBox { Color::rgb(255, 255, 255) } else { Color::Theme(4, 0) }); - let line = super::format::color_param(p.get("line")).unwrap_or(if kind == ShapeKind::TextBox { - Color::rgb(0x80, 0x80, 0x80) - } else { - Color::Theme(4, -250) - }); - let shape = Shape { - id, - kind, - anchor: Anchor { - cell: at, - dx: 0.0, - dy: 0.0, - width: f64_param(p, "width").unwrap_or(144.0) as f32, - height: f64_param(p, "height").unwrap_or(if kind == ShapeKind::Line { 0.0 } else { 96.0 }) as f32, - mode: anchor_mode_param(p).unwrap_or_default(), - }, - fill, - line, - text: str_param(p, "text").unwrap_or("").into(), - format: Default::default(), - }; - let sheet = d.wb.active_sheet(); - edit(s, |cx| { - cx.sheet_mut(sheet)?.shapes.push(shape); - Ok(json!({"shape": id})) - }) -} - -/// A text box holds at most as many characters as a cell. -const SHAPE_TEXT_MAX_CHARS: usize = 32_767; - -fn shape_set_text(s: &mut Session, p: &Json) -> Result { - let id = - p.get("id").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()).ok_or_else(|| bad("shape.setText", "`id` must be a text box id"))?; - let text = str_param(p, "text").ok_or_else(|| bad("shape.setText", "`text` must be a string"))?; - if text.chars().nth(SHAPE_TEXT_MAX_CHARS).is_some() { - return Err(bad("shape.setText", "`text` is longer than 32,767 characters")); - } - let d = s.doc()?; - let sheet = d.wb.active_sheet(); - let sh = d.wb.active().ok_or_else(|| bad("shape.setText", "no such text box"))?; - let shape = sh.shapes.iter().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?; - if shape.kind != ShapeKind::TextBox { - return Err(bad("shape.setText", "the shape is not a text box")); - } - // Sheet protection locks drawing objects (there is no "edit objects" allowance yet). - if sh.is_protected() { - return Err(EngineError::Other(PROTECTED.into())); - } - if shape.text == text { - return ok(); - } - edit(s, |cx| { - let shape = cx.sheet_mut(sheet)?.shapes.iter_mut().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?; - shape.text = text.into(); - Ok(Json::Null) - }) -} - -fn delete_object(s: &mut Session, p: &Json) -> Result { - let id = u32_param(p, "id").ok_or_else(|| bad("object.delete", "missing `id`"))?; - let kind = str_param(p, "kind").unwrap_or("").to_string(); - let sheet = s.doc()?.wb.active_sheet(); - edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - match kind.as_str() { - "chart" => sh.charts.retain(|c| c.id != id), - "image" => sh.images.retain(|c| c.id != id), - "slicer" => sh.slicers.retain(|c| c.id != id), - _ => sh.shapes.retain(|c| c.id != id), - } - Ok(Json::Null) - }) -} - -fn move_object(s: &mut Session, p: &Json) -> Result { - let id = u32_param(p, "id").ok_or_else(|| bad("object.move", "missing `id`"))?; - let kind = str_param(p, "kind").unwrap_or("").to_string(); - let sheet = s.doc()?.wb.active_sheet(); - let at = cell_param(s, p, "at")?; - edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - let anchor = match kind.as_str() { - "chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), - "image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), - "slicer" => sh.slicers.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), - "shape" => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), - _ => None, - }; - let Some(a) = anchor else { return Err(bad("object.move", "no such object")) }; - if let Some(at) = at { - a.cell = at; - } - if let Some(v) = f64_param(p, "dx") { - a.dx = v as f32; - } - if let Some(v) = f64_param(p, "dy") { - a.dy = v as f32; - } - if let Some(v) = f64_param(p, "width") { - a.width = v.clamp(1.0, 10000.0) as f32; - } - if let Some(v) = f64_param(p, "height") { - a.height = v.clamp(0.0, 10000.0) as f32; - } - Ok(Json::Null) - }) -} - -/// Sets an object's anchor mode (Excel's "Move and size with cells" / "Move but don't size with -/// cells" / "Don't move or size with cells"), so it follows — or ignores — its rows and columns -/// when the sheet is sorted or edited structurally. -fn set_anchor_mode(s: &mut Session, p: &Json) -> Result { - let id = u32_param(p, "id").ok_or_else(|| bad("object.setAnchorMode", "missing `id`"))?; - let kind = str_param(p, "kind").unwrap_or("").to_string(); - if !matches!(kind.as_str(), "chart" | "image" | "shape") { - return Err(bad("object.setAnchorMode", "kind must be chart, image or shape")); - } - let mode = anchor_mode_param(p).ok_or_else(|| bad("object.setAnchorMode", "mode must be moveAndSize, moveOnly or absolute"))?; - let sheet = s.doc()?.wb.active_sheet(); - edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - let anchor = match kind.as_str() { - "chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), - "image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), - _ => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), - }; - let Some(a) = anchor else { return Err(bad("object.setAnchorMode", "no such object")) }; - a.mode = mode; - Ok(json!({"mode": anchor_mode_name(mode)})) - }) -} - -// ---------------------------------------------------------------- links & comments - -fn insert_link(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; - let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); - if bool_param(p, "remove").unwrap_or(false) { - return edit(s, |cx| { - cx.sheet_mut(sheet)?.hyperlinks.remove(&at); - Ok(Json::Null) - }); - } - let Some(target) = str_param(p, "target").map(str::to_string) else { - s.ui_requests.push(crate::UiRequest::Dialog("insertLink".into(), json!({}))); - return ok(); - }; - let text = str_param(p, "text").map(str::to_string); - let tooltip = str_param(p, "tooltip").map(str::to_string); - let external = links_outside(&s.doc()?.wb, sheet, &target); - edit(s, |cx| { - let link_style = cx.wb.styles.derive(cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), |st| { - st.font.color = Color::Theme(10, 0); - st.font.underline = Underline::Single; - }); - let sh = cx.sheet_mut(sheet)?; - if let Some(t) = text.clone().or_else(|| sh.value(at).is_empty().then(|| target.clone())) { - sh.set_cell(at, Cell { value: gridcraft_core::Value::text(t.as_str()), formula: None, style: link_style }); - } else { - sh.set_style(at, link_style); - } - sh.hyperlinks.insert(at, Hyperlink { target: target.clone(), tooltip: tooltip.clone(), external }); - cx.touch(sheet, at); - Ok(Json::Null) - }) -} - -/// Follows a link: a reference selects it, an `http`/`https`/`mailto` address is handed to the -/// host (`UiRequest::OpenUrl`); anything else is refused ([`crate::links::classify`]). Opens no -/// dialog; the interactive click asks for confirmation first, in the UI. -fn open_link(s: &mut Session, p: &Json) -> Result { - let target = match str_param(p, "url").or_else(|| str_param(p, "target")) { - Some(t) => t.to_string(), - None => { - let sheet = target_sheet(s, p)?; - let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); - let Some(h) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.hyperlinks.get(&at)) else { - return Err(EngineError::Other("There's no hyperlink in this cell.".into())); - }; - h.target.clone() - } - }; - match crate::links::classify(&target) { - crate::links::LinkKind::Internal(r) => super::edit::go_to(s, &json!({"reference": r.trim_start_matches('#')})), - crate::links::LinkKind::External(u) => { - s.ui_requests.push(crate::UiRequest::OpenUrl(u.clone())); - Ok(json!({"opened": u})) - } - crate::links::LinkKind::Refused(why) => Err(EngineError::Other(why)), - } -} - -/// Sorts a link target as the two pages of Excel's Insert Hyperlink dialog do: "Place in This -/// Document" is a sheet reference, a defined name or `#…`; anything else ("Existing File or Web -/// Page") is outside the workbook and saved as a relationship. -fn links_outside(wb: &Workbook, sheet: usize, target: &str) -> bool { - let t = target.trim(); - if t.starts_with('#') || wb.name(t, sheet).is_some() || wb.table(t).is_some() { - return false; - } - let (on, body) = split_sheet(t); - // Sheet names can't hold `: \ / ? * [ ]`, so `https://…!A1` or `C:\…!A1` is no sheet reference, - // and `Book.xlsx#Sheet1!A1` is a place in another file unless a sheet has that name. - let on_sheet = on.is_none_or(|name| { - wb.sheets.iter().any(|s| s.name.eq_ignore_ascii_case(&name)) - || (!name.is_empty() && !name.contains([':', '\\', '/', '?', '*', '[', ']', '#'])) - }); - !(on_sheet && RangeRef::parse(body).is_some()) -} - -fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result { - let sheet = target_sheet(s, p)?; - let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); - let text = str_param(p, "text").unwrap_or("").to_string(); - let named = str_param(p, "author").map(str::to_string); - let author = named.clone().unwrap_or_else(|| s.prefs.user_name.clone()); - edit(s, |cx| { - let comments = &mut cx.sheet_mut(sheet)?.comments; - // Writing a note on a cell that has one edits it, as Excel's Edit Note does: the box, the - // replies, the dates and who wrote it stay. A note replaces a comment and vice versa. - if let Some(c) = comments.get_mut(&at).filter(|c| c.threaded == threaded) { - c.text = text.clone(); - if let Some(author) = named.clone() { - c.author = author; - } - return Ok(json!({"cell": at.a1()})); - } - comments.insert( - at, - Comment { - author: author.clone(), - text: text.clone(), - replies: vec![], - threaded, - resolved: false, - visible: false, - dates: vec![], - note_box: None, - }, - ); - Ok(json!({"cell": at.a1()})) - }) -} - -fn reply_comment(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; - let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); - let text = str_param(p, "text").unwrap_or("").to_string(); - let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone()); - edit(s, |cx| { - let c = cx.sheet_mut(sheet)?.comments.get_mut(&at).ok_or_else(|| EngineError::Other("There's no comment in this cell.".into()))?; - c.replies.push((author.clone(), text.clone())); - Ok(Json::Null) - }) -} - -fn resolve_comment(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; - let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); - let v = bool_param(p, "resolved").unwrap_or(true); - edit(s, |cx| { - if let Some(c) = cx.sheet_mut(sheet)?.comments.get_mut(&at) { - c.resolved = v; - } - Ok(Json::Null) - }) -} - -fn delete_comment(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; - let ranges = target_ranges(s, p)?; - edit(s, |cx| { - cx.sheet_mut(sheet)?.comments.retain(|c, _| !ranges.iter().any(|r| r.contains(*c))); - Ok(Json::Null) - }) -} - -fn show_note(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; - let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); - edit(s, |cx| { - if let Some(c) = cx.sheet_mut(sheet)?.comments.get_mut(&at) { - c.visible = !c.visible; - } - Ok(Json::Null) - }) -} - fn insert_symbol(s: &mut Session, p: &Json) -> Result { let ch = str_param(p, "char").unwrap_or("").to_string(); let d = s.doc()?; @@ -1399,344 +247,6 @@ fn insert_symbol(s: &mut Session, p: &Json) -> Result { s.execute("cell.set", json!({"cell": at.a1(), "input": format!("{cur}{ch}")})) } -// ---------------------------------------------------------------- conditional formatting - -fn preset_style(name: &str) -> Style { - let mut s = Style::default(); - match name { - "redText" => s.font.color = Color::rgb(0x9C, 0x00, 0x06), - "yellowFill" | "yellowFillDarkYellowText" => { - s.fill = Fill::solid(Color::rgb(0xFF, 0xEB, 0x9C)); - s.font.color = Color::rgb(0x9C, 0x57, 0x00); - } - "greenFill" | "greenFillDarkGreenText" => { - s.fill = Fill::solid(Color::rgb(0xC6, 0xEF, 0xCE)); - s.font.color = Color::rgb(0x00, 0x61, 0x00); - } - "lightRedFill" => s.fill = Fill::solid(Color::rgb(0xFF, 0xC7, 0xCE)), - "redBorder" => { - let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0xC0, 0x00, 0x00) }; - s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() }; - } - _ => { - s.fill = Fill::solid(Color::rgb(0xFF, 0xC7, 0xCE)); - s.font.color = Color::rgb(0x9C, 0x00, 0x06); - } - } - s -} - -/// Whether `v` is one formula string literal: `"…"` with every inner quote doubled. -fn is_string_literal(v: &str) -> bool { - v.len() >= 2 && v.starts_with('"') && v.ends_with('"') && v.get(1..v.len() - 1).is_some_and(|inner| !inner.replace("\"\"", "").contains('"')) -} - -/// A conditional format's `style` as the model's `Style` JSON (`font`, `fill`, `borders`, `align`, -/// `num_fmt`, `protection`, merged over the default by the caller), where a colour may also be -/// written as `format_range` takes it (`"#RRGGBB"`, `{theme, tint}`, `"auto"`) and `fill` may be a -/// colour alone (a solid fill) or `"none"` (no fill). An unknown key is an error rather than -/// dropped: dropped, it left a rule with an empty format that matches and shows nothing. -fn cf_style_json(st: &Json) -> Result { - const KEYS: [&str; 6] = ["font", "fill", "borders", "align", "num_fmt", "protection"]; - let err = |m: String| bad("home.conditionalFormat", m); - let Some(obj) = st.as_object() else { return Err(err("`style` must be an object, e.g. {\"fill\": \"#FFC7CE\"}".into())) }; - let mut st = obj.clone(); - if let Some(k) = st.keys().find(|k| !KEYS.contains(&k.as_str())) { - let hint = if k == "preset" { "; a preset goes in `rule.preset`" } else { "" }; - return Err(err(format!("unknown key `{k}` in `style` (known: {}){hint}", KEYS.join(", ")))); - } - // A colour written the way the other formatting commands take it, as the model stores it. - let color = |v: &mut Json, what: &str| -> Result<()> { - if v.is_string() || v.get("theme").is_some() { - let c = super::format::color_param(Some(v)).ok_or_else(|| err(format!("invalid colour {v} for `{what}`")))?; - *v = serde_json::to_value(c).map_err(|e| EngineError::Other(e.to_string()))?; - } - Ok(()) - }; - if let Some(f) = st.get_mut("font").and_then(Json::as_object_mut) - && let Some(c) = f.get_mut("color") - { - color(c, "font.color")?; - } - if let Some(fill) = st.get_mut("fill") { - // `"none"` (or `"auto"`) is no fill, as `home.fillColor` reads it, not a solid automatic one. - if fill.is_string() && super::format::color_param(Some(fill)) == Some(Color::Auto) { - *fill = serde_json::to_value(Fill::default()).map_err(|e| EngineError::Other(e.to_string()))?; - } else if fill.is_string() || fill.get("theme").is_some() { - *fill = json!({"pattern": "Solid", "fg": fill.clone()}); - } - if let Some(f) = fill.as_object_mut() { - for k in ["fg", "bg"] { - if let Some(c) = f.get_mut(k) { - color(c, &format!("fill.{k}"))?; - } - } - // A colour with no pattern is a solid fill, not an invisible one. - if f.contains_key("fg") && !f.contains_key("pattern") { - f.insert("pattern".into(), json!("Solid")); - } - } - } - if let Some(b) = st.get_mut("borders").and_then(Json::as_object_mut) { - for (side, line) in b.iter_mut() { - if let Some(c) = line.get_mut("color") { - color(c, &format!("borders.{side}.color"))?; - } - } - } - Ok(Json::Object(st)) -} - -/// #1999: a formula that doesn't parse must never reach the workbook — Excel refuses to open -/// files carrying it, even in repair mode. Validated here so the API and the New Rule dialog -/// (which dispatches this command) share one gate, the way Excel's object model refuses. -fn check_cf_formula(cmd: &str, what: &str, formula: &str) -> Result<()> { - // Strip exactly one leading '=', the way the XLSX writer does: a value like '===A1' - // only parses after several strips and would still be saved unparseable. (#1999 review) - if gridcraft_formula::parse(formula.strip_prefix('=').unwrap_or(formula)).is_err() { - return Err(bad(cmd, format!("invalid {what} formula `{formula}` — it does not parse; Excel would reject the saved file"))); - } - Ok(()) -} - -fn check_cf_value(what: &str, v: &gridcraft_model::features::CfValueKind) -> Result<()> { - if let gridcraft_model::features::CfValueKind::Formula(f) = v { - check_cf_formula("home.conditionalFormat", what, f)?; - } - Ok(()) -} - -fn check_cf_rule(rule: &CfRule) -> Result<()> { - const CMD: &str = "home.conditionalFormat"; - match rule { - CfRule::CellIs { a, b, .. } => { - check_cf_formula(CMD, "comparison value", a)?; - if let Some(b) = b { - check_cf_formula(CMD, "second comparison value", b)?; - } - } - CfRule::Expression { formula, .. } => check_cf_formula(CMD, "rule", formula)?, - CfRule::DataBar { min, max, .. } => { - check_cf_value("data bar minimum", min)?; - check_cf_value("data bar maximum", max)?; - } - CfRule::ColorScale { stops } => { - for (kind, _) in stops { - check_cf_value("color scale threshold", kind)?; - } - } - _ => {} - } - Ok(()) -} - -fn add_cf(s: &mut Session, p: &Json) -> Result { - let ranges = target_ranges(s, p)?; - let sheet = target_sheet(s, p)?; - let Some(rule) = p.get("rule") else { - s.ui_requests.push(crate::UiRequest::Dialog("newFormattingRule".into(), json!({}))); - return ok(); - }; - let g = |k: &str| rule.get(k).and_then(Json::as_str).map(str::to_string); - let style = Box::new(match rule.get("style") { - Some(st) if !st.is_null() => { - let st = cf_style_json(st)?; - let base = serde_json::to_value(Style::default()).map_err(|e| EngineError::Other(e.to_string()))?; - serde_json::from_value(super::format::merge_json(base, &st)).map_err(|e| bad("home.conditionalFormat", e.to_string()))? - } - _ => preset_style(g("preset").as_deref().unwrap_or("lightRedFillDarkRedText")), - }); - let kind = g("type").unwrap_or_else(|| "cellIs".into()); - let value_kind = |v: Option<&Json>, default: CfValueKind| -> CfValueKind { - match v { - Some(Json::Object(o)) => { - let t = o.get("type").and_then(Json::as_str).unwrap_or("num"); - let val = o.get("value").and_then(Json::as_f64).unwrap_or(0.0); - match t { - "min" => CfValueKind::Min, - "max" => CfValueKind::Max, - "percent" => CfValueKind::Percent(val), - "percentile" => CfValueKind::Percentile(val), - "formula" => CfValueKind::Formula(o.get("value").and_then(Json::as_str).unwrap_or("").into()), - _ => CfValueKind::Number(val), - } - } - _ => default, - } - }; - let rule = match kind.as_str() { - "cellIs" => { - // Excel's file and VBA names (`lessThan`, …) are accepted too. - let op = match g("operator").as_deref().unwrap_or("greater") { - "between" => CfOperator::Between, - "notBetween" => CfOperator::NotBetween, - "equal" => CfOperator::Equal, - "notEqual" => CfOperator::NotEqual, - "less" | "lessThan" => CfOperator::Less, - "greaterOrEqual" | "greaterThanOrEqual" => CfOperator::GreaterOrEqual, - "lessOrEqual" | "lessThanOrEqual" => CfOperator::LessOrEqual, - "greater" | "greaterThan" => CfOperator::Greater, - other => return Err(bad("home.conditionalFormat", format!("unknown operator `{other}`"))), - }; - let a = rule.get("value").map(super::edit::json_to_input).unwrap_or_default(); - let b = rule.get("value2").map(super::edit::json_to_input); - let quote = |v: String| { - // A number, a formula, or text already written as a string literal (`"Closed"`, as - // in the file and in openpyxl's `CellIsRule`) is kept; other text is quoted. - if gridcraft_core::parse::parse_number_text(&v).is_some() || v.starts_with('=') { - v.trim_start_matches('=').to_string() - } else if is_string_literal(&v) { - v - } else { - format!("\"{}\"", v.replace('"', "\"\"")) - } - }; - CfRule::CellIs { op, a: quote(a), b: b.map(quote), style } - } - "expression" => CfRule::Expression { formula: g("formula").unwrap_or_default().trim_start_matches('=').to_string(), style }, - "containsText" => CfRule::ContainsText { text: g("text").unwrap_or_default(), style }, - "notContainsText" => CfRule::NotContainsText { text: g("text").unwrap_or_default(), style }, - "beginsWith" => CfRule::BeginsWith { text: g("text").unwrap_or_default(), style }, - "endsWith" => CfRule::EndsWith { text: g("text").unwrap_or_default(), style }, - "blanks" => CfRule::Blanks { style }, - "noBlanks" => CfRule::NoBlanks { style }, - "errors" => CfRule::Errors { style }, - "noErrors" => CfRule::NoErrors { style }, - "duplicate" => CfRule::Duplicate { style }, - "unique" => CfRule::Unique { style }, - "top10" => CfRule::Top10 { - bottom: rule.get("bottom").and_then(Json::as_bool).unwrap_or(false), - percent: rule.get("percent").and_then(Json::as_bool).unwrap_or(false), - rank: rule.get("rank").and_then(Json::as_u64).unwrap_or(10) as u32, - style, - }, - "aboveAverage" => CfRule::AboveAverage { below: rule.get("below").and_then(Json::as_bool).unwrap_or(false), equal: false, std_dev: 0, style }, - "timePeriod" => CfRule::TimePeriod { period: g("period").unwrap_or_else(|| "today".into()), style }, - "colorScale" => { - let cols: Vec = rule - .get("colors") - .and_then(Json::as_array) - .map(|a| a.iter().filter_map(|c| super::format::color_param(Some(c))).collect()) - .unwrap_or_else(|| vec![Color::rgb(0xF8, 0x69, 0x6B), Color::rgb(0xFF, 0xEB, 0x84), Color::rgb(0x63, 0xBE, 0x7B)]); - let stops = match cols.len() { - 2 => vec![(CfValueKind::Min, cols[0]), (CfValueKind::Max, cols[1])], - _ => vec![ - (CfValueKind::Min, cols.first().copied().unwrap_or_default()), - (CfValueKind::Percentile(50.0), cols.get(1).copied().unwrap_or_default()), - (CfValueKind::Max, cols.get(2).copied().unwrap_or_default()), - ], - }; - CfRule::ColorScale { stops } - } - "dataBar" => CfRule::DataBar { - min: value_kind(rule.get("min"), CfValueKind::Min), - max: value_kind(rule.get("max"), CfValueKind::Max), - color: super::format::color_param(rule.get("color")).unwrap_or(Color::rgb(0x63, 0x8E, 0xC6)), - gradient: rule.get("gradient").and_then(Json::as_bool).unwrap_or(true), - show_value: true, - }, - "iconSet" => CfRule::IconSet { - set: g("set").unwrap_or_else(|| "3TrafficLights1".into()), - thresholds: vec![], - reverse: rule.get("reverse").and_then(Json::as_bool).unwrap_or(false), - show_value: true, - }, - other => return Err(bad("home.conditionalFormat", format!("unknown rule type `{other}`"))), - }; - check_cf_rule(&rule)?; - edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - let priority = sh.cond_formats.iter().map(|c| c.priority).max().unwrap_or(0) + 1; - // New rules take precedence over older ones. - for cf in sh.cond_formats.iter_mut() { - cf.priority += 1; - } - sh.cond_formats.insert(0, CondFormat { ranges: ranges.clone(), rule, priority: 1, stop_if_true: false }); - let _ = priority; - Ok(json!({"rules": sh.cond_formats.len()})) - }) -} - -fn clear_cf(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; - let whole = bool_param(p, "sheet").unwrap_or(false); - let ranges = target_ranges(s, p)?; - edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - if whole { - sh.cond_formats.clear(); - } else { - for cf in sh.cond_formats.iter_mut() { - cf.ranges.retain(|r| !ranges.iter().any(|x| x.intersects(r))); - } - sh.cond_formats.retain(|c| !c.ranges.is_empty()); - } - Ok(Json::Null) - }) -} - -fn manage_cf(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; - if p.get("delete").is_none() && p.get("moveUp").is_none() && p.get("moveDown").is_none() { - let list: Vec = s - .doc()? - .wb - .sheet(sheet) - .map(|sh| { - sh.cond_formats.iter().map(|c| json!({"ranges": c.ranges.iter().map(|r| r.a1()).collect::>(), "rule": c.rule})).collect() - }) - .unwrap_or_default(); - return Ok(json!({"rules": list})); - } - edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - // The count is taken again after each step: a delete shortens the list. - if let Some(i) = u32_param(p, "delete").map(|v| v as usize) - && i < sh.cond_formats.len() - { - sh.cond_formats.remove(i); - } - if let Some(i) = u32_param(p, "moveUp").map(|v| v as usize) - && i > 0 - && i < sh.cond_formats.len() - { - sh.cond_formats.swap(i, i - 1); - } - if let Some(i) = u32_param(p, "moveDown").map(|v| v as usize) - && i + 1 < sh.cond_formats.len() - { - sh.cond_formats.swap(i, i + 1); - } - for (k, cf) in sh.cond_formats.iter_mut().enumerate() { - cf.priority = k as u32 + 1; - } - Ok(Json::Null) - }) -} - -fn insert_icon(s: &mut Session, p: &Json) -> Result { - let name = str_param(p, "name").unwrap_or("check").to_string(); - let size = f64_param(p, "size").unwrap_or(48.0).clamp(8.0, 1000.0) as f32; - let d = s.doc()?; - let id = d.wb.next_object_id(); - let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); - let color = super::format::color_param(p.get("color")).unwrap_or(Color::Theme(1, 0)); - let shape = Shape { - id, - kind: ShapeKind::Icon, - anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width: size, height: size, mode: anchor_mode_param(p).unwrap_or_default() }, - fill: color, - line: Color::Auto, - text: name, - format: Default::default(), - }; - let sheet = d.wb.active_sheet(); - edit(s, |cx| { - cx.sheet_mut(sheet)?.shapes.push(shape); - Ok(json!({"shape": id})) - }) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/cmd/insert/chart.rs b/crates/engine/src/cmd/insert/chart.rs new file mode 100644 index 000000000..4817e41ec --- /dev/null +++ b/crates/engine/src/cmd/insert/chart.rs @@ -0,0 +1,420 @@ +use super::*; + +fn chart_areas(s: &Session, p: &Json) -> Result> { + if p.get("range").is_some() { + return target_ranges(s, p); + } + let d = s.doc()?; + if d.selection.ranges.len() > 1 { + return Ok(d.selection.ranges.clone()); + } + Ok(vec![block(s, p)?]) +} + +// ---------------------------------------------------------------- charts + +pub fn chart_kind(t: &str, sub: &str) -> ChartKind { + match (t, sub) { + ("column", "stacked") => ChartKind::ColumnStacked, + ("column", "stacked100") => ChartKind::ColumnStacked100, + ("bar", "stacked") => ChartKind::BarStacked, + ("bar", "stacked100") => ChartKind::BarStacked100, + ("bar", _) => ChartKind::BarClustered, + ("line", "markers") => ChartKind::LineMarkers, + ("line", "stacked") => ChartKind::LineStacked, + ("line", _) => ChartKind::Line, + ("pie", _) => ChartKind::Pie, + ("doughnut", _) => ChartKind::Doughnut, + ("area", "stacked") => ChartKind::AreaStacked, + ("area", "stacked100") => ChartKind::AreaStacked100, + ("area", _) => ChartKind::Area, + ("scatter", "lines") => ChartKind::ScatterLines, + ("scatter", _) => ChartKind::Scatter, + ("bubble", _) => ChartKind::Bubble, + ("radar", _) => ChartKind::Radar, + ("histogram", _) => ChartKind::Histogram, + ("waterfall", _) => ChartKind::Waterfall, + ("funnel", _) => ChartKind::Funnel, + ("treemap", _) => ChartKind::Treemap, + ("sunburst", _) => ChartKind::Sunburst, + ("boxWhisker", _) => ChartKind::BoxWhisker, + ("stock", _) => ChartKind::Stock, + ("combo", _) => ChartKind::Combo, + _ => ChartKind::ColumnClustered, + } +} + +/// Builds series from a block: first row/column as names/categories when they're text. +pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec { + let q = gridcraft_formula::quote_sheet(&sh.name); + let abs = |r: RangeRef| { + format!( + "{q}!${}${}:${}${}", + gridcraft_core::col_to_letters(r.start.col), + r.start.row + 1, + gridcraft_core::col_to_letters(r.end.col), + r.end.row + 1 + ) + }; + let abs1 = |c: CellRef| format!("{q}!${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1); + let is_text = |c: CellRef| sh.value(c).is_text() || sh.value(c).is_empty(); + let header_row = r.height() > 1 + && (r.start.col..=r.end.col).any(|c| sh.value(CellRef::new(r.start.row, c)).is_text()) + && (r.start.col..=r.end.col).filter(|c| *c != r.start.col).all(|c| is_text(CellRef::new(r.start.row, c))); + // A blank top-left cell above a header row marks the first column as the category labels even + // when it holds numbers (years, X values), the usual spreadsheet convention, so it isn't + // plotted as a series of its own (#287). + let blank_corner = header_row && sh.value(r.start).is_empty(); + let label_col = + r.width() > 1 && (blank_corner || (r.start.row + header_row as u32..=r.end.row).all(|row| is_text(CellRef::new(row, r.start.col)))); + let data = RangeRef::new(CellRef::new(r.start.row + header_row as u32, r.start.col + label_col as u32), r.end); + let mut out = Vec::new(); + if !by_rows { + for c in data.start.col..=data.end.col { + out.push(Series { + name: header_row.then(|| abs1(CellRef::new(r.start.row, c))), + categories: label_col.then(|| abs(RangeRef::new(CellRef::new(data.start.row, r.start.col), CellRef::new(data.end.row, r.start.col)))), + values: abs(RangeRef::new(CellRef::new(data.start.row, c), CellRef::new(data.end.row, c))), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + quartiles: Default::default(), + smooth: false, + trendlines: Vec::new(), + }); + } + } else { + for row in data.start.row..=data.end.row { + out.push(Series { + name: label_col.then(|| abs1(CellRef::new(row, r.start.col))), + categories: header_row + .then(|| abs(RangeRef::new(CellRef::new(r.start.row, data.start.col), CellRef::new(r.start.row, data.end.col)))), + values: abs(RangeRef::new(CellRef::new(row, data.start.col), CellRef::new(row, data.end.col))), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + quartiles: Default::default(), + smooth: false, + trendlines: Vec::new(), + }); + } + } + out +} + +fn series_from_areas(sh: &Sheet, areas: &[RangeRef]) -> Result<(RangeRef, bool, Vec)> { + let Some(first) = areas.first().copied() else { return Err(EngineError::Other("Select data for the chart first.".into())) }; + let bounds = areas.iter().skip(1).fold(first, |r, area| r.union(area)); + if areas.len() == 1 { + let by_rows = first.width() > first.height() + 1; + return Ok((bounds, by_rows, series_from_range(sh, first, by_rows))); + } + let valid_shape = first.width() == 1 + && first.height() > 1 + && areas.iter().skip(1).all(|area| area.width() == 1 && area.height() == first.height()) + && first.iter().all(|cell| sh.value(cell).is_text() || sh.value(cell).is_empty()); + if !valid_shape { + return Err(bad("insert.chart", "multi-area charts require a text category column followed by equal-height series columns")); + } + let category = series_from_range(sh, first, false).into_iter().next().map(|series| series.values); + let mut series = Vec::with_capacity(areas.len() - 1); + for area in &areas[1..] { + let mut area_series = series_from_range(sh, *area, false); + let Some(mut item) = area_series.pop() else { + return Err(EngineError::Other("Select data for the chart first.".into())); + }; + if item.name.is_none() { + return Err(bad("insert.chart", "multi-area series columns require headers")); + } + item.categories = category.clone(); + series.push(item); + } + Ok((bounds, false, series)) +} + +/// A scatter plot of a block without category labels takes its first column (or row) as the X +/// values of the others, as Excel does: two plain columns make one series of points, not two (#428). +pub fn scatter_x_from_first(kind: ChartKind, series: &mut Vec) { + if matches!(kind, ChartKind::Scatter | ChartKind::ScatterLines) && series.len() > 1 && series[0].categories.is_none() { + let x = series.remove(0).values; + for se in series.iter_mut() { + se.categories = Some(x.clone()); + } + } +} + +/// Most cells a chart's source block may span (bounds the series it builds). +const MAX_CHART_CELLS: u64 = 1_000_000; + +/// Chart types that suit the shape of the data, best first, as `(type, subtype, label)` for +/// `insert.chart`. Our own heuristic from the shape of the block (series count, points per +/// series, text categories, negative values), not a copy of Excel's ranking: two numeric columns +/// without labels → scatter; one short series → column, pie, bar; long series → line; several +/// series → clustered and stacked columns and bars, line. +pub fn recommend_charts(s: &Session, p: &Json) -> Result> { + let areas = chart_areas(s, p)?; + if areas.iter().fold(0_u64, |count, area| count.saturating_add(area.count())) > MAX_CHART_CELLS { + return Err(bad("insert.chart", "range too large for a chart")); + } + let sheet = target_sheet(s, p)?; + let d = s.doc()?; + let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + let (_, by_rows, series) = series_from_areas(sh, &areas)?; + let ranges: Vec = series + .iter() + .filter_map(|x| match gridcraft_formula::parse(&x.values) { + Ok(gridcraft_formula::Expr::Ref(rf)) => Some(rf.range()), + _ => None, + }) + .collect(); + let points = ranges.first().map_or(0, RangeRef::count); + let (mut any_number, mut non_negative) = (false, true); + for n in ranges.iter().flat_map(|rr| rr.iter()).filter_map(|c| sh.value(c).as_f64()) { + any_number = true; + non_negative &= n >= 0.0; + } + if series.is_empty() || !any_number { + return Err(EngineError::Other("Select data for the chart first.".into())); + } + let labelled = series.first().is_some_and(|x| x.categories.is_some()); + const COLUMN: (&str, &str, &str) = ("column", "", "Clustered Column"); + const BAR: (&str, &str, &str) = ("bar", "", "Clustered Bar"); + const LINE: (&str, &str, &str) = ("line", "", "Line"); + const LINE_MARKERS: (&str, &str, &str) = ("line", "markers", "Line with Markers"); + const PIE: (&str, &str, &str) = ("pie", "", "Pie"); + const DOUGHNUT: (&str, &str, &str) = ("doughnut", "", "Doughnut"); + const AREA: (&str, &str, &str) = ("area", "", "Area"); + const SCATTER: (&str, &str, &str) = ("scatter", "", "Scatter"); + const STACKED_COLUMN: (&str, &str, &str) = ("column", "stacked", "Stacked Column"); + const STACKED_BAR: (&str, &str, &str) = ("bar", "stacked", "Stacked Bar"); + let mut out = Vec::new(); + if series.len() == 2 && !labelled && !by_rows { + out.extend([SCATTER, LINE_MARKERS, COLUMN]); + } else if series.len() == 1 { + if points > 12 { + out.extend([LINE, COLUMN, AREA]); + } else { + out.extend([COLUMN, BAR]); + if non_negative && points >= 2 { + out.extend([PIE, DOUGHNUT]); + } + out.push(LINE_MARKERS); + } + } else if points > 12 { + out.extend([LINE, COLUMN, AREA, STACKED_COLUMN]); + } else { + out.extend([COLUMN, BAR, STACKED_COLUMN, LINE_MARKERS, STACKED_BAR]); + } + Ok(out) +} + +/// The chart `insert.chart` would insert for these params, without inserting it: the sheet it +/// goes on and the chart (previews in the Recommended Charts dialog). +pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> { + let areas = chart_areas(s, p)?; + if areas.iter().fold(0_u64, |count, area| count.saturating_add(area.count())) > MAX_CHART_CELLS { + return Err(bad("insert.chart", "range too large for a chart")); + } + let mut t = str_param(p, "type").unwrap_or("column").to_string(); + let mut sub = str_param(p, "subtype").unwrap_or("").to_string(); + if t == "auto" { + let first = recommend_charts(s, p)?.into_iter().next().unwrap_or(("column", "", "")); + t = first.0.into(); + sub = first.1.into(); + } + let sheet = target_sheet(s, p)?; + let d = s.doc()?; + let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + let kind = chart_kind(&t, &sub); + let (bounds, by_rows, mut series) = series_from_areas(sh, &areas)?; + if series.is_empty() { + return Err(EngineError::Other("Select data for the chart first.".into())); + } + scatter_x_from_first(kind, &mut series); + let id = d.wb.next_object_id(); + let at = cell_param_on(s, p, "at", sheet)?.unwrap_or_else(|| CellRef::new(bounds.start.row, bounds.end.col + 2)); + let title = str_param(p, "title").map(str::to_string).or_else(|| { + if series.len() == 1 { + series[0].name.as_ref().map(|n| gridcraft_calc::evaluate(&d.wb, sheet, at, n).display()) + } else { + Some("Chart Title".into()) + } + }); + let chart = Chart { + id, + kind, + anchor: Anchor { + cell: at, + dx: 0.0, + dy: 0.0, + width: f64_param(p, "width").unwrap_or(480.0) as f32, + height: f64_param(p, "height").unwrap_or(288.0) as f32, + mode: anchor_mode_param(p).unwrap_or_default(), + }, + title, + series, + legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom }, + data_labels: false, + gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel), + style: 1, + x_title: None, + y_title: None, + source: (areas.len() == 1).then(|| format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), bounds.a1())), + by_rows, + pivot: None, + }; + Ok((sheet, chart)) +} + +pub(super) fn insert_chart(s: &mut Session, p: &Json) -> Result { + let (sheet, chart) = build_chart(s, p)?; + let id = chart.id; + edit(s, |cx| { + cx.sheet_mut(sheet)?.charts.push(chart); + Ok(json!({"chart": id})) + }) +} + +fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> { + let d = s.doc()?; + let sheet = d.wb.active_sheet(); + let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + let idx = match u32_param(p, "chart") { + Some(id) => sh.charts.iter().position(|c| c.id == id), + None => sh.charts.len().checked_sub(1), + }; + idx.map(|i| (sheet, i)).ok_or_else(|| bad("chart", "no such chart")) +} + +pub(super) fn chart_set(s: &mut Session, p: &Json) -> Result { + let (si, ci) = find_chart(s, p)?; + let at = cell_param_on(s, p, "at", si)?; + let quartiles = p + .get("quartiles") + .map(|v| match v.as_str() { + Some("exclusive") => Ok(gridcraft_model::QuartileMethod::Exclusive), + Some("inclusive") => Ok(gridcraft_model::QuartileMethod::Inclusive), + _ => Err(bad("quartiles", "expected exclusive or inclusive")), + }) + .transpose()?; + edit(s, |cx| { + let sh = cx.sheet_mut(si)?; + let Some(c) = sh.charts.get_mut(ci) else { return Ok(Json::Null) }; + if let Some(t) = str_param(p, "type") { + c.kind = chart_kind(t, str_param(p, "subtype").unwrap_or("")); + } + if let Some(method) = quartiles { + for series in &mut c.series { + series.quartiles = method; + } + } + if let Some(t) = p.get("title") { + c.title = t.as_str().map(str::to_string); + } + if let Some(l) = str_param(p, "legend") { + c.legend = match l { + "none" => LegendPos::None, + "top" => LegendPos::Top, + "left" => LegendPos::Left, + "right" => LegendPos::Right, + _ => LegendPos::Bottom, + }; + } + if let Some(v) = bool_param(p, "dataLabels") { + c.data_labels = v; + } + if let Some(v) = bool_param(p, "gridlines") { + c.gridlines = v; + } + if let Some(v) = u32_param(p, "style") { + c.style = v.clamp(1, 16); + } + if let Some(t) = p.get("xTitle") { + c.x_title = t.as_str().map(str::to_string); + } + if let Some(t) = p.get("yTitle") { + c.y_title = t.as_str().map(str::to_string); + } + if let Some(at) = at { + c.anchor.cell = at; + } + if let Some(w) = f64_param(p, "width") { + c.anchor.width = w.clamp(20.0, 10000.0) as f32; + } + if let Some(h) = f64_param(p, "height") { + c.anchor.height = h.clamp(20.0, 10000.0) as f32; + } + if let Some(v) = f64_param(p, "dx") { + c.anchor.dx = v as f32; + } + if let Some(v) = f64_param(p, "dy") { + c.anchor.dy = v as f32; + } + Ok(Json::Null) + }) +} + +pub(super) fn chart_switch(s: &mut Session, p: &Json) -> Result { + let (si, ci) = find_chart(s, p)?; + edit(s, |cx| { + let Some(sh) = cx.wb.sheet(si) else { return Ok(Json::Null) }; + let Some(c) = sh.charts.get(ci) else { return Ok(Json::Null) }; + // Charts read from a file have no source range: take the block their series span. + let Some((src, by_rows)) = c.source.clone().map(|s| (s, c.by_rows)).or_else(|| series_block(&c.series)) else { return Ok(Json::Null) }; + let (sheet_name, body) = split_sheet(&src); + let src_sheet = sheet_name.and_then(|n| cx.wb.sheet_index(&n)).unwrap_or(si); + let Some(mut r) = RangeRef::parse(body) else { return Ok(Json::Null) }; + let Some(ssh) = cx.wb.sheet(src_sheet) else { return Ok(Json::Null) }; + // Whole-column/row references (common in files) span a million cells: clip the block's + // far edges to the cells in use, then refuse anything still too large to chart. + if let Some(used) = ssh.used_range() { + r.end.row = r.end.row.min(used.end.row).max(r.start.row); + r.end.col = r.end.col.min(used.end.col).max(r.start.col); + } + if r.count() > MAX_CHART_CELLS { + return Err(bad("chart.switchRowColumn", "range too large for a chart")); + } + let by_rows = !by_rows; + let series = series_from_range(ssh, r, by_rows); + if let Some(c) = cx.sheet_mut(si)?.charts.get_mut(ci) { + c.source.get_or_insert(src); + c.by_rows = by_rows; + c.series = series; + scatter_x_from_first(c.kind, &mut c.series); + } + Ok(Json::Null) + }) +} + +/// The block a chart's series come from, and whether each series is a row of it: the bounding +/// range of the series names, categories and values, when they're all references to one sheet. +fn series_block(series: &[Series]) -> Option<(String, bool)> { + let mut sheet: Option = None; + let mut bounds: Option = None; + let mut rows = 0; + for s in series { + for (i, f) in [Some(&s.values), s.categories.as_ref(), s.name.as_ref()].into_iter().flatten().enumerate() { + let Ok(gridcraft_formula::Expr::Ref(r)) = gridcraft_formula::parse(f) else { return None }; + let gridcraft_formula::SheetSel::Named(n) = &r.sheet else { return None }; + if !sheet.get_or_insert_with(|| n.clone()).eq_ignore_ascii_case(n) { + return None; + } + let range = r.range(); + if i == 0 && range.height() == 1 && range.width() > 1 { + rows += 1; + } + bounds = Some(bounds.map_or(range, |b| b.union(&range))); + } + } + Some((format!("{}!{}", gridcraft_formula::quote_sheet(&sheet?), bounds?.a1()), rows > 0 && rows == series.len())) +} + +pub(super) fn chart_delete(s: &mut Session, p: &Json) -> Result { + let (si, ci) = find_chart(s, p)?; + edit(s, |cx| { + cx.sheet_mut(si)?.charts.remove(ci); + Ok(Json::Null) + }) +} diff --git a/crates/engine/src/cmd/insert/cond_format.rs b/crates/engine/src/cmd/insert/cond_format.rs new file mode 100644 index 000000000..aeeb9db55 --- /dev/null +++ b/crates/engine/src/cmd/insert/cond_format.rs @@ -0,0 +1,339 @@ +use super::*; + +// ---------------------------------------------------------------- conditional formatting + +fn preset_style(name: &str) -> Style { + let mut s = Style::default(); + match name { + "redText" => s.font.color = Color::rgb(0x9C, 0x00, 0x06), + "yellowFill" | "yellowFillDarkYellowText" => { + s.fill = Fill::solid(Color::rgb(0xFF, 0xEB, 0x9C)); + s.font.color = Color::rgb(0x9C, 0x57, 0x00); + } + "greenFill" | "greenFillDarkGreenText" => { + s.fill = Fill::solid(Color::rgb(0xC6, 0xEF, 0xCE)); + s.font.color = Color::rgb(0x00, 0x61, 0x00); + } + "lightRedFill" => s.fill = Fill::solid(Color::rgb(0xFF, 0xC7, 0xCE)), + "redBorder" => { + let l = BorderLine { style: BorderStyle::Thin, color: Color::rgb(0xC0, 0x00, 0x00) }; + s.borders = Borders { left: l, right: l, top: l, bottom: l, ..Default::default() }; + } + _ => { + s.fill = Fill::solid(Color::rgb(0xFF, 0xC7, 0xCE)); + s.font.color = Color::rgb(0x9C, 0x00, 0x06); + } + } + s +} + +/// Whether `v` is one formula string literal: `"…"` with every inner quote doubled. +fn is_string_literal(v: &str) -> bool { + v.len() >= 2 && v.starts_with('"') && v.ends_with('"') && v.get(1..v.len() - 1).is_some_and(|inner| !inner.replace("\"\"", "").contains('"')) +} + +/// A conditional format's `style` as the model's `Style` JSON (`font`, `fill`, `borders`, `align`, +/// `num_fmt`, `protection`, merged over the default by the caller), where a colour may also be +/// written as `format_range` takes it (`"#RRGGBB"`, `{theme, tint}`, `"auto"`) and `fill` may be a +/// colour alone (a solid fill) or `"none"` (no fill). An unknown key is an error rather than +/// dropped: dropped, it left a rule with an empty format that matches and shows nothing. +fn cf_style_json(st: &Json) -> Result { + const KEYS: [&str; 6] = ["font", "fill", "borders", "align", "num_fmt", "protection"]; + let err = |m: String| bad("home.conditionalFormat", m); + let Some(obj) = st.as_object() else { return Err(err("`style` must be an object, e.g. {\"fill\": \"#FFC7CE\"}".into())) }; + let mut st = obj.clone(); + if let Some(k) = st.keys().find(|k| !KEYS.contains(&k.as_str())) { + let hint = if k == "preset" { "; a preset goes in `rule.preset`" } else { "" }; + return Err(err(format!("unknown key `{k}` in `style` (known: {}){hint}", KEYS.join(", ")))); + } + // A colour written the way the other formatting commands take it, as the model stores it. + let color = |v: &mut Json, what: &str| -> Result<()> { + if v.is_string() || v.get("theme").is_some() { + let c = crate::cmd::format::color_param(Some(v)).ok_or_else(|| err(format!("invalid colour {v} for `{what}`")))?; + *v = serde_json::to_value(c).map_err(|e| EngineError::Other(e.to_string()))?; + } + Ok(()) + }; + if let Some(f) = st.get_mut("font").and_then(Json::as_object_mut) + && let Some(c) = f.get_mut("color") + { + color(c, "font.color")?; + } + if let Some(fill) = st.get_mut("fill") { + // `"none"` (or `"auto"`) is no fill, as `home.fillColor` reads it, not a solid automatic one. + if fill.is_string() && crate::cmd::format::color_param(Some(fill)) == Some(Color::Auto) { + *fill = serde_json::to_value(Fill::default()).map_err(|e| EngineError::Other(e.to_string()))?; + } else if fill.is_string() || fill.get("theme").is_some() { + *fill = json!({"pattern": "Solid", "fg": fill.clone()}); + } + if let Some(f) = fill.as_object_mut() { + for k in ["fg", "bg"] { + if let Some(c) = f.get_mut(k) { + color(c, &format!("fill.{k}"))?; + } + } + // A colour with no pattern is a solid fill, not an invisible one. + if f.contains_key("fg") && !f.contains_key("pattern") { + f.insert("pattern".into(), json!("Solid")); + } + } + } + if let Some(b) = st.get_mut("borders").and_then(Json::as_object_mut) { + for (side, line) in b.iter_mut() { + if let Some(c) = line.get_mut("color") { + color(c, &format!("borders.{side}.color"))?; + } + } + } + Ok(Json::Object(st)) +} + +/// #1999: a formula that doesn't parse must never reach the workbook — Excel refuses to open +/// files carrying it, even in repair mode. Validated here so the API and the New Rule dialog +/// (which dispatches this command) share one gate, the way Excel's object model refuses. +fn check_cf_formula(cmd: &str, what: &str, formula: &str) -> Result<()> { + // Strip exactly one leading '=', the way the XLSX writer does: a value like '===A1' + // only parses after several strips and would still be saved unparseable. (#1999 review) + if gridcraft_formula::parse(formula.strip_prefix('=').unwrap_or(formula)).is_err() { + return Err(bad(cmd, format!("invalid {what} formula `{formula}` — it does not parse; Excel would reject the saved file"))); + } + Ok(()) +} + +fn check_cf_value(what: &str, v: &gridcraft_model::features::CfValueKind) -> Result<()> { + if let gridcraft_model::features::CfValueKind::Formula(f) = v { + check_cf_formula("home.conditionalFormat", what, f)?; + } + Ok(()) +} + +fn check_cf_rule(rule: &CfRule) -> Result<()> { + const CMD: &str = "home.conditionalFormat"; + match rule { + CfRule::CellIs { a, b, .. } => { + check_cf_formula(CMD, "comparison value", a)?; + if let Some(b) = b { + check_cf_formula(CMD, "second comparison value", b)?; + } + } + CfRule::Expression { formula, .. } => check_cf_formula(CMD, "rule", formula)?, + CfRule::DataBar { min, max, .. } => { + check_cf_value("data bar minimum", min)?; + check_cf_value("data bar maximum", max)?; + } + CfRule::ColorScale { stops } => { + for (kind, _) in stops { + check_cf_value("color scale threshold", kind)?; + } + } + _ => {} + } + Ok(()) +} + +pub(super) fn add_cf(s: &mut Session, p: &Json) -> Result { + let ranges = target_ranges(s, p)?; + let sheet = target_sheet(s, p)?; + let Some(rule) = p.get("rule") else { + s.ui_requests.push(crate::UiRequest::Dialog("newFormattingRule".into(), json!({}))); + return ok(); + }; + let g = |k: &str| rule.get(k).and_then(Json::as_str).map(str::to_string); + let style = Box::new(match rule.get("style") { + Some(st) if !st.is_null() => { + let st = cf_style_json(st)?; + let base = serde_json::to_value(Style::default()).map_err(|e| EngineError::Other(e.to_string()))?; + serde_json::from_value(crate::cmd::format::merge_json(base, &st)).map_err(|e| bad("home.conditionalFormat", e.to_string()))? + } + _ => preset_style(g("preset").as_deref().unwrap_or("lightRedFillDarkRedText")), + }); + let kind = g("type").unwrap_or_else(|| "cellIs".into()); + let value_kind = |v: Option<&Json>, default: CfValueKind| -> CfValueKind { + match v { + Some(Json::Object(o)) => { + let t = o.get("type").and_then(Json::as_str).unwrap_or("num"); + let val = o.get("value").and_then(Json::as_f64).unwrap_or(0.0); + match t { + "min" => CfValueKind::Min, + "max" => CfValueKind::Max, + "percent" => CfValueKind::Percent(val), + "percentile" => CfValueKind::Percentile(val), + "formula" => CfValueKind::Formula(o.get("value").and_then(Json::as_str).unwrap_or("").into()), + _ => CfValueKind::Number(val), + } + } + _ => default, + } + }; + let rule = match kind.as_str() { + "cellIs" => { + // Excel's file and VBA names (`lessThan`, …) are accepted too. + let op = match g("operator").as_deref().unwrap_or("greater") { + "between" => CfOperator::Between, + "notBetween" => CfOperator::NotBetween, + "equal" => CfOperator::Equal, + "notEqual" => CfOperator::NotEqual, + "less" | "lessThan" => CfOperator::Less, + "greaterOrEqual" | "greaterThanOrEqual" => CfOperator::GreaterOrEqual, + "lessOrEqual" | "lessThanOrEqual" => CfOperator::LessOrEqual, + "greater" | "greaterThan" => CfOperator::Greater, + other => return Err(bad("home.conditionalFormat", format!("unknown operator `{other}`"))), + }; + let a = rule.get("value").map(crate::cmd::edit::json_to_input).unwrap_or_default(); + let b = rule.get("value2").map(crate::cmd::edit::json_to_input); + let quote = |v: String| { + // A number, a formula, or text already written as a string literal (`"Closed"`, as + // in the file and in openpyxl's `CellIsRule`) is kept; other text is quoted. + if gridcraft_core::parse::parse_number_text(&v).is_some() || v.starts_with('=') { + v.trim_start_matches('=').to_string() + } else if is_string_literal(&v) { + v + } else { + format!("\"{}\"", v.replace('"', "\"\"")) + } + }; + CfRule::CellIs { op, a: quote(a), b: b.map(quote), style } + } + "expression" => CfRule::Expression { formula: g("formula").unwrap_or_default().trim_start_matches('=').to_string(), style }, + "containsText" => CfRule::ContainsText { text: g("text").unwrap_or_default(), style }, + "notContainsText" => CfRule::NotContainsText { text: g("text").unwrap_or_default(), style }, + "beginsWith" => CfRule::BeginsWith { text: g("text").unwrap_or_default(), style }, + "endsWith" => CfRule::EndsWith { text: g("text").unwrap_or_default(), style }, + "blanks" => CfRule::Blanks { style }, + "noBlanks" => CfRule::NoBlanks { style }, + "errors" => CfRule::Errors { style }, + "noErrors" => CfRule::NoErrors { style }, + "duplicate" => CfRule::Duplicate { style }, + "unique" => CfRule::Unique { style }, + "top10" => CfRule::Top10 { + bottom: rule.get("bottom").and_then(Json::as_bool).unwrap_or(false), + percent: rule.get("percent").and_then(Json::as_bool).unwrap_or(false), + rank: rule.get("rank").and_then(Json::as_u64).unwrap_or(10) as u32, + style, + }, + "aboveAverage" => CfRule::AboveAverage { below: rule.get("below").and_then(Json::as_bool).unwrap_or(false), equal: false, std_dev: 0, style }, + "timePeriod" => CfRule::TimePeriod { period: g("period").unwrap_or_else(|| "today".into()), style }, + "colorScale" => { + let cols: Vec = rule + .get("colors") + .and_then(Json::as_array) + .map(|a| a.iter().filter_map(|c| crate::cmd::format::color_param(Some(c))).collect()) + .unwrap_or_else(|| vec![Color::rgb(0xF8, 0x69, 0x6B), Color::rgb(0xFF, 0xEB, 0x84), Color::rgb(0x63, 0xBE, 0x7B)]); + let stops = match cols.len() { + 2 => vec![(CfValueKind::Min, cols[0]), (CfValueKind::Max, cols[1])], + _ => vec![ + (CfValueKind::Min, cols.first().copied().unwrap_or_default()), + (CfValueKind::Percentile(50.0), cols.get(1).copied().unwrap_or_default()), + (CfValueKind::Max, cols.get(2).copied().unwrap_or_default()), + ], + }; + CfRule::ColorScale { stops } + } + "dataBar" => CfRule::DataBar { + min: value_kind(rule.get("min"), CfValueKind::Min), + max: value_kind(rule.get("max"), CfValueKind::Max), + color: crate::cmd::format::color_param(rule.get("color")).unwrap_or(Color::rgb(0x63, 0x8E, 0xC6)), + gradient: rule.get("gradient").and_then(Json::as_bool).unwrap_or(true), + show_value: true, + }, + "iconSet" => CfRule::IconSet { + set: g("set").unwrap_or_else(|| "3TrafficLights1".into()), + thresholds: vec![], + reverse: rule.get("reverse").and_then(Json::as_bool).unwrap_or(false), + show_value: true, + }, + other => return Err(bad("home.conditionalFormat", format!("unknown rule type `{other}`"))), + }; + check_cf_rule(&rule)?; + edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + let priority = sh.cond_formats.iter().map(|c| c.priority).max().unwrap_or(0) + 1; + // New rules take precedence over older ones. + for cf in sh.cond_formats.iter_mut() { + cf.priority += 1; + } + sh.cond_formats.insert(0, CondFormat { ranges: ranges.clone(), rule, priority: 1, stop_if_true: false }); + let _ = priority; + Ok(json!({"rules": sh.cond_formats.len()})) + }) +} + +pub(super) fn clear_cf(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let whole = bool_param(p, "sheet").unwrap_or(false); + let ranges = target_ranges(s, p)?; + edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + if whole { + sh.cond_formats.clear(); + } else { + for cf in sh.cond_formats.iter_mut() { + cf.ranges.retain(|r| !ranges.iter().any(|x| x.intersects(r))); + } + sh.cond_formats.retain(|c| !c.ranges.is_empty()); + } + Ok(Json::Null) + }) +} + +pub(super) fn manage_cf(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + if p.get("delete").is_none() && p.get("moveUp").is_none() && p.get("moveDown").is_none() { + let list: Vec = s + .doc()? + .wb + .sheet(sheet) + .map(|sh| { + sh.cond_formats.iter().map(|c| json!({"ranges": c.ranges.iter().map(|r| r.a1()).collect::>(), "rule": c.rule})).collect() + }) + .unwrap_or_default(); + return Ok(json!({"rules": list})); + } + edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + // The count is taken again after each step: a delete shortens the list. + if let Some(i) = u32_param(p, "delete").map(|v| v as usize) + && i < sh.cond_formats.len() + { + sh.cond_formats.remove(i); + } + if let Some(i) = u32_param(p, "moveUp").map(|v| v as usize) + && i > 0 + && i < sh.cond_formats.len() + { + sh.cond_formats.swap(i, i - 1); + } + if let Some(i) = u32_param(p, "moveDown").map(|v| v as usize) + && i + 1 < sh.cond_formats.len() + { + sh.cond_formats.swap(i, i + 1); + } + for (k, cf) in sh.cond_formats.iter_mut().enumerate() { + cf.priority = k as u32 + 1; + } + Ok(Json::Null) + }) +} + +pub(super) fn insert_icon(s: &mut Session, p: &Json) -> Result { + let name = str_param(p, "name").unwrap_or("check").to_string(); + let size = f64_param(p, "size").unwrap_or(48.0).clamp(8.0, 1000.0) as f32; + let d = s.doc()?; + let id = d.wb.next_object_id(); + let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); + let color = crate::cmd::format::color_param(p.get("color")).unwrap_or(Color::Theme(1, 0)); + let shape = Shape { + id, + kind: ShapeKind::Icon, + anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width: size, height: size, mode: anchor_mode_param(p).unwrap_or_default() }, + fill: color, + line: Color::Auto, + text: name, + format: Default::default(), + }; + let sheet = d.wb.active_sheet(); + edit(s, |cx| { + cx.sheet_mut(sheet)?.shapes.push(shape); + Ok(json!({"shape": id})) + }) +} diff --git a/crates/engine/src/cmd/insert/links.rs b/crates/engine/src/cmd/insert/links.rs new file mode 100644 index 000000000..a024350fd --- /dev/null +++ b/crates/engine/src/cmd/insert/links.rs @@ -0,0 +1,157 @@ +use super::*; + +// ---------------------------------------------------------------- links & comments + +pub(super) fn insert_link(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); + if bool_param(p, "remove").unwrap_or(false) { + return edit(s, |cx| { + cx.sheet_mut(sheet)?.hyperlinks.remove(&at); + Ok(Json::Null) + }); + } + let Some(target) = str_param(p, "target").map(str::to_string) else { + s.ui_requests.push(crate::UiRequest::Dialog("insertLink".into(), json!({}))); + return ok(); + }; + let text = str_param(p, "text").map(str::to_string); + let tooltip = str_param(p, "tooltip").map(str::to_string); + let external = links_outside(&s.doc()?.wb, sheet, &target); + edit(s, |cx| { + let link_style = cx.wb.styles.derive(cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), |st| { + st.font.color = Color::Theme(10, 0); + st.font.underline = Underline::Single; + }); + let sh = cx.sheet_mut(sheet)?; + if let Some(t) = text.clone().or_else(|| sh.value(at).is_empty().then(|| target.clone())) { + sh.set_cell(at, Cell { value: gridcraft_core::Value::text(t.as_str()), formula: None, style: link_style }); + } else { + sh.set_style(at, link_style); + } + sh.hyperlinks.insert(at, Hyperlink { target: target.clone(), tooltip: tooltip.clone(), external }); + cx.touch(sheet, at); + Ok(Json::Null) + }) +} + +/// Follows a link: a reference selects it, an `http`/`https`/`mailto` address is handed to the +/// host (`UiRequest::OpenUrl`); anything else is refused ([`crate::links::classify`]). Opens no +/// dialog; the interactive click asks for confirmation first, in the UI. +pub(super) fn open_link(s: &mut Session, p: &Json) -> Result { + let target = match str_param(p, "url").or_else(|| str_param(p, "target")) { + Some(t) => t.to_string(), + None => { + let sheet = target_sheet(s, p)?; + let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); + let Some(h) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.hyperlinks.get(&at)) else { + return Err(EngineError::Other("There's no hyperlink in this cell.".into())); + }; + h.target.clone() + } + }; + match crate::links::classify(&target) { + crate::links::LinkKind::Internal(r) => crate::cmd::edit::go_to(s, &json!({"reference": r.trim_start_matches('#')})), + crate::links::LinkKind::External(u) => { + s.ui_requests.push(crate::UiRequest::OpenUrl(u.clone())); + Ok(json!({"opened": u})) + } + crate::links::LinkKind::Refused(why) => Err(EngineError::Other(why)), + } +} + +/// Sorts a link target as the two pages of Excel's Insert Hyperlink dialog do: "Place in This +/// Document" is a sheet reference, a defined name or `#…`; anything else ("Existing File or Web +/// Page") is outside the workbook and saved as a relationship. +fn links_outside(wb: &Workbook, sheet: usize, target: &str) -> bool { + let t = target.trim(); + if t.starts_with('#') || wb.name(t, sheet).is_some() || wb.table(t).is_some() { + return false; + } + let (on, body) = split_sheet(t); + // Sheet names can't hold `: \ / ? * [ ]`, so `https://…!A1` or `C:\…!A1` is no sheet reference, + // and `Book.xlsx#Sheet1!A1` is a place in another file unless a sheet has that name. + let on_sheet = on.is_none_or(|name| { + wb.sheets.iter().any(|s| s.name.eq_ignore_ascii_case(&name)) + || (!name.is_empty() && !name.contains([':', '\\', '/', '?', '*', '[', ']', '#'])) + }); + !(on_sheet && RangeRef::parse(body).is_some()) +} + +pub(super) fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result { + let sheet = target_sheet(s, p)?; + let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); + let text = str_param(p, "text").unwrap_or("").to_string(); + let named = str_param(p, "author").map(str::to_string); + let author = named.clone().unwrap_or_else(|| s.prefs.user_name.clone()); + edit(s, |cx| { + let comments = &mut cx.sheet_mut(sheet)?.comments; + // Writing a note on a cell that has one edits it, as Excel's Edit Note does: the box, the + // replies, the dates and who wrote it stay. A note replaces a comment and vice versa. + if let Some(c) = comments.get_mut(&at).filter(|c| c.threaded == threaded) { + c.text = text.clone(); + if let Some(author) = named.clone() { + c.author = author; + } + return Ok(json!({"cell": at.a1()})); + } + comments.insert( + at, + Comment { + author: author.clone(), + text: text.clone(), + replies: vec![], + threaded, + resolved: false, + visible: false, + dates: vec![], + note_box: None, + }, + ); + Ok(json!({"cell": at.a1()})) + }) +} + +pub(super) fn reply_comment(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); + let text = str_param(p, "text").unwrap_or("").to_string(); + let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone()); + edit(s, |cx| { + let c = cx.sheet_mut(sheet)?.comments.get_mut(&at).ok_or_else(|| EngineError::Other("There's no comment in this cell.".into()))?; + c.replies.push((author.clone(), text.clone())); + Ok(Json::Null) + }) +} + +pub(super) fn resolve_comment(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); + let v = bool_param(p, "resolved").unwrap_or(true); + edit(s, |cx| { + if let Some(c) = cx.sheet_mut(sheet)?.comments.get_mut(&at) { + c.resolved = v; + } + Ok(Json::Null) + }) +} + +pub(super) fn delete_comment(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let ranges = target_ranges(s, p)?; + edit(s, |cx| { + cx.sheet_mut(sheet)?.comments.retain(|c, _| !ranges.iter().any(|r| r.contains(*c))); + Ok(Json::Null) + }) +} + +pub(super) fn show_note(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active); + edit(s, |cx| { + if let Some(c) = cx.sheet_mut(sheet)?.comments.get_mut(&at) { + c.visible = !c.visible; + } + Ok(Json::Null) + }) +} diff --git a/crates/engine/src/cmd/insert/objects.rs b/crates/engine/src/cmd/insert/objects.rs new file mode 100644 index 000000000..e4486b7e4 --- /dev/null +++ b/crates/engine/src/cmd/insert/objects.rs @@ -0,0 +1,264 @@ +use super::*; + +pub(super) fn insert_sparkline(s: &mut Session, p: &Json) -> Result { + let src = str_param(p, "range").ok_or_else(|| bad("insert.sparkline", "missing `range`"))?.to_string(); + let loc = cell_param(s, p, "location")?.ok_or_else(|| bad("insert.sparkline", "missing `location`"))?; + let kind = match str_param(p, "type") { + Some("column") => SparklineKind::Column, + Some("winLoss") => SparklineKind::WinLoss, + _ => SparklineKind::Line, + }; + let markers = bool_param(p, "markers").unwrap_or(false); + let sheet = s.doc()?.wb.active_sheet(); + edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + sh.sparklines.retain(|x| x.cell != loc); + sh.sparklines.push(Sparkline { cell: loc, source: src.clone(), kind, color: Color::Theme(4, 0), markers }); + Ok(Json::Null) + }) +} + +pub(super) fn insert_picture(s: &mut Session, p: &Json) -> Result { + let placement = str_param(p, "placement").unwrap_or("overCells"); + if !matches!(placement, "cell" | "overCells") { + return Err(bad("insert.picture", "placement must be cell or overCells")); + } + let data = if let Some(b) = str_param(p, "base64") { + crate::io::base64_decode(b).ok_or_else(|| bad("insert.picture", "invalid base64"))? + } else if let Some(path) = str_param(p, "path") { + crate::io::read_file(path)? + } else { + s.ui_requests.push(crate::UiRequest::Dialog("insertPicture".into(), json!({"placement": placement}))); + return ok(); + }; + if placement == "cell" { + let mime = validate_cell_picture(&data)?; + let d = s.doc()?; + let sheet = d.wb.active_sheet(); + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let at = match str_param(p, "at") { + Some(a) => CellRef::parse(a).ok_or_else(|| bad("insert.picture", "invalid cell address"))?, + None => d.selection.active, + }; + let at = sh.merge_at(at).map(|r| r.start).unwrap_or(at); + let style = sh.style_id(at); + if sh.is_protected() && d.wb.styles.get(style).protection.locked && locked_cell_error(sh, at, &d.unlocked_ranges).is_some() { + return Err(EngineError::Other("The cell you're trying to change is on a protected sheet.".into())); + } + let picture = CellPicture { data, mime: mime.into(), alt: str_param(p, "alt").unwrap_or("").into() }; + return edit(s, |cx| { + cx.sheet_mut(sheet)?.set_picture(at, std::sync::Arc::new(picture)); + cx.touch(sheet, at); + Ok(json!({"cell": at.a1(), "placement": "cell"})) + }); + } + if data.len() > 64 * 1024 * 1024 { + return Err(bad("insert.picture", "image larger than 64 MB")); + } + let mime = str_param(p, "mime") + .map(str::to_string) + .unwrap_or_else(|| if data.starts_with(&[0x89, b'P', b'N', b'G']) { "image/png".into() } else { "image/jpeg".into() }); + let (w, h) = crate::io::image_size(&data).unwrap_or((320, 240)); + let d = s.doc()?; + let id = d.wb.next_object_id(); + let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); + let scale = (640.0 / w.max(1) as f32).min(1.0); + let img = Image { + id, + anchor: Anchor { + cell: at, + dx: 0.0, + dy: 0.0, + width: f64_param(p, "width").map(|v| v as f32).unwrap_or(w as f32 * scale), + height: f64_param(p, "height").map(|v| v as f32).unwrap_or(h as f32 * scale), + // Excel inserts pictures as "Move but don't size with cells" (`editAs="oneCell"`). + mode: anchor_mode_param(p).unwrap_or(gridcraft_model::AnchorMode::MoveOnly), + }, + data: data.into(), + mime, + alt: str_param(p, "alt").unwrap_or("").into(), + }; + let sheet = d.wb.active_sheet(); + edit(s, |cx| { + cx.sheet_mut(sheet)?.images.push(img); + Ok(json!({"image": id})) + }) +} + +/// Validate before mutating the workbook; header dimensions alone do not prove the pixels decode. +fn validate_cell_picture(data: &[u8]) -> Result<&'static str> { + if data.len() > 16 * 1024 * 1024 { + return Err(bad("insert.picture", "cell pictures must be at most 16 MiB")); + } + let format = image::guess_format(data).map_err(|_| bad("insert.picture", "choose a valid PNG or JPEG picture"))?; + let mime = match format { + image::ImageFormat::Png => "image/png", + image::ImageFormat::Jpeg => "image/jpeg", + _ => return Err(bad("insert.picture", "cell pictures support PNG and JPEG")), + }; + let (width, height) = image::ImageReader::with_format(std::io::Cursor::new(data), format) + .into_dimensions() + .map_err(|_| bad("insert.picture", "invalid image dimensions"))?; + if width == 0 || height == 0 || width > 8192 || height > 8192 || u64::from(width) * u64::from(height) > 16_000_000 { + return Err(bad("insert.picture", "cell pictures are limited to 8192 pixels per side and 16 million pixels")); + } + let mut reader = image::ImageReader::with_format(std::io::Cursor::new(data), format); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(8192); + limits.max_image_height = Some(8192); + limits.max_alloc = Some(64 * 1024 * 1024); + reader.limits(limits); + reader.decode().map_err(|_| bad("insert.picture", "picture cannot be decoded within the 64 MiB limit"))?; + Ok(mime) +} + +pub(super) fn insert_shape(s: &mut Session, p: &Json) -> Result { + let kind = match str_param(p, "kind").unwrap_or("rectangle") { + "roundedRectangle" => ShapeKind::RoundedRectangle, + "ellipse" | "oval" => ShapeKind::Ellipse, + "triangle" => ShapeKind::Triangle, + "line" => ShapeKind::Line, + "arrow" => ShapeKind::Arrow, + "textBox" => ShapeKind::TextBox, + _ => ShapeKind::Rectangle, + }; + let d = s.doc()?; + let id = d.wb.next_object_id(); + let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); + let fill = crate::cmd::format::color_param(p.get("fill")).unwrap_or(if kind == ShapeKind::TextBox { + Color::rgb(255, 255, 255) + } else { + Color::Theme(4, 0) + }); + let line = crate::cmd::format::color_param(p.get("line")).unwrap_or(if kind == ShapeKind::TextBox { + Color::rgb(0x80, 0x80, 0x80) + } else { + Color::Theme(4, -250) + }); + let shape = Shape { + id, + kind, + anchor: Anchor { + cell: at, + dx: 0.0, + dy: 0.0, + width: f64_param(p, "width").unwrap_or(144.0) as f32, + height: f64_param(p, "height").unwrap_or(if kind == ShapeKind::Line { 0.0 } else { 96.0 }) as f32, + mode: anchor_mode_param(p).unwrap_or_default(), + }, + fill, + line, + text: str_param(p, "text").unwrap_or("").into(), + format: Default::default(), + }; + let sheet = d.wb.active_sheet(); + edit(s, |cx| { + cx.sheet_mut(sheet)?.shapes.push(shape); + Ok(json!({"shape": id})) + }) +} + +/// A text box holds at most as many characters as a cell. +const SHAPE_TEXT_MAX_CHARS: usize = 32_767; + +pub(super) fn shape_set_text(s: &mut Session, p: &Json) -> Result { + let id = + p.get("id").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()).ok_or_else(|| bad("shape.setText", "`id` must be a text box id"))?; + let text = str_param(p, "text").ok_or_else(|| bad("shape.setText", "`text` must be a string"))?; + if text.chars().nth(SHAPE_TEXT_MAX_CHARS).is_some() { + return Err(bad("shape.setText", "`text` is longer than 32,767 characters")); + } + let d = s.doc()?; + let sheet = d.wb.active_sheet(); + let sh = d.wb.active().ok_or_else(|| bad("shape.setText", "no such text box"))?; + let shape = sh.shapes.iter().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?; + if shape.kind != ShapeKind::TextBox { + return Err(bad("shape.setText", "the shape is not a text box")); + } + // Sheet protection locks drawing objects (there is no "edit objects" allowance yet). + if sh.is_protected() { + return Err(EngineError::Other(PROTECTED.into())); + } + if shape.text == text { + return ok(); + } + edit(s, |cx| { + let shape = cx.sheet_mut(sheet)?.shapes.iter_mut().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?; + shape.text = text.into(); + Ok(Json::Null) + }) +} + +pub(super) fn delete_object(s: &mut Session, p: &Json) -> Result { + let id = u32_param(p, "id").ok_or_else(|| bad("object.delete", "missing `id`"))?; + let kind = str_param(p, "kind").unwrap_or("").to_string(); + let sheet = s.doc()?.wb.active_sheet(); + edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + match kind.as_str() { + "chart" => sh.charts.retain(|c| c.id != id), + "image" => sh.images.retain(|c| c.id != id), + "slicer" => sh.slicers.retain(|c| c.id != id), + _ => sh.shapes.retain(|c| c.id != id), + } + Ok(Json::Null) + }) +} + +pub(super) fn move_object(s: &mut Session, p: &Json) -> Result { + let id = u32_param(p, "id").ok_or_else(|| bad("object.move", "missing `id`"))?; + let kind = str_param(p, "kind").unwrap_or("").to_string(); + let sheet = s.doc()?.wb.active_sheet(); + let at = cell_param(s, p, "at")?; + edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + let anchor = match kind.as_str() { + "chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), + "image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), + "slicer" => sh.slicers.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), + "shape" => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), + _ => None, + }; + let Some(a) = anchor else { return Err(bad("object.move", "no such object")) }; + if let Some(at) = at { + a.cell = at; + } + if let Some(v) = f64_param(p, "dx") { + a.dx = v as f32; + } + if let Some(v) = f64_param(p, "dy") { + a.dy = v as f32; + } + if let Some(v) = f64_param(p, "width") { + a.width = v.clamp(1.0, 10000.0) as f32; + } + if let Some(v) = f64_param(p, "height") { + a.height = v.clamp(0.0, 10000.0) as f32; + } + Ok(Json::Null) + }) +} + +/// Sets an object's anchor mode (Excel's "Move and size with cells" / "Move but don't size with +/// cells" / "Don't move or size with cells"), so it follows — or ignores — its rows and columns +/// when the sheet is sorted or edited structurally. +pub(super) fn set_anchor_mode(s: &mut Session, p: &Json) -> Result { + let id = u32_param(p, "id").ok_or_else(|| bad("object.setAnchorMode", "missing `id`"))?; + let kind = str_param(p, "kind").unwrap_or("").to_string(); + if !matches!(kind.as_str(), "chart" | "image" | "shape") { + return Err(bad("object.setAnchorMode", "kind must be chart, image or shape")); + } + let mode = anchor_mode_param(p).ok_or_else(|| bad("object.setAnchorMode", "mode must be moveAndSize, moveOnly or absolute"))?; + let sheet = s.doc()?.wb.active_sheet(); + edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + let anchor = match kind.as_str() { + "chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), + "image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), + _ => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor), + }; + let Some(a) = anchor else { return Err(bad("object.setAnchorMode", "no such object")) }; + a.mode = mode; + Ok(json!({"mode": anchor_mode_name(mode)})) + }) +} diff --git a/crates/engine/src/cmd/insert/table.rs b/crates/engine/src/cmd/insert/table.rs new file mode 100644 index 000000000..461af7861 --- /dev/null +++ b/crates/engine/src/cmd/insert/table.rs @@ -0,0 +1,331 @@ +use super::*; + +// ---------------------------------------------------------------- tables + +pub(super) fn insert_table(s: &mut Session, p: &Json) -> Result { + let r = block(s, p)?; + let sheet = target_sheet(s, p)?; + let d = s.doc()?; + if d.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.range.intersects(&r))) { + return Err(EngineError::Other("A table can't overlap another table.".into())); + } + let arrays = |sh: &gridcraft_model::Sheet| { + sh.cells.iter().any(|(_, x)| x.formula.as_ref().and_then(|f| f.array).is_some_and(|a| !a.is_single() && a.intersects(&r))) + }; + if d.wb.sheet(sheet).is_some_and(arrays) { + return Err(EngineError::Other("Multi-cell array formulas aren't allowed in tables.".into())); + } + if r.count() > 50_000_000 { + return Err(bad("insert.table", "range too large")); + } + let header = bool_param(p, "header").unwrap_or_else(|| { + let sh = d.wb.sheet(sheet); + sh.is_some_and(|sh| (r.start.col..=r.end.col).all(|c| sh.value(CellRef::new(r.start.row, c)).is_text()) && r.height() > 1) + }); + let name = str_param(p, "name") + .map(str::to_string) + .unwrap_or_else(|| (1..).map(|n| format!("Table{n}")).find(|n| d.wb.table(n).is_none()).unwrap_or_else(|| "Table".into())); + if !valid_table_name(&name) { + return Err(EngineError::Other("The name that you entered is not valid.".into())); + } + if d.wb.table(&name).is_some() || d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) { + return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into())); + } + // Without a header row, the data moves down a row to make room for one: never a table with it. + let moves_a_table = |sh: &gridcraft_model::Sheet| { + sh.tables.iter().any(|t| t.range.end.row >= r.start.row && t.range.start.col <= r.end.col && t.range.end.col >= r.start.col) + }; + if !header && d.wb.sheet(sheet).is_some_and(moves_a_table) { + return Err(EngineError::Other("This operation isn't allowed: it would shift cells in a table on your worksheet.".into())); + } + let style = str_param(p, "style").unwrap_or("TableStyleMedium2").to_string(); + let id = d.wb.next_object_id(); + edit(s, |cx| { + // Without a header row, insert one with generic names. + let r = if header { + r + } else { + let sh = cx.sheet_mut(sheet)?; + // The header row pushes the data down one row: never off the end of the sheet. + let last = RangeRef::new(CellRef::new(MAX_ROWS - 1, r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col)); + if sh.cells.iter_range(last).any(|(_, x)| x.formula.is_some() || !x.value.is_empty()) { + return Err(EngineError::Other("To prevent possible loss of data, cells can't be pushed off the end of the worksheet.".into())); + } + sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, 1); + // Like Insert Cells > Shift down: move merges, conditional formats, notes, links and pictures, and re-point every formula + // reference (on any sheet) at the cells that moved. + let e = Edit::InsertCells { axis: Axis::Rows, range: RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) }; + shift_cell_features(sh, &e); + let name = sh.name.clone(); + rewrite(&mut cx.wb, &name, &e); + let sh = cx.sheet_mut(sheet)?; + for (i, c) in (r.start.col..=r.end.col).enumerate() { + sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1))); + } + cx.structural = true; + RangeRef::new(r.start, CellRef::new((r.end.row + 1).min(MAX_ROWS - 1), r.end.col)) + }; + let sh = cx.sheet_mut(sheet)?; + let mut names: Vec = Vec::new(); + // Lower-case names taken, and the next suffix for each header, so that thousands of equal + // headers don't take cubic time. + let mut taken = std::collections::HashSet::new(); + let mut next_suffix = std::collections::HashMap::new(); + for c in r.start.col..=r.end.col { + let mut n = sh.value(CellRef::new(r.start.row, c)).display(); + if n.is_empty() { + n = format!("Column{}", c - r.start.col + 1); + } + let base = n.clone(); + let key = base.to_ascii_lowercase(); + let mut k = next_suffix.get(&key).copied().unwrap_or(2); + while taken.contains(&n.to_ascii_lowercase()) { + n = format!("{base}{k}"); + k += 1; + } + next_suffix.insert(key, k); + taken.insert(n.to_ascii_lowercase()); + // Headers are text. + sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(n.as_str())); + names.push(n); + } + sh.tables.push(Table { + id, + name: name.clone(), + range: r, + header_row: true, + totals_row: false, + columns: names + .into_iter() + .map(|n| TableColumn { name: n, totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None }) + .collect(), + style, + banded_rows: true, + banded_cols: false, + first_col: false, + last_col: false, + filter_button: true, + filter: vec![], + table_type: None, + connection_id: None, + }); + cx.structural = true; + Ok(json!({"table": name, "range": r.a1()})) + }) +} + +/// Format as Table inside a table restyles that table, as in Excel; elsewhere it makes a new one. +pub(super) fn format_as_table(s: &mut Session, p: &Json) -> Result { + // Without a range, the active area decides (as in insert_table), not the first one. + let r = if p.get("range").is_some() { target_range(s, p)? } else { s.doc()?.selection.current() }; + let sheet = target_sheet(s, p)?; + let inside = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.tables.iter().find(|t| t.range.contains_range(&r))).map(|t| (t.name.clone(), t.range)); + match inside { + Some((table, range)) => { + table_style(s, &with(p, "table", json!(table)))?; + Ok(json!({"table": table, "range": range.a1()})) + } + None => insert_table(s, p), + } +} + +fn find_table(s: &Session, p: &Json) -> Result<(usize, usize)> { + let d = s.doc()?; + if let Some(n) = str_param(p, "table") { + return d.wb.table(n).ok_or_else(|| bad("table", format!("no table named `{n}`"))); + } + let sheet = d.wb.active_sheet(); + let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + let ti = sh + .tables + .iter() + .position(|t| t.range.contains(d.selection.active)) + .ok_or_else(|| EngineError::Disabled("table".into(), "select a cell in a table".into()))?; + Ok((sheet, ti)) +} + +pub(super) fn table_flag(s: &mut Session, p: &Json, which: &str) -> Result { + let (si, ti) = find_table(s, p)?; + let on = bool_param(p, "on"); + edit(s, |cx| { + let sh = cx.sheet_mut(si)?; + let Some(t) = sh.tables.get_mut(ti) else { return Ok(Json::Null) }; + match which { + "totals" => { + let v = on.unwrap_or(!t.totals_row); + if v != t.totals_row { + if v && t.range.end.row.saturating_add(1) >= MAX_ROWS { + return Err(EngineError::Other("There's no room below the table for the total row.".into())); + } + t.totals_row = v; + if v { + t.range.end.row += 1; + if let Some(first) = t.columns.first_mut() { + first.totals_label = Some("Total".into()); + } + if t.columns.len() > 1 + && let Some(last) = t.columns.last_mut() + && last.totals == TotalsFn::None + { + last.totals = TotalsFn::Sum; + } + } else { + let row = t.range.end.row; + t.range.end.row = t.range.end.row.saturating_sub(1); + let (c0, c1) = (t.range.start.col, t.range.end.col); + for c in c0..=c1 { + sh.remove_cell(CellRef::new(row, c)); + } + } + write_totals(sh, ti); + cx.structural = true; + } + } + "bandedRows" => t.banded_rows = on.unwrap_or(!t.banded_rows), + "bandedCols" => t.banded_cols = on.unwrap_or(!t.banded_cols), + "firstCol" => t.first_col = on.unwrap_or(!t.first_col), + "lastCol" => t.last_col = on.unwrap_or(!t.last_col), + "header" => { + t.filter_button = on.unwrap_or(!t.filter_button); + if !t.filter_button { + t.filter.clear(); + } + } + _ => {} + } + Ok(Json::Null) + }) +} + +/// Writes the totals row cells (labels and SUBTOTAL formulas). +fn write_totals(sh: &mut Sheet, ti: usize) { + let Some(t) = sh.tables.get(ti).cloned() else { return }; + if !t.totals_row { + return; + } + let row = t.range.end.row; + for (i, col) in t.columns.iter().enumerate() { + let c = CellRef::new(row, t.range.start.col + i as u32); + if let Some(code) = col.totals.subtotal_code() { + let f = format!("=SUBTOTAL({code},{}[{}])", t.name, col.name); + sh.set_cell(c, Cell { formula: Some(std::sync::Arc::new(Formula::new(&f))), value: gridcraft_core::Value::Empty, style: sh.style_id(c) }); + } else if let Some(l) = &col.totals_label { + sh.set_value(c, gridcraft_core::Value::text(l.as_str())); + } else { + sh.remove_cell(c); + } + } +} + +pub(super) fn table_total_fn(s: &mut Session, p: &Json) -> Result { + let (si, ti) = find_table(s, p)?; + let col = str_param(p, "column").ok_or_else(|| bad("table.totalFunction", "missing `column`"))?.to_string(); + let f = match str_param(p, "function").unwrap_or("sum") { + "average" => TotalsFn::Average, + "count" => TotalsFn::Count, + "countNums" => TotalsFn::CountNums, + "max" => TotalsFn::Max, + "min" => TotalsFn::Min, + "stdDev" => TotalsFn::StdDev, + "var" => TotalsFn::Var, + "none" => TotalsFn::None, + _ => TotalsFn::Sum, + }; + edit(s, |cx| { + let sh = cx.sheet_mut(si)?; + if let Some(t) = sh.tables.get_mut(ti) + && let Some(i) = t.column_index(&col) + && let Some(c) = t.columns.get_mut(i) + { + c.totals = f; + } + write_totals(sh, ti); + cx.structural = true; + Ok(Json::Null) + }) +} + +pub(super) fn table_style(s: &mut Session, p: &Json) -> Result { + let (si, ti) = find_table(s, p)?; + let style = str_param(p, "style").unwrap_or("TableStyleMedium2").to_string(); + edit(s, |cx| { + if let Some(t) = cx.sheet_mut(si)?.tables.get_mut(ti) { + t.style = style.clone(); + } + Ok(Json::Null) + }) +} + +/// Table names follow the Microsoft name rules, as the xlsx writer does: letters, digits, `_` and `.`, +/// starting with a letter, `_` or `\`, and at most 255 characters. Anything else (a hyphen, say) would +/// read as an operator inside a structured reference, and the writer would rename the table on save. +fn valid_table_name(name: &str) -> bool { + name.chars().count() <= 255 + && name.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\') + && CellRef::parse(name).is_none() + && !crate::cmd::formulas::is_r1c1(name) + && name.chars().enumerate().all(|(i, c)| c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\')) +} + +pub(super) fn table_rename(s: &mut Session, p: &Json) -> Result { + let (si, ti) = find_table(s, p)?; + let name = str_param(p, "name").ok_or_else(|| bad("table.rename", "missing `name`"))?.to_string(); + if !valid_table_name(&name) { + return Err(EngineError::Other("The name that you entered is not valid.".into())); + } + let wb = &s.doc()?.wb; + if wb.table(&name).is_some_and(|x| x != (si, ti)) || wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) { + return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into())); + } + edit(s, |cx| { + let old = cx.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.name.clone()).unwrap_or_default(); + if let Some(t) = cx.sheet_mut(si)?.tables.get_mut(ti) { + t.name = name.clone(); + } + // Structured references follow the rename. + for i in 0..cx.wb.sheets.len() { + let keys: Vec<(CellRef, std::sync::Arc)> = + cx.wb.sheets.get(i).map(|s| s.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default(); + // A bare name that is also a defined name resolves to the defined name, so it is not the table. + let shadowed = cx.wb.name(&old, i).is_some(); + let Some(sh) = cx.wb.sheet_mut(i) else { continue }; + for (c, f) in keys { + let Some(e) = f.expr() else { continue }; + let ne = e.map(&mut |x| match x { + gridcraft_formula::Expr::Struct(mut st) if st.table.eq_ignore_ascii_case(&old) => { + st.table = name.clone(); + gridcraft_formula::Expr::Struct(st) + } + gridcraft_formula::Expr::Name(n) if !shadowed && n.eq_ignore_ascii_case(&old) => gridcraft_formula::Expr::Name(name.clone()), + o => o, + }); + if let Some(cell) = sh.cells.get_mut(c) { + cell.formula = Some(std::sync::Arc::new(Formula::from_expr(ne))); + } + } + } + cx.structural = true; + Ok(Json::Null) + }) +} + +pub(super) fn table_convert(s: &mut Session, p: &Json) -> Result { + let (si, ti) = find_table(s, p)?; + edit(s, |cx| { + // Apply the table look as plain formatting, then drop the table. + let wb = &mut cx.wb; + let Some(t) = wb.sheet(si).and_then(|sh| sh.tables.get(ti)).cloned() else { return Ok(Json::Null) }; + let looks: Vec<(CellRef, Style)> = t.range.iter().map(|c| (c, crate::tables::table_cell_style(wb, si, &t, c))).collect(); + for (c, st) in looks { + let id = wb.styles.intern(st); + if let Some(sh) = wb.sheet_mut(si) { + sh.set_style(c, id); + } + } + if let Some(sh) = wb.sheet_mut(si) { + sh.tables.remove(ti); + } + cx.structural = true; + Ok(Json::Null) + }) +} From b0818bccc35991e014bdeee38878c92b0ee5bf7e Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:44:09 +0000 Subject: [PATCH 43/76] refactor(calc): move eval.rs items to eval/ --- crates/calc/src/eval.rs | 1445 +------------------------ crates/calc/src/eval/array.rs | 180 +++ crates/calc/src/eval/call.rs | 424 ++++++++ crates/calc/src/eval/format.rs | 74 ++ crates/calc/src/eval/lambda.rs | 198 ++++ crates/calc/src/eval/reference_fns.rs | 407 +++++++ crates/calc/src/eval/special.rs | 167 +++ 7 files changed, 1462 insertions(+), 1433 deletions(-) create mode 100644 crates/calc/src/eval/array.rs create mode 100644 crates/calc/src/eval/call.rs create mode 100644 crates/calc/src/eval/format.rs create mode 100644 crates/calc/src/eval/lambda.rs create mode 100644 crates/calc/src/eval/reference_fns.rs create mode 100644 crates/calc/src/eval/special.rs diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 4bdb3a30d..45d81ccca 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -11,6 +11,18 @@ use gridcraft_formula::{BinOp, Expr, RefKind, Reference, SheetSel, StructItem, S use gridcraft_functions::{Arg, Ctx}; use gridcraft_model::Workbook; +mod array; +mod call; +mod format; +mod lambda; +mod reference_fns; +mod special; + +use array::map_unary; +pub use array::{binary_values, zip2}; +pub use format::r1c1_to_a1; +pub use special::{SPECIAL_FUNCTIONS, is_known_function, precedents, resized_sum_range}; + /// Largest array a reference may expand to. pub const MAX_CELLS: u64 = 16_000_000; /// References up to this many cells (one whole row) are always read at their full size. @@ -615,1021 +627,6 @@ impl<'h> Evaluator<'h> { } } } - - fn call(&mut self, name: &str, args: &[Expr]) -> Ev { - match name { - "IF" => { - if args.is_empty() || args.len() > 3 { - return err(CellError::Value); - } - let c = self.arg_val(args, 0).unwrap_or_default(); - if let Value::Array(arr) = &c { - // Array condition: element-wise. - let t = self.arg_val(args, 1).unwrap_or(Value::Bool(true)); - let f = if args.len() > 2 { self.arg_val(args, 2).unwrap_or(Value::Bool(false)) } else { Value::Bool(false) }; - return Ev::V(zip3(arr, &t, &f, |c, t, f| match c.to_bool() { - Ok(true) => t.clone(), - Ok(false) => f.clone(), - Err(e) => Value::Error(e), - })); - } - match c.to_bool() { - Ok(true) => match args.get(1) { - Some(Expr::Missing) => Ev::V(Value::Number(0.0)), - Some(e) => self.eval(e), - None => Ev::V(Value::Bool(true)), - }, - Ok(false) => match args.get(2) { - Some(Expr::Missing) => Ev::V(Value::Number(0.0)), - Some(e) => self.eval(e), - None => Ev::V(Value::Bool(false)), - }, - Err(e) => err(e), - } - } - "IFS" => { - if args.len() < 2 || !args.len().is_multiple_of(2) { - return err(CellError::Value); - } - for pair in args.chunks(2) { - let [c, v] = pair else { break }; - let cv = self.value(c); - match cv.scalar().to_bool() { - Ok(true) => return self.eval(v), - Ok(false) => {} - Err(e) => return err(e), - } - } - err(CellError::NA) - } - "IFERROR" | "IFNA" => { - if args.len() != 2 { - return err(CellError::Value); - } - let v = self.arg_ev(args, 0).unwrap_or(Ev::V(Value::Empty)); - let na_only = name == "IFNA"; - let catch = |e: CellError| if na_only { e == CellError::NA } else { true }; - match &v { - Ev::V(Value::Error(e)) if catch(*e) => self.eval(&args[1]), - Ev::V(Value::Array(a)) if a.data.iter().any(|x| x.as_error().is_some_and(catch)) => { - let alt = self.arg_val(args, 1).unwrap_or_default(); - let data = a.data.iter().map(|x| if x.as_error().is_some_and(catch) { alt.scalar() } else { x.clone() }).collect(); - Ev::V(Array::new(a.rows, a.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))) - } - Ev::R(_) => { - let val = self.deref(v.clone()); - match val { - Value::Error(e) if catch(e) => self.eval(&args[1]), - Value::Array(ref a) if a.data.iter().any(|x| x.as_error().is_some_and(catch)) => { - let alt = self.arg_val(args, 1).unwrap_or_default(); - let data = a.data.iter().map(|x| if x.as_error().is_some_and(catch) { alt.scalar() } else { x.clone() }).collect(); - Ev::V(Array::new(a.rows, a.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))) - } - _ => v, - } - } - _ => v, - } - } - "CHOOSE" => { - if args.len() < 2 { - return err(CellError::Value); - } - let index = self.value(&args[0]); - if let Value::Array(_) = index { - // An array of indexes picks element by element: `CHOOSE({1,2},A1:A3,B1:B3)` - // is a two-column array. The library CHOOSE lifts over its arguments. - let Some(spec) = gridcraft_functions::lookup(name) else { return err(CellError::Name) }; - let mut evaluated = vec![Arg { value: index, from_ref: false }]; - for a in &args[1..] { - let value = self.value(a); - evaluated.push(Arg { value, from_ref: false }); - } - let mut ctx = FnCtx { ev: self, criteria: None, lambda: None }; - return gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V); - } - let i = match index.to_number() { - Ok(n) => n.trunc() as i64, - Err(e) => return err(e), - }; - if i < 1 || i as usize >= args.len() { - return err(CellError::Value); - } - self.eval(&args[i as usize]) - } - "SWITCH" => { - if args.len() < 3 { - return err(CellError::Value); - } - let v = self.arg_val(args, 0).unwrap_or_default().scalar(); - if let Value::Error(e) = v { - return err(e); - } - let rest = &args[1..]; - let mut i = 0; - while i + 1 < rest.len() { - let cand = self.value(&rest[i]).scalar(); - if !cand.is_error() && compare(&v, &cand) == std::cmp::Ordering::Equal && same_kind(&v, &cand) { - return self.eval(&rest[i + 1]); - } - i += 2; - } - if rest.len() % 2 == 1 { rest.last().map_or(err(CellError::NA), |d| self.eval(d)) } else { err(CellError::NA) } - } - "ROW" | "COLUMN" => { - let is_row = name == "ROW"; - if args.is_empty() || matches!(args.first(), Some(Expr::Missing)) { - return Ev::V(Value::number(if is_row { self.at.row + 1 } else { self.at.col + 1 } as f64)); - } - match self.eval(&args[0]) { - Ev::R(a) => { - let Some(a) = a.first() else { return err(CellError::Ref) }; - let r = self.trim_lines(a); - if is_row { - let rows: Vec = (r.start.row..=r.end.row).map(|x| Value::number(x as f64 + 1.0)).collect(); - if rows.len() == 1 { Ev::V(rows.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::column(rows).into()) } - } else { - let cols: Vec = (r.start.col..=r.end.col).map(|x| Value::number(x as f64 + 1.0)).collect(); - if cols.len() == 1 { Ev::V(cols.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::row(cols).into()) } - } - } - Ev::V(Value::Error(e)) => err(e), - _ => err(CellError::Value), - } - } - "ROWS" | "COLUMNS" => { - if args.len() != 1 { - return err(CellError::Value); - } - let is_rows = name == "ROWS"; - match self.eval(&args[0]) { - Ev::R(a) => match a.as_slice() { - [a] => Ev::V(Value::number(if is_rows { a.range.height() } else { a.range.width() } as f64)), - _ => err(CellError::Ref), - }, - Ev::V(Value::Array(arr)) => Ev::V(Value::number(if is_rows { arr.rows } else { arr.cols } as f64)), - Ev::V(Value::Error(e)) => err(e), - Ev::V(_) => Ev::V(Value::Number(1.0)), - Ev::L(_) => err(CellError::Value), - } - } - "AREAS" => match args.first().map(|a| self.eval(a)) { - Some(Ev::R(a)) => Ev::V(Value::number(a.len() as f64)), - Some(Ev::V(Value::Error(e))) => err(e), - _ => err(CellError::Value), - }, - "ISREF" => Ev::V(Value::Bool(matches!(args.first().map(|a| self.eval(a)), Some(Ev::R(_))))), - "ISFORMULA" | "FORMULATEXT" => { - let Some(Ev::R(a)) = args.first().map(|a| self.eval(a)) else { return err(CellError::NA) }; - let Some(a) = a.first() else { return err(CellError::NA) }; - let f = self.host.workbook().sheet(a.sheet).and_then(|s| s.cell(a.range.start)).and_then(|c| c.formula.clone()); - if name == "ISFORMULA" { - Ev::V(Value::Bool(f.is_some())) - } else { - match f { - Some(f) => Ev::V(Value::text(format!("={}", f.text))), - None => err(CellError::NA), - } - } - } - "SHEET" | "SHEETS" => { - let wb = self.host.workbook(); - if args.is_empty() { - return Ev::V(Value::number(if name == "SHEET" { self.sheet + 1 } else { wb.sheets.len() } as f64)); - } - match self.eval(&args[0]) { - Ev::R(a) => { - if name == "SHEET" { - a.first().map_or(err(CellError::Ref), |a| Ev::V(Value::number(a.sheet as f64 + 1.0))) - } else { - let mut s: Vec = a.iter().map(|x| x.sheet).collect(); - s.dedup(); - Ev::V(Value::number(s.len() as f64)) - } - } - Ev::V(Value::Text(t)) if name == "SHEET" => { - let wb = self.host.workbook(); - wb.sheet_index(&t).map_or(err(CellError::NA), |i| Ev::V(Value::number(i as f64 + 1.0))) - } - _ => err(CellError::NA), - } - } - "OFFSET" => self.offset(args), - "INDIRECT" => self.indirect(args), - "INDEX" => self.index(args), - "XLOOKUP" => self.xlookup(args), - "CELL" => self.cell_info(args), - "TRIMRANGE" => self.trimrange(args), - "SUBTOTAL" | "AGGREGATE" => self.subtotal(name, args), - "TEXT" => { - if args.len() != 2 { - return err(CellError::Value); - } - let v = self.arg_val(args, 0).unwrap_or_default(); - let f = self.arg_val(args, 1).unwrap_or_default(); - let sys = self.host.workbook().date_system; - let one = |v: &Value, f: &Value| -> Value { - if let Value::Error(e) = v { - return Value::Error(*e); - } - let code = match f.to_text() { - Ok(c) => c, - Err(e) => return Value::Error(e), - }; - match gridcraft_numfmt::text_function(v, &code, sys) { - Ok(s) => Value::text(s), - Err(e) => Value::Error(e), - } - }; - Ev::V(zip2(&v, &f, one)) - } - "LET" => self.let_(args), - "LAMBDA" => { - if args.is_empty() { - return err(CellError::Value); - } - let mut params = Vec::new(); - for p in &args[..args.len() - 1] { - match p { - Expr::Name(n) => params.push(n.trim_start_matches('[').trim_end_matches(']').to_string()), - _ => return err(CellError::Value), - } - } - let Some(body) = args.last() else { return err(CellError::Value) }; - Ev::L(Arc::new(Lambda { params, body: body.clone(), env: self.env.clone() })) - } - "ISOMITTED" => { - let r = matches!(args.first(), Some(Expr::Name(n)) if self.env.iter().rev().find(|(k, _)| gridcraft_formula::same_name(k, n)).is_some_and(|(_, v)| matches!(v, Ev::V(Value::Empty)))); - Ev::V(Value::Bool(r)) - } - "MAP" | "REDUCE" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" => self.higher_order(name, args), - "GROUPBY" | "PIVOTBY" => { - // The aggregation may be a function given by name (`SUM`): pass it as its name. - let i = if name == "GROUPBY" { 2 } else { 3 }; - match args.get(i).and_then(|a| self.function_by_name(a)) { - Some(f) => { - let mut args = args.to_vec(); - if let Some(a) = args.get_mut(i) { - *a = Expr::Text(f.into()); - } - self.builtin(name, &args) - } - None => self.builtin(name, args), - } - } - "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" => { - self.host.note_volatile(); - self.builtin(name, args) - } - _ => self.builtin(name, args), - } - } - - fn builtin(&mut self, name: &str, args: &[Expr]) -> Ev { - let Some(spec) = gridcraft_functions::lookup(name) else { - // A defined name holding a LAMBDA, or a LET-bound lambda. - return match self.name(name) { - Ev::L(l) => self.apply_lambda(&l, args), - Ev::V(Value::Error(x)) => err(x), - _ => err(CellError::Name), - }; - }; - if args.len() < spec.min_args || args.len() > spec.max_args { - return err(CellError::Value); - } - if let Some(kind) = Agg::of(spec.name) { - return self.aggregate(kind, args); - } - let mut evaluated = Vec::with_capacity(args.len()); - // SUMIF/AVERAGEIF: the size of the criteria range (argument 1), which the sum range takes. - let mut criteria_shape = None; - // Database functions: the criteria range (argument 2), whose formulas are computed criteria. - let mut criteria = None; - // GROUPBY/PIVOTBY: the LAMBDA given as their function. - let mut lambda = None; - let lambda_at = match name { - "GROUPBY" => Some(2), - "PIVOTBY" => Some(3), - _ => None, - }; - for (i, a) in args.iter().enumerate() { - let mut ev = self.eval(a); - if lambda_at == Some(i) - && let Ev::L(l) = &ev - { - lambda = Some(l.clone()); - ev = Ev::V(Value::Empty); - } - if spec.category == gridcraft_functions::Category::Database - && i == 2 - && let Ev::R(areas) = &ev - && let [area] = areas.as_slice() - { - criteria = Some(*area); - } - if resizes_sum_range(name, args.len()) { - if i == 0 { - criteria_shape = ev_shape(&ev); - } else if i == 2 - && let (Some((h, w)), Ev::R(areas)) = (criteria_shape, &ev) - && let [area] = areas.as_slice() - { - ev = Ev::R(vec![Area { sheet: area.sheet, range: resized_sum_range(area.range, h, w) }]); - } - } - let from_ref = matches!(ev, Ev::R(_)); - let value = match ev { - Ev::R(areas) if areas.len() > 1 => { - // Multi-area references (unions): flatten into one row for aggregates. - let mut vals = Vec::new(); - for ar in &areas { - match self.area_value(ar) { - Value::Array(a) => vals.extend(a.data.iter().cloned()), - v => vals.push(v), - } - } - Value::from(Array::row(vals)) - } - ev => self.deref(ev), - }; - evaluated.push(Arg { value, from_ref }); - } - let mut ctx = FnCtx { ev: self, criteria, lambda }; - gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V) - } - - /// Performs aggregation operations without copying its ranges: blocks are folded over the stored - /// cells, so no array is built and there is no `MAX_CELLS` limit. Arguments are evaluated and read one - /// after another, as the array path does, so volatile formulas (like `RAND`) see the same order. - /// Only `SUM`, `COUNT` and `AVG` are supported currently. - fn aggregate(&mut self, kind: Agg, args: &[Expr]) -> Ev { - let mut acc = Acc::default(); - let mut first_error = None; - for a in args { - let ev = self.eval(a); - let steps = match ev { - Ev::R(areas) => areas.iter().map(|area| self.aggregate_area(kind, area, &mut acc)).collect(), - ev => { - let value = self.deref(ev); - vec![self.aggregate_value(kind, value, false, &mut acc)] - } - }; - if first_error.is_none() { - first_error = steps.into_iter().find_map(Result::err); - } - } - first_error.unwrap_or_else(|| Ev::V(acc.finish(kind))) - } - - /// Adds one area to `acc`; `Err` is the result the function ends with. - fn aggregate_area(&mut self, kind: Agg, a: &Area, acc: &mut Acc) -> Result<(), Ev> { - if !a.range.is_single() { - let mut error = None; - let walked = self.host.visit_range(a.sheet, a.range, &mut |v| match acc.add_cell(kind, v) { - Ok(()) => true, - Err(e) => { - error = Some(e); - false - } - }); - if walked { - return error.map_or(Ok(()), |e| Err(err(e))); - } - } - let value = self.area_value(a); - self.aggregate_value(kind, value, true, acc) - } - - /// Adds one argument's value by calling `SUM` and `COUNT` on it, so coercion and error rules - /// stay in one place (`AVERAGE` of an argument is its `SUM` over its `COUNT`). - fn aggregate_value(&mut self, kind: Agg, value: Value, from_ref: bool, acc: &mut Acc) -> Result<(), Ev> { - let (Some(sum), Some(count)) = (gridcraft_functions::lookup("SUM"), gridcraft_functions::lookup("COUNT")) else { - return Err(err(CellError::Name)); - }; - let args = [Arg { value, from_ref }]; - if kind != Agg::Count { - match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) { - Value::Number(n) => acc.sum += n, - other => return Err(Ev::V(other)), - } - } - if kind != Agg::Sum { - match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) { - Value::Number(n) => acc.count = acc.count.saturating_add(n as u64), - other => return Err(Ev::V(other)), - } - } - Ok(()) - } - - pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev { - // Too many or too few argument positions is #VALUE!. An explicitly empty position, as in - // `(1,)`, is `Expr::Missing` and still counts as a position (it binds as omitted). `f()` - // parses to no arguments, which for a one-parameter lambda can also be read as one empty - // position, so that call still binds the parameter as omitted. - let positions_ok = args.len() == l.params.len() || (args.is_empty() && l.params.len() == 1); - if !positions_ok { - return err(CellError::Value); - } - let mut bound = Vec::with_capacity(l.params.len()); - for (i, p) in l.params.iter().enumerate() { - let v = match args.get(i) { - Some(Expr::Missing) | None => Ev::V(Value::Empty), - Some(e) => self.eval(e), - }; - bound.push((p.clone(), v)); - } - self.call_lambda_values(l, bound) - } - - fn call_lambda_values(&mut self, l: &Lambda, bound: Vec<(String, Ev)>) -> Ev { - if self.calls >= MAX_LAMBDA_CALLS { - return err(CellError::Num); - } - // #2382: one charge per invocation, so a cell's total work stays bounded however the - // lambdas nest. Foregrounding the charge here means every path into a body (direct - // `(2,3)` application, `MAP`/`REDUCE`/`BYROW` helpers, D-function criteria) meters. - self.fuel = match self.fuel.checked_sub(1) { - Some(f) => f, - None => return err(CellError::Calc), - }; - self.calls += 1; - let saved = std::mem::replace(&mut self.env, l.env.clone()); - self.env.extend(bound); - let r = self.eval(&l.body); - self.env = saved; - self.calls -= 1; - r - } - - fn let_(&mut self, args: &[Expr]) -> Ev { - if args.len() < 3 || args.len().is_multiple_of(2) { - return err(CellError::Value); - } - let base = self.env.len(); - for pair in args[..args.len() - 1].chunks(2) { - let [Expr::Name(n), v] = pair else { - self.env.truncate(base); - return err(CellError::Value); - }; - let val = self.eval(v); - self.env.push((n.clone(), val)); - } - let r = match args.last() { - Some(body) => self.eval(body), - None => err(CellError::Value), - }; - self.env.truncate(base); - r - } - - /// A worksheet function given by name where a function is expected (`BYROW(A1:C3,SUM)`, - /// `GROUPBY(A2:A9,B2:B9,SUM)`): its name, unless a LET/LAMBDA variable or a defined name has - /// that name. - fn function_by_name(&self, e: &Expr) -> Option { - let Expr::Name(n) = e else { return None }; - if self.env.iter().any(|(k, _)| gridcraft_formula::same_name(k, n)) || self.host.workbook().name(n, self.sheet).is_some() { - return None; - } - let u = n.to_ascii_uppercase(); - gridcraft_functions::lookup(&u).map(|_| u) - } - - /// The function argument of a LAMBDA helper; a function given by name takes `arity` - /// arguments. - fn lambda_arg(&mut self, e: Option<&Expr>, arity: usize) -> Option> { - if let Some(f) = e.and_then(|x| self.function_by_name(x)) { - let params: Vec = (0..arity).map(|i| format!("_xleta.{i}")).collect(); - let body = Expr::Call(f, params.iter().map(|p| Expr::Name(p.clone())).collect()); - return Some(Arc::new(Lambda { params, body, env: Vec::new() })); - } - match e.map(|x| self.eval(x)) { - Some(Ev::L(l)) => Some(l), - _ => None, - } - } - - fn higher_order(&mut self, name: &str, args: &[Expr]) -> Ev { - let as_array = |v: Value| -> Arc { - match v { - Value::Array(a) => a, - other => Arc::new(Array::scalar(other)), - } - }; - match name { - "MAP" => { - if args.len() < 2 { - return err(CellError::Value); - } - let Some(l) = self.lambda_arg(args.last(), args.len() - 1) else { return err(CellError::Value) }; - let arrays: Vec> = args[..args.len() - 1].iter().map(|a| as_array(self.value(a))).collect(); - let rows = arrays.iter().map(|a| a.rows).max().unwrap_or(1); - let cols = arrays.iter().map(|a| a.cols).max().unwrap_or(1); - let Some(n) = broadcast_len(rows, cols) else { return err(CellError::Num) }; - // The LAMBDA takes one parameter per array. - if l.params.len() != arrays.len() { - return err(CellError::Value); - } - let mut out = Vec::with_capacity(n); - for r in 0..rows { - for c in 0..cols { - let bound = l.params.iter().zip(arrays.iter()).map(|(p, a)| (p.clone(), Ev::V(a.get_broadcast(r, c)))).collect(); - let v = self.call_lambda_values(&l, bound); - let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) }; - out.push(one); - } - } - Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc))) - } - "REDUCE" | "SCAN" => { - if args.len() != 3 { - return err(CellError::Value); - } - let mut acc = self.value(&args[0]); - let arr = as_array(self.value(&args[1])); - let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) }; - if l.params.len() != 2 { - return err(CellError::Value); - } - let mut scan = Vec::with_capacity(arr.data.len()); - for v in arr.data.iter() { - let bound = vec![(l.params[0].clone(), Ev::V(acc.clone())), (l.params[1].clone(), Ev::V(v.clone()))]; - let r = self.call_lambda_values(&l, bound); - acc = self.deref(r); - if name == "SCAN" { - let Some(one) = single(acc.clone()) else { return err(CellError::Calc) }; - scan.push(one); - } - } - if name == "SCAN" { - Ev::V(Array::new(arr.rows, arr.cols, scan).map(Value::from).unwrap_or(Value::Error(CellError::Calc))) - } else { - Ev::V(acc) - } - } - "BYROW" | "BYCOL" => { - if args.len() != 2 { - return err(CellError::Value); - } - let arr = as_array(self.value(&args[0])); - let Some(l) = self.lambda_arg(args.get(1), 1) else { return err(CellError::Value) }; - let Some(p) = l.params.first().cloned() else { return err(CellError::Value) }; - if l.params.len() != 1 { - return err(CellError::Value); - } - let by_row = name == "BYROW"; - let n = if by_row { arr.rows } else { arr.cols }; - let mut out = Vec::with_capacity(n); - for i in 0..n { - let slice: Vec = if by_row { - (0..arr.cols).map(|c| arr.get(i, c).cloned().unwrap_or_default()).collect() - } else { - (0..arr.rows).map(|r| arr.get(r, i).cloned().unwrap_or_default()).collect() - }; - let a = if by_row { Array::row(slice) } else { Array::column(slice) }; - let r = self.call_lambda_values(&l, vec![(p.clone(), Ev::V(a.into()))]); - let Some(one) = single(self.deref(r)) else { return err(CellError::Calc) }; - out.push(one); - } - let a = if by_row { Array::column(out) } else { Array::row(out) }; - Ev::V(a.into()) - } - "MAKEARRAY" => { - if args.len() != 3 { - return err(CellError::Value); - } - let (rows, cols) = match (self.arg_num(args, 0, 0.0), self.arg_num(args, 1, 0.0)) { - (Ok(r), Ok(c)) => (r.trunc(), c.trunc()), - (Err(e), _) | (_, Err(e)) => return err(e), - }; - if rows < 1.0 || cols < 1.0 || rows * cols > MAX_CELLS as f64 { - return err(CellError::Value); - } - let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) }; - if l.params.len() != 2 { - return err(CellError::Value); - } - let (rows, cols) = (rows as usize, cols as usize); - let mut out = Vec::with_capacity(rows * cols); - for r in 0..rows { - for c in 0..cols { - let bound = vec![ - (l.params[0].clone(), Ev::V(Value::number(r as f64 + 1.0))), - (l.params[1].clone(), Ev::V(Value::number(c as f64 + 1.0))), - ]; - let v = self.call_lambda_values(&l, bound); - let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) }; - out.push(one); - } - } - Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc))) - } - _ => err(CellError::Name), - } - } - - /// Whole rows/columns trimmed for ROW()/COLUMN() arrays. - fn trim_lines(&mut self, a: &Area) -> RangeRef { - if a.range.is_full_cols() || a.range.is_full_rows() { self.trim(a) } else { a.range } - } - - fn offset(&mut self, args: &[Expr]) -> Ev { - self.host.note_volatile(); - if args.len() < 3 || args.len() > 5 { - return err(CellError::Value); - } - let base = match self.eval(&args[0]) { - Ev::R(a) if a.len() == 1 => a[0], - Ev::V(Value::Error(e)) => return err(e), - _ => return err(CellError::Value), - }; - let rows = match self.arg_num(args, 1, 0.0) { - Ok(n) => n.trunc() as i64, - Err(e) => return err(e), - }; - let cols = match self.arg_num(args, 2, 0.0) { - Ok(n) => n.trunc() as i64, - Err(e) => return err(e), - }; - let h = match self.arg_num(args, 3, base.range.height() as f64) { - Ok(n) => n.trunc() as i64, - Err(e) => return err(e), - }; - let w = match self.arg_num(args, 4, base.range.width() as f64) { - Ok(n) => n.trunc() as i64, - Err(e) => return err(e), - }; - if h == 0 || w == 0 { - return err(CellError::Ref); - } - let r0 = (base.range.start.row as i64).saturating_add(rows); - let c0 = (base.range.start.col as i64).saturating_add(cols); - let (r0, r1) = if h > 0 { (r0, r0.saturating_add(h - 1)) } else { (r0.saturating_add(h + 1), r0) }; - let (c0, c1) = if w > 0 { (c0, c0.saturating_add(w - 1)) } else { (c0.saturating_add(w + 1), c0) }; - if r0 < 0 || c0 < 0 || r1 >= MAX_ROWS as i64 || c1 >= MAX_COLS as i64 { - return err(CellError::Ref); - } - Ev::R(vec![Area { sheet: base.sheet, range: RangeRef::new(CellRef::new(r0 as u32, c0 as u32), CellRef::new(r1 as u32, c1 as u32)) }]) - } - - fn indirect(&mut self, args: &[Expr]) -> Ev { - self.host.note_volatile(); - if args.is_empty() || args.len() > 2 { - return err(CellError::Value); - } - let text = match self.arg_val(args, 0).map(|v| v.scalar().to_text()) { - Some(Ok(t)) => t, - Some(Err(e)) => return err(e), - None => return err(CellError::Value), - }; - let a1 = match args.get(1) { - None | Some(Expr::Missing) => true, - Some(e) => { - let v = self.value(e); - v.scalar().to_bool().unwrap_or(true) - } - }; - // With a1 = FALSE, text that isn't R1C1 can still be a name, but never an A1 reference. - let (text, a1_ok) = if a1 { - (text, true) - } else { - match r1c1_to_a1(&text, self.at) { - Ok(Some(a1_text)) => (a1_text, true), - Ok(None) => (text, false), - Err(e) => return err(e), - } - }; - match gridcraft_formula::parse(&text) { - Ok(Expr::Ref(_)) if !a1_ok => err(CellError::Ref), - Ok(e @ (Expr::Ref(_) | Expr::Name(_) | Expr::Struct(_))) => match self.eval(&e) { - r @ Ev::R(_) => r, - _ => err(CellError::Ref), - }, - _ => err(CellError::Ref), - } - } - - fn index(&mut self, args: &[Expr]) -> Ev { - if args.is_empty() || args.len() > 4 { - return err(CellError::Value); - } - let src = self.eval(&args[0]); - let mut arg = |i: usize| match args.get(i) { - None | Some(Expr::Missing) => None, - Some(e) => Some(self.value(e)), - }; - let (rows, cols) = (arg(1), arg(2)); - let area_n = match self.arg_num(args, 3, 1.0) { - Ok(n) => n.trunc() as i64, - Err(e) => return err(e), - }; - let two = args.len() == 2; - let num = |v: &Option| v.as_ref().map_or(Ok(0.0), |v| v.to_number()).map(|n| n.trunc() as i64); - // Arrays of positions pick a value for each: INDEX(A1:C10,{1;2},{1,3}) is 2×2. - if let (Some(Value::Array(_)), _) | (_, Some(Value::Array(_))) = (&rows, &cols) { - let as_array = |v: &Option| match v { - Some(Value::Array(a)) => Arc::clone(a), - Some(v) => Arc::new(Array::scalar(v.clone())), - None => Arc::new(Array::scalar(Value::Number(0.0))), - }; - let (ra, ca) = (as_array(&rows), as_array(&cols)); - let (h, w) = (ra.rows.max(ca.rows), ra.cols.max(ca.cols)); - if (h as u64).saturating_mul(w as u64) > MAX_CELLS { - return err(CellError::Num); - } - let mut out = Vec::with_capacity(h * w); - for r in 0..h { - for c in 0..w { - let pos = (num(&Some(ra.get_broadcast(r, c))), num(&Some(ca.get_broadcast(r, c)))); - let v = match pos { - (Ok(row), Ok(col)) => { - let one = self.index_one(src.clone(), row, col, area_n, two); - match self.deref(one) { - Value::Array(a) if a.rows * a.cols > 1 => Value::Error(CellError::Calc), - v => v.scalar(), - } - } - (Err(e), _) | (_, Err(e)) => Value::Error(e), - }; - out.push(v); - } - } - return Ev::V(Array::new(h, w, out).map(Value::from).unwrap_or(Value::Error(CellError::Value))); - } - let (row, col) = match (num(&rows), num(&cols)) { - (Ok(r), Ok(c)) => (r, c), - (Err(e), _) | (_, Err(e)) => return err(e), - }; - self.index_one(src, row, col, area_n, two) - } - - /// INDEX for one row and column position (0 = the whole column or row). - fn index_one(&mut self, src: Ev, row: i64, col: i64, area_n: i64, two_args: bool) -> Ev { - if row < 0 || col < 0 { - return err(CellError::Value); - } - match src { - Ev::R(areas) => { - let area = usize::try_from((area_n as u64).saturating_sub(1)).ok(); - let Some(a) = area.and_then(|i| areas.get(i)).copied() else { return err(CellError::Ref) }; - let r = a.range; - // A single row or column with one index picks along it. An area with several rows - // and columns needs both: `INDEX(A1:C3,2)` is #REF! in Excel (an empty third - // argument, `INDEX(A1:C3,2,)`, still means the whole row). Arrays differ: - // `INDEX({1,2;3,4},2)` is the row. - if two_args && r.height() > 1 && r.width() > 1 { - return err(CellError::Ref); - } - let (row, col) = if two_args && r.height() == 1 { (1, row) } else { (row, col) }; - if row as u64 > r.height() as u64 || col as u64 > r.width() as u64 { - return err(CellError::Ref); - } - let (r0, r1) = if row == 0 { (r.start.row, r.end.row) } else { (r.start.row + row as u32 - 1, r.start.row + row as u32 - 1) }; - let (c0, c1) = if col == 0 { (r.start.col, r.end.col) } else { (r.start.col + col as u32 - 1, r.start.col + col as u32 - 1) }; - Ev::R(vec![Area { sheet: a.sheet, range: RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)) }]) - } - Ev::V(Value::Array(arr)) => { - let (row, col) = if two_args && arr.rows == 1 { (1, row) } else { (row, col) }; - if row as usize > arr.rows || col as usize > arr.cols { - return err(CellError::Ref); - } - match (row, col) { - (0, 0) => Ev::V(Value::Array(arr)), - (0, c) => Ev::V(Array::column((0..arr.rows).map(|r| arr.get(r, c as usize - 1).cloned().unwrap_or_default()).collect()).into()), - (r, 0) => Ev::V(Array::row((0..arr.cols).map(|c| arr.get(r as usize - 1, c).cloned().unwrap_or_default()).collect()).into()), - (r, c) => Ev::V(arr.get(r as usize - 1, c as usize - 1).cloned().unwrap_or(Value::Error(CellError::Ref))), - } - } - Ev::V(Value::Error(e)) => err(e), - Ev::V(v) => { - if row <= 1 && col <= 1 { - Ev::V(v) - } else { - err(CellError::Ref) - } - } - Ev::L(_) => err(CellError::Value), - } - } - - /// XLOOKUP whose result range is a reference returns a reference, as in Excel, so that it can - /// end a range (`SUM(XLOOKUP(a,…):XLOOKUP(b,…))`) or feed CELL. Everything else (arrays of - /// lookup values, array arguments, errors) goes to the library function. - fn xlookup(&mut self, args: &[Expr]) -> Ev { - if (3..=6).contains(&args.len()) - && let (Ev::R(look), Ev::R(ret)) = (self.eval(&args[1]), self.eval(&args[2])) - && let ([l], [r]) = (look.as_slice(), ret.as_slice()) - && let Some(spec) = gridcraft_functions::lookup("XMATCH") - { - let key = self.value(&args[0]); - let (l, r) = (*l, *r); - let vertical = l.range.width() == 1 && l.range.height() > 1; - let horizontal = l.range.height() == 1 && l.range.width() > 1; - let same_len = if vertical { r.range.height() == l.range.height() } else { r.range.width() == l.range.width() }; - if !matches!(key, Value::Array(_)) && (vertical || horizontal) && same_len { - let mut xargs = vec![Arg { value: key, from_ref: false }, Arg { value: self.area_value(&l), from_ref: true }]; - // An empty match_mode before a given search_mode is the default (0), not the end. - let given = |i: usize| args.get(i).is_some_and(|e| !matches!(e, Expr::Missing)); - let last = if given(5) { - 5 - } else if given(4) { - 4 - } else { - 3 - }; - for i in 4..=last { - match args.get(i) { - Some(Expr::Missing) | None => xargs.push(Arg { value: Value::Number(0.0), from_ref: false }), - Some(e) => { - let value = self.value(e); - xargs.push(Arg { value, from_ref: false }); - } - } - } - let mut ctx = FnCtx { ev: self, criteria: None, lambda: None }; - return match gridcraft_functions::call(spec, &xargs, &mut ctx) { - Value::Number(p) if p >= 1.0 => { - let k = p as u32 - 1; - let range = if vertical { - RangeRef::new( - CellRef::new(r.range.start.row + k, r.range.start.col), - CellRef::new(r.range.start.row + k, r.range.end.col), - ) - } else { - RangeRef::new( - CellRef::new(r.range.start.row, r.range.start.col + k), - CellRef::new(r.range.end.row, r.range.start.col + k), - ) - }; - Ev::R(vec![Area { sheet: r.sheet, range }]) - } - Value::Error(CellError::NA) if args.get(3).is_some_and(|e| !matches!(e, Expr::Missing)) => self.eval(&args[3]), - Value::Error(e) => err(e), - _ => err(CellError::Value), - }; - } - } - self.builtin("XLOOKUP", args) - } - - fn cell_info(&mut self, args: &[Expr]) -> Ev { - let kind = match self.arg_val(args, 0).map(|v| v.scalar().to_text()) { - Some(Ok(t)) => t.to_ascii_lowercase(), - Some(Err(e)) => return err(e), - None => return err(CellError::Value), - }; - let area = match args.get(1).map(|a| self.eval(a)) { - Some(Ev::R(a)) => a.first().copied(), - None => Some(Area { sheet: self.sheet, range: RangeRef::cell(self.at) }), - _ => return err(CellError::Value), - }; - let Some(a) = area else { return err(CellError::Ref) }; - let c = a.range.start; - let wb = self.host.workbook(); - let sheet = wb.sheet(a.sheet); - match kind.as_str() { - "address" => Ev::V(Value::text(format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1))), - "row" => Ev::V(Value::number(c.row as f64 + 1.0)), - "col" => Ev::V(Value::number(c.col as f64 + 1.0)), - "contents" => Ev::V(self.host.cell_value(a.sheet, c)), - "type" => { - let v = self.host.cell_value(a.sheet, c); - Ev::V(Value::text(match v { - Value::Empty => "b", - Value::Text(_) => "l", - _ => "v", - })) - } - "width" => Ev::V(Value::number(sheet.map_or(8.43, |s| gridcraft_model::points_to_chars(s.col_width(c.col) as f64)).round())), - // Excel's `/[]`, which the classic sheet-name formula splits on - // the `]`. Empty until the workbook has a file of its own, as in Excel (#1489). - "filename" => Ev::V(Value::text(match &wb.source_path { - Some(file) => { - let name = file.file_name().unwrap_or_default().to_string_lossy(); - let on = sheet.map_or("", |s| s.name.as_str()); - file.parent().unwrap_or(std::path::Path::new("")).join(format!("[{name}]{on}")).to_string_lossy().into_owned() - } - None => String::new(), - })), - "sheetname" => Ev::V(Value::text(sheet.map(|s| s.name.clone()).unwrap_or_default())), - "protect" => Ev::V(Value::number(if sheet.is_some_and(|s| wb.styles.get(s.style_id(c)).protection.locked) { 1.0 } else { 0.0 })), - "format" => { - let code = sheet.map(|s| wb.styles.get(s.style_id(c)).num_fmt.as_str().to_string()).unwrap_or_default(); - Ev::V(Value::text(cell_format_code(&code))) - } - "color" | "parentheses" => Ev::V(Value::Number(0.0)), - "prefix" => Ev::V(Value::text("")), - _ => err(CellError::Value), - } - } - - fn subtotal(&mut self, name: &str, args: &[Expr]) -> Ev { - // SUBTOTAL needs function_num and ref1; AGGREGATE needs function_num, options and ref1. - let min_args = if name == "AGGREGATE" { 3 } else { 2 }; - if args.len() < min_args { - return err(CellError::Value); - } - let code = match self.arg_num(args, 0, 0.0) { - Ok(n) => n.trunc() as u32, - Err(e) => return err(e), - }; - let (func, options, rest) = if name == "AGGREGATE" { - let opt = match self.arg_num(args, 1, 0.0) { - Ok(n) => n.trunc() as u32, - Err(e) => return err(e), - }; - (code, opt, &args[2..]) - } else { - let (f, ignore_hidden) = if code > 100 { (code - 100, true) } else { (code, false) }; - (f, if ignore_hidden { 5 } else { 0 }, &args[1..]) - }; - if !(1..=19).contains(&func) || (name == "SUBTOTAL" && func > 11) { - return err(CellError::Value); - } - let skip_hidden = matches!(options, 1 | 3 | 5 | 7); - // SUBTOTAL leaves out rows a filter hides whatever the function number (Microsoft's - // SUBTOTAL documentation); 101-111 also leave out rows hidden by hand. - let skip_filtered = name == "SUBTOTAL"; - let skip_errors = matches!(options, 2 | 3 | 6 | 7); - // SUBTOTAL always leaves out nested SUBTOTAL/AGGREGATE cells (101-111 too); AGGREGATE does so for options 0-3. - let skip_nested = name == "SUBTOTAL" || options <= 3; - let mut values = Vec::new(); - let mut k = None; - let refs = if name == "AGGREGATE" && func >= 14 { &rest[..1.min(rest.len())] } else { rest }; - if name == "AGGREGATE" && func >= 14 { - k = rest.get(1).map(|e| self.value(e)); - } - // Every cell visited (even one skipped as hidden, nested or an error) and every value - // counts against one budget for the whole call. All arguments are evaluated (references - // stay lazy) and charged before any cell is read: a value stored in XFD1048576 makes - // `A:XFD` trim to the whole sheet, ~17 billion cells (#2175). - enum Input { - Cells(usize, RangeRef), - Values(Value), - } - let mut inputs = Vec::new(); - let mut budget = MAX_CELLS; - for e in refs { - match self.eval(e) { - Ev::R(areas) => { - for a in areas { - let range = self.trim(&a); - budget = match budget.checked_sub(range.count()) { - Some(left) => left, - None => return err(CellError::Num), - }; - inputs.push(Input::Cells(a.sheet, range)); - } - } - Ev::V(v) => { - let n = match &v { - Value::Array(a) => u64::try_from(a.data.len()).unwrap_or(u64::MAX), - _ => 1, - }; - budget = match budget.checked_sub(n) { - Some(left) => left, - None => return err(CellError::Num), - }; - inputs.push(Input::Values(v)); - } - Ev::L(_) => return err(CellError::Value), - } - } - for input in inputs { - match input { - Input::Cells(sheet, range) => { - for c in range.iter() { - let (hidden, nested) = { - let wb = self.host.workbook(); - let s = wb.sheet(sheet); - let hidden = - s.is_some_and(|s| if skip_hidden { s.is_row_hidden(c.row) } else { skip_filtered && s.is_row_filtered(c.row) }); - let nested = skip_nested - && s.and_then(|s| s.cell(c)).and_then(|cell| cell.formula.as_ref()).is_some_and(|f| { - let u = f.text.to_ascii_uppercase(); - u.contains("SUBTOTAL(") || u.contains("AGGREGATE(") - }); - (hidden, nested) - }; - if hidden || nested { - continue; - } - let v = self.host.cell_value(sheet, c); - if skip_errors && v.is_error() { - continue; - } - values.push(v); - } - } - Input::Values(Value::Array(a)) => values.extend(a.data.iter().filter(|v| !(skip_errors && v.is_error())).cloned()), - Input::Values(v) => values.push(v), - } - } - if let Some(k) = k { - return Ev::V(gridcraft_functions::aggregate_values_k(func, &values, &k)); - } - Ev::V(gridcraft_functions::aggregate_values(func, &values)) - } } trait Pipe: Sized { @@ -1639,424 +636,6 @@ trait Pipe: Sized { } impl Pipe for T {} -/// CELL("format") code for a number format. -fn cell_format_code(code: &str) -> String { - let c = code.to_ascii_lowercase(); - if c == "general" { - return "G".into(); - } - if c.contains('%') { - return format!("P{}", decimals(&c)); - } - if c.contains('e') && c.contains('0') { - return format!("S{}", decimals(&c)); - } - if c.contains('$') { - return format!("C{}", decimals(&c)); - } - if c.contains('y') || c.contains('d') { - return "D1".into(); - } - if c.contains('h') { - return "D9".into(); - } - if c.contains(',') { - return format!(",{}", decimals(&c)); - } - format!("F{}", decimals(&c)) -} - -fn decimals(c: &str) -> usize { - c.split_once('.').map_or(0, |(_, d)| d.chars().take_while(|ch| *ch == '0').count()) -} - -fn same_kind(a: &Value, b: &Value) -> bool { - std::mem::discriminant(a) == std::mem::discriminant(b) || (a.is_empty() || b.is_empty()) -} - -/// Converts an R1C1 reference text to A1 (for INDIRECT(…, FALSE)). `Ok(None)` means the text is -/// not R1C1; `Err` means it is R1C1 but points past the sheet. -pub fn r1c1_to_a1(s: &str, at: CellRef) -> Result, CellError> { - let (sheet, body) = match s.rsplit_once('!') { - Some((sh, b)) => (format!("{sh}!"), b), - None => (String::new(), s), - }; - let conv = |part: &str| -> Result, CellError> { - let u = part.to_ascii_uppercase(); - let Some((rpart, cpart)) = u.strip_prefix('R').and_then(|rest| rest.split_once('C')) else { return Ok(None) }; - // `None`: not R1C1. `Some(None)`: R1C1, but past the sheet. - let num = |p: &str, base: u32, max: u32| -> Option> { - let (v, absolute) = if p.is_empty() { - (Some(i64::from(base)), false) - } else if let Some(inner) = p.strip_prefix('[').and_then(|x| x.strip_suffix(']')) { - (i64::from(base).checked_add(inner.parse().ok()?), false) - } else { - (p.parse::().ok()?.checked_sub(1).map(i64::from), true) - }; - Some(v.filter(|v| (0..i64::from(max)).contains(v)).map(|v| (v as u32, absolute))) - }; - let (Some(r), Some(c)) = (num(rpart, at.row, MAX_ROWS), num(cpart, at.col, MAX_COLS)) else { return Ok(None) }; - let (Some((r, ra)), Some((c, ca))) = (r, c) else { return Err(CellError::Ref) }; - Ok(Some(format!("{}{}{}{}", if ca { "$" } else { "" }, gridcraft_core::col_to_letters(c), if ra { "$" } else { "" }, r + 1))) - }; - let out = match body.split_once(':') { - Some((a, b)) => match (conv(a)?, conv(b)?) { - (Some(a), Some(b)) => format!("{a}:{b}"), - _ => return Ok(None), - }, - None => match conv(body)? { - Some(x) => x, - None => return Ok(None), - }, - }; - Ok(Some(format!("{sheet}{out}"))) -} - -/// The aggregates that can fold a reference without building an array. -#[derive(Clone, Copy, PartialEq, Eq)] -enum Agg { - Sum, - Count, - Average, -} - -impl Agg { - fn of(name: &str) -> Option { - match name { - "SUM" => Some(Agg::Sum), - "COUNT" => Some(Agg::Count), - "AVERAGE" => Some(Agg::Average), - _ => None, - } - } -} - -/// What a folded aggregate carries from one value to the next. -#[derive(Default)] -struct Acc { - sum: f64, - count: u64, -} - -impl Acc { - /// Takes a value found inside a reference: only numbers count. `Err` is an error value that - /// ends `SUM` and `AVERAGE`; `COUNT` skips errors. - fn add_cell(&mut self, kind: Agg, v: &Value) -> Result<(), CellError> { - match v { - Value::Number(n) => { - self.sum += n; - self.count = self.count.saturating_add(1); - Ok(()) - } - Value::Error(e) if kind != Agg::Count => Err(*e), - _ => Ok(()), - } - } - - fn finish(&self, kind: Agg) -> Value { - match kind { - Agg::Sum => Value::number(self.sum), - Agg::Count => Value::number(self.count as f64), - Agg::Average if self.count == 0 => Value::Error(CellError::Div0), - Agg::Average => Value::number(self.sum / self.count as f64), - } - } -} - -// ---------------------------------------------------------------- operators - -fn map_unary(v: &Value, f: &dyn Fn(f64) -> Value, plus: bool, sys: gridcraft_core::DateSystem) -> Value { - match v { - Value::Array(a) => Value::from(Array { rows: a.rows, cols: a.cols, data: a.data.iter().map(|x| map_unary(x, f, plus, sys)).collect() }), - Value::Error(e) => Value::Error(*e), - Value::Text(_) if plus => v.clone(), - other => match other.to_number_in(sys) { - Ok(n) => f(n), - Err(e) => Value::Error(e), - }, - } -} - -/// Element-wise combination with Excel's broadcasting. -pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value { - match (a, b) { - (Value::Array(x), Value::Array(y)) => { - let rows = broadcast_dim(x.rows, y.rows); - let cols = broadcast_dim(x.cols, y.cols); - let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) }; - let mut data = Vec::with_capacity(n); - for r in 0..rows { - for c in 0..cols { - data.push(f(&x.get_broadcast(r, c), &y.get_broadcast(r, c))); - } - } - Value::from(Array { rows, cols, data }) - } - (Value::Array(x), s) => Value::from(Array { rows: x.rows, cols: x.cols, data: x.data.iter().map(|v| f(v, s)).collect() }), - (s, Value::Array(y)) => Value::from(Array { rows: y.rows, cols: y.cols, data: y.data.iter().map(|v| f(s, v)).collect() }), - (x, y) => f(x, y), - } -} - -/// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`]. -/// Each operand passes its own size cap, but their broadcast product can be far larger -/// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating. -fn broadcast_len(rows: usize, cols: usize) -> Option { - rows.checked_mul(cols).filter(|&n| n as u64 <= MAX_CELLS) -} - -fn broadcast_dim(a: usize, b: usize) -> usize { - if a == 1 { - b - } else if b == 1 { - a - } else { - a.max(b) - } -} - -fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &Value) -> Value) -> Value { - let dims = |v: &Value| if let Value::Array(a) = v { (a.rows, a.cols) } else { (1, 1) }; - let (tr, tc) = dims(t); - let (fr, fc) = dims(f); - let rows = broadcast_dim(broadcast_dim(c.rows, tr), fr); - let cols = broadcast_dim(broadcast_dim(c.cols, tc), fc); - let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) }; - let get = |v: &Value, r: usize, col: usize| if let Value::Array(a) = v { a.get_broadcast(r, col) } else { v.clone() }; - let mut data = Vec::with_capacity(n); - for r in 0..rows { - for col in 0..cols { - data.push(g(&c.get_broadcast(r, col), &get(t, r, col), &get(f, r, col))); - } - } - Value::from(Array { rows, cols, data }) -} - -/// One cell of a LAMBDA helper's result, or `None` for an array of more than one value: arrays -/// can't nest, so the whole helper gives `#CALC!`, as in Excel. -fn single(v: Value) -> Option { - match v { - Value::Array(a) if a.rows * a.cols > 1 => None, - v => Some(v.scalar()), - } -} - -pub fn binary_values(op: BinOp, x: &Value, y: &Value, sys: gridcraft_core::DateSystem) -> Value { - zip2(x, y, |a, b| binary_scalar(op, a, b, sys)) -} - -fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSystem) -> Value { - if let Value::Error(e) = a { - return Value::Error(*e); - } - if let Value::Error(e) = b { - return Value::Error(*e); - } - let num = |f: fn(f64, f64) -> Value| match (a.to_number_in(sys), b.to_number_in(sys)) { - (Ok(x), Ok(y)) => f(x, y), - (Err(e), _) | (_, Err(e)) => Value::Error(e), - }; - use std::cmp::Ordering::*; - match op { - BinOp::Add => num(|x, y| Value::number(x + y)), - BinOp::Sub => num(|x, y| Value::number(x - y)), - BinOp::Mul => num(|x, y| Value::number(x * y)), - BinOp::Div => num(|x, y| if y == 0.0 { Value::Error(CellError::Div0) } else { Value::number(x / y) }), - BinOp::Pow => num(|x, y| { - if x == 0.0 && y == 0.0 { - Value::Error(CellError::Num) - } else if x == 0.0 && y < 0.0 { - Value::Error(CellError::Div0) - } else { - Value::number(x.powf(y)) - } - }), - BinOp::Concat => match (a.to_text(), b.to_text()) { - (Ok(x), Ok(y)) => { - if x.len() + y.len() > 32767 * 4 { - Value::Error(CellError::Value) - } else { - Value::text(x + &y) - } - } - (Err(e), _) | (_, Err(e)) => Value::Error(e), - }, - BinOp::Eq => Value::Bool(compare(a, b) == Equal), - BinOp::Ne => Value::Bool(compare(a, b) != Equal), - BinOp::Lt => Value::Bool(compare(a, b) == Less), - BinOp::Gt => Value::Bool(compare(a, b) == Greater), - BinOp::Le => Value::Bool(compare(a, b) != Greater), - BinOp::Ge => Value::Bool(compare(a, b) != Less), - BinOp::Range | BinOp::Union | BinOp::Intersect => Value::Error(CellError::Value), - } -} - -/// SUMIF and AVERAGEIF with a sum range: Excel sizes the sum range like the criteria range. -fn resizes_sum_range(name: &str, args: usize) -> bool { - args == 3 && matches!(name, "SUMIF" | "AVERAGEIF") -} - -/// Height and width of a single-area reference or an array argument. -fn ev_shape(ev: &Ev) -> Option<(u32, u32)> { - match ev { - Ev::R(areas) => match areas.as_slice() { - [a] => Some((a.range.height(), a.range.width())), - _ => None, - }, - Ev::V(Value::Array(a)) => Some((u32::try_from(a.rows).unwrap_or(u32::MAX), u32::try_from(a.cols).unwrap_or(u32::MAX))), - Ev::V(_) => Some((1, 1)), - Ev::L(_) => None, - } -} - -/// The range SUMIF/AVERAGEIF add up: from the sum range's top-left cell, as many rows and columns -/// as the criteria range (`height` × `width`), cut off at the sheet's edge. -pub fn resized_sum_range(sum: RangeRef, height: u32, width: u32) -> RangeRef { - let row = sum.start.row.saturating_add(height.saturating_sub(1)).min(MAX_ROWS - 1); - let col = sum.start.col.saturating_add(width.saturating_sub(1)).min(MAX_COLS - 1); - RangeRef::new(sum.start, CellRef::new(row, col)) -} - -/// Collects the static references of an expression with their sheets resolved; `dynamic` is -/// set when the formula uses INDIRECT/OFFSET/names/tables whose targets can change. -pub fn precedents(wb: &Workbook, sheet: usize, e: &Expr) -> (Vec, bool) { - let mut out = Vec::new(); - let mut dynamic = false; - let push = |out: &mut Vec, r: &Reference, range: RangeRef| match &r.sheet { - SheetSel::Current => out.push(Area { sheet, range }), - SheetSel::Named(n) => { - if let Some(i) = wb.sheet_index(n) { - out.push(Area { sheet: i, range }); - } - } - SheetSel::Span(a, b) => { - if let (Some(i), Some(j)) = (wb.sheet_index(a), wb.sheet_index(b)) { - for s in i.min(j)..=i.max(j) { - out.push(Area { sheet: s, range }); - } - } - } - }; - e.walk(&mut |x| match x { - Expr::Ref(r) => push(&mut out, r, r.range()), - // The cells SUMIF/AVERAGEIF really read from the sum range (see `resized_sum_range`); when - // the criteria range's size isn't known statically, recalculate the formula every time. - Expr::Call(n, args) if resizes_sum_range(n, args.len()) => match (args.first(), args.get(2)) { - (Some(Expr::Ref(c)), Some(Expr::Ref(s))) => { - let (crit, sum) = (c.range(), s.range()); - push(&mut out, s, resized_sum_range(sum, crit.height(), crit.width())); - } - (_, Some(Expr::Number(_) | Expr::Text(_) | Expr::Bool(_) | Expr::Error(_) | Expr::Array(_) | Expr::Missing)) => {} - _ => dynamic = true, - }, - Expr::Call(n, _) - if matches!(n.as_str(), "INDIRECT" | "OFFSET" | "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" | "CELL" | "INFO") => - { - dynamic = true - } - Expr::Name(_) | Expr::Struct(_) => dynamic = true, - Expr::Call(n, _) if gridcraft_functions::lookup(n).is_none() && !is_special(n) => dynamic = true, - // A range ending in a function's result (`A1:INDEX(B1:B9,n)`) covers the cells between - // the references inside it, not only those references. - Expr::Binary(BinOp::Range, a, b) if !matches!((&**a, &**b), (Expr::Ref(_), Expr::Ref(_))) => { - let mut inner = precedents(wb, sheet, a).0; - inner.extend(precedents(wb, sheet, b).0); - let mut boxes: Vec = Vec::new(); - for area in inner { - match boxes.iter_mut().find(|x| x.sheet == area.sheet) { - Some(x) => x.range = x.range.union(&area.range), - None => boxes.push(area), - } - } - out.extend(boxes); - } - _ => {} - }); - (out, dynamic) -} - -fn is_special(n: &str) -> bool { - matches!( - n, - "IF" | "IFS" - | "IFERROR" - | "IFNA" - | "CHOOSE" - | "SWITCH" - | "ROW" - | "COLUMN" - | "ROWS" - | "COLUMNS" - | "AREAS" - | "ISREF" - | "ISFORMULA" - | "FORMULATEXT" - | "SHEET" - | "SHEETS" - | "OFFSET" - | "INDIRECT" - | "INDEX" - | "CELL" - | "TRIMRANGE" - | "SUBTOTAL" - | "AGGREGATE" - | "TEXT" - | "LET" - | "LAMBDA" - | "ISOMITTED" - | "MAP" - | "REDUCE" - | "SCAN" - | "BYROW" - | "BYCOL" - | "MAKEARRAY" - ) -} - -/// Names of functions the evaluator implements itself (merged into the function list for UI). -pub const SPECIAL_FUNCTIONS: &[&str] = &[ - "IF", - "IFS", - "IFERROR", - "IFNA", - "CHOOSE", - "SWITCH", - "ROW", - "COLUMN", - "ROWS", - "COLUMNS", - "AREAS", - "ISREF", - "ISFORMULA", - "FORMULATEXT", - "SHEET", - "SHEETS", - "OFFSET", - "INDIRECT", - "INDEX", - "CELL", - "TRIMRANGE", - "SUBTOTAL", - "AGGREGATE", - "TEXT", - "LET", - "LAMBDA", - "ISOMITTED", - "MAP", - "REDUCE", - "SCAN", - "BYROW", - "BYCOL", - "MAKEARRAY", -]; - -pub fn is_known_function(n: &str) -> bool { - is_special(&n.to_ascii_uppercase()) || gridcraft_functions::lookup(n).is_some() -} - -#[allow(dead_code)] -fn unused(_: RefKind) {} - #[cfg(test)] mod fuel_tests { use super::*; diff --git a/crates/calc/src/eval/array.rs b/crates/calc/src/eval/array.rs new file mode 100644 index 000000000..b17a8c70f --- /dev/null +++ b/crates/calc/src/eval/array.rs @@ -0,0 +1,180 @@ +use super::*; + +/// The aggregates that can fold a reference without building an array. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum Agg { + Sum, + Count, + Average, +} + +impl Agg { + pub(super) fn of(name: &str) -> Option { + match name { + "SUM" => Some(Agg::Sum), + "COUNT" => Some(Agg::Count), + "AVERAGE" => Some(Agg::Average), + _ => None, + } + } +} + +/// What a folded aggregate carries from one value to the next. +#[derive(Default)] +pub(super) struct Acc { + pub(super) sum: f64, + pub(super) count: u64, +} + +impl Acc { + /// Takes a value found inside a reference: only numbers count. `Err` is an error value that + /// ends `SUM` and `AVERAGE`; `COUNT` skips errors. + pub(super) fn add_cell(&mut self, kind: Agg, v: &Value) -> Result<(), CellError> { + match v { + Value::Number(n) => { + self.sum += n; + self.count = self.count.saturating_add(1); + Ok(()) + } + Value::Error(e) if kind != Agg::Count => Err(*e), + _ => Ok(()), + } + } + + pub(super) fn finish(&self, kind: Agg) -> Value { + match kind { + Agg::Sum => Value::number(self.sum), + Agg::Count => Value::number(self.count as f64), + Agg::Average if self.count == 0 => Value::Error(CellError::Div0), + Agg::Average => Value::number(self.sum / self.count as f64), + } + } +} + +// ---------------------------------------------------------------- operators + +pub(super) fn map_unary(v: &Value, f: &dyn Fn(f64) -> Value, plus: bool, sys: gridcraft_core::DateSystem) -> Value { + match v { + Value::Array(a) => Value::from(Array { rows: a.rows, cols: a.cols, data: a.data.iter().map(|x| map_unary(x, f, plus, sys)).collect() }), + Value::Error(e) => Value::Error(*e), + Value::Text(_) if plus => v.clone(), + other => match other.to_number_in(sys) { + Ok(n) => f(n), + Err(e) => Value::Error(e), + }, + } +} + +/// Element-wise combination with Excel's broadcasting. +pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value { + match (a, b) { + (Value::Array(x), Value::Array(y)) => { + let rows = broadcast_dim(x.rows, y.rows); + let cols = broadcast_dim(x.cols, y.cols); + let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) }; + let mut data = Vec::with_capacity(n); + for r in 0..rows { + for c in 0..cols { + data.push(f(&x.get_broadcast(r, c), &y.get_broadcast(r, c))); + } + } + Value::from(Array { rows, cols, data }) + } + (Value::Array(x), s) => Value::from(Array { rows: x.rows, cols: x.cols, data: x.data.iter().map(|v| f(v, s)).collect() }), + (s, Value::Array(y)) => Value::from(Array { rows: y.rows, cols: y.cols, data: y.data.iter().map(|v| f(s, v)).collect() }), + (x, y) => f(x, y), + } +} + +/// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`]. +/// Each operand passes its own size cap, but their broadcast product can be far larger +/// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating. +pub(super) fn broadcast_len(rows: usize, cols: usize) -> Option { + rows.checked_mul(cols).filter(|&n| n as u64 <= MAX_CELLS) +} + +fn broadcast_dim(a: usize, b: usize) -> usize { + if a == 1 { + b + } else if b == 1 { + a + } else { + a.max(b) + } +} + +pub(super) fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &Value) -> Value) -> Value { + let dims = |v: &Value| if let Value::Array(a) = v { (a.rows, a.cols) } else { (1, 1) }; + let (tr, tc) = dims(t); + let (fr, fc) = dims(f); + let rows = broadcast_dim(broadcast_dim(c.rows, tr), fr); + let cols = broadcast_dim(broadcast_dim(c.cols, tc), fc); + let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) }; + let get = |v: &Value, r: usize, col: usize| if let Value::Array(a) = v { a.get_broadcast(r, col) } else { v.clone() }; + let mut data = Vec::with_capacity(n); + for r in 0..rows { + for col in 0..cols { + data.push(g(&c.get_broadcast(r, col), &get(t, r, col), &get(f, r, col))); + } + } + Value::from(Array { rows, cols, data }) +} + +/// One cell of a LAMBDA helper's result, or `None` for an array of more than one value: arrays +/// can't nest, so the whole helper gives `#CALC!`, as in Excel. +pub(super) fn single(v: Value) -> Option { + match v { + Value::Array(a) if a.rows * a.cols > 1 => None, + v => Some(v.scalar()), + } +} + +pub fn binary_values(op: BinOp, x: &Value, y: &Value, sys: gridcraft_core::DateSystem) -> Value { + zip2(x, y, |a, b| binary_scalar(op, a, b, sys)) +} + +fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSystem) -> Value { + if let Value::Error(e) = a { + return Value::Error(*e); + } + if let Value::Error(e) = b { + return Value::Error(*e); + } + let num = |f: fn(f64, f64) -> Value| match (a.to_number_in(sys), b.to_number_in(sys)) { + (Ok(x), Ok(y)) => f(x, y), + (Err(e), _) | (_, Err(e)) => Value::Error(e), + }; + use std::cmp::Ordering::*; + match op { + BinOp::Add => num(|x, y| Value::number(x + y)), + BinOp::Sub => num(|x, y| Value::number(x - y)), + BinOp::Mul => num(|x, y| Value::number(x * y)), + BinOp::Div => num(|x, y| if y == 0.0 { Value::Error(CellError::Div0) } else { Value::number(x / y) }), + BinOp::Pow => num(|x, y| { + if x == 0.0 && y == 0.0 { + Value::Error(CellError::Num) + } else if x == 0.0 && y < 0.0 { + Value::Error(CellError::Div0) + } else { + Value::number(x.powf(y)) + } + }), + BinOp::Concat => match (a.to_text(), b.to_text()) { + (Ok(x), Ok(y)) => { + if x.len() + y.len() > 32767 * 4 { + Value::Error(CellError::Value) + } else { + Value::text(x + &y) + } + } + (Err(e), _) | (_, Err(e)) => Value::Error(e), + }, + BinOp::Eq => Value::Bool(compare(a, b) == Equal), + BinOp::Ne => Value::Bool(compare(a, b) != Equal), + BinOp::Lt => Value::Bool(compare(a, b) == Less), + BinOp::Gt => Value::Bool(compare(a, b) == Greater), + BinOp::Le => Value::Bool(compare(a, b) != Greater), + BinOp::Ge => Value::Bool(compare(a, b) != Less), + BinOp::Range | BinOp::Union | BinOp::Intersect => Value::Error(CellError::Value), + } +} diff --git a/crates/calc/src/eval/call.rs b/crates/calc/src/eval/call.rs new file mode 100644 index 000000000..9013ca882 --- /dev/null +++ b/crates/calc/src/eval/call.rs @@ -0,0 +1,424 @@ +use super::array::{Acc, Agg, zip2, zip3}; +use super::format::same_kind; +use super::special::{ev_shape, resized_sum_range, resizes_sum_range}; +use super::*; + +impl<'h> Evaluator<'h> { + pub(super) fn call(&mut self, name: &str, args: &[Expr]) -> Ev { + match name { + "IF" => { + if args.is_empty() || args.len() > 3 { + return err(CellError::Value); + } + let c = self.arg_val(args, 0).unwrap_or_default(); + if let Value::Array(arr) = &c { + // Array condition: element-wise. + let t = self.arg_val(args, 1).unwrap_or(Value::Bool(true)); + let f = if args.len() > 2 { self.arg_val(args, 2).unwrap_or(Value::Bool(false)) } else { Value::Bool(false) }; + return Ev::V(zip3(arr, &t, &f, |c, t, f| match c.to_bool() { + Ok(true) => t.clone(), + Ok(false) => f.clone(), + Err(e) => Value::Error(e), + })); + } + match c.to_bool() { + Ok(true) => match args.get(1) { + Some(Expr::Missing) => Ev::V(Value::Number(0.0)), + Some(e) => self.eval(e), + None => Ev::V(Value::Bool(true)), + }, + Ok(false) => match args.get(2) { + Some(Expr::Missing) => Ev::V(Value::Number(0.0)), + Some(e) => self.eval(e), + None => Ev::V(Value::Bool(false)), + }, + Err(e) => err(e), + } + } + "IFS" => { + if args.len() < 2 || !args.len().is_multiple_of(2) { + return err(CellError::Value); + } + for pair in args.chunks(2) { + let [c, v] = pair else { break }; + let cv = self.value(c); + match cv.scalar().to_bool() { + Ok(true) => return self.eval(v), + Ok(false) => {} + Err(e) => return err(e), + } + } + err(CellError::NA) + } + "IFERROR" | "IFNA" => { + if args.len() != 2 { + return err(CellError::Value); + } + let v = self.arg_ev(args, 0).unwrap_or(Ev::V(Value::Empty)); + let na_only = name == "IFNA"; + let catch = |e: CellError| if na_only { e == CellError::NA } else { true }; + match &v { + Ev::V(Value::Error(e)) if catch(*e) => self.eval(&args[1]), + Ev::V(Value::Array(a)) if a.data.iter().any(|x| x.as_error().is_some_and(catch)) => { + let alt = self.arg_val(args, 1).unwrap_or_default(); + let data = a.data.iter().map(|x| if x.as_error().is_some_and(catch) { alt.scalar() } else { x.clone() }).collect(); + Ev::V(Array::new(a.rows, a.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))) + } + Ev::R(_) => { + let val = self.deref(v.clone()); + match val { + Value::Error(e) if catch(e) => self.eval(&args[1]), + Value::Array(ref a) if a.data.iter().any(|x| x.as_error().is_some_and(catch)) => { + let alt = self.arg_val(args, 1).unwrap_or_default(); + let data = a.data.iter().map(|x| if x.as_error().is_some_and(catch) { alt.scalar() } else { x.clone() }).collect(); + Ev::V(Array::new(a.rows, a.cols, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))) + } + _ => v, + } + } + _ => v, + } + } + "CHOOSE" => { + if args.len() < 2 { + return err(CellError::Value); + } + let index = self.value(&args[0]); + if let Value::Array(_) = index { + // An array of indexes picks element by element: `CHOOSE({1,2},A1:A3,B1:B3)` + // is a two-column array. The library CHOOSE lifts over its arguments. + let Some(spec) = gridcraft_functions::lookup(name) else { return err(CellError::Name) }; + let mut evaluated = vec![Arg { value: index, from_ref: false }]; + for a in &args[1..] { + let value = self.value(a); + evaluated.push(Arg { value, from_ref: false }); + } + let mut ctx = FnCtx { ev: self, criteria: None, lambda: None }; + return gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V); + } + let i = match index.to_number() { + Ok(n) => n.trunc() as i64, + Err(e) => return err(e), + }; + if i < 1 || i as usize >= args.len() { + return err(CellError::Value); + } + self.eval(&args[i as usize]) + } + "SWITCH" => { + if args.len() < 3 { + return err(CellError::Value); + } + let v = self.arg_val(args, 0).unwrap_or_default().scalar(); + if let Value::Error(e) = v { + return err(e); + } + let rest = &args[1..]; + let mut i = 0; + while i + 1 < rest.len() { + let cand = self.value(&rest[i]).scalar(); + if !cand.is_error() && compare(&v, &cand) == std::cmp::Ordering::Equal && same_kind(&v, &cand) { + return self.eval(&rest[i + 1]); + } + i += 2; + } + if rest.len() % 2 == 1 { rest.last().map_or(err(CellError::NA), |d| self.eval(d)) } else { err(CellError::NA) } + } + "ROW" | "COLUMN" => { + let is_row = name == "ROW"; + if args.is_empty() || matches!(args.first(), Some(Expr::Missing)) { + return Ev::V(Value::number(if is_row { self.at.row + 1 } else { self.at.col + 1 } as f64)); + } + match self.eval(&args[0]) { + Ev::R(a) => { + let Some(a) = a.first() else { return err(CellError::Ref) }; + let r = self.trim_lines(a); + if is_row { + let rows: Vec = (r.start.row..=r.end.row).map(|x| Value::number(x as f64 + 1.0)).collect(); + if rows.len() == 1 { Ev::V(rows.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::column(rows).into()) } + } else { + let cols: Vec = (r.start.col..=r.end.col).map(|x| Value::number(x as f64 + 1.0)).collect(); + if cols.len() == 1 { Ev::V(cols.into_iter().next().unwrap_or_default()) } else { Ev::V(Array::row(cols).into()) } + } + } + Ev::V(Value::Error(e)) => err(e), + _ => err(CellError::Value), + } + } + "ROWS" | "COLUMNS" => { + if args.len() != 1 { + return err(CellError::Value); + } + let is_rows = name == "ROWS"; + match self.eval(&args[0]) { + Ev::R(a) => match a.as_slice() { + [a] => Ev::V(Value::number(if is_rows { a.range.height() } else { a.range.width() } as f64)), + _ => err(CellError::Ref), + }, + Ev::V(Value::Array(arr)) => Ev::V(Value::number(if is_rows { arr.rows } else { arr.cols } as f64)), + Ev::V(Value::Error(e)) => err(e), + Ev::V(_) => Ev::V(Value::Number(1.0)), + Ev::L(_) => err(CellError::Value), + } + } + "AREAS" => match args.first().map(|a| self.eval(a)) { + Some(Ev::R(a)) => Ev::V(Value::number(a.len() as f64)), + Some(Ev::V(Value::Error(e))) => err(e), + _ => err(CellError::Value), + }, + "ISREF" => Ev::V(Value::Bool(matches!(args.first().map(|a| self.eval(a)), Some(Ev::R(_))))), + "ISFORMULA" | "FORMULATEXT" => { + let Some(Ev::R(a)) = args.first().map(|a| self.eval(a)) else { return err(CellError::NA) }; + let Some(a) = a.first() else { return err(CellError::NA) }; + let f = self.host.workbook().sheet(a.sheet).and_then(|s| s.cell(a.range.start)).and_then(|c| c.formula.clone()); + if name == "ISFORMULA" { + Ev::V(Value::Bool(f.is_some())) + } else { + match f { + Some(f) => Ev::V(Value::text(format!("={}", f.text))), + None => err(CellError::NA), + } + } + } + "SHEET" | "SHEETS" => { + let wb = self.host.workbook(); + if args.is_empty() { + return Ev::V(Value::number(if name == "SHEET" { self.sheet + 1 } else { wb.sheets.len() } as f64)); + } + match self.eval(&args[0]) { + Ev::R(a) => { + if name == "SHEET" { + a.first().map_or(err(CellError::Ref), |a| Ev::V(Value::number(a.sheet as f64 + 1.0))) + } else { + let mut s: Vec = a.iter().map(|x| x.sheet).collect(); + s.dedup(); + Ev::V(Value::number(s.len() as f64)) + } + } + Ev::V(Value::Text(t)) if name == "SHEET" => { + let wb = self.host.workbook(); + wb.sheet_index(&t).map_or(err(CellError::NA), |i| Ev::V(Value::number(i as f64 + 1.0))) + } + _ => err(CellError::NA), + } + } + "OFFSET" => self.offset(args), + "INDIRECT" => self.indirect(args), + "INDEX" => self.index(args), + "XLOOKUP" => self.xlookup(args), + "CELL" => self.cell_info(args), + "TRIMRANGE" => self.trimrange(args), + "SUBTOTAL" | "AGGREGATE" => self.subtotal(name, args), + "TEXT" => { + if args.len() != 2 { + return err(CellError::Value); + } + let v = self.arg_val(args, 0).unwrap_or_default(); + let f = self.arg_val(args, 1).unwrap_or_default(); + let sys = self.host.workbook().date_system; + let one = |v: &Value, f: &Value| -> Value { + if let Value::Error(e) = v { + return Value::Error(*e); + } + let code = match f.to_text() { + Ok(c) => c, + Err(e) => return Value::Error(e), + }; + match gridcraft_numfmt::text_function(v, &code, sys) { + Ok(s) => Value::text(s), + Err(e) => Value::Error(e), + } + }; + Ev::V(zip2(&v, &f, one)) + } + "LET" => self.let_(args), + "LAMBDA" => { + if args.is_empty() { + return err(CellError::Value); + } + let mut params = Vec::new(); + for p in &args[..args.len() - 1] { + match p { + Expr::Name(n) => params.push(n.trim_start_matches('[').trim_end_matches(']').to_string()), + _ => return err(CellError::Value), + } + } + let Some(body) = args.last() else { return err(CellError::Value) }; + Ev::L(Arc::new(Lambda { params, body: body.clone(), env: self.env.clone() })) + } + "ISOMITTED" => { + let r = matches!(args.first(), Some(Expr::Name(n)) if self.env.iter().rev().find(|(k, _)| gridcraft_formula::same_name(k, n)).is_some_and(|(_, v)| matches!(v, Ev::V(Value::Empty)))); + Ev::V(Value::Bool(r)) + } + "MAP" | "REDUCE" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" => self.higher_order(name, args), + "GROUPBY" | "PIVOTBY" => { + // The aggregation may be a function given by name (`SUM`): pass it as its name. + let i = if name == "GROUPBY" { 2 } else { 3 }; + match args.get(i).and_then(|a| self.function_by_name(a)) { + Some(f) => { + let mut args = args.to_vec(); + if let Some(a) = args.get_mut(i) { + *a = Expr::Text(f.into()); + } + self.builtin(name, &args) + } + None => self.builtin(name, args), + } + } + "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" => { + self.host.note_volatile(); + self.builtin(name, args) + } + _ => self.builtin(name, args), + } + } + + pub(super) fn builtin(&mut self, name: &str, args: &[Expr]) -> Ev { + let Some(spec) = gridcraft_functions::lookup(name) else { + // A defined name holding a LAMBDA, or a LET-bound lambda. + return match self.name(name) { + Ev::L(l) => self.apply_lambda(&l, args), + Ev::V(Value::Error(x)) => err(x), + _ => err(CellError::Name), + }; + }; + if args.len() < spec.min_args || args.len() > spec.max_args { + return err(CellError::Value); + } + if let Some(kind) = Agg::of(spec.name) { + return self.aggregate(kind, args); + } + let mut evaluated = Vec::with_capacity(args.len()); + // SUMIF/AVERAGEIF: the size of the criteria range (argument 1), which the sum range takes. + let mut criteria_shape = None; + // Database functions: the criteria range (argument 2), whose formulas are computed criteria. + let mut criteria = None; + // GROUPBY/PIVOTBY: the LAMBDA given as their function. + let mut lambda = None; + let lambda_at = match name { + "GROUPBY" => Some(2), + "PIVOTBY" => Some(3), + _ => None, + }; + for (i, a) in args.iter().enumerate() { + let mut ev = self.eval(a); + if lambda_at == Some(i) + && let Ev::L(l) = &ev + { + lambda = Some(l.clone()); + ev = Ev::V(Value::Empty); + } + if spec.category == gridcraft_functions::Category::Database + && i == 2 + && let Ev::R(areas) = &ev + && let [area] = areas.as_slice() + { + criteria = Some(*area); + } + if resizes_sum_range(name, args.len()) { + if i == 0 { + criteria_shape = ev_shape(&ev); + } else if i == 2 + && let (Some((h, w)), Ev::R(areas)) = (criteria_shape, &ev) + && let [area] = areas.as_slice() + { + ev = Ev::R(vec![Area { sheet: area.sheet, range: resized_sum_range(area.range, h, w) }]); + } + } + let from_ref = matches!(ev, Ev::R(_)); + let value = match ev { + Ev::R(areas) if areas.len() > 1 => { + // Multi-area references (unions): flatten into one row for aggregates. + let mut vals = Vec::new(); + for ar in &areas { + match self.area_value(ar) { + Value::Array(a) => vals.extend(a.data.iter().cloned()), + v => vals.push(v), + } + } + Value::from(Array::row(vals)) + } + ev => self.deref(ev), + }; + evaluated.push(Arg { value, from_ref }); + } + let mut ctx = FnCtx { ev: self, criteria, lambda }; + gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V) + } + + /// Performs aggregation operations without copying its ranges: blocks are folded over the stored + /// cells, so no array is built and there is no `MAX_CELLS` limit. Arguments are evaluated and read one + /// after another, as the array path does, so volatile formulas (like `RAND`) see the same order. + /// Only `SUM`, `COUNT` and `AVG` are supported currently. + fn aggregate(&mut self, kind: Agg, args: &[Expr]) -> Ev { + let mut acc = Acc::default(); + let mut first_error = None; + for a in args { + let ev = self.eval(a); + let steps = match ev { + Ev::R(areas) => areas.iter().map(|area| self.aggregate_area(kind, area, &mut acc)).collect(), + ev => { + let value = self.deref(ev); + vec![self.aggregate_value(kind, value, false, &mut acc)] + } + }; + if first_error.is_none() { + first_error = steps.into_iter().find_map(Result::err); + } + } + first_error.unwrap_or_else(|| Ev::V(acc.finish(kind))) + } + + /// Adds one area to `acc`; `Err` is the result the function ends with. + fn aggregate_area(&mut self, kind: Agg, a: &Area, acc: &mut Acc) -> Result<(), Ev> { + if !a.range.is_single() { + let mut error = None; + let walked = self.host.visit_range(a.sheet, a.range, &mut |v| match acc.add_cell(kind, v) { + Ok(()) => true, + Err(e) => { + error = Some(e); + false + } + }); + if walked { + return error.map_or(Ok(()), |e| Err(err(e))); + } + } + let value = self.area_value(a); + self.aggregate_value(kind, value, true, acc) + } + + /// Adds one argument's value by calling `SUM` and `COUNT` on it, so coercion and error rules + /// stay in one place (`AVERAGE` of an argument is its `SUM` over its `COUNT`). + fn aggregate_value(&mut self, kind: Agg, value: Value, from_ref: bool, acc: &mut Acc) -> Result<(), Ev> { + let (Some(sum), Some(count)) = (gridcraft_functions::lookup("SUM"), gridcraft_functions::lookup("COUNT")) else { + return Err(err(CellError::Name)); + }; + let args = [Arg { value, from_ref }]; + if kind != Agg::Count { + match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) { + Value::Number(n) => acc.sum += n, + other => return Err(Ev::V(other)), + } + } + if kind != Agg::Sum { + match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) { + Value::Number(n) => acc.count = acc.count.saturating_add(n as u64), + other => return Err(Ev::V(other)), + } + } + Ok(()) + } + + /// A worksheet function given by name where a function is expected (`BYROW(A1:C3,SUM)`, + /// `GROUPBY(A2:A9,B2:B9,SUM)`): its name, unless a LET/LAMBDA variable or a defined name has + /// that name. + pub(super) fn function_by_name(&self, e: &Expr) -> Option { + let Expr::Name(n) = e else { return None }; + if self.env.iter().any(|(k, _)| gridcraft_formula::same_name(k, n)) || self.host.workbook().name(n, self.sheet).is_some() { + return None; + } + let u = n.to_ascii_uppercase(); + gridcraft_functions::lookup(&u).map(|_| u) + } +} diff --git a/crates/calc/src/eval/format.rs b/crates/calc/src/eval/format.rs new file mode 100644 index 000000000..a4c52b492 --- /dev/null +++ b/crates/calc/src/eval/format.rs @@ -0,0 +1,74 @@ +use super::*; + +/// CELL("format") code for a number format. +pub(super) fn cell_format_code(code: &str) -> String { + let c = code.to_ascii_lowercase(); + if c == "general" { + return "G".into(); + } + if c.contains('%') { + return format!("P{}", decimals(&c)); + } + if c.contains('e') && c.contains('0') { + return format!("S{}", decimals(&c)); + } + if c.contains('$') { + return format!("C{}", decimals(&c)); + } + if c.contains('y') || c.contains('d') { + return "D1".into(); + } + if c.contains('h') { + return "D9".into(); + } + if c.contains(',') { + return format!(",{}", decimals(&c)); + } + format!("F{}", decimals(&c)) +} + +fn decimals(c: &str) -> usize { + c.split_once('.').map_or(0, |(_, d)| d.chars().take_while(|ch| *ch == '0').count()) +} + +pub(super) fn same_kind(a: &Value, b: &Value) -> bool { + std::mem::discriminant(a) == std::mem::discriminant(b) || (a.is_empty() || b.is_empty()) +} + +/// Converts an R1C1 reference text to A1 (for INDIRECT(…, FALSE)). `Ok(None)` means the text is +/// not R1C1; `Err` means it is R1C1 but points past the sheet. +pub fn r1c1_to_a1(s: &str, at: CellRef) -> Result, CellError> { + let (sheet, body) = match s.rsplit_once('!') { + Some((sh, b)) => (format!("{sh}!"), b), + None => (String::new(), s), + }; + let conv = |part: &str| -> Result, CellError> { + let u = part.to_ascii_uppercase(); + let Some((rpart, cpart)) = u.strip_prefix('R').and_then(|rest| rest.split_once('C')) else { return Ok(None) }; + // `None`: not R1C1. `Some(None)`: R1C1, but past the sheet. + let num = |p: &str, base: u32, max: u32| -> Option> { + let (v, absolute) = if p.is_empty() { + (Some(i64::from(base)), false) + } else if let Some(inner) = p.strip_prefix('[').and_then(|x| x.strip_suffix(']')) { + (i64::from(base).checked_add(inner.parse().ok()?), false) + } else { + (p.parse::().ok()?.checked_sub(1).map(i64::from), true) + }; + Some(v.filter(|v| (0..i64::from(max)).contains(v)).map(|v| (v as u32, absolute))) + }; + let (Some(r), Some(c)) = (num(rpart, at.row, MAX_ROWS), num(cpart, at.col, MAX_COLS)) else { return Ok(None) }; + let (Some((r, ra)), Some((c, ca))) = (r, c) else { return Err(CellError::Ref) }; + Ok(Some(format!("{}{}{}{}", if ca { "$" } else { "" }, gridcraft_core::col_to_letters(c), if ra { "$" } else { "" }, r + 1))) + }; + let out = match body.split_once(':') { + Some((a, b)) => match (conv(a)?, conv(b)?) { + (Some(a), Some(b)) => format!("{a}:{b}"), + _ => return Ok(None), + }, + None => match conv(body)? { + Some(x) => x, + None => return Ok(None), + }, + }; + Ok(Some(format!("{sheet}{out}"))) +} diff --git a/crates/calc/src/eval/lambda.rs b/crates/calc/src/eval/lambda.rs new file mode 100644 index 000000000..45f99b488 --- /dev/null +++ b/crates/calc/src/eval/lambda.rs @@ -0,0 +1,198 @@ +use super::array::{broadcast_len, single}; +use super::*; + +impl<'h> Evaluator<'h> { + pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev { + // Too many or too few argument positions is #VALUE!. An explicitly empty position, as in + // `(1,)`, is `Expr::Missing` and still counts as a position (it binds as omitted). `f()` + // parses to no arguments, which for a one-parameter lambda can also be read as one empty + // position, so that call still binds the parameter as omitted. + let positions_ok = args.len() == l.params.len() || (args.is_empty() && l.params.len() == 1); + if !positions_ok { + return err(CellError::Value); + } + let mut bound = Vec::with_capacity(l.params.len()); + for (i, p) in l.params.iter().enumerate() { + let v = match args.get(i) { + Some(Expr::Missing) | None => Ev::V(Value::Empty), + Some(e) => self.eval(e), + }; + bound.push((p.clone(), v)); + } + self.call_lambda_values(l, bound) + } + + pub(super) fn call_lambda_values(&mut self, l: &Lambda, bound: Vec<(String, Ev)>) -> Ev { + if self.calls >= MAX_LAMBDA_CALLS { + return err(CellError::Num); + } + // #2382: one charge per invocation, so a cell's total work stays bounded however the + // lambdas nest. Foregrounding the charge here means every path into a body (direct + // `(2,3)` application, `MAP`/`REDUCE`/`BYROW` helpers, D-function criteria) meters. + self.fuel = match self.fuel.checked_sub(1) { + Some(f) => f, + None => return err(CellError::Calc), + }; + self.calls += 1; + let saved = std::mem::replace(&mut self.env, l.env.clone()); + self.env.extend(bound); + let r = self.eval(&l.body); + self.env = saved; + self.calls -= 1; + r + } + + pub(super) fn let_(&mut self, args: &[Expr]) -> Ev { + if args.len() < 3 || args.len().is_multiple_of(2) { + return err(CellError::Value); + } + let base = self.env.len(); + for pair in args[..args.len() - 1].chunks(2) { + let [Expr::Name(n), v] = pair else { + self.env.truncate(base); + return err(CellError::Value); + }; + let val = self.eval(v); + self.env.push((n.clone(), val)); + } + let r = match args.last() { + Some(body) => self.eval(body), + None => err(CellError::Value), + }; + self.env.truncate(base); + r + } + + /// The function argument of a LAMBDA helper; a function given by name takes `arity` + /// arguments. + fn lambda_arg(&mut self, e: Option<&Expr>, arity: usize) -> Option> { + if let Some(f) = e.and_then(|x| self.function_by_name(x)) { + let params: Vec = (0..arity).map(|i| format!("_xleta.{i}")).collect(); + let body = Expr::Call(f, params.iter().map(|p| Expr::Name(p.clone())).collect()); + return Some(Arc::new(Lambda { params, body, env: Vec::new() })); + } + match e.map(|x| self.eval(x)) { + Some(Ev::L(l)) => Some(l), + _ => None, + } + } + + pub(super) fn higher_order(&mut self, name: &str, args: &[Expr]) -> Ev { + let as_array = |v: Value| -> Arc { + match v { + Value::Array(a) => a, + other => Arc::new(Array::scalar(other)), + } + }; + match name { + "MAP" => { + if args.len() < 2 { + return err(CellError::Value); + } + let Some(l) = self.lambda_arg(args.last(), args.len() - 1) else { return err(CellError::Value) }; + let arrays: Vec> = args[..args.len() - 1].iter().map(|a| as_array(self.value(a))).collect(); + let rows = arrays.iter().map(|a| a.rows).max().unwrap_or(1); + let cols = arrays.iter().map(|a| a.cols).max().unwrap_or(1); + let Some(n) = broadcast_len(rows, cols) else { return err(CellError::Num) }; + // The LAMBDA takes one parameter per array. + if l.params.len() != arrays.len() { + return err(CellError::Value); + } + let mut out = Vec::with_capacity(n); + for r in 0..rows { + for c in 0..cols { + let bound = l.params.iter().zip(arrays.iter()).map(|(p, a)| (p.clone(), Ev::V(a.get_broadcast(r, c)))).collect(); + let v = self.call_lambda_values(&l, bound); + let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) }; + out.push(one); + } + } + Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc))) + } + "REDUCE" | "SCAN" => { + if args.len() != 3 { + return err(CellError::Value); + } + let mut acc = self.value(&args[0]); + let arr = as_array(self.value(&args[1])); + let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) }; + if l.params.len() != 2 { + return err(CellError::Value); + } + let mut scan = Vec::with_capacity(arr.data.len()); + for v in arr.data.iter() { + let bound = vec![(l.params[0].clone(), Ev::V(acc.clone())), (l.params[1].clone(), Ev::V(v.clone()))]; + let r = self.call_lambda_values(&l, bound); + acc = self.deref(r); + if name == "SCAN" { + let Some(one) = single(acc.clone()) else { return err(CellError::Calc) }; + scan.push(one); + } + } + if name == "SCAN" { + Ev::V(Array::new(arr.rows, arr.cols, scan).map(Value::from).unwrap_or(Value::Error(CellError::Calc))) + } else { + Ev::V(acc) + } + } + "BYROW" | "BYCOL" => { + if args.len() != 2 { + return err(CellError::Value); + } + let arr = as_array(self.value(&args[0])); + let Some(l) = self.lambda_arg(args.get(1), 1) else { return err(CellError::Value) }; + let Some(p) = l.params.first().cloned() else { return err(CellError::Value) }; + if l.params.len() != 1 { + return err(CellError::Value); + } + let by_row = name == "BYROW"; + let n = if by_row { arr.rows } else { arr.cols }; + let mut out = Vec::with_capacity(n); + for i in 0..n { + let slice: Vec = if by_row { + (0..arr.cols).map(|c| arr.get(i, c).cloned().unwrap_or_default()).collect() + } else { + (0..arr.rows).map(|r| arr.get(r, i).cloned().unwrap_or_default()).collect() + }; + let a = if by_row { Array::row(slice) } else { Array::column(slice) }; + let r = self.call_lambda_values(&l, vec![(p.clone(), Ev::V(a.into()))]); + let Some(one) = single(self.deref(r)) else { return err(CellError::Calc) }; + out.push(one); + } + let a = if by_row { Array::column(out) } else { Array::row(out) }; + Ev::V(a.into()) + } + "MAKEARRAY" => { + if args.len() != 3 { + return err(CellError::Value); + } + let (rows, cols) = match (self.arg_num(args, 0, 0.0), self.arg_num(args, 1, 0.0)) { + (Ok(r), Ok(c)) => (r.trunc(), c.trunc()), + (Err(e), _) | (_, Err(e)) => return err(e), + }; + if rows < 1.0 || cols < 1.0 || rows * cols > MAX_CELLS as f64 { + return err(CellError::Value); + } + let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) }; + if l.params.len() != 2 { + return err(CellError::Value); + } + let (rows, cols) = (rows as usize, cols as usize); + let mut out = Vec::with_capacity(rows * cols); + for r in 0..rows { + for c in 0..cols { + let bound = vec![ + (l.params[0].clone(), Ev::V(Value::number(r as f64 + 1.0))), + (l.params[1].clone(), Ev::V(Value::number(c as f64 + 1.0))), + ]; + let v = self.call_lambda_values(&l, bound); + let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) }; + out.push(one); + } + } + Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc))) + } + _ => err(CellError::Name), + } + } +} diff --git a/crates/calc/src/eval/reference_fns.rs b/crates/calc/src/eval/reference_fns.rs new file mode 100644 index 000000000..a21797c23 --- /dev/null +++ b/crates/calc/src/eval/reference_fns.rs @@ -0,0 +1,407 @@ +use super::format::{cell_format_code, r1c1_to_a1}; +use super::*; + +impl<'h> Evaluator<'h> { + /// Whole rows/columns trimmed for ROW()/COLUMN() arrays. + pub(super) fn trim_lines(&mut self, a: &Area) -> RangeRef { + if a.range.is_full_cols() || a.range.is_full_rows() { self.trim(a) } else { a.range } + } + + pub(super) fn offset(&mut self, args: &[Expr]) -> Ev { + self.host.note_volatile(); + if args.len() < 3 || args.len() > 5 { + return err(CellError::Value); + } + let base = match self.eval(&args[0]) { + Ev::R(a) if a.len() == 1 => a[0], + Ev::V(Value::Error(e)) => return err(e), + _ => return err(CellError::Value), + }; + let rows = match self.arg_num(args, 1, 0.0) { + Ok(n) => n.trunc() as i64, + Err(e) => return err(e), + }; + let cols = match self.arg_num(args, 2, 0.0) { + Ok(n) => n.trunc() as i64, + Err(e) => return err(e), + }; + let h = match self.arg_num(args, 3, base.range.height() as f64) { + Ok(n) => n.trunc() as i64, + Err(e) => return err(e), + }; + let w = match self.arg_num(args, 4, base.range.width() as f64) { + Ok(n) => n.trunc() as i64, + Err(e) => return err(e), + }; + if h == 0 || w == 0 { + return err(CellError::Ref); + } + let r0 = (base.range.start.row as i64).saturating_add(rows); + let c0 = (base.range.start.col as i64).saturating_add(cols); + let (r0, r1) = if h > 0 { (r0, r0.saturating_add(h - 1)) } else { (r0.saturating_add(h + 1), r0) }; + let (c0, c1) = if w > 0 { (c0, c0.saturating_add(w - 1)) } else { (c0.saturating_add(w + 1), c0) }; + if r0 < 0 || c0 < 0 || r1 >= MAX_ROWS as i64 || c1 >= MAX_COLS as i64 { + return err(CellError::Ref); + } + Ev::R(vec![Area { sheet: base.sheet, range: RangeRef::new(CellRef::new(r0 as u32, c0 as u32), CellRef::new(r1 as u32, c1 as u32)) }]) + } + + pub(super) fn indirect(&mut self, args: &[Expr]) -> Ev { + self.host.note_volatile(); + if args.is_empty() || args.len() > 2 { + return err(CellError::Value); + } + let text = match self.arg_val(args, 0).map(|v| v.scalar().to_text()) { + Some(Ok(t)) => t, + Some(Err(e)) => return err(e), + None => return err(CellError::Value), + }; + let a1 = match args.get(1) { + None | Some(Expr::Missing) => true, + Some(e) => { + let v = self.value(e); + v.scalar().to_bool().unwrap_or(true) + } + }; + // With a1 = FALSE, text that isn't R1C1 can still be a name, but never an A1 reference. + let (text, a1_ok) = if a1 { + (text, true) + } else { + match r1c1_to_a1(&text, self.at) { + Ok(Some(a1_text)) => (a1_text, true), + Ok(None) => (text, false), + Err(e) => return err(e), + } + }; + match gridcraft_formula::parse(&text) { + Ok(Expr::Ref(_)) if !a1_ok => err(CellError::Ref), + Ok(e @ (Expr::Ref(_) | Expr::Name(_) | Expr::Struct(_))) => match self.eval(&e) { + r @ Ev::R(_) => r, + _ => err(CellError::Ref), + }, + _ => err(CellError::Ref), + } + } + + pub(super) fn index(&mut self, args: &[Expr]) -> Ev { + if args.is_empty() || args.len() > 4 { + return err(CellError::Value); + } + let src = self.eval(&args[0]); + let mut arg = |i: usize| match args.get(i) { + None | Some(Expr::Missing) => None, + Some(e) => Some(self.value(e)), + }; + let (rows, cols) = (arg(1), arg(2)); + let area_n = match self.arg_num(args, 3, 1.0) { + Ok(n) => n.trunc() as i64, + Err(e) => return err(e), + }; + let two = args.len() == 2; + let num = |v: &Option| v.as_ref().map_or(Ok(0.0), |v| v.to_number()).map(|n| n.trunc() as i64); + // Arrays of positions pick a value for each: INDEX(A1:C10,{1;2},{1,3}) is 2×2. + if let (Some(Value::Array(_)), _) | (_, Some(Value::Array(_))) = (&rows, &cols) { + let as_array = |v: &Option| match v { + Some(Value::Array(a)) => Arc::clone(a), + Some(v) => Arc::new(Array::scalar(v.clone())), + None => Arc::new(Array::scalar(Value::Number(0.0))), + }; + let (ra, ca) = (as_array(&rows), as_array(&cols)); + let (h, w) = (ra.rows.max(ca.rows), ra.cols.max(ca.cols)); + if (h as u64).saturating_mul(w as u64) > MAX_CELLS { + return err(CellError::Num); + } + let mut out = Vec::with_capacity(h * w); + for r in 0..h { + for c in 0..w { + let pos = (num(&Some(ra.get_broadcast(r, c))), num(&Some(ca.get_broadcast(r, c)))); + let v = match pos { + (Ok(row), Ok(col)) => { + let one = self.index_one(src.clone(), row, col, area_n, two); + match self.deref(one) { + Value::Array(a) if a.rows * a.cols > 1 => Value::Error(CellError::Calc), + v => v.scalar(), + } + } + (Err(e), _) | (_, Err(e)) => Value::Error(e), + }; + out.push(v); + } + } + return Ev::V(Array::new(h, w, out).map(Value::from).unwrap_or(Value::Error(CellError::Value))); + } + let (row, col) = match (num(&rows), num(&cols)) { + (Ok(r), Ok(c)) => (r, c), + (Err(e), _) | (_, Err(e)) => return err(e), + }; + self.index_one(src, row, col, area_n, two) + } + + /// INDEX for one row and column position (0 = the whole column or row). + fn index_one(&mut self, src: Ev, row: i64, col: i64, area_n: i64, two_args: bool) -> Ev { + if row < 0 || col < 0 { + return err(CellError::Value); + } + match src { + Ev::R(areas) => { + let area = usize::try_from((area_n as u64).saturating_sub(1)).ok(); + let Some(a) = area.and_then(|i| areas.get(i)).copied() else { return err(CellError::Ref) }; + let r = a.range; + // A single row or column with one index picks along it. An area with several rows + // and columns needs both: `INDEX(A1:C3,2)` is #REF! in Excel (an empty third + // argument, `INDEX(A1:C3,2,)`, still means the whole row). Arrays differ: + // `INDEX({1,2;3,4},2)` is the row. + if two_args && r.height() > 1 && r.width() > 1 { + return err(CellError::Ref); + } + let (row, col) = if two_args && r.height() == 1 { (1, row) } else { (row, col) }; + if row as u64 > r.height() as u64 || col as u64 > r.width() as u64 { + return err(CellError::Ref); + } + let (r0, r1) = if row == 0 { (r.start.row, r.end.row) } else { (r.start.row + row as u32 - 1, r.start.row + row as u32 - 1) }; + let (c0, c1) = if col == 0 { (r.start.col, r.end.col) } else { (r.start.col + col as u32 - 1, r.start.col + col as u32 - 1) }; + Ev::R(vec![Area { sheet: a.sheet, range: RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)) }]) + } + Ev::V(Value::Array(arr)) => { + let (row, col) = if two_args && arr.rows == 1 { (1, row) } else { (row, col) }; + if row as usize > arr.rows || col as usize > arr.cols { + return err(CellError::Ref); + } + match (row, col) { + (0, 0) => Ev::V(Value::Array(arr)), + (0, c) => Ev::V(Array::column((0..arr.rows).map(|r| arr.get(r, c as usize - 1).cloned().unwrap_or_default()).collect()).into()), + (r, 0) => Ev::V(Array::row((0..arr.cols).map(|c| arr.get(r as usize - 1, c).cloned().unwrap_or_default()).collect()).into()), + (r, c) => Ev::V(arr.get(r as usize - 1, c as usize - 1).cloned().unwrap_or(Value::Error(CellError::Ref))), + } + } + Ev::V(Value::Error(e)) => err(e), + Ev::V(v) => { + if row <= 1 && col <= 1 { + Ev::V(v) + } else { + err(CellError::Ref) + } + } + Ev::L(_) => err(CellError::Value), + } + } + + /// XLOOKUP whose result range is a reference returns a reference, as in Excel, so that it can + /// end a range (`SUM(XLOOKUP(a,…):XLOOKUP(b,…))`) or feed CELL. Everything else (arrays of + /// lookup values, array arguments, errors) goes to the library function. + pub(super) fn xlookup(&mut self, args: &[Expr]) -> Ev { + if (3..=6).contains(&args.len()) + && let (Ev::R(look), Ev::R(ret)) = (self.eval(&args[1]), self.eval(&args[2])) + && let ([l], [r]) = (look.as_slice(), ret.as_slice()) + && let Some(spec) = gridcraft_functions::lookup("XMATCH") + { + let key = self.value(&args[0]); + let (l, r) = (*l, *r); + let vertical = l.range.width() == 1 && l.range.height() > 1; + let horizontal = l.range.height() == 1 && l.range.width() > 1; + let same_len = if vertical { r.range.height() == l.range.height() } else { r.range.width() == l.range.width() }; + if !matches!(key, Value::Array(_)) && (vertical || horizontal) && same_len { + let mut xargs = vec![Arg { value: key, from_ref: false }, Arg { value: self.area_value(&l), from_ref: true }]; + // An empty match_mode before a given search_mode is the default (0), not the end. + let given = |i: usize| args.get(i).is_some_and(|e| !matches!(e, Expr::Missing)); + let last = if given(5) { + 5 + } else if given(4) { + 4 + } else { + 3 + }; + for i in 4..=last { + match args.get(i) { + Some(Expr::Missing) | None => xargs.push(Arg { value: Value::Number(0.0), from_ref: false }), + Some(e) => { + let value = self.value(e); + xargs.push(Arg { value, from_ref: false }); + } + } + } + let mut ctx = FnCtx { ev: self, criteria: None, lambda: None }; + return match gridcraft_functions::call(spec, &xargs, &mut ctx) { + Value::Number(p) if p >= 1.0 => { + let k = p as u32 - 1; + let range = if vertical { + RangeRef::new( + CellRef::new(r.range.start.row + k, r.range.start.col), + CellRef::new(r.range.start.row + k, r.range.end.col), + ) + } else { + RangeRef::new( + CellRef::new(r.range.start.row, r.range.start.col + k), + CellRef::new(r.range.end.row, r.range.start.col + k), + ) + }; + Ev::R(vec![Area { sheet: r.sheet, range }]) + } + Value::Error(CellError::NA) if args.get(3).is_some_and(|e| !matches!(e, Expr::Missing)) => self.eval(&args[3]), + Value::Error(e) => err(e), + _ => err(CellError::Value), + }; + } + } + self.builtin("XLOOKUP", args) + } + + pub(super) fn cell_info(&mut self, args: &[Expr]) -> Ev { + let kind = match self.arg_val(args, 0).map(|v| v.scalar().to_text()) { + Some(Ok(t)) => t.to_ascii_lowercase(), + Some(Err(e)) => return err(e), + None => return err(CellError::Value), + }; + let area = match args.get(1).map(|a| self.eval(a)) { + Some(Ev::R(a)) => a.first().copied(), + None => Some(Area { sheet: self.sheet, range: RangeRef::cell(self.at) }), + _ => return err(CellError::Value), + }; + let Some(a) = area else { return err(CellError::Ref) }; + let c = a.range.start; + let wb = self.host.workbook(); + let sheet = wb.sheet(a.sheet); + match kind.as_str() { + "address" => Ev::V(Value::text(format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1))), + "row" => Ev::V(Value::number(c.row as f64 + 1.0)), + "col" => Ev::V(Value::number(c.col as f64 + 1.0)), + "contents" => Ev::V(self.host.cell_value(a.sheet, c)), + "type" => { + let v = self.host.cell_value(a.sheet, c); + Ev::V(Value::text(match v { + Value::Empty => "b", + Value::Text(_) => "l", + _ => "v", + })) + } + "width" => Ev::V(Value::number(sheet.map_or(8.43, |s| gridcraft_model::points_to_chars(s.col_width(c.col) as f64)).round())), + // Excel's `/[]`, which the classic sheet-name formula splits on + // the `]`. Empty until the workbook has a file of its own, as in Excel (#1489). + "filename" => Ev::V(Value::text(match &wb.source_path { + Some(file) => { + let name = file.file_name().unwrap_or_default().to_string_lossy(); + let on = sheet.map_or("", |s| s.name.as_str()); + file.parent().unwrap_or(std::path::Path::new("")).join(format!("[{name}]{on}")).to_string_lossy().into_owned() + } + None => String::new(), + })), + "sheetname" => Ev::V(Value::text(sheet.map(|s| s.name.clone()).unwrap_or_default())), + "protect" => Ev::V(Value::number(if sheet.is_some_and(|s| wb.styles.get(s.style_id(c)).protection.locked) { 1.0 } else { 0.0 })), + "format" => { + let code = sheet.map(|s| wb.styles.get(s.style_id(c)).num_fmt.as_str().to_string()).unwrap_or_default(); + Ev::V(Value::text(cell_format_code(&code))) + } + "color" | "parentheses" => Ev::V(Value::Number(0.0)), + "prefix" => Ev::V(Value::text("")), + _ => err(CellError::Value), + } + } + + pub(super) fn subtotal(&mut self, name: &str, args: &[Expr]) -> Ev { + // SUBTOTAL needs function_num and ref1; AGGREGATE needs function_num, options and ref1. + let min_args = if name == "AGGREGATE" { 3 } else { 2 }; + if args.len() < min_args { + return err(CellError::Value); + } + let code = match self.arg_num(args, 0, 0.0) { + Ok(n) => n.trunc() as u32, + Err(e) => return err(e), + }; + let (func, options, rest) = if name == "AGGREGATE" { + let opt = match self.arg_num(args, 1, 0.0) { + Ok(n) => n.trunc() as u32, + Err(e) => return err(e), + }; + (code, opt, &args[2..]) + } else { + let (f, ignore_hidden) = if code > 100 { (code - 100, true) } else { (code, false) }; + (f, if ignore_hidden { 5 } else { 0 }, &args[1..]) + }; + if !(1..=19).contains(&func) || (name == "SUBTOTAL" && func > 11) { + return err(CellError::Value); + } + let skip_hidden = matches!(options, 1 | 3 | 5 | 7); + // SUBTOTAL leaves out rows a filter hides whatever the function number (Microsoft's + // SUBTOTAL documentation); 101-111 also leave out rows hidden by hand. + let skip_filtered = name == "SUBTOTAL"; + let skip_errors = matches!(options, 2 | 3 | 6 | 7); + // SUBTOTAL always leaves out nested SUBTOTAL/AGGREGATE cells (101-111 too); AGGREGATE does so for options 0-3. + let skip_nested = name == "SUBTOTAL" || options <= 3; + let mut values = Vec::new(); + let mut k = None; + let refs = if name == "AGGREGATE" && func >= 14 { &rest[..1.min(rest.len())] } else { rest }; + if name == "AGGREGATE" && func >= 14 { + k = rest.get(1).map(|e| self.value(e)); + } + // Every cell visited (even one skipped as hidden, nested or an error) and every value + // counts against one budget for the whole call. All arguments are evaluated (references + // stay lazy) and charged before any cell is read: a value stored in XFD1048576 makes + // `A:XFD` trim to the whole sheet, ~17 billion cells (#2175). + enum Input { + Cells(usize, RangeRef), + Values(Value), + } + let mut inputs = Vec::new(); + let mut budget = MAX_CELLS; + for e in refs { + match self.eval(e) { + Ev::R(areas) => { + for a in areas { + let range = self.trim(&a); + budget = match budget.checked_sub(range.count()) { + Some(left) => left, + None => return err(CellError::Num), + }; + inputs.push(Input::Cells(a.sheet, range)); + } + } + Ev::V(v) => { + let n = match &v { + Value::Array(a) => u64::try_from(a.data.len()).unwrap_or(u64::MAX), + _ => 1, + }; + budget = match budget.checked_sub(n) { + Some(left) => left, + None => return err(CellError::Num), + }; + inputs.push(Input::Values(v)); + } + Ev::L(_) => return err(CellError::Value), + } + } + for input in inputs { + match input { + Input::Cells(sheet, range) => { + for c in range.iter() { + let (hidden, nested) = { + let wb = self.host.workbook(); + let s = wb.sheet(sheet); + let hidden = + s.is_some_and(|s| if skip_hidden { s.is_row_hidden(c.row) } else { skip_filtered && s.is_row_filtered(c.row) }); + let nested = skip_nested + && s.and_then(|s| s.cell(c)).and_then(|cell| cell.formula.as_ref()).is_some_and(|f| { + let u = f.text.to_ascii_uppercase(); + u.contains("SUBTOTAL(") || u.contains("AGGREGATE(") + }); + (hidden, nested) + }; + if hidden || nested { + continue; + } + let v = self.host.cell_value(sheet, c); + if skip_errors && v.is_error() { + continue; + } + values.push(v); + } + } + Input::Values(Value::Array(a)) => values.extend(a.data.iter().filter(|v| !(skip_errors && v.is_error())).cloned()), + Input::Values(v) => values.push(v), + } + } + if let Some(k) = k { + return Ev::V(gridcraft_functions::aggregate_values_k(func, &values, &k)); + } + Ev::V(gridcraft_functions::aggregate_values(func, &values)) + } +} diff --git a/crates/calc/src/eval/special.rs b/crates/calc/src/eval/special.rs new file mode 100644 index 000000000..f68badbfc --- /dev/null +++ b/crates/calc/src/eval/special.rs @@ -0,0 +1,167 @@ +use super::*; + +/// SUMIF and AVERAGEIF with a sum range: Excel sizes the sum range like the criteria range. +pub(super) fn resizes_sum_range(name: &str, args: usize) -> bool { + args == 3 && matches!(name, "SUMIF" | "AVERAGEIF") +} + +/// Height and width of a single-area reference or an array argument. +pub(super) fn ev_shape(ev: &Ev) -> Option<(u32, u32)> { + match ev { + Ev::R(areas) => match areas.as_slice() { + [a] => Some((a.range.height(), a.range.width())), + _ => None, + }, + Ev::V(Value::Array(a)) => Some((u32::try_from(a.rows).unwrap_or(u32::MAX), u32::try_from(a.cols).unwrap_or(u32::MAX))), + Ev::V(_) => Some((1, 1)), + Ev::L(_) => None, + } +} + +/// The range SUMIF/AVERAGEIF add up: from the sum range's top-left cell, as many rows and columns +/// as the criteria range (`height` × `width`), cut off at the sheet's edge. +pub fn resized_sum_range(sum: RangeRef, height: u32, width: u32) -> RangeRef { + let row = sum.start.row.saturating_add(height.saturating_sub(1)).min(MAX_ROWS - 1); + let col = sum.start.col.saturating_add(width.saturating_sub(1)).min(MAX_COLS - 1); + RangeRef::new(sum.start, CellRef::new(row, col)) +} + +/// Collects the static references of an expression with their sheets resolved; `dynamic` is +/// set when the formula uses INDIRECT/OFFSET/names/tables whose targets can change. +pub fn precedents(wb: &Workbook, sheet: usize, e: &Expr) -> (Vec, bool) { + let mut out = Vec::new(); + let mut dynamic = false; + let push = |out: &mut Vec, r: &Reference, range: RangeRef| match &r.sheet { + SheetSel::Current => out.push(Area { sheet, range }), + SheetSel::Named(n) => { + if let Some(i) = wb.sheet_index(n) { + out.push(Area { sheet: i, range }); + } + } + SheetSel::Span(a, b) => { + if let (Some(i), Some(j)) = (wb.sheet_index(a), wb.sheet_index(b)) { + for s in i.min(j)..=i.max(j) { + out.push(Area { sheet: s, range }); + } + } + } + }; + e.walk(&mut |x| match x { + Expr::Ref(r) => push(&mut out, r, r.range()), + // The cells SUMIF/AVERAGEIF really read from the sum range (see `resized_sum_range`); when + // the criteria range's size isn't known statically, recalculate the formula every time. + Expr::Call(n, args) if resizes_sum_range(n, args.len()) => match (args.first(), args.get(2)) { + (Some(Expr::Ref(c)), Some(Expr::Ref(s))) => { + let (crit, sum) = (c.range(), s.range()); + push(&mut out, s, resized_sum_range(sum, crit.height(), crit.width())); + } + (_, Some(Expr::Number(_) | Expr::Text(_) | Expr::Bool(_) | Expr::Error(_) | Expr::Array(_) | Expr::Missing)) => {} + _ => dynamic = true, + }, + Expr::Call(n, _) + if matches!(n.as_str(), "INDIRECT" | "OFFSET" | "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" | "CELL" | "INFO") => + { + dynamic = true + } + Expr::Name(_) | Expr::Struct(_) => dynamic = true, + Expr::Call(n, _) if gridcraft_functions::lookup(n).is_none() && !is_special(n) => dynamic = true, + // A range ending in a function's result (`A1:INDEX(B1:B9,n)`) covers the cells between + // the references inside it, not only those references. + Expr::Binary(BinOp::Range, a, b) if !matches!((&**a, &**b), (Expr::Ref(_), Expr::Ref(_))) => { + let mut inner = precedents(wb, sheet, a).0; + inner.extend(precedents(wb, sheet, b).0); + let mut boxes: Vec = Vec::new(); + for area in inner { + match boxes.iter_mut().find(|x| x.sheet == area.sheet) { + Some(x) => x.range = x.range.union(&area.range), + None => boxes.push(area), + } + } + out.extend(boxes); + } + _ => {} + }); + (out, dynamic) +} + +fn is_special(n: &str) -> bool { + matches!( + n, + "IF" | "IFS" + | "IFERROR" + | "IFNA" + | "CHOOSE" + | "SWITCH" + | "ROW" + | "COLUMN" + | "ROWS" + | "COLUMNS" + | "AREAS" + | "ISREF" + | "ISFORMULA" + | "FORMULATEXT" + | "SHEET" + | "SHEETS" + | "OFFSET" + | "INDIRECT" + | "INDEX" + | "CELL" + | "TRIMRANGE" + | "SUBTOTAL" + | "AGGREGATE" + | "TEXT" + | "LET" + | "LAMBDA" + | "ISOMITTED" + | "MAP" + | "REDUCE" + | "SCAN" + | "BYROW" + | "BYCOL" + | "MAKEARRAY" + ) +} + +/// Names of functions the evaluator implements itself (merged into the function list for UI). +pub const SPECIAL_FUNCTIONS: &[&str] = &[ + "IF", + "IFS", + "IFERROR", + "IFNA", + "CHOOSE", + "SWITCH", + "ROW", + "COLUMN", + "ROWS", + "COLUMNS", + "AREAS", + "ISREF", + "ISFORMULA", + "FORMULATEXT", + "SHEET", + "SHEETS", + "OFFSET", + "INDIRECT", + "INDEX", + "CELL", + "TRIMRANGE", + "SUBTOTAL", + "AGGREGATE", + "TEXT", + "LET", + "LAMBDA", + "ISOMITTED", + "MAP", + "REDUCE", + "SCAN", + "BYROW", + "BYCOL", + "MAKEARRAY", +]; + +pub fn is_known_function(n: &str) -> bool { + is_special(&n.to_ascii_uppercase()) || gridcraft_functions::lookup(n).is_some() +} + +#[allow(dead_code)] +fn unused(_: RefKind) {} From c5098a07651c0ec4469ba3de29a6ce1d54336485 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:54:38 +0000 Subject: [PATCH 44/76] refactor(calc): move recalc.rs items to recalc/ --- crates/calc/src/recalc.rs | 895 +--------------------------- crates/calc/src/recalc/graph.rs | 340 +++++++++++ crates/calc/src/recalc/pass_host.rs | 554 +++++++++++++++++ 3 files changed, 901 insertions(+), 888 deletions(-) create mode 100644 crates/calc/src/recalc/graph.rs create mode 100644 crates/calc/src/recalc/pass_host.rs diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 17a0e6649..b7f403131 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -13,359 +13,20 @@ use std::collections::{BTreeSet, VecDeque}; use std::hash::{BuildHasherDefault, Hasher}; -/// A fast, non-cryptographic hasher for cell keys (FxHash-style multiply-rotate). -#[derive(Default, Clone, Copy)] -pub struct FxHasher(u64); - -impl Hasher for FxHasher { - fn finish(&self) -> u64 { - self.0 - } - fn write(&mut self, bytes: &[u8]) { - for b in bytes { - self.write_u64(*b as u64); - } - } - fn write_u64(&mut self, i: u64) { - self.0 = (self.0.rotate_left(5) ^ i).wrapping_mul(0x51_7c_c1_b7_27_22_0a_95); - } - fn write_u32(&mut self, i: u32) { - self.write_u64(i as u64); - } - fn write_usize(&mut self, i: usize) { - self.write_u64(i as u64); - } -} - -type HashMap = std::collections::HashMap>; -type HashSet = std::collections::HashSet>; - use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value}; use gridcraft_formula::Expr; use gridcraft_model::{CalcMode, Workbook}; use crate::eval::{Area, Evaluator, Host, precedents, stack_position}; -pub type Key = (usize, CellRef); +mod graph; +mod pass_host; -#[derive(Clone, Debug, Default)] -struct Node { - areas: Vec, - dynamic: bool, -} - -/// Ranges wider than this many columns go to the per-sheet wide index instead of per-column ones. -const WIDE: u32 = 64; - -/// Insertions an [`Intervals`] keeps aside (scanned by every query) before merging them in. -const PENDING: usize = 32; - -/// Formulas a formula may calculate on demand one inside the other (each needing one another's -/// value before it is done). Each level takes stack, so a chain deeper than this is suspended and -/// calculated from the top instead (see [`PassHost::calculate`]): long dependency chains calculate -/// whatever order they come in, with bounded stack. -const MAX_NESTED: usize = 64; - -/// Formula evaluations iterative calculation may spend on one recalculation (passes × formulas -/// evaluated again), however high Maximum Iterations is. -const MAX_ITERATION_WORK: usize = 10_000_000; - -/// Row intervals `[r0, r1]`, each with a payload, answering "which intervals overlap rows -/// `a..=b`?" in O(log n + k). -/// -/// The intervals are sorted by start with an implicit segment tree of their largest end, so a query -/// only descends into subtrees that hold a match. Removals mark entries dead (re-inserting one -/// revives it); insertions wait in a small sorted set. [`Intervals::settle`] merges them and drops -/// dead entries once either grows, so mutations cost O(log n) amortised. -#[derive(Clone, Debug)] -struct Intervals { - /// Sorted, without duplicates. - items: Vec<(u32, u32, T)>, - dead: Vec, - dead_count: usize, - /// Segment tree over `items`: node `i` has children `2i` and `2i + 1`, leaves start at - /// `max_end.len() / 2`. Each node holds the largest end below it (dead entries included). - max_end: Vec, - /// Inserted since the last merge. - pending: BTreeSet<(u32, u32, T)>, -} - -impl Default for Intervals { - fn default() -> Self { - Intervals { items: Vec::new(), dead: Vec::new(), dead_count: 0, max_end: Vec::new(), pending: BTreeSet::new() } - } -} - -impl Intervals { - fn is_empty(&self) -> bool { - self.items.len() == self.dead_count && self.pending.is_empty() - } - - fn insert(&mut self, r0: u32, r1: u32, t: T) { - let it = (r0, r1, t); - if let Ok(i) = self.items.binary_search(&it) { - if let Some(d) = self.dead.get_mut(i) - && *d - { - *d = false; - self.dead_count -= 1; - } - return; - } - self.pending.insert(it); - } - - fn remove(&mut self, r0: u32, r1: u32, t: T) { - let it = (r0, r1, t); - if let Ok(i) = self.items.binary_search(&it) { - if let Some(d) = self.dead.get_mut(i) - && !*d - { - *d = true; - self.dead_count += 1; - } - return; - } - self.pending.remove(&it); - } - - /// Merges pending insertions and drops dead entries when there are enough of them. - fn settle(&mut self) { - if self.pending.len() <= PENDING && self.dead_count <= (self.items.len() / 2).max(PENDING) { - return; - } - let mut all: Vec<(u32, u32, T)> = Vec::with_capacity(self.items.len() - self.dead_count + self.pending.len()); - all.extend(self.items.iter().zip(&self.dead).filter(|(_, d)| !**d).map(|(it, _)| *it)); - all.extend(std::mem::take(&mut self.pending)); - // Two sorted runs: the stable sort merges them in linear time. - all.sort(); - all.dedup(); - let size = all.len().next_power_of_two(); - let mut max_end = vec![0; 2 * size]; - if let Some(leaves) = max_end.get_mut(size..) { - for (slot, it) in leaves.iter_mut().zip(&all) { - *slot = it.1; - } - } - for i in (1..size).rev() { - let m = max_end.get(2 * i).copied().unwrap_or(0).max(max_end.get(2 * i + 1).copied().unwrap_or(0)); - if let Some(slot) = max_end.get_mut(i) { - *slot = m; - } - } - self.dead = vec![false; all.len()]; - self.dead_count = 0; - self.items = all; - self.max_end = max_end; - } - - /// Calls `f` for every interval overlapping rows `a..=b` (`r0 <= b && r1 >= a`). Returns the - /// number of tree nodes visited. - fn query(&self, a: u32, b: u32, f: &mut dyn FnMut(&T)) -> usize { - for it in &self.pending { - if it.0 <= b && it.1 >= a { - f(&it.2); - } - } - // Entries before `p` start at or before row `b`. - let p = self.items.partition_point(|it| it.0 <= b); - if p == 0 { - return 0; - } - self.walk(1, 0, self.max_end.len() / 2, p, a, f) - } - - fn walk(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, f: &mut dyn FnMut(&T)) -> usize { - if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) { - return 1; - } - if hi - lo <= 1 { - if let (Some(it), Some(false)) = (self.items.get(lo), self.dead.get(lo)) - && it.1 >= a - { - f(&it.2); - } - return 1; - } - let mid = lo + (hi - lo) / 2; - 1 + self.walk(2 * node, lo, mid, p, a, f) + self.walk(2 * node + 1, mid, hi, p, a, f) - } -} - -#[derive(Clone, Debug, Default)] -pub struct Graph { - nodes: HashMap, - /// single cell → dependents - cell_deps: HashMap>, - /// (sheet, col) → row intervals of the ranges (up to `WIDE` columns) that cover the column - col_deps: HashMap<(usize, u32), Intervals>, - /// sheet → row intervals of wider ranges, with their columns: (first, last, dependent) - wide_deps: HashMap>, - dynamic: HashSet, - /// Range indexes changed since the last [`Graph::settle`]. - unsettled_cols: HashSet<(usize, u32)>, - unsettled_wide: HashSet, -} - -impl Graph { - pub fn len(&self) -> usize { - self.nodes.len() - } - pub fn is_empty(&self) -> bool { - self.nodes.is_empty() - } - - /// Index entries of a formula's precedents, without duplicates: single cells, column - /// intervals and wide ranges. - fn entries(areas: &[Area]) -> (Vec, Vec<(usize, u32, u32, u32)>, Vec<(usize, RangeRef)>) { - let (mut cells, mut cols, mut wide) = (Vec::new(), Vec::new(), Vec::new()); - for a in areas { - if a.range.is_single() { - cells.push((a.sheet, a.range.start)); - } else if a.range.width() > WIDE { - wide.push((a.sheet, a.range)); - } else { - for c in a.range.start.col..=a.range.end.col { - cols.push((a.sheet, c, a.range.start.row, a.range.end.row)); - } - } - } - cells.sort_unstable(); - cells.dedup(); - cols.sort_unstable(); - cols.dedup(); - wide.sort_unstable_by_key(|(s, r)| (*s, r.start, r.end)); - wide.dedup(); - (cells, cols, wide) - } - - fn insert(&mut self, k: Key, node: Node) { - self.remove(k); - let (cells, cols, wide) = Self::entries(&node.areas); - for c in cells { - self.cell_deps.entry(c).or_default().insert(k); - } - for (s, c, r0, r1) in cols { - self.col_deps.entry((s, c)).or_default().insert(r0, r1, k); - self.unsettled_cols.insert((s, c)); - } - for (s, r) in wide { - self.wide_deps.entry(s).or_default().insert(r.start.row, r.end.row, (r.start.col, r.end.col, k)); - self.unsettled_wide.insert(s); - } - if node.dynamic { - self.dynamic.insert(k); - } - self.nodes.insert(k, node); - } - - fn remove(&mut self, k: Key) { - let Some(old) = self.nodes.remove(&k) else { return }; - let (cells, cols, wide) = Self::entries(&old.areas); - for c in cells { - if let Some(v) = self.cell_deps.get_mut(&c) { - v.remove(&k); - if v.is_empty() { - self.cell_deps.remove(&c); - } - } - } - for (s, c, r0, r1) in cols { - if let Some(v) = self.col_deps.get_mut(&(s, c)) { - v.remove(r0, r1, k); - self.unsettled_cols.insert((s, c)); - } - } - for (s, r) in wide { - if let Some(v) = self.wide_deps.get_mut(&s) { - v.remove(r.start.row, r.end.row, (r.start.col, r.end.col, k)); - self.unsettled_wide.insert(s); - } - } - self.dynamic.remove(&k); - } - - /// Tidies the range indexes changed by `insert`/`remove` (queries stay correct without it, - /// just slower). - fn settle(&mut self) { - for key in std::mem::take(&mut self.unsettled_cols) { - if let Some(v) = self.col_deps.get_mut(&key) { - v.settle(); - if v.is_empty() { - self.col_deps.remove(&key); - } - } - } - for key in std::mem::take(&mut self.unsettled_wide) { - if let Some(v) = self.wide_deps.get_mut(&key) { - v.settle(); - if v.is_empty() { - self.wide_deps.remove(&key); - } - } - } - } - - /// Formulas that read cell `c` of `sheet`. - pub fn dependents(&self, sheet: usize, c: CellRef, out: &mut Vec) { - if let Some(v) = self.cell_deps.get(&(sheet, c)) { - out.extend(v.iter().copied()); - } - if let Some(v) = self.col_deps.get(&(sheet, c.col)) { - v.query(c.row, c.row, &mut |k| out.push(*k)); - } - if let Some(v) = self.wide_deps.get(&sheet) { - v.query(c.row, c.row, &mut |(c0, c1, k)| { - if *c0 <= c.col && c.col <= *c1 { - out.push(*k); - } - }); - } - } - - /// Formulas that read any cell of `range` (a formula can be listed more than once). - pub fn dependents_of_range(&self, sheet: usize, range: RangeRef, out: &mut Vec) { - if range.count() <= 4096 { - for c in range.iter() { - if let Some(v) = self.cell_deps.get(&(sheet, c)) { - out.extend(v.iter().copied()); - } - } - } else { - for ((s, c), v) in &self.cell_deps { - if *s == sheet && range.contains(*c) { - out.extend(v.iter().copied()); - } - } - } - let (r0, r1) = (range.start.row, range.end.row); - if (range.width() as usize) <= self.col_deps.len() { - for col in range.start.col..=range.end.col { - if let Some(v) = self.col_deps.get(&(sheet, col)) { - v.query(r0, r1, &mut |k| out.push(*k)); - } - } - } else { - for ((s, col), v) in &self.col_deps { - if *s == sheet && range.start.col <= *col && *col <= range.end.col { - v.query(r0, r1, &mut |k| out.push(*k)); - } - } - } - if let Some(v) = self.wide_deps.get(&sheet) { - v.query(r0, r1, &mut |(c0, c1, k)| { - if *c0 <= range.end.col && range.start.col <= *c1 { - out.push(*k); - } - }); - } - } - - /// Precedent areas of a formula cell (for Trace Precedents). - pub fn precedents_of(&self, k: Key) -> Vec { - self.nodes.get(&k).map(|n| n.areas.clone()).unwrap_or_default() - } -} +#[cfg(test)] +use graph::Intervals; +pub use graph::{FxHasher, Graph, Key}; +use graph::{HashMap, HashSet, Node}; +use pass_host::{PassHost, spills_as_before}; /// Calculation state kept beside a workbook. #[derive(Clone, Debug)] @@ -385,548 +46,6 @@ impl Default for Calc { } } -struct PassHost<'a> { - wb: &'a Workbook, - exprs: &'a HashMap>, - results: HashMap, - pending: HashSet, - in_progress: HashSet, - /// New spills found during the pass: anchor → array. - spills: HashMap>, - /// Used range per sheet: the workbook doesn't change during a pass, and working it out walks - /// every row, so once per sheet rather than once per range read. - used: HashMap>, - rng: &'a mut u64, - now: f64, - cycle: bool, - cycles: Vec, - depth: usize, - /// A formula needed at [`MAX_NESTED`] levels deep: the evaluations in progress are abandoned - /// and it is calculated first. - suspend: Option, - /// Formulas whose evaluation was abandoned for another one, to be calculated again after it. - /// They all need it, so reading one of them while it is calculated is a circular reference, - /// as reading a formula in progress is. - waiting: HashSet, - /// The formulas abandoned since the last suspension. - abandoned: Vec, - /// Other formulas read at [`MAX_NESTED`] levels deep by the evaluations being abandoned. They - /// are calculated along with the suspended one, rather than one per restart (a wide range - /// read at the limit would restart the abandoned evaluations once per cell in it). - also: Vec, - /// Suspensions so far (tests check that a wide read at the limit needs one, not one per cell). - #[cfg(test)] - suspensions: usize, - /// The formulas in `cycles`. - cycle_seen: HashSet, - /// Iterative calculation: a circular reference reads the cell's previous value instead of - /// giving `#CIRC!`. - iterate: bool, - /// Values from the previous iteration; cells not in it read their stored value. - prev: HashMap, - /// Formulas being evaluated, innermost last (kept only when iterating). - stack: Vec, - /// Formulas found on a circular reference (when iterating). - members: HashSet, - /// Results that read a previous value, directly or through other formulas. - tainted: HashSet, - /// Whether the formula being evaluated has read a previous value. - taint: bool, - /// `stack_position()` where the outermost formula being calculated started. - stack_top: usize, - /// Whether an evaluation ran past the stack budget since the innermost formula started. - out_of_stack: bool, -} - -impl<'a> PassHost<'a> { - fn new(wb: &'a Workbook, exprs: &'a HashMap>, rng: &'a mut u64, now: f64) -> Self { - PassHost { - wb, - exprs, - results: HashMap::with_capacity_and_hasher(exprs.len(), Default::default()), - pending: exprs.keys().copied().collect(), - in_progress: HashSet::default(), - spills: HashMap::default(), - used: HashMap::default(), - rng, - now, - cycle: false, - cycles: vec![], - depth: 0, - suspend: None, - waiting: HashSet::default(), - abandoned: Vec::new(), - also: Vec::new(), - #[cfg(test)] - suspensions: 0, - cycle_seen: HashSet::default(), - iterate: false, - prev: HashMap::default(), - stack: Vec::new(), - members: HashSet::default(), - tainted: HashSet::default(), - taint: false, - stack_top: 0, - out_of_stack: false, - } - } -} - -/// Whether an array result at `k` can't spill: it is in a table, or its area runs off the sheet, -/// overlaps merged cells, holds other cells, or another formula's spill (unless that formula is -/// recalculated in this pass, and so may spill elsewhere now). -fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool { - let Some(sh) = host.wb.sheet(sheet) else { return false }; - let end = - CellRef::new(anchor.row.saturating_add((a.rows as u32).saturating_sub(1)), anchor.col.saturating_add((a.cols as u32).saturating_sub(1))); - let range = RangeRef::new(anchor, end); - let own = sh.spill_ranges.get(&anchor); - let spilled_over = |c: CellRef| { - !own.is_some_and(|o| o.contains(c)) - && sh.spill_ranges.iter().any(|(o, r)| *o != anchor && r.contains(c) && !host.exprs.contains_key(&(sheet, *o))) - }; - end.row >= gridcraft_core::MAX_ROWS - || end.col >= gridcraft_core::MAX_COLS - || sh.merges.iter().any(|m| m.intersects(&range)) - || sh.tables.iter().any(|t| t.range.contains(anchor)) - || sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (!cell.value.is_empty() || cell.formula.is_some())) - || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c) && spilled_over(*c)) -} - -/// Whether `arr`, anchored at `anchor`, would spill exactly what the sheet already holds over -/// `range`: the same area, nothing new in the way, and the same values. -fn spills_as_before(sheet: &gridcraft_model::Sheet, anchor: CellRef, range: RangeRef, arr: &Array) -> bool { - let end = - CellRef::new(anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)), anchor.col.saturating_add((arr.cols as u32).saturating_sub(1))); - RangeRef::new(anchor, end) == range - && !sheet.merges.iter().any(|m| m.intersects(&range)) - && !sheet.tables.iter().any(|t| t.range.contains(anchor)) - && range - .iter() - .enumerate() - .all(|(i, c)| c == anchor || (!sheet.cells.has(c) && sheet.spill.get(&c) == Some(arr.data.get(i).unwrap_or(&Value::Empty)))) -} - -impl PassHost<'_> { - /// Calculates `k` (and the formulas it reads) with bounded stack: when [`Self::compute`] would - /// go deeper than [`MAX_NESTED`], it suspends what is in progress, the formula it needed is - /// calculated from here, then the suspended ones again (reading the results kept so far). - fn calculate(&mut self, k: Key) { - // Formulas to calculate, innermost last, each with the ones abandoned to calculate the - // next. - let mut work: Vec<(Key, Vec)> = vec![(k, Vec::new())]; - while let Some(&(top, _)) = work.last() { - self.compute(top); - match self.suspend.take() { - Some(next) => { - #[cfg(test)] - { - self.suspensions += 1; - } - let held = std::mem::take(&mut self.abandoned); - self.waiting.extend(held.iter().copied()); - if let Some(entry) = work.last_mut() { - entry.1 = held; - } - // The other formulas read at the limit are calculated after `next`, before the - // held evaluations restart. Each has nothing held of its own, so the held ones - // are released only when the last of them is done. - let mut seen: HashSet = HashSet::default(); - seen.insert(next); - for s in std::mem::take(&mut self.also) { - if seen.insert(s) { - work.push((s, Vec::new())); - } - } - work.push((next, Vec::new())); - } - None => { - work.pop(); - if let Some(entry) = work.last_mut() { - for a in entry.1.drain(..) { - self.waiting.remove(&a); - } - } - } - } - } - } - - fn compute(&mut self, k: Key) -> Value { - if let Some(v) = self.results.get(&k) { - let v = v.clone(); - if self.iterate && self.tainted.contains(&k) { - self.taint = true; - } - return v; - } - let Some(expr) = self.exprs.get(&k) else { - return self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default(); - }; - if self.suspend.is_some() { - // Unwinding to `calculate`: the value is thrown away. A formula read at the limit is - // calculated with the suspended one, so the evaluations are not restarted for each. - if self.depth >= MAX_NESTED && !self.in_progress.contains(&k) && !self.waiting.contains(&k) { - self.also.push(k); - } - return Value::Error(CellError::Calc); - } - if self.in_progress.contains(&k) || self.waiting.contains(&k) { - self.cycle = true; - if self.iterate { - return self.circular_read(k); - } - // Once each, though a suspended evaluation meets it again. - if self.cycle_seen.insert(k) { - self.cycles.push(k); - } - return Value::Error(CellError::Circ); - } - if self.depth >= MAX_NESTED { - self.suspend = Some(k); - return Value::Error(CellError::Calc); - } - if self.depth == 0 { - self.stack_top = stack_position(); - } - self.in_progress.insert(k); - self.depth += 1; - let outer_taint = std::mem::replace(&mut self.taint, false); - if self.iterate { - self.stack.push(k); - } - let expr = std::sync::Arc::clone(expr); - let outer_out_of_stack = std::mem::replace(&mut self.out_of_stack, false); - let v = { - let mut ev = Evaluator::new(self, k.0, k.1); - ev.value(&expr) - }; - let out_of_stack = std::mem::replace(&mut self.out_of_stack, outer_out_of_stack); - if out_of_stack && self.depth > 1 && self.suspend.is_none() { - // It ran out of the stack left below the formulas that read it: it is calculated - // from the top instead, as a formula needed at `MAX_NESTED` levels deep is. - self.suspend = Some(k); - self.depth -= 1; - self.in_progress.remove(&k); - if self.iterate { - self.stack.pop(); - } - self.taint = outer_taint; - return Value::Error(CellError::Calc); - } - if self.suspend.is_some() { - // Abandoned: `calculate` evaluates this formula again once the one it needs is done. - self.depth -= 1; - self.in_progress.remove(&k); - self.abandoned.push(k); - if self.iterate { - self.stack.pop(); - } - self.taint = outer_taint; - return Value::Error(CellError::Calc); - } - // A legacy array formula fills the range it was entered in. - let legacy = self.wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.as_ref()).and_then(|f| f.array); - let v = match legacy { - Some(range) => fit_to_range(v, range), - None => v, - }; - self.depth -= 1; - self.in_progress.remove(&k); - self.pending.remove(&k); - if self.iterate { - self.stack.pop(); - if self.taint { - self.tainted.insert(k); - } else { - self.tainted.remove(&k); - } - } - self.taint |= outer_taint; - // A blank cell shows as 0 in a formula's result, inside an array as well (`=A1:A3`, - // FILTER or SORT of a range with blanks). GROUPBY and PIVOTBY lay out blank cells - // of their own, which stay blank. - let keep_blanks = matches!(&*expr, Expr::Call(n, _) if matches!(n.as_str(), "GROUPBY" | "PIVOTBY")); - let v = match v { - Value::Array(a) => { - let a = if keep_blanks || !a.data.iter().any(|x| matches!(x, Value::Empty)) { - a - } else { - let data = a.data.iter().map(|x| if matches!(x, Value::Empty) { Value::Number(0.0) } else { x.clone() }).collect(); - Array::new(a.rows, a.cols, data).map(std::sync::Arc::new).unwrap_or(a) - }; - // A dynamic-array formula anchors a spill range even while its result is one - // value -- a FILTER matching one row, a SORT or UNIQUE over one item -- so `C1#` - // is the 1 x 1 range and `ROWS(C1#)` is 1, as in Excel (#1491). A formula that - // returns a scalar never reaches this arm, so it does not become an anchor. - self.spills.insert(k, a.clone()); - a.data.first().cloned().unwrap_or_default() - } - Value::Empty => Value::Number(0.0), - v => v, - }; - // An array that can't spill is #SPILL! for the formulas evaluated after it in this pass too. - let v = match self.spills.get(&k) { - Some(a) if spill_blocked(self, k, a) => { - self.spills.remove(&k); - Value::Error(CellError::Spill) - } - _ => v, - }; - self.results.insert(k, v.clone()); - v - } - - /// A formula's previous value for iterative calculation: from the last iteration, else the - /// value it shows. Blank, and the `#CIRC!` of a loop calculated with iteration off, are 0. - fn previous(&self, k: Key) -> Value { - let v = match self.prev.get(&k) { - Some(v) => v.clone(), - None => self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default(), - }; - if matches!(v, Value::Empty | Value::Error(CellError::Circ)) { Value::Number(0.0) } else { v } - } - - /// Reading `k` while it is being evaluated, with iterative calculation on: its previous value. - /// The formulas around the loop are the ones whose changes decide when iteration stops. - fn circular_read(&mut self, k: Key) -> Value { - self.taint = true; - match self.stack.iter().rposition(|s| *s == k) { - Some(i) => self.members.extend(self.stack.get(i..).unwrap_or_default().iter().copied()), - // Too deep to follow: the formula itself is watched. - None => { - self.members.insert(k); - } - } - self.previous(k) - } - - /// Iterative calculation, after a first pass over `order` that met circular references. The - /// formulas that read a previous value (the loops and what depends on them) are evaluated - /// again in the same order, each pass starting from the last one's values and reading what it - /// has already produced (Gauss-Seidel), until no formula on a loop changes by `max_change` or - /// more, or after `max_iterations` passes in all. Returns the number of passes. - fn iterate_loops(&mut self, order: &[Key], max_iterations: u32, max_change: f64) -> u32 { - let again: Vec = order.iter().copied().filter(|k| self.tainted.contains(k)).collect(); - let set: HashSet = again.iter().copied().collect(); - // Bounds the work whatever Maximum Iterations says. - let max_passes = u32::try_from(MAX_ITERATION_WORK / again.len().max(1)).unwrap_or(u32::MAX).max(1); - let mut passes = 1u32; - while passes < max_iterations.min(max_passes) && !self.converged(max_change) { - self.prev = again.iter().filter_map(|k| self.results.remove(k).map(|v| (*k, v))).collect(); - self.pending = set.clone(); - self.spills.retain(|a, _| !set.contains(a)); - for k in &again { - self.calculate(*k); - } - passes += 1; - } - passes - } - - /// Whether every formula on a loop changed by less than `max_change` in the last pass (text, - /// logical and error values: not at all). - fn converged(&self, max_change: f64) -> bool { - self.members.iter().all(|k| { - let Some(new) = self.results.get(k) else { return true }; - match (new, &self.previous(*k)) { - (Value::Number(a), Value::Number(b)) => { - let d = (a - b).abs(); - d < max_change || d == 0.0 - } - (new, old) => new == old, - } - }) - } -} - -/// A legacy (Ctrl+Shift+Enter) array formula's result fitted to the range it was entered in, as -/// Excel does: a single row or column repeats across the range, a single value fills it, cells -/// past the result show `#N/A`, and a result larger than the range is cut off. -fn fit_to_range(v: Value, range: RangeRef) -> Value { - if range.count() > crate::eval::MAX_CELLS { - return Value::Error(CellError::Num); - } - let (h, w) = (range.height() as usize, range.width() as usize); - let Value::Array(a) = v else { - return if h * w == 1 { v } else { Array::new(h, w, vec![v; h * w]).map(Value::from).unwrap_or(Value::Error(CellError::Value)) }; - }; - if h * w == 1 { - return a.data.first().cloned().unwrap_or_default(); - } - let mut data = Vec::with_capacity(h * w); - for r in 0..h { - for c in 0..w { - let (r, c) = (if a.rows == 1 { 0 } else { r }, if a.cols == 1 { 0 } else { c }); - data.push(a.get(r, c).cloned().unwrap_or(Value::Error(CellError::NA))); - } - } - Array::new(h, w, data).map(Value::from).unwrap_or(Value::Error(CellError::Value)) -} - -impl Host for PassHost<'_> { - fn workbook(&self) -> &Workbook { - self.wb - } - fn stack_top(&self) -> Option { - (self.depth > 1).then_some(self.stack_top) - } - fn stack_exhausted(&mut self) { - self.out_of_stack = true; - } - fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option { - let k = (sheet, anchor); - if !(self.pending.contains(&k) || self.results.contains_key(&k)) { - return self.wb.sheet(sheet)?.spill_ranges.get(&anchor).copied(); - } - // Recalculated in this pass: its new array, unless something blocks it (#SPILL!). - self.compute(k); - let arr = self.spills.get(&k)?; - let end = CellRef::new( - anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)), - anchor.col.saturating_add((arr.cols as u32).saturating_sub(1)), - ); - let range = RangeRef::new(anchor, end); - let sh = self.wb.sheet(sheet)?; - let blocked = end.row >= gridcraft_core::MAX_ROWS - || end.col >= gridcraft_core::MAX_COLS - || sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (cell.formula.is_some() || !cell.value.is_empty())) - || sh.merges.iter().any(|m| m.intersects(&range)); - (!blocked).then_some(range) - } - fn cell_value(&mut self, sheet: usize, c: CellRef) -> Value { - let k = (sheet, c); - if self.pending.contains(&k) || self.results.contains_key(&k) { - return self.compute(k); - } - // A cell covered by a spill computed in this pass. - for (anchor, arr) in &self.spills { - if anchor.0 == sheet && c.row >= anchor.1.row && c.col >= anchor.1.col { - let (dr, dc) = ((c.row - anchor.1.row) as usize, (c.col - anchor.1.col) as usize); - if dr < arr.rows && dc < arr.cols { - if self.iterate && self.tainted.contains(anchor) { - self.taint = true; - } - return arr.get(dr, dc).cloned().unwrap_or_default(); - } - } - } - self.wb.sheet(sheet).map(|s| s.value(c)).unwrap_or_default() - } - fn range_values(&mut self, sheet: usize, range: RangeRef) -> Vec { - let (h, w) = (range.height() as usize, range.width() as usize); - let mut out = vec![Value::Empty; h * w]; - let Some(sh) = self.wb.sheet(sheet) else { return out }; - // Sparse walk: only stored cells; formula cells that are (or were) dirty go through - // `compute`, constants are copied straight from the store. - let mut formulas: Vec<(usize, CellRef)> = Vec::new(); - for (c, cell) in sh.cells.iter_range(range) { - let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize; - if cell.formula.is_some() { - formulas.push((i, c)); - } else if let Some(slot) = out.get_mut(i) { - *slot = cell.value.clone(); - } - } - for (c, v) in sh.spill.range(range.start..=range.end) { - if range.contains(*c) { - let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize; - if let Some(slot) = out.get_mut(i) - && slot.is_empty() - { - *slot = v.clone(); - } - } - } - for (i, c) in formulas { - let v = self.cell_value(sheet, c); - if let Some(slot) = out.get_mut(i) { - *slot = v; - } - } - if !self.spills.is_empty() { - let new: Vec<(usize, Value)> = self - .spills - .iter() - .filter(|(a, _)| a.0 == sheet) - .flat_map(|(a, arr)| { - let mut v = Vec::new(); - for r in 0..arr.rows { - for cc in 0..arr.cols { - let c = CellRef::new(a.1.row.saturating_add(r as u32), a.1.col.saturating_add(cc as u32)); - if c != a.1 && range.contains(c) { - v.push(( - (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize, - arr.get(r, cc).cloned().unwrap_or_default(), - )); - } - } - } - v - }) - .collect(); - // Iterating: an array spilled by a formula that read a previous value. - if self.iterate - && self.spills.iter().any(|(a, arr)| { - let end = CellRef::new( - a.1.row.saturating_add((arr.rows as u32).saturating_sub(1)), - a.1.col.saturating_add((arr.cols as u32).saturating_sub(1)), - ); - a.0 == sheet && self.tainted.contains(a) && RangeRef::new(a.1, end).intersects(&range) - }) - { - self.taint = true; - } - for (i, v) in new { - if let Some(slot) = out.get_mut(i) { - *slot = v; - } - } - } - out - } - fn visit_range(&mut self, sheet: usize, range: RangeRef, f: &mut dyn FnMut(&Value) -> bool) -> bool { - let wb = self.wb; - let Some(sh) = wb.sheet(sheet) else { return false }; - // Formula cells first, in row-major order like `range_values`: reading one calculates it - // and the pass remembers the value, so the walk below gets the same value again without - // this method holding any. - for (c, cell) in sh.cells.iter_range(range) { - if cell.formula.is_some() { - let _ = self.cell_value(sheet, c); - } - } - // Spilled values sit between the stored cells (and the formulas just calculated may have - // produced some): leave such blocks to `range_values`. - if self.spills.keys().any(|a| a.0 == sheet) || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c)) { - return false; - } - for (c, cell) in sh.cells.iter_range(range) { - let go = if cell.formula.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) }; - if !go { - break; - } - } - true - } - fn used_range(&mut self, sheet: usize) -> Option { - let wb = self.wb; - *self.used.entry(sheet).or_insert_with(|| wb.sheet(sheet).and_then(|s| s.used_range())) - } - fn now_serial(&self) -> f64 { - self.now - } - fn random(&mut self) -> f64 { - // xorshift64* - let mut x = *self.rng; - x ^= x >> 12; - x ^= x << 25; - x ^= x >> 27; - *self.rng = x; - (x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64 - } -} - /// Pops one finished component off Tarjan's stack, labelling its nodes with `comp_index`. The /// component is everything up to and including `root`. fn pop_component(stack: &mut Vec, on_stack: &mut [bool], comp: &mut [usize], comp_index: usize, root: usize) { diff --git a/crates/calc/src/recalc/graph.rs b/crates/calc/src/recalc/graph.rs new file mode 100644 index 000000000..aee4b46d5 --- /dev/null +++ b/crates/calc/src/recalc/graph.rs @@ -0,0 +1,340 @@ +use super::*; + +/// A fast, non-cryptographic hasher for cell keys (FxHash-style multiply-rotate). +#[derive(Default, Clone, Copy)] +pub struct FxHasher(u64); + +impl Hasher for FxHasher { + fn finish(&self) -> u64 { + self.0 + } + fn write(&mut self, bytes: &[u8]) { + for b in bytes { + self.write_u64(*b as u64); + } + } + fn write_u64(&mut self, i: u64) { + self.0 = (self.0.rotate_left(5) ^ i).wrapping_mul(0x51_7c_c1_b7_27_22_0a_95); + } + fn write_u32(&mut self, i: u32) { + self.write_u64(i as u64); + } + fn write_usize(&mut self, i: usize) { + self.write_u64(i as u64); + } +} + +pub(super) type HashMap = std::collections::HashMap>; + +pub(super) type HashSet = std::collections::HashSet>; + +pub type Key = (usize, CellRef); + +#[derive(Clone, Debug, Default)] +pub(super) struct Node { + pub(super) areas: Vec, + pub(super) dynamic: bool, +} + +/// Ranges wider than this many columns go to the per-sheet wide index instead of per-column ones. +const WIDE: u32 = 64; + +/// Insertions an [`Intervals`] keeps aside (scanned by every query) before merging them in. +const PENDING: usize = 32; + +/// Row intervals `[r0, r1]`, each with a payload, answering "which intervals overlap rows +/// `a..=b`?" in O(log n + k). +/// +/// The intervals are sorted by start with an implicit segment tree of their largest end, so a query +/// only descends into subtrees that hold a match. Removals mark entries dead (re-inserting one +/// revives it); insertions wait in a small sorted set. [`Intervals::settle`] merges them and drops +/// dead entries once either grows, so mutations cost O(log n) amortised. +#[derive(Clone, Debug)] +pub(super) struct Intervals { + /// Sorted, without duplicates. + items: Vec<(u32, u32, T)>, + dead: Vec, + dead_count: usize, + /// Segment tree over `items`: node `i` has children `2i` and `2i + 1`, leaves start at + /// `max_end.len() / 2`. Each node holds the largest end below it (dead entries included). + max_end: Vec, + /// Inserted since the last merge. + pending: BTreeSet<(u32, u32, T)>, +} + +impl Default for Intervals { + fn default() -> Self { + Intervals { items: Vec::new(), dead: Vec::new(), dead_count: 0, max_end: Vec::new(), pending: BTreeSet::new() } + } +} + +impl Intervals { + pub(super) fn is_empty(&self) -> bool { + self.items.len() == self.dead_count && self.pending.is_empty() + } + + pub(super) fn insert(&mut self, r0: u32, r1: u32, t: T) { + let it = (r0, r1, t); + if let Ok(i) = self.items.binary_search(&it) { + if let Some(d) = self.dead.get_mut(i) + && *d + { + *d = false; + self.dead_count -= 1; + } + return; + } + self.pending.insert(it); + } + + pub(super) fn remove(&mut self, r0: u32, r1: u32, t: T) { + let it = (r0, r1, t); + if let Ok(i) = self.items.binary_search(&it) { + if let Some(d) = self.dead.get_mut(i) + && !*d + { + *d = true; + self.dead_count += 1; + } + return; + } + self.pending.remove(&it); + } + + /// Merges pending insertions and drops dead entries when there are enough of them. + pub(super) fn settle(&mut self) { + if self.pending.len() <= PENDING && self.dead_count <= (self.items.len() / 2).max(PENDING) { + return; + } + let mut all: Vec<(u32, u32, T)> = Vec::with_capacity(self.items.len() - self.dead_count + self.pending.len()); + all.extend(self.items.iter().zip(&self.dead).filter(|(_, d)| !**d).map(|(it, _)| *it)); + all.extend(std::mem::take(&mut self.pending)); + // Two sorted runs: the stable sort merges them in linear time. + all.sort(); + all.dedup(); + let size = all.len().next_power_of_two(); + let mut max_end = vec![0; 2 * size]; + if let Some(leaves) = max_end.get_mut(size..) { + for (slot, it) in leaves.iter_mut().zip(&all) { + *slot = it.1; + } + } + for i in (1..size).rev() { + let m = max_end.get(2 * i).copied().unwrap_or(0).max(max_end.get(2 * i + 1).copied().unwrap_or(0)); + if let Some(slot) = max_end.get_mut(i) { + *slot = m; + } + } + self.dead = vec![false; all.len()]; + self.dead_count = 0; + self.items = all; + self.max_end = max_end; + } + + /// Calls `f` for every interval overlapping rows `a..=b` (`r0 <= b && r1 >= a`). Returns the + /// number of tree nodes visited. + pub(super) fn query(&self, a: u32, b: u32, f: &mut dyn FnMut(&T)) -> usize { + for it in &self.pending { + if it.0 <= b && it.1 >= a { + f(&it.2); + } + } + // Entries before `p` start at or before row `b`. + let p = self.items.partition_point(|it| it.0 <= b); + if p == 0 { + return 0; + } + self.walk(1, 0, self.max_end.len() / 2, p, a, f) + } + + fn walk(&self, node: usize, lo: usize, hi: usize, p: usize, a: u32, f: &mut dyn FnMut(&T)) -> usize { + if lo >= p || self.max_end.get(node).is_none_or(|m| *m < a) { + return 1; + } + if hi - lo <= 1 { + if let (Some(it), Some(false)) = (self.items.get(lo), self.dead.get(lo)) + && it.1 >= a + { + f(&it.2); + } + return 1; + } + let mid = lo + (hi - lo) / 2; + 1 + self.walk(2 * node, lo, mid, p, a, f) + self.walk(2 * node + 1, mid, hi, p, a, f) + } +} + +#[derive(Clone, Debug, Default)] +pub struct Graph { + pub(super) nodes: HashMap, + /// single cell → dependents + cell_deps: HashMap>, + /// (sheet, col) → row intervals of the ranges (up to `WIDE` columns) that cover the column + col_deps: HashMap<(usize, u32), Intervals>, + /// sheet → row intervals of wider ranges, with their columns: (first, last, dependent) + wide_deps: HashMap>, + pub(super) dynamic: HashSet, + /// Range indexes changed since the last [`Graph::settle`]. + unsettled_cols: HashSet<(usize, u32)>, + unsettled_wide: HashSet, +} + +impl Graph { + pub fn len(&self) -> usize { + self.nodes.len() + } + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() + } + + /// Index entries of a formula's precedents, without duplicates: single cells, column + /// intervals and wide ranges. + fn entries(areas: &[Area]) -> (Vec, Vec<(usize, u32, u32, u32)>, Vec<(usize, RangeRef)>) { + let (mut cells, mut cols, mut wide) = (Vec::new(), Vec::new(), Vec::new()); + for a in areas { + if a.range.is_single() { + cells.push((a.sheet, a.range.start)); + } else if a.range.width() > WIDE { + wide.push((a.sheet, a.range)); + } else { + for c in a.range.start.col..=a.range.end.col { + cols.push((a.sheet, c, a.range.start.row, a.range.end.row)); + } + } + } + cells.sort_unstable(); + cells.dedup(); + cols.sort_unstable(); + cols.dedup(); + wide.sort_unstable_by_key(|(s, r)| (*s, r.start, r.end)); + wide.dedup(); + (cells, cols, wide) + } + + pub(super) fn insert(&mut self, k: Key, node: Node) { + self.remove(k); + let (cells, cols, wide) = Self::entries(&node.areas); + for c in cells { + self.cell_deps.entry(c).or_default().insert(k); + } + for (s, c, r0, r1) in cols { + self.col_deps.entry((s, c)).or_default().insert(r0, r1, k); + self.unsettled_cols.insert((s, c)); + } + for (s, r) in wide { + self.wide_deps.entry(s).or_default().insert(r.start.row, r.end.row, (r.start.col, r.end.col, k)); + self.unsettled_wide.insert(s); + } + if node.dynamic { + self.dynamic.insert(k); + } + self.nodes.insert(k, node); + } + + pub(super) fn remove(&mut self, k: Key) { + let Some(old) = self.nodes.remove(&k) else { return }; + let (cells, cols, wide) = Self::entries(&old.areas); + for c in cells { + if let Some(v) = self.cell_deps.get_mut(&c) { + v.remove(&k); + if v.is_empty() { + self.cell_deps.remove(&c); + } + } + } + for (s, c, r0, r1) in cols { + if let Some(v) = self.col_deps.get_mut(&(s, c)) { + v.remove(r0, r1, k); + self.unsettled_cols.insert((s, c)); + } + } + for (s, r) in wide { + if let Some(v) = self.wide_deps.get_mut(&s) { + v.remove(r.start.row, r.end.row, (r.start.col, r.end.col, k)); + self.unsettled_wide.insert(s); + } + } + self.dynamic.remove(&k); + } + + /// Tidies the range indexes changed by `insert`/`remove` (queries stay correct without it, + /// just slower). + pub(super) fn settle(&mut self) { + for key in std::mem::take(&mut self.unsettled_cols) { + if let Some(v) = self.col_deps.get_mut(&key) { + v.settle(); + if v.is_empty() { + self.col_deps.remove(&key); + } + } + } + for key in std::mem::take(&mut self.unsettled_wide) { + if let Some(v) = self.wide_deps.get_mut(&key) { + v.settle(); + if v.is_empty() { + self.wide_deps.remove(&key); + } + } + } + } + + /// Formulas that read cell `c` of `sheet`. + pub fn dependents(&self, sheet: usize, c: CellRef, out: &mut Vec) { + if let Some(v) = self.cell_deps.get(&(sheet, c)) { + out.extend(v.iter().copied()); + } + if let Some(v) = self.col_deps.get(&(sheet, c.col)) { + v.query(c.row, c.row, &mut |k| out.push(*k)); + } + if let Some(v) = self.wide_deps.get(&sheet) { + v.query(c.row, c.row, &mut |(c0, c1, k)| { + if *c0 <= c.col && c.col <= *c1 { + out.push(*k); + } + }); + } + } + + /// Formulas that read any cell of `range` (a formula can be listed more than once). + pub fn dependents_of_range(&self, sheet: usize, range: RangeRef, out: &mut Vec) { + if range.count() <= 4096 { + for c in range.iter() { + if let Some(v) = self.cell_deps.get(&(sheet, c)) { + out.extend(v.iter().copied()); + } + } + } else { + for ((s, c), v) in &self.cell_deps { + if *s == sheet && range.contains(*c) { + out.extend(v.iter().copied()); + } + } + } + let (r0, r1) = (range.start.row, range.end.row); + if (range.width() as usize) <= self.col_deps.len() { + for col in range.start.col..=range.end.col { + if let Some(v) = self.col_deps.get(&(sheet, col)) { + v.query(r0, r1, &mut |k| out.push(*k)); + } + } + } else { + for ((s, col), v) in &self.col_deps { + if *s == sheet && range.start.col <= *col && *col <= range.end.col { + v.query(r0, r1, &mut |k| out.push(*k)); + } + } + } + if let Some(v) = self.wide_deps.get(&sheet) { + v.query(r0, r1, &mut |(c0, c1, k)| { + if *c0 <= range.end.col && range.start.col <= *c1 { + out.push(*k); + } + }); + } + } + + /// Precedent areas of a formula cell (for Trace Precedents). + pub fn precedents_of(&self, k: Key) -> Vec { + self.nodes.get(&k).map(|n| n.areas.clone()).unwrap_or_default() + } +} diff --git a/crates/calc/src/recalc/pass_host.rs b/crates/calc/src/recalc/pass_host.rs new file mode 100644 index 000000000..00e92e82b --- /dev/null +++ b/crates/calc/src/recalc/pass_host.rs @@ -0,0 +1,554 @@ +use super::graph::{HashMap, HashSet, Key}; +use super::*; + +/// Formulas a formula may calculate on demand one inside the other (each needing one another's +/// value before it is done). Each level takes stack, so a chain deeper than this is suspended and +/// calculated from the top instead (see [`PassHost::calculate`]): long dependency chains calculate +/// whatever order they come in, with bounded stack. +const MAX_NESTED: usize = 64; + +/// Formula evaluations iterative calculation may spend on one recalculation (passes × formulas +/// evaluated again), however high Maximum Iterations is. +const MAX_ITERATION_WORK: usize = 10_000_000; + +pub(super) struct PassHost<'a> { + wb: &'a Workbook, + exprs: &'a HashMap>, + pub(super) results: HashMap, + pending: HashSet, + in_progress: HashSet, + /// New spills found during the pass: anchor → array. + pub(super) spills: HashMap>, + /// Used range per sheet: the workbook doesn't change during a pass, and working it out walks + /// every row, so once per sheet rather than once per range read. + used: HashMap>, + rng: &'a mut u64, + now: f64, + cycle: bool, + pub(super) cycles: Vec, + depth: usize, + /// A formula needed at [`MAX_NESTED`] levels deep: the evaluations in progress are abandoned + /// and it is calculated first. + suspend: Option, + /// Formulas whose evaluation was abandoned for another one, to be calculated again after it. + /// They all need it, so reading one of them while it is calculated is a circular reference, + /// as reading a formula in progress is. + waiting: HashSet, + /// The formulas abandoned since the last suspension. + abandoned: Vec, + /// Other formulas read at [`MAX_NESTED`] levels deep by the evaluations being abandoned. They + /// are calculated along with the suspended one, rather than one per restart (a wide range + /// read at the limit would restart the abandoned evaluations once per cell in it). + also: Vec, + /// Suspensions so far (tests check that a wide read at the limit needs one, not one per cell). + #[cfg(test)] + pub(super) suspensions: usize, + /// The formulas in `cycles`. + cycle_seen: HashSet, + /// Iterative calculation: a circular reference reads the cell's previous value instead of + /// giving `#CIRC!`. + pub(super) iterate: bool, + /// Values from the previous iteration; cells not in it read their stored value. + prev: HashMap, + /// Formulas being evaluated, innermost last (kept only when iterating). + stack: Vec, + /// Formulas found on a circular reference (when iterating). + pub(super) members: HashSet, + /// Results that read a previous value, directly or through other formulas. + pub(super) tainted: HashSet, + /// Whether the formula being evaluated has read a previous value. + taint: bool, + /// `stack_position()` where the outermost formula being calculated started. + stack_top: usize, + /// Whether an evaluation ran past the stack budget since the innermost formula started. + out_of_stack: bool, +} + +impl<'a> PassHost<'a> { + pub(super) fn new(wb: &'a Workbook, exprs: &'a HashMap>, rng: &'a mut u64, now: f64) -> Self { + PassHost { + wb, + exprs, + results: HashMap::with_capacity_and_hasher(exprs.len(), Default::default()), + pending: exprs.keys().copied().collect(), + in_progress: HashSet::default(), + spills: HashMap::default(), + used: HashMap::default(), + rng, + now, + cycle: false, + cycles: vec![], + depth: 0, + suspend: None, + waiting: HashSet::default(), + abandoned: Vec::new(), + also: Vec::new(), + #[cfg(test)] + suspensions: 0, + cycle_seen: HashSet::default(), + iterate: false, + prev: HashMap::default(), + stack: Vec::new(), + members: HashSet::default(), + tainted: HashSet::default(), + taint: false, + stack_top: 0, + out_of_stack: false, + } + } +} + +/// Whether an array result at `k` can't spill: it is in a table, or its area runs off the sheet, +/// overlaps merged cells, holds other cells, or another formula's spill (unless that formula is +/// recalculated in this pass, and so may spill elsewhere now). +fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool { + let Some(sh) = host.wb.sheet(sheet) else { return false }; + let end = + CellRef::new(anchor.row.saturating_add((a.rows as u32).saturating_sub(1)), anchor.col.saturating_add((a.cols as u32).saturating_sub(1))); + let range = RangeRef::new(anchor, end); + let own = sh.spill_ranges.get(&anchor); + let spilled_over = |c: CellRef| { + !own.is_some_and(|o| o.contains(c)) + && sh.spill_ranges.iter().any(|(o, r)| *o != anchor && r.contains(c) && !host.exprs.contains_key(&(sheet, *o))) + }; + end.row >= gridcraft_core::MAX_ROWS + || end.col >= gridcraft_core::MAX_COLS + || sh.merges.iter().any(|m| m.intersects(&range)) + || sh.tables.iter().any(|t| t.range.contains(anchor)) + || sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (!cell.value.is_empty() || cell.formula.is_some())) + || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c) && spilled_over(*c)) +} + +/// Whether `arr`, anchored at `anchor`, would spill exactly what the sheet already holds over +/// `range`: the same area, nothing new in the way, and the same values. +pub(super) fn spills_as_before(sheet: &gridcraft_model::Sheet, anchor: CellRef, range: RangeRef, arr: &Array) -> bool { + let end = + CellRef::new(anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)), anchor.col.saturating_add((arr.cols as u32).saturating_sub(1))); + RangeRef::new(anchor, end) == range + && !sheet.merges.iter().any(|m| m.intersects(&range)) + && !sheet.tables.iter().any(|t| t.range.contains(anchor)) + && range + .iter() + .enumerate() + .all(|(i, c)| c == anchor || (!sheet.cells.has(c) && sheet.spill.get(&c) == Some(arr.data.get(i).unwrap_or(&Value::Empty)))) +} + +impl PassHost<'_> { + /// Calculates `k` (and the formulas it reads) with bounded stack: when [`Self::compute`] would + /// go deeper than [`MAX_NESTED`], it suspends what is in progress, the formula it needed is + /// calculated from here, then the suspended ones again (reading the results kept so far). + pub(super) fn calculate(&mut self, k: Key) { + // Formulas to calculate, innermost last, each with the ones abandoned to calculate the + // next. + let mut work: Vec<(Key, Vec)> = vec![(k, Vec::new())]; + while let Some(&(top, _)) = work.last() { + self.compute(top); + match self.suspend.take() { + Some(next) => { + #[cfg(test)] + { + self.suspensions += 1; + } + let held = std::mem::take(&mut self.abandoned); + self.waiting.extend(held.iter().copied()); + if let Some(entry) = work.last_mut() { + entry.1 = held; + } + // The other formulas read at the limit are calculated after `next`, before the + // held evaluations restart. Each has nothing held of its own, so the held ones + // are released only when the last of them is done. + let mut seen: HashSet = HashSet::default(); + seen.insert(next); + for s in std::mem::take(&mut self.also) { + if seen.insert(s) { + work.push((s, Vec::new())); + } + } + work.push((next, Vec::new())); + } + None => { + work.pop(); + if let Some(entry) = work.last_mut() { + for a in entry.1.drain(..) { + self.waiting.remove(&a); + } + } + } + } + } + } + + fn compute(&mut self, k: Key) -> Value { + if let Some(v) = self.results.get(&k) { + let v = v.clone(); + if self.iterate && self.tainted.contains(&k) { + self.taint = true; + } + return v; + } + let Some(expr) = self.exprs.get(&k) else { + return self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default(); + }; + if self.suspend.is_some() { + // Unwinding to `calculate`: the value is thrown away. A formula read at the limit is + // calculated with the suspended one, so the evaluations are not restarted for each. + if self.depth >= MAX_NESTED && !self.in_progress.contains(&k) && !self.waiting.contains(&k) { + self.also.push(k); + } + return Value::Error(CellError::Calc); + } + if self.in_progress.contains(&k) || self.waiting.contains(&k) { + self.cycle = true; + if self.iterate { + return self.circular_read(k); + } + // Once each, though a suspended evaluation meets it again. + if self.cycle_seen.insert(k) { + self.cycles.push(k); + } + return Value::Error(CellError::Circ); + } + if self.depth >= MAX_NESTED { + self.suspend = Some(k); + return Value::Error(CellError::Calc); + } + if self.depth == 0 { + self.stack_top = stack_position(); + } + self.in_progress.insert(k); + self.depth += 1; + let outer_taint = std::mem::replace(&mut self.taint, false); + if self.iterate { + self.stack.push(k); + } + let expr = std::sync::Arc::clone(expr); + let outer_out_of_stack = std::mem::replace(&mut self.out_of_stack, false); + let v = { + let mut ev = Evaluator::new(self, k.0, k.1); + ev.value(&expr) + }; + let out_of_stack = std::mem::replace(&mut self.out_of_stack, outer_out_of_stack); + if out_of_stack && self.depth > 1 && self.suspend.is_none() { + // It ran out of the stack left below the formulas that read it: it is calculated + // from the top instead, as a formula needed at `MAX_NESTED` levels deep is. + self.suspend = Some(k); + self.depth -= 1; + self.in_progress.remove(&k); + if self.iterate { + self.stack.pop(); + } + self.taint = outer_taint; + return Value::Error(CellError::Calc); + } + if self.suspend.is_some() { + // Abandoned: `calculate` evaluates this formula again once the one it needs is done. + self.depth -= 1; + self.in_progress.remove(&k); + self.abandoned.push(k); + if self.iterate { + self.stack.pop(); + } + self.taint = outer_taint; + return Value::Error(CellError::Calc); + } + // A legacy array formula fills the range it was entered in. + let legacy = self.wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.as_ref()).and_then(|f| f.array); + let v = match legacy { + Some(range) => fit_to_range(v, range), + None => v, + }; + self.depth -= 1; + self.in_progress.remove(&k); + self.pending.remove(&k); + if self.iterate { + self.stack.pop(); + if self.taint { + self.tainted.insert(k); + } else { + self.tainted.remove(&k); + } + } + self.taint |= outer_taint; + // A blank cell shows as 0 in a formula's result, inside an array as well (`=A1:A3`, + // FILTER or SORT of a range with blanks). GROUPBY and PIVOTBY lay out blank cells + // of their own, which stay blank. + let keep_blanks = matches!(&*expr, Expr::Call(n, _) if matches!(n.as_str(), "GROUPBY" | "PIVOTBY")); + let v = match v { + Value::Array(a) => { + let a = if keep_blanks || !a.data.iter().any(|x| matches!(x, Value::Empty)) { + a + } else { + let data = a.data.iter().map(|x| if matches!(x, Value::Empty) { Value::Number(0.0) } else { x.clone() }).collect(); + Array::new(a.rows, a.cols, data).map(std::sync::Arc::new).unwrap_or(a) + }; + // A dynamic-array formula anchors a spill range even while its result is one + // value -- a FILTER matching one row, a SORT or UNIQUE over one item -- so `C1#` + // is the 1 x 1 range and `ROWS(C1#)` is 1, as in Excel (#1491). A formula that + // returns a scalar never reaches this arm, so it does not become an anchor. + self.spills.insert(k, a.clone()); + a.data.first().cloned().unwrap_or_default() + } + Value::Empty => Value::Number(0.0), + v => v, + }; + // An array that can't spill is #SPILL! for the formulas evaluated after it in this pass too. + let v = match self.spills.get(&k) { + Some(a) if spill_blocked(self, k, a) => { + self.spills.remove(&k); + Value::Error(CellError::Spill) + } + _ => v, + }; + self.results.insert(k, v.clone()); + v + } + + /// A formula's previous value for iterative calculation: from the last iteration, else the + /// value it shows. Blank, and the `#CIRC!` of a loop calculated with iteration off, are 0. + fn previous(&self, k: Key) -> Value { + let v = match self.prev.get(&k) { + Some(v) => v.clone(), + None => self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default(), + }; + if matches!(v, Value::Empty | Value::Error(CellError::Circ)) { Value::Number(0.0) } else { v } + } + + /// Reading `k` while it is being evaluated, with iterative calculation on: its previous value. + /// The formulas around the loop are the ones whose changes decide when iteration stops. + fn circular_read(&mut self, k: Key) -> Value { + self.taint = true; + match self.stack.iter().rposition(|s| *s == k) { + Some(i) => self.members.extend(self.stack.get(i..).unwrap_or_default().iter().copied()), + // Too deep to follow: the formula itself is watched. + None => { + self.members.insert(k); + } + } + self.previous(k) + } + + /// Iterative calculation, after a first pass over `order` that met circular references. The + /// formulas that read a previous value (the loops and what depends on them) are evaluated + /// again in the same order, each pass starting from the last one's values and reading what it + /// has already produced (Gauss-Seidel), until no formula on a loop changes by `max_change` or + /// more, or after `max_iterations` passes in all. Returns the number of passes. + pub(super) fn iterate_loops(&mut self, order: &[Key], max_iterations: u32, max_change: f64) -> u32 { + let again: Vec = order.iter().copied().filter(|k| self.tainted.contains(k)).collect(); + let set: HashSet = again.iter().copied().collect(); + // Bounds the work whatever Maximum Iterations says. + let max_passes = u32::try_from(MAX_ITERATION_WORK / again.len().max(1)).unwrap_or(u32::MAX).max(1); + let mut passes = 1u32; + while passes < max_iterations.min(max_passes) && !self.converged(max_change) { + self.prev = again.iter().filter_map(|k| self.results.remove(k).map(|v| (*k, v))).collect(); + self.pending = set.clone(); + self.spills.retain(|a, _| !set.contains(a)); + for k in &again { + self.calculate(*k); + } + passes += 1; + } + passes + } + + /// Whether every formula on a loop changed by less than `max_change` in the last pass (text, + /// logical and error values: not at all). + fn converged(&self, max_change: f64) -> bool { + self.members.iter().all(|k| { + let Some(new) = self.results.get(k) else { return true }; + match (new, &self.previous(*k)) { + (Value::Number(a), Value::Number(b)) => { + let d = (a - b).abs(); + d < max_change || d == 0.0 + } + (new, old) => new == old, + } + }) + } +} + +/// A legacy (Ctrl+Shift+Enter) array formula's result fitted to the range it was entered in, as +/// Excel does: a single row or column repeats across the range, a single value fills it, cells +/// past the result show `#N/A`, and a result larger than the range is cut off. +fn fit_to_range(v: Value, range: RangeRef) -> Value { + if range.count() > crate::eval::MAX_CELLS { + return Value::Error(CellError::Num); + } + let (h, w) = (range.height() as usize, range.width() as usize); + let Value::Array(a) = v else { + return if h * w == 1 { v } else { Array::new(h, w, vec![v; h * w]).map(Value::from).unwrap_or(Value::Error(CellError::Value)) }; + }; + if h * w == 1 { + return a.data.first().cloned().unwrap_or_default(); + } + let mut data = Vec::with_capacity(h * w); + for r in 0..h { + for c in 0..w { + let (r, c) = (if a.rows == 1 { 0 } else { r }, if a.cols == 1 { 0 } else { c }); + data.push(a.get(r, c).cloned().unwrap_or(Value::Error(CellError::NA))); + } + } + Array::new(h, w, data).map(Value::from).unwrap_or(Value::Error(CellError::Value)) +} + +impl Host for PassHost<'_> { + fn workbook(&self) -> &Workbook { + self.wb + } + fn stack_top(&self) -> Option { + (self.depth > 1).then_some(self.stack_top) + } + fn stack_exhausted(&mut self) { + self.out_of_stack = true; + } + fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option { + let k = (sheet, anchor); + if !(self.pending.contains(&k) || self.results.contains_key(&k)) { + return self.wb.sheet(sheet)?.spill_ranges.get(&anchor).copied(); + } + // Recalculated in this pass: its new array, unless something blocks it (#SPILL!). + self.compute(k); + let arr = self.spills.get(&k)?; + let end = CellRef::new( + anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)), + anchor.col.saturating_add((arr.cols as u32).saturating_sub(1)), + ); + let range = RangeRef::new(anchor, end); + let sh = self.wb.sheet(sheet)?; + let blocked = end.row >= gridcraft_core::MAX_ROWS + || end.col >= gridcraft_core::MAX_COLS + || sh.cells.iter_range(range).any(|(c, cell)| c != anchor && (cell.formula.is_some() || !cell.value.is_empty())) + || sh.merges.iter().any(|m| m.intersects(&range)); + (!blocked).then_some(range) + } + fn cell_value(&mut self, sheet: usize, c: CellRef) -> Value { + let k = (sheet, c); + if self.pending.contains(&k) || self.results.contains_key(&k) { + return self.compute(k); + } + // A cell covered by a spill computed in this pass. + for (anchor, arr) in &self.spills { + if anchor.0 == sheet && c.row >= anchor.1.row && c.col >= anchor.1.col { + let (dr, dc) = ((c.row - anchor.1.row) as usize, (c.col - anchor.1.col) as usize); + if dr < arr.rows && dc < arr.cols { + if self.iterate && self.tainted.contains(anchor) { + self.taint = true; + } + return arr.get(dr, dc).cloned().unwrap_or_default(); + } + } + } + self.wb.sheet(sheet).map(|s| s.value(c)).unwrap_or_default() + } + fn range_values(&mut self, sheet: usize, range: RangeRef) -> Vec { + let (h, w) = (range.height() as usize, range.width() as usize); + let mut out = vec![Value::Empty; h * w]; + let Some(sh) = self.wb.sheet(sheet) else { return out }; + // Sparse walk: only stored cells; formula cells that are (or were) dirty go through + // `compute`, constants are copied straight from the store. + let mut formulas: Vec<(usize, CellRef)> = Vec::new(); + for (c, cell) in sh.cells.iter_range(range) { + let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize; + if cell.formula.is_some() { + formulas.push((i, c)); + } else if let Some(slot) = out.get_mut(i) { + *slot = cell.value.clone(); + } + } + for (c, v) in sh.spill.range(range.start..=range.end) { + if range.contains(*c) { + let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize; + if let Some(slot) = out.get_mut(i) + && slot.is_empty() + { + *slot = v.clone(); + } + } + } + for (i, c) in formulas { + let v = self.cell_value(sheet, c); + if let Some(slot) = out.get_mut(i) { + *slot = v; + } + } + if !self.spills.is_empty() { + let new: Vec<(usize, Value)> = self + .spills + .iter() + .filter(|(a, _)| a.0 == sheet) + .flat_map(|(a, arr)| { + let mut v = Vec::new(); + for r in 0..arr.rows { + for cc in 0..arr.cols { + let c = CellRef::new(a.1.row.saturating_add(r as u32), a.1.col.saturating_add(cc as u32)); + if c != a.1 && range.contains(c) { + v.push(( + (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize, + arr.get(r, cc).cloned().unwrap_or_default(), + )); + } + } + } + v + }) + .collect(); + // Iterating: an array spilled by a formula that read a previous value. + if self.iterate + && self.spills.iter().any(|(a, arr)| { + let end = CellRef::new( + a.1.row.saturating_add((arr.rows as u32).saturating_sub(1)), + a.1.col.saturating_add((arr.cols as u32).saturating_sub(1)), + ); + a.0 == sheet && self.tainted.contains(a) && RangeRef::new(a.1, end).intersects(&range) + }) + { + self.taint = true; + } + for (i, v) in new { + if let Some(slot) = out.get_mut(i) { + *slot = v; + } + } + } + out + } + fn visit_range(&mut self, sheet: usize, range: RangeRef, f: &mut dyn FnMut(&Value) -> bool) -> bool { + let wb = self.wb; + let Some(sh) = wb.sheet(sheet) else { return false }; + // Formula cells first, in row-major order like `range_values`: reading one calculates it + // and the pass remembers the value, so the walk below gets the same value again without + // this method holding any. + for (c, cell) in sh.cells.iter_range(range) { + if cell.formula.is_some() { + let _ = self.cell_value(sheet, c); + } + } + // Spilled values sit between the stored cells (and the formulas just calculated may have + // produced some): leave such blocks to `range_values`. + if self.spills.keys().any(|a| a.0 == sheet) || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c)) { + return false; + } + for (c, cell) in sh.cells.iter_range(range) { + let go = if cell.formula.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) }; + if !go { + break; + } + } + true + } + fn used_range(&mut self, sheet: usize) -> Option { + let wb = self.wb; + *self.used.entry(sheet).or_insert_with(|| wb.sheet(sheet).and_then(|s| s.used_range())) + } + fn now_serial(&self) -> f64 { + self.now + } + fn random(&mut self) -> f64 { + // xorshift64* + let mut x = *self.rng; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + *self.rng = x; + (x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64 + } +} From 3813413b651ee9bc142ac1f6b271ad5fa51421ab Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:04:39 +0000 Subject: [PATCH 45/76] refactor(chart): move render.rs items to render/ --- crates/chart/src/render.rs | 1661 +------------------------- crates/chart/src/render/cartesian.rs | 431 +++++++ crates/chart/src/render/frame.rs | 245 ++++ crates/chart/src/render/kinds.rs | 505 ++++++++ crates/chart/src/render/legend.rs | 161 +++ crates/chart/src/render/lines.rs | 157 +++ crates/chart/src/render/trend.rs | 166 +++ 7 files changed, 1679 insertions(+), 1647 deletions(-) create mode 100644 crates/chart/src/render/cartesian.rs create mode 100644 crates/chart/src/render/frame.rs create mode 100644 crates/chart/src/render/kinds.rs create mode 100644 crates/chart/src/render/legend.rs create mode 100644 crates/chart/src/render/lines.rs create mode 100644 crates/chart/src/render/trend.rs diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 3257082bb..76a1619e3 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -10,6 +10,20 @@ use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, series_color}; use crate::trend::{auto_name, fit_trendline, moving_average}; use crate::{ChartData, HAlign, Measure, Prim, Rgba, SeriesData, VAlign}; +mod cartesian; +mod frame; +mod kinds; +mod legend; +mod lines; +mod trend; + +use cartesian::{cartesian, scatter}; +use frame::{Bx, axis_titles, chart_title}; +use kinds::{box_whisker, funnel, histogram, pie, radar, stock, treemap, waterfall}; +use legend::draw_legend; +pub use legend::{Swatch, legend_entries}; +pub use trend::supports_trendline; + pub(crate) const TEXT: Rgba = [0x59, 0x59, 0x59, 0xFF]; const DARK_TEXT: Rgba = [0x26, 0x26, 0x26, 0xFF]; const GRID: Rgba = [0xD9, 0xD9, 0xD9, 0xFF]; @@ -31,32 +45,6 @@ const MAX_MARKERS: usize = 5000; /// Data labels are skipped when a series has more points than this. const MAX_LABELS: usize = 200; -#[derive(Clone, Copy, Debug)] -struct Bx { - x: f32, - y: f32, - w: f32, - h: f32, -} - -impl Bx { - fn right(&self) -> f32 { - self.x + self.w - } - fn bottom(&self) -> f32 { - self.y + self.h - } - fn ok(&self) -> bool { - self.w.is_finite() && self.h.is_finite() && self.w > 4.0 && self.h > 4.0 - } - fn cx(&self) -> f32 { - self.x + self.w / 2.0 - } - fn cy(&self) -> f32 { - self.y + self.h / 2.0 - } -} - struct Ctx<'a> { out: Vec, m: &'a dyn Measure, @@ -105,85 +93,6 @@ impl Ctx<'_> { } } -/// Text colour readable on `fill`. -fn on(fill: Rgba) -> Rgba { - let l = 0.299 * fill[0] as f32 + 0.587 * fill[1] as f32 + 0.114 * fill[2] as f32; - if l < 150.0 { WHITE } else { DARK_TEXT } -} - -/// How a legend entry's swatch is drawn. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Swatch { - Box, - Line, - LineMarker, - Marker, -} - -/// The kind a series is drawn as (per-series kinds only apply to combo charts). -fn plot_kind(chart: &Chart, s: &SeriesData) -> ChartKind { - if chart.kind == ChartKind::Combo { if s.kind == ChartKind::Combo { ChartKind::Line } else { s.kind } } else { chart.kind } -} - -/// Colour of point `j` for charts that colour by point (pie, doughnut, treemap, sunburst). -fn point_color(data: &ChartData, j: usize) -> Rgba { - match (j, data.series.first()) { - (0, Some(s)) => s.color, - _ => j.checked_rem(data.palette.len()).and_then(|k| data.palette.get(k)).copied().unwrap_or_else(|| series_color(&Theme::default(), j)), - } -} - -fn first_len(data: &ChartData) -> usize { - data.series.first().map(|s| s.values.len()).unwrap_or(0) -} - -fn cat_label(data: &ChartData, j: usize) -> String { - data.categories.get(j).cloned().unwrap_or_else(|| (j + 1).to_string()) -} - -/// Legend entries (text, colour, swatch) `render` draws for this chart; empty when hidden. -pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swatch)> { - if chart.legend == LegendPos::None { - return vec![]; - } - match chart.kind { - ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst => { - let n = first_len(data).min(MAX_CATS); - (0..n).map(|j| (cat_label(data, j), point_color(data, j), Swatch::Box)).collect() - } - ChartKind::Histogram | ChartKind::Funnel | ChartKind::Stock => vec![], - ChartKind::Waterfall => { - if data.series.is_empty() { - return vec![]; - } - let total = data.series.first().map(|s| s.color).unwrap_or(TEXT); - vec![("Increase".into(), UP, Swatch::Box), ("Decrease".into(), DOWN, Swatch::Box), ("Total".into(), total, Swatch::Box)] - } - _ => { - let series = data.series.iter().take(MAX_SERIES); - let mut out: Vec<(String, Rgba, Swatch)> = series - .clone() - .map(|s| { - let sw = match plot_kind(chart, s) { - ChartKind::Line | ChartKind::LineStacked | ChartKind::Radar | ChartKind::ScatterLines => Swatch::Line, - ChartKind::LineMarkers => Swatch::LineMarker, - ChartKind::Scatter | ChartKind::Bubble => Swatch::Marker, - _ => Swatch::Box, - }; - (s.name.clone(), s.color, sw) - }) - .collect(); - // Trendlines follow the series, named "Linear (Sales)" unless given a name. - for s in series.filter(|s| supports_trendline(plot_kind(chart, s))) { - for t in s.trendlines.iter().take(MAX_TRENDLINES) { - out.push((t.spec.name.clone().unwrap_or_else(|| auto_name(t.spec.kind, &s.name)), t.color, Swatch::Line)); - } - } - out - } - } -} - fn sanitize_vals(v: &[Option]) -> Vec> { v.iter().take(MAX_POINTS).map(|x| x.filter(|n| n.is_finite()).map(clean)).collect() } @@ -213,14 +122,6 @@ fn sanitize(d: &ChartData) -> ChartData { } } -fn chart_title(chart: &Chart, data: &ChartData) -> Option { - match &chart.title { - Some(t) => Some(t.trim().to_string()).filter(|t| !t.is_empty()), - None if data.series.len() == 1 => data.series.first().map(|s| s.name.clone()).filter(|t| !t.is_empty()), - None => None, - } -} - /// Lays out and draws a chart into a `w`×`h` box at the origin. Empty for a degenerate box. pub fn render(chart: &Chart, data: &ChartData, w: f32, h: f32, m: &dyn Measure) -> Vec { if !(w.is_finite() && h.is_finite()) || w < 1.0 || h < 1.0 || w > 1e7 || h > 1e7 { @@ -276,1483 +177,6 @@ fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { } } -fn axis_titles(ctx: &mut Ctx, chart: &Chart, mut area: Bx) -> Bx { - let lh = AXIS_TITLE_SIZE * 1.4 + 2.0; - if let Some(t) = chart.x_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) { - let t = ctx.fit(t, AXIS_TITLE_SIZE, area.w); - ctx.text(area.cx(), area.bottom(), &t, AXIS_TITLE_SIZE, TEXT, HAlign::Center, VAlign::Bottom); - area.h -= lh; - } - if let Some(t) = chart.y_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) { - let t = ctx.fit(t, AXIS_TITLE_SIZE, area.h); - if !t.is_empty() { - ctx.out.push(Prim::Text { - x: area.x, - y: area.cy(), - text: t, - size: AXIS_TITLE_SIZE, - color: TEXT, - bold: false, - align: HAlign::Center, - valign: VAlign::Top, - rotation: -PI / 2.0, - }); - } - area.x += lh; - area.w -= lh; - } - area -} - -// ---------------------------------------------------------------- legend - -fn swatch_w(sw: Swatch, s: f32) -> f32 { - if sw == Swatch::Box { s } else { s * 2.0 } -} - -fn draw_swatch(ctx: &mut Ctx, sw: Swatch, x: f32, yc: f32, s: f32, color: Rgba) { - match sw { - Swatch::Box => ctx.rect(x, yc - s / 2.0, s, s, color, None), - Swatch::Line => ctx.hline(x, x + 2.0 * s, yc, color, 2.0), - Swatch::LineMarker => { - ctx.hline(x, x + 2.0 * s, yc, color, 2.0); - ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.35, fill: Some(color), stroke: None }); - } - Swatch::Marker => ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.4, fill: Some(color), stroke: None }), - } -} - -fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos: LegendPos, area: Bx) -> Bx { - let size = LABEL_SIZE; - let row_h = size * 1.6; - let s = size * 0.8; - let gap = 4.0; - let spacing = size * 1.3; - let item_w = |ctx: &Ctx, e: &(String, Rgba, Swatch)| swatch_w(e.2, s) + gap + ctx.tw(&e.0, size); - match pos { - LegendPos::None => area, - LegendPos::Top | LegendPos::Bottom => { - let max_rows = ((area.h * 0.3 / row_h).floor() as usize).max(1); - let mut rows: Vec> = vec![vec![]]; - let mut cur = 0.0f32; - for (i, e) in entries.iter().enumerate() { - let iw = item_w(ctx, e).min(area.w); - let need = if cur > 0.0 { spacing + iw } else { iw }; - if cur > 0.0 && cur + need > area.w { - if rows.len() >= max_rows { - break; - } - rows.push(vec![]); - cur = 0.0; - } - cur += if cur > 0.0 { spacing + iw } else { iw }; - if let Some(r) = rows.last_mut() { - r.push((i, iw)); - } - } - let lh = rows.len() as f32 * row_h; - let y0 = if pos == LegendPos::Bottom { area.bottom() - lh } else { area.y }; - for (ri, row) in rows.iter().enumerate() { - let total: f32 = row.iter().map(|(_, w)| w).sum::() + spacing * row.len().saturating_sub(1) as f32; - let mut x = area.x + (area.w - total).max(0.0) / 2.0; - let yc = y0 + ri as f32 * row_h + row_h / 2.0; - for &(i, iw) in row { - if let Some(e) = entries.get(i) { - draw_swatch(ctx, e.2, x, yc, s, e.1); - let sx = swatch_w(e.2, s) + gap; - let t = ctx.fit(&e.0, size, (iw - sx).max(0.0) + 0.5); - ctx.text(x + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle); - } - x += iw + spacing; - } - } - let used = lh + 4.0; - if pos == LegendPos::Bottom { Bx { h: area.h - used, ..area } } else { Bx { y: area.y + used, h: area.h - used, ..area } } - } - LegendPos::Left | LegendPos::Right => { - let colw = entries.iter().map(|e| item_w(ctx, e)).fold(0.0f32, f32::max).min(area.w * 0.35); - let max_rows = ((area.h / row_h).floor() as usize).max(1); - let n = entries.len().min(max_rows); - let lh = n as f32 * row_h; - let y0 = area.y + (area.h - lh).max(0.0) / 2.0; - let x0 = if pos == LegendPos::Right { area.right() - colw } else { area.x }; - for (i, e) in entries.iter().take(n).enumerate() { - let yc = y0 + i as f32 * row_h + row_h / 2.0; - draw_swatch(ctx, e.2, x0, yc, s, e.1); - let sx = swatch_w(e.2, s) + gap; - let t = ctx.fit(&e.0, size, (colw - sx).max(0.0) + 0.5); - ctx.text(x0 + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle); - } - let used = colw + 8.0; - if pos == LegendPos::Right { Bx { w: area.w - used, ..area } } else { Bx { x: area.x + used, w: area.w - used, ..area } } - } - } -} - -// ---------------------------------------------------------------- axes frame - -enum CatAxis<'a> { - /// Category labels and, for a multi-level axis, the outer levels (nearest the labels first; - /// one entry per category, non-empty where a group starts). - Labels(&'a [String], &'a [Vec]), - Numeric(Scale, &'a Fmt), -} - -/// Groups of an outer category level: `(first, end)` index pairs, a group starting at every -/// non-empty entry (and at the first category). -fn level_groups(level: &[String], n: usize) -> Vec<(usize, usize)> { - let mut starts: Vec = (0..n).filter(|&j| j == 0 || level.get(j).is_some_and(|t| !t.is_empty())).collect(); - starts.push(n); - starts.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect() -} - -struct ValAxis<'a> { - scale: Scale, - fmt: &'a Fmt, -} - -impl ValAxis<'_> { - fn labels(&self) -> Vec<(f64, String)> { - self.scale.ticks().into_iter().map(|v| (v, self.fmt.format(v, Some(self.scale.step)))).collect() - } -} - -/// Draws gridlines, axis labels and the category axis line; returns the plot rectangle. -fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis>, horizontal: bool, grid: bool) -> Bx { - let ls = LABEL_SIZE; - let lh = ls * 1.4; - let ylabels = y.labels(); - let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw(t, ls)).fold(0.0f32, f32::max); - if !horizontal { - let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4); - let y2labels = y2.map(ValAxis::labels); - let right = match (&y2labels, &cat) { - (Some(l), _) => (maxw(ctx, l) + 6.0).min(area.w * 0.3), - (None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2), - _ => 4.0, - }; - let levels = match &cat { - CatAxis::Labels(_, o) => o.len() as f32, - CatAxis::Numeric(..) => 0.0, - }; - let plot = Bx { x: area.x + left, y: area.y + lh * 0.5, w: area.w - left - right, h: area.h - lh * 1.5 - 4.0 - lh * levels }; - if !plot.ok() { - return plot; - } - for (v, t) in &ylabels { - let yy = plot.bottom() - y.scale.frac(*v) * plot.h; - if grid { - ctx.hline(plot.x, plot.right(), yy, GRID, 0.75); - } - ctx.text(plot.x - 4.0, yy, t, ls, TEXT, HAlign::Right, VAlign::Middle); - } - if let (Some(a), Some(l)) = (y2, &y2labels) { - for (v, t) in l { - let yy = plot.bottom() - a.scale.frac(*v) * plot.h; - ctx.text(plot.right() + 4.0, yy, t, ls, TEXT, HAlign::Left, VAlign::Middle); - } - } - let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min }; - let y0 = plot.bottom() - y.scale.frac(zero) * plot.h; - ctx.hline(plot.x, plot.right(), y0, AXIS, 0.75); - match cat { - CatAxis::Labels(l, outer) => { - let n = l.len().max(1); - let cw = plot.w / n as f32; - let lw = l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max); - let k = (((lw + 6.0) / cw.max(1e-3)).ceil() as usize).clamp(1, n); - for (j, t) in l.iter().enumerate().step_by(k) { - let t = ctx.fit(t, ls, cw * k as f32 - 2.0); - ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, HAlign::Center, VAlign::Top); - } - // Outer levels, one row each below the labels: a group's name centred under its - // categories, and a separator at each group boundary down through the rows. - for (i, level) in outer.iter().enumerate() { - let top = plot.bottom() + 3.0 + lh * (i as f32 + 1.0); - for (first, end) in level_groups(level, n) { - let (x0, x1) = (plot.x + first as f32 * cw, plot.x + end as f32 * cw); - if let Some(t) = level.get(first).filter(|t| !t.is_empty()) { - let t = ctx.fit(t, ls, x1 - x0 - 2.0); - ctx.text((x0 + x1) / 2.0, top, &t, ls, TEXT, HAlign::Center, VAlign::Top); - } - if first > 0 { - ctx.vline(x0, plot.bottom(), top + lh - 2.0, AXIS, 0.75); - } - } - ctx.vline(plot.right(), plot.bottom(), top + lh - 2.0, AXIS, 0.75); - } - } - CatAxis::Numeric(s, f) => { - let ticks = s.ticks(); - let labels: Vec = ticks.iter().map(|v| f.format(*v, Some(s.step))).collect(); - let lw = labels.iter().map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max); - let slot = plot.w / ticks.len().max(1) as f32; - let k = (((lw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1); - for (v, t) in ticks.iter().zip(&labels).step_by(k) { - let xx = plot.x + s.frac(*v) * plot.w; - ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top); - } - if s.min <= 0.0 && s.max >= 0.0 { - let xx = plot.x + s.frac(0.0) * plot.w; - ctx.vline(xx, plot.y, plot.bottom(), AXIS, 0.75); - } - } - } - plot - } else { - let (labels, outer): (Vec, &[Vec]) = match cat { - CatAxis::Labels(l, o) => (l.to_vec(), o), - CatAxis::Numeric(..) => (vec![], &[]), - }; - let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.35); - // Outer levels stand in columns left of the labels, each as wide as its longest name. - let level_w: Vec = - outer.iter().map(|l| (l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.15)).collect(); - let levels_w: f32 = level_w.iter().sum(); - let right = ylabels.last().map(|(_, t)| ctx.tw(t, ls) / 2.0).unwrap_or(0.0).min(area.w * 0.2) + 2.0; - let plot = Bx { x: area.x + lw + levels_w, y: area.y + 2.0, w: area.w - lw - levels_w - right, h: area.h - lh - 6.0 }; - if !plot.ok() { - return plot; - } - let vw = maxw(ctx, &ylabels); - let slot = plot.w / ylabels.len().max(1) as f32; - let k = (((vw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1); - for (i, (v, t)) in ylabels.iter().enumerate() { - let xx = plot.x + y.scale.frac(*v) * plot.w; - if grid { - ctx.vline(xx, plot.y, plot.bottom(), GRID, 0.75); - } - if i % k == 0 { - ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top); - } - } - let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min }; - let x0 = plot.x + y.scale.frac(zero) * plot.w; - ctx.vline(x0, plot.y, plot.bottom(), AXIS, 0.75); - let n = labels.len().max(1); - let ch = plot.h / n as f32; - let k = ((lh / ch.max(1e-3)).ceil() as usize).clamp(1, n); - for (j, t) in labels.iter().enumerate().step_by(k) { - let t = ctx.fit(t, ls, lw - 6.0); - ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, HAlign::Right, VAlign::Middle); - } - // Outer levels: a group's name centred beside its categories, separators at the - // boundaries running right to the axis. - let mut x1 = plot.x - lw; - for (level, w) in outer.iter().zip(&level_w) { - let x0 = x1 - w; - for (first, end) in level_groups(level, n) { - let (y0, y1) = (plot.bottom() - end as f32 * ch, plot.bottom() - first as f32 * ch); - if let Some(t) = level.get(first).filter(|t| !t.is_empty()) { - let t = ctx.fit(t, ls, w - 4.0); - ctx.text((x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, ls, TEXT, HAlign::Center, VAlign::Middle); - } - if first > 0 { - ctx.hline(x0, plot.x, y1, AXIS, 0.75); - } - } - ctx.hline(x0, plot.x, plot.y, AXIS, 0.75); - x1 = x0; - } - plot - } -} - -// ---------------------------------------------------------------- helpers - -/// Reduces a long polyline to first/min/max/last per pixel column. -fn decimate(pts: Vec<[f32; 2]>, max: usize) -> Vec<[f32; 2]> { - if pts.len() <= max { - return pts; - } - let mut out = Vec::with_capacity(max * 2); - let mut i = 0; - while let Some(p) = pts.get(i) { - let col = p[0].floor(); - let (start, mut mn, mut mx) = (i, i, i); - let mut k = i + 1; - while let Some(q) = pts.get(k) { - if q[0].floor() != col { - break; - } - if pts.get(mn).is_some_and(|m| q[1] < m[1]) { - mn = k; - } - if pts.get(mx).is_some_and(|m| q[1] > m[1]) { - mx = k; - } - k += 1; - } - let mut idx = [start, mn, mx, k - 1]; - idx.sort_unstable(); - let mut last = usize::MAX; - for ix in idx { - if ix != last - && let Some(q) = pts.get(ix) - { - out.push(*q); - last = ix; - } - } - i = k; - } - if out.len() > max * 4 { - let step = out.len() / (max * 2) + 1; - let last = out.last().copied(); - let mut s: Vec<[f32; 2]> = out.into_iter().step_by(step).collect(); - if let Some(l) = last - && s.last() != Some(&l) - { - s.push(l); - } - return s; - } - out -} - -/// Draws a polyline split at gaps (`None`), decimated when long. With `smooth` (the plot box), -/// each run is interpolated with a Catmull-Rom spline so the line curves through the data points. -fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32, smooth: Option) { - let mut run: Vec<[f32; 2]> = Vec::new(); - for p in pts.iter().chain(std::iter::once(&None)) { - match p { - Some(p) => run.push(*p), - None => { - if run.len() >= 2 { - let r = decimate(std::mem::take(&mut run), MAX_LINE_PTS); - let r = match smooth { - Some(plot) => smooth_run(r, plot), - None => r, - }; - ctx.line(r, color, width); - } else { - run.clear(); - } - } - } - } -} - -/// Catmull-Rom spline through `pts`: every segment gets the same number of sub-segments (up to -/// 100), chosen so the whole curve stays within `MAX_LINE_PTS * 2` points. Endpoints are -/// duplicated so the curve passes through the first and last points, and the curve is clamped -/// to the `plot` box (a spline overshoots its data near sharp turns). -fn smooth_run(pts: Vec<[f32; 2]>, plot: Bx) -> Vec<[f32; 2]> { - let n = pts.len(); - if n < 3 { - return pts; - } - let cap = MAX_LINE_PTS * 2; - let steps = (cap / n).clamp(1, 100); - let mut out: Vec<[f32; 2]> = Vec::with_capacity(n.saturating_mul(steps).min(cap) + 1); - let at = |i: isize| -> [f32; 2] { - let j = i.clamp(0, n as isize - 1) as usize; - pts.get(j).copied().unwrap_or([0.0, 0.0]) - }; - let (x0, x1) = (plot.x.min(plot.right()), plot.x.max(plot.right())); - let (y0, y1) = (plot.y.min(plot.bottom()), plot.y.max(plot.bottom())); - let clip = |p: [f32; 2]| [p[0].clamp(x0, x1), p[1].clamp(y0, y1)]; - for i in 0..n.saturating_sub(1) { - let (p0, p1, p2, p3) = (at(i as isize - 1), at(i as isize), at(i as isize + 1), at(i as isize + 2)); - for s in 0..steps { - let t = s as f32 / steps as f32; - out.push(clip(catmull(p0, p1, p2, p3, t))); - } - } - out.push(clip(at(n as isize - 1))); - out -} - -/// One Catmull-Rom point between `p1` and `p2` (uniform parameterisation), tangent-scaled by 0.5. -fn catmull(p0: [f32; 2], p1: [f32; 2], p2: [f32; 2], p3: [f32; 2], t: f32) -> [f32; 2] { - let t2 = t * t; - let t3 = t2 * t; - let mut o = [0.0f32; 2]; - for k in 0..2 { - let (a0, a1, a2, a3) = (p0[k], p1[k], p2[k], p3[k]); - o[k] = 0.5 * ((2.0 * a1) + (-a0 + a2) * t + (2.0 * a0 - 5.0 * a1 + 4.0 * a2 - a3) * t2 + (-a0 + 3.0 * a1 - 3.0 * a2 + a3) * t3); - } - o -} - -fn draw_markers(ctx: &mut Ctx, pts: impl Iterator, r: f32, color: Rgba) { - let mut seen = HashSet::new(); - for p in pts { - if seen.len() >= MAX_MARKERS { - break; - } - if seen.insert(((p[0] * 0.5) as i32, (p[1] * 0.5) as i32)) { - ctx.out.push(Prim::Circle { cx: p[0], cy: p[1], r, fill: Some(color), stroke: Some((WHITE, 0.75)) }); - } - } -} - -fn value_range(it: impl Iterator) -> Option<(f64, f64)> { - it.fold(None, |acc, v| match acc { - None => Some((v, v)), - Some((a, b)) => Some((a.min(v), b.max(v))), - }) -} - -// ---------------------------------------------------------------- trendlines - -/// Samples along a regression trendline. -const TREND_STEPS: usize = 200; - -/// Whether series drawn as `k` show trendlines (Excel offers them on 2-D unstacked column, -/// bar, line, area, scatter and bubble charts). -pub fn supports_trendline(k: ChartKind) -> bool { - use ChartKind::*; - matches!(k, ColumnClustered | BarClustered | Line | LineMarkers | Area | Scatter | ScatterLines | Bubble) -} - -/// A forecast distance as drawn: finite, non-negative and bounded. -fn forecast(v: f64) -> f64 { - if v.is_finite() && v > 0.0 { v.min(1e9) } else { 0.0 } -} - -/// Most empty category slots a forward forecast may add after `ncat` categories: four times the -/// data (at least 32), and never past `MAX_CATS` in total. A larger forecast would shrink every -/// column to nothing. -fn max_forward_slots(ncat: usize) -> f64 { - (ncat.saturating_mul(4).max(32)).min(MAX_CATS.saturating_sub(ncat)) as f64 -} - -/// Longest forward forecast among `s`'s regression trendlines. -fn max_forward(s: &SeriesData) -> f64 { - s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.forward)).fold(0.0, f64::max) -} - -/// Longest backward forecast among `s`'s regression trendlines. -fn max_backward(s: &SeriesData) -> f64 { - s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.backward)).fold(0.0, f64::max) -} - -/// Liang–Barsky: the part of segment `a`–`b` inside the unit square, with whether the start and -/// end were cut. -#[allow(clippy::type_complexity)] -fn clip_segment(a: (f64, f64), b: (f64, f64)) -> Option<((f64, f64), (f64, f64), bool, bool)> { - let (dx, dy) = (b.0 - a.0, b.1 - a.1); - let (mut t0, mut t1) = (0.0f64, 1.0f64); - for (p, q) in [(-dx, a.0), (dx, 1.0 - a.0), (-dy, a.1), (dy, 1.0 - a.1)] { - if p == 0.0 { - if q < 0.0 { - return None; - } - } else { - let r = q / p; - if p < 0.0 { - if r > t1 { - return None; - } - t0 = t0.max(r); - } else { - if r < t0 { - return None; - } - t1 = t1.min(r); - } - } - } - Some(((a.0 + t0 * dx, a.1 + t0 * dy), (a.0 + t1 * dx, a.1 + t1 * dy), t0 > 0.0, t1 < 1.0)) -} - -/// Clips a sampled polyline in plot fractions (`None` = gap) to the plot box (the unit -/// square), returning the visible runs. -fn clip_unit(pts: &[Option<(f64, f64)>]) -> Vec> { - const LIM: f64 = 1e9; - let ok = |p: &(f64, f64)| p.0.is_finite() && p.1.is_finite(); - let cl = |p: (f64, f64)| (p.0.clamp(-LIM, LIM), p.1.clamp(-LIM, LIM)); - let mut runs: Vec> = Vec::new(); - let mut run: Vec<(f64, f64)> = Vec::new(); - for w in pts.windows(2) { - let seg = match w { - [Some(a), Some(b)] if ok(a) && ok(b) => clip_segment(cl(*a), cl(*b)), - _ => None, - }; - match seg { - Some((p, q, cut_start, cut_end)) => { - if cut_start || run.is_empty() { - if run.len() >= 2 { - runs.push(std::mem::take(&mut run)); - } - run.clear(); - run.push(p); - } - run.push(q); - if cut_end { - runs.push(std::mem::take(&mut run)); - } - } - None => { - if run.len() >= 2 { - runs.push(std::mem::take(&mut run)); - } - run.clear(); - } - } - } - if run.len() >= 2 { - runs.push(run); - } - runs -} - -/// Draws `s`'s trendlines fitted to its plotted points `pts` (data x, y). `frac` maps data to -/// fractions of the plot box (0..1 inside, along the x and y axes), `px` fractions to pixels. -/// Equation and R² label lines are appended to `labels`. Degenerate fits draw nothing. -fn draw_trendlines( - ctx: &mut Ctx, - s: &SeriesData, - pts: &[(f64, f64)], - frac: &dyn Fn(f64, f64) -> (f64, f64), - px: &dyn Fn((f64, f64)) -> [f32; 2], - labels: &mut Vec, - window: (f64, f64), -) { - let Some((xlo, xhi)) = value_range(pts.iter().map(|p| p.0)) else { return }; - for t in s.trendlines.iter().take(MAX_TRENDLINES) { - let spec = &t.spec; - let samples: Vec> = match spec.kind { - TrendlineKind::MovingAverage(p) => moving_average(pts, p).into_iter().map(|(x, y)| Some(frac(x, y))).collect(), - _ => { - let Some(fit) = fit_trendline(spec, pts) else { continue }; - // Sample only the part of the forecast that is on the plot. - let (lo, hi) = ((xlo - forecast(spec.backward)).max(window.0), (xhi + forecast(spec.forward)).min(window.1)); - if spec.display_equation { - labels.push(fit.equation()); - } - if spec.display_r_squared { - labels.push(fit.r_squared()); - } - (0..=TREND_STEPS) - .map(|i| { - let x = lo + (hi - lo) * i as f64 / TREND_STEPS as f64; - fit.eval(x).map(|y| frac(x, y)) - }) - .collect() - } - }; - let width = spec.width.filter(|w| w.is_finite()).unwrap_or(1.5).clamp(0.25, 12.0); - let dash = spec.dash != Some(LineDash::Solid); - for run in clip_unit(&samples) { - let line = decimate(run.into_iter().map(px).collect(), MAX_LINE_PTS); - if line.len() >= 2 { - ctx.out.push(Prim::Line { pts: line, color: t.color, width, dash }); - } - } - } -} - -/// Trendline equation / R² labels, stacked in the plot's top-right corner. -fn draw_trend_labels(ctx: &mut Ctx, plot: Bx, labels: &[String]) { - let lh = LABEL_SIZE * 1.3; - for (i, t) in labels.iter().enumerate() { - let y = plot.y + 4.0 + i as f32 * lh; - if y + lh > plot.bottom() { - break; - } - let t = ctx.fit(t, LABEL_SIZE, plot.w - 8.0); - ctx.text(plot.right() - 4.0, y, &t, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Top); - } -} - -// ---------------------------------------------------------------- cartesian (column/bar/line/area/combo) - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Fam { - Col, - ColStack, - Col100, - Line, - LineStack, - Area, - AreaStack, - Area100, -} - -fn family(k: ChartKind) -> Fam { - use ChartKind::*; - match k { - ColumnStacked | BarStacked => Fam::ColStack, - ColumnStacked100 | BarStacked100 => Fam::Col100, - Line | LineMarkers | Scatter | ScatterLines | Radar | Stock => Fam::Line, - LineStacked => Fam::LineStack, - Area => Fam::Area, - AreaStacked => Fam::AreaStack, - AreaStacked100 => Fam::Area100, - _ => Fam::Col, - } -} - -/// Families stacked to 100%: each category's values are shares of its total. -fn is_100(f: Fam) -> bool { - matches!(f, Fam::Col100 | Fam::Area100) -} - -fn is_col(f: Fam) -> bool { - matches!(f, Fam::Col | Fam::ColStack | Fam::Col100) -} -fn is_line(f: Fam) -> bool { - matches!(f, Fam::Line | Fam::LineStack) -} - -fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizontal: bool) { - let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS); - if ncat == 0 { - return; - } - let kinds: Vec = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).collect(); - let fams: Vec = kinds.iter().map(|k| family(*k)).collect(); - let any_primary = data.series.iter().any(|s| !s.secondary); - let sec: Vec = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect(); - - // Totals for 100% stacking, per axis and family (100% columns and 100% areas stack apart). - let mut totals: Vec<((bool, Fam), Vec)> = Vec::new(); - for (i, s) in data.series.iter().enumerate() { - let fam = fams.get(i).copied().unwrap_or(Fam::Col); - if is_100(fam) { - let key = (sec.get(i).copied().unwrap_or(false), fam); - let p = match totals.iter().position(|(k, _)| *k == key) { - Some(p) => p, - None => { - totals.push((key, vec![0.0; ncat])); - totals.len() - 1 - } - }; - for (j, v) in s.values.iter().take(ncat).enumerate() { - if let (Some(v), Some(t)) = (v, totals.get_mut(p).and_then(|(_, t)| t.get_mut(j))) { - *t += v.abs(); - } - } - } - } - // A value's share of its category's total, for the 100% families. - let share = |key: (bool, Fam), j: usize, v: f64| { - let t = totals.iter().find(|(k, _)| *k == key).and_then(|(_, t)| t.get(j)).copied().unwrap_or(0.0); - if t > 0.0 { v / t } else { 0.0 } - }; - // Stack accumulators keyed by (axis, family): positive and negative running sums. - let mut stacks: Vec<((bool, Fam), Vec, Vec)> = Vec::new(); - let mut spans: Vec>> = Vec::with_capacity(data.series.len()); - for (i, s) in data.series.iter().enumerate() { - let fam = fams.get(i).copied().unwrap_or(Fam::Col); - let a = sec.get(i).copied().unwrap_or(false); - let stacked = matches!(fam, Fam::ColStack | Fam::Col100 | Fam::LineStack | Fam::AreaStack | Fam::Area100); - let key = (a, fam); - let si = if stacked { - match stacks.iter().position(|(k, _, _)| *k == key) { - Some(p) => Some(p), - None => { - stacks.push((key, vec![0.0; ncat], vec![0.0; ncat])); - Some(stacks.len() - 1) - } - } - } else { - None - }; - let mut row = Vec::with_capacity(ncat); - for j in 0..ncat { - let v = s.values.get(j).copied().flatten(); - let span = match (si.and_then(|p| stacks.get_mut(p)), fam) { - (Some((_, pos, neg)), Fam::ColStack | Fam::Col100) => v.and_then(|v| { - let v = if fam == Fam::Col100 { share(key, j, v) } else { v }; - let acc = if v >= 0.0 { pos.get_mut(j) } else { neg.get_mut(j) }?; - let base = *acc; - *acc += v; - Some((base, *acc)) - }), - (Some((_, pos, _)), _) => { - let v = v.unwrap_or(0.0); - let v = if fam == Fam::Area100 { share(key, j, v) } else { v }; - pos.get_mut(j).map(|acc| { - let base = *acc; - *acc = clean(*acc + v); - (base, *acc) - }) - } - (None, _) => v.map(|v| (0.0, v)), - }; - row.push(span); - } - spans.push(row); - } - - // Axis scales. - let mut axes: Vec<(Scale, Fmt)> = Vec::new(); - for axis in [false, true] { - let members: Vec = (0..data.series.len()).filter(|i| sec.get(*i).copied().unwrap_or(false) == axis).collect(); - if members.is_empty() { - continue; - } - let force_zero = members.iter().any(|i| fams.get(*i).is_some_and(|f| !is_line(*f))); - let all100 = members.iter().all(|i| fams.get(*i).is_some_and(|f| is_100(*f))); - let range = value_range(members.iter().flat_map(|i| { - let line = fams.get(*i).is_some_and(|f| is_line(*f)); - spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| if line { vec![*t] } else { vec![*b, *t] }) - })); - let (lo, hi) = range.unwrap_or((0.0, 0.0)); - let (scale, fmt) = if all100 { - (fixed_scale(if lo < -1e-9 { -1.0 } else { 0.0 }, if hi > 1e-9 || lo >= -1e-9 { 1.0 } else { 0.0 }, 0.2), Fmt::new("0%")) - } else { - let code = members.first().and_then(|i| data.series.get(*i)).map(|s| s.number_format.as_str()).unwrap_or("General"); - (nice_scale(lo, hi, force_zero), Fmt::new(code)) - }; - axes.push((scale, fmt)); - if !axis && sec.iter().all(|s| !s) { - break; - } - } - let Some((s1, f1)) = axes.first() else { return }; - let y1 = ValAxis { scale: *s1, fmt: f1 }; - let y2 = axes.get(1).map(|(s, f)| ValAxis { scale: *s, fmt: f }); - // A forward forecast on a category axis adds empty category slots after the data. - let fwd = data - .series - .iter() - .enumerate() - .filter(|(i, _)| kinds.get(*i).is_some_and(|k| supports_trendline(*k))) - .map(|(_, s)| max_forward(s)) - .fold(0.0, f64::max); - let ncat_slots = ncat.saturating_add(fwd.min(max_forward_slots(ncat)).ceil() as usize).min(MAX_CATS.max(ncat)); - let labels: Vec = (0..ncat_slots).map(|j| if j < ncat { cat_label(data, j) } else { String::new() }).collect(); - // Outer levels padded to the slots (forecast slots stay in the last group). - let outer: Vec> = - data.category_levels.iter().map(|l| (0..ncat_slots).map(|j| l.get(j).cloned().unwrap_or_default()).collect()).collect(); - let plot = frame(ctx, area, CatAxis::Labels(&labels, &outer), &y1, y2.as_ref(), horizontal, chart.gridlines); - if !plot.ok() { - return; - } - let scale_of = |i: usize| -> Scale { if sec.get(i).copied().unwrap_or(false) { y2.as_ref().map(|a| a.scale).unwrap_or(*s1) } else { *s1 } }; - let cat_len = if horizontal { plot.h } else { plot.w }; - let cw = cat_len / ncat_slots as f32; - let center = |j: usize| if horizontal { plot.bottom() - (j as f32 + 0.5) * cw } else { plot.x + (j as f32 + 0.5) * cw }; - let vpos = |v: f64, sc: &Scale| if horizontal { plot.x + sc.frac(v) * plot.w } else { plot.bottom() - sc.frac(v) * plot.h }; - - // Column slots: each clustered series gets one, each stack group shares one. - let mut slot_keys: Vec<(bool, Fam, usize)> = Vec::new(); - let mut slot_of = vec![0usize; data.series.len()]; - for (i, f) in fams.iter().enumerate() { - if !is_col(*f) { - continue; - } - let a = sec.get(i).copied().unwrap_or(false); - let key = if *f == Fam::Col { (a, *f, i) } else { (a, *f, usize::MAX) }; - let k = match slot_keys.iter().position(|k| *k == key) { - Some(p) => p, - None => { - slot_keys.push(key); - slot_keys.len() - 1 - } - }; - if let Some(s) = slot_of.get_mut(i) { - *s = k; - } - } - let nslots = slot_keys.len().max(1) as f32; - let bw = cw / (nslots + 1.5); - let labels_on = chart.data_labels && ncat <= MAX_LABELS; - // Area labels wait until every area is filled, so a later (stacked) area can't paint over them. - let mut area_labels = Vec::new(); - - // Areas first, then columns, then lines on top. - for pass in 0..3 { - for (i, s) in data.series.iter().enumerate() { - let fam = fams.get(i).copied().unwrap_or(Fam::Col); - let want = match pass { - 0 => matches!(fam, Fam::Area | Fam::AreaStack | Fam::Area100), - 1 => is_col(fam), - _ => is_line(fam), - }; - if !want { - continue; - } - let sc = scale_of(i); - let fmt = Fmt::new(&s.number_format); - let row = match spans.get(i) { - Some(r) => r, - None => continue, - }; - match pass { - 0 => { - let mut top = Vec::with_capacity(ncat * 2); - let mut base = Vec::with_capacity(ncat); - for (j, sp) in row.iter().enumerate() { - let (b, t) = sp.unwrap_or((0.0, 0.0)); - let c = center(j); - top.push([c, vpos(t, &sc)]); - base.push([c, vpos(b, &sc)]); - // Source values (not stack totals), just above the top edge like the line labels. - if labels_on - && sp.is_some() - && let Some(v) = s.values.get(j).copied().flatten() - { - area_labels.push((c, vpos(t, &sc) - 5.0, fmt.format(v, None))); - } - } - if top.len() == 1 { - // A single point: give it some width. - if let (Some(t), Some(b)) = (top.first().copied(), base.first().copied()) { - top = vec![[t[0] - cw * 0.3, t[1]], [t[0] + cw * 0.3, t[1]]]; - base = vec![[b[0] - cw * 0.3, b[1]], [b[0] + cw * 0.3, b[1]]]; - } - } - let top = decimate(top, MAX_LINE_PTS * 2); - let base = decimate(base, MAX_LINE_PTS * 2); - let mut poly = top; - poly.extend(base.into_iter().rev()); - if poly.len() >= 3 { - ctx.out.push(Prim::Polygon { pts: poly, fill: s.color, stroke: None }); - } - } - 1 => { - let k = slot_of.get(i).copied().unwrap_or(0) as f32; - for (j, sp) in row.iter().enumerate() { - let Some((b, t)) = sp else { continue }; - let (pb, pt) = (vpos(*b, &sc), vpos(*t, &sc)); - let c = center(j); - let lo = pb.min(pt); - let len = (pb - pt).abs(); - if horizontal { - let y = c + nslots * bw / 2.0 - (k + 1.0) * bw; - ctx.rect(lo, y, len, bw, s.color, None); - } else { - let x = c - nslots * bw / 2.0 + k * bw; - ctx.rect(x, lo, bw, len, s.color, None); - } - if labels_on && let Some(v) = s.values.get(j).copied().flatten() { - let t = fmt.format(v, None); - let stacked = fam != Fam::Col; - let mid = (pb + pt) / 2.0; - if horizontal { - let y = c + nslots * bw / 2.0 - (k + 0.5) * bw; - if stacked { - ctx.text(mid, y, &t, LABEL_SIZE, on(s.color), HAlign::Center, VAlign::Middle); - } else if v >= 0.0 { - ctx.text(pb.max(pt) + 3.0, y, &t, LABEL_SIZE, TEXT, HAlign::Left, VAlign::Middle); - } else { - ctx.text(lo - 3.0, y, &t, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle); - } - } else { - let x = c - nslots * bw / 2.0 + (k + 0.5) * bw; - if stacked { - ctx.text(x, mid, &t, LABEL_SIZE, on(s.color), HAlign::Center, VAlign::Middle); - } else if v >= 0.0 { - ctx.text(x, lo - 2.0, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); - } else { - ctx.text(x, lo + len + 2.0, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Top); - } - } - } - } - } - _ => { - let pts: Vec> = row.iter().enumerate().map(|(j, sp)| sp.map(|(_, t)| [center(j), vpos(t, &sc)])).collect(); - draw_runs(ctx, &pts, s.color, 2.25, s.smooth.then_some(plot)); - let k = kinds.get(i).copied().unwrap_or(ChartKind::Line); - if k == ChartKind::LineMarkers && cw >= 3.0 { - draw_markers(ctx, pts.iter().flatten().copied(), 3.5, s.color); - } - if labels_on { - for (j, p) in pts.iter().enumerate() { - if let (Some(p), Some(v)) = (p, s.values.get(j).copied().flatten()) { - ctx.text(p[0], p[1] - 5.0, &fmt.format(v, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); - } - } - } - } - } - } - for (x, y, t) in area_labels.drain(..) { - ctx.text(x, y, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); - } - } - // Trendlines on top: x is the category number 1..n. - let mut trend_labels = Vec::new(); - for (i, s) in data.series.iter().enumerate() { - if s.trendlines.is_empty() || !kinds.get(i).is_some_and(|k| supports_trendline(*k)) { - continue; - } - let sc = scale_of(i); - let pts: Vec<(f64, f64)> = s.values.iter().take(ncat).enumerate().filter_map(|(j, v)| v.map(|v| ((j + 1) as f64, v))).collect(); - let frac = |x: f64, y: f64| ((x - 0.5) / ncat_slots as f64, (y - sc.min) / (sc.max - sc.min)); - let px = |(fx, fy): (f64, f64)| { - if horizontal { - [plot.x + fy as f32 * plot.w, plot.bottom() - fx as f32 * plot.h] - } else { - [plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h] - } - }; - draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (0.5, ncat_slots as f64 + 0.5)); - } - draw_trend_labels(ctx, plot, &trend_labels); -} - -// ---------------------------------------------------------------- scatter & bubble - -fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { - let pts_of = |s: &SeriesData| -> Vec> { - s.values - .iter() - .enumerate() - .map(|(j, v)| { - let x = match &s.x { - Some(xs) => xs.get(j).copied().flatten(), - None => Some((j + 1) as f64), - }; - match (x, v) { - (Some(x), Some(y)) => Some((x, *y)), - _ => None, - } - }) - .collect() - }; - let all: Vec>> = data.series.iter().map(pts_of).collect(); - let Some((mut xlo, mut xhi)) = value_range(all.iter().flatten().flatten().map(|p| p.0)) else { return }; - // Forecasts extend the x axis to the trendline's ends. - for (s, pts) in data.series.iter().zip(&all) { - if let Some((lo, hi)) = value_range(pts.iter().flatten().map(|p| p.0)).filter(|_| !s.trendlines.is_empty()) { - xlo = xlo.min(lo - max_backward(s)); - xhi = xhi.max(hi + max_forward(s)); - } - } - let Some((ylo, yhi)) = value_range(all.iter().flatten().flatten().map(|p| p.1)) else { return }; - let xs = nice_scale(xlo, xhi, false); - let ys = nice_scale(ylo, yhi, false); - let fx = Fmt::new("General"); - let fy = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); - let plot = frame(ctx, area, CatAxis::Numeric(xs, &fx), &ValAxis { scale: ys, fmt: &fy }, None, false, chart.gridlines); - if !plot.ok() { - return; - } - let map = |(x, y): (f64, f64)| [plot.x + xs.frac(x) * plot.w, plot.bottom() - ys.frac(y) * plot.h]; - let bubble = chart.kind == ChartKind::Bubble; - let rmax = plot.w.min(plot.h) * 0.12; - for (s, pts) in data.series.iter().zip(&all) { - let px: Vec> = pts.iter().map(|p| p.map(map)).collect(); - if bubble { - let sizes = s.sizes.as_deref().unwrap_or(&[]); - let smax = sizes.iter().flatten().fold(0.0f64, |a, b| a.max(*b)); - let mut bubbles: Vec<([f32; 2], f64)> = px - .iter() - .enumerate() - .filter_map(|(j, p)| { - let sz = if sizes.is_empty() { Some(1.0) } else { sizes.get(j).copied().flatten() }; - match (p, sz) { - (Some(p), Some(sz)) if sz > 0.0 => Some((*p, sz)), - _ => None, - } - }) - .take(MAX_MARKERS) - .collect(); - bubbles.sort_by(|a, b| b.1.total_cmp(&a.1)); - let smax = if sizes.is_empty() { 1.0 } else { smax }; - let fill = [s.color[0], s.color[1], s.color[2], 0xC0]; - for (p, sz) in bubbles { - let r = ((sz / smax).sqrt() as f32 * rmax).max(1.0); - ctx.out.push(Prim::Circle { cx: p[0], cy: p[1], r, fill: Some(fill), stroke: Some((WHITE, 0.75)) }); - } - } else { - let lines = chart.kind == ChartKind::ScatterLines || s.kind == ChartKind::ScatterLines; - if lines { - draw_runs(ctx, &px, s.color, 2.0, s.smooth.then_some(plot)); - } - if !lines || px.len() <= 500 { - draw_markers(ctx, px.iter().flatten().copied(), 3.5, s.color); - } - } - if chart.data_labels && pts.len() <= MAX_LABELS { - for (p, raw) in px.iter().zip(pts) { - if let (Some(p), Some((_, y))) = (p, raw) { - ctx.text(p[0], p[1] - 6.0, &fy.format(*y, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); - } - } - } - } - let mut trend_labels = Vec::new(); - for (s, pts) in data.series.iter().zip(&all) { - if s.trendlines.is_empty() { - continue; - } - let pts: Vec<(f64, f64)> = pts.iter().flatten().copied().collect(); - let frac = |x: f64, y: f64| ((x - xs.min) / (xs.max - xs.min), (y - ys.min) / (ys.max - ys.min)); - let px = |(fx, fy): (f64, f64)| [plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h]; - draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (xs.min, xs.max)); - } - draw_trend_labels(ctx, plot, &trend_labels); -} - -// ---------------------------------------------------------------- histogram - -fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { - let Some(s) = data.series.first() else { return }; - let vals: Vec = s.values.iter().flatten().copied().collect(); - let Some((lo, hi)) = value_range(vals.iter().copied()) else { return }; - let n = vals.len(); - let span = hi - lo; - let mut bins = ((n as f64).sqrt().ceil() as usize).clamp(1, 100); - if !(span.is_finite() && span > 0.0) { - bins = 1; - } - let width = if bins == 1 && span.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) { 1.0 } else { span / bins as f64 }; - // Upper edge of each bin, exactly as shown in the labels below. Bins are right-inclusive (the first is closed on both sides), so a value - // equal to an edge belongs to the bin that ends there. - let edges: Vec = (0..bins).map(|b| if b + 1 == bins { hi.max(lo + b as f64 * width) } else { lo + (b + 1) as f64 * width }).collect(); - let mut counts = vec![0usize; bins]; - for v in &vals { - let b = edges.partition_point(|e| e < v).min(bins - 1); - if let Some(c) = counts.get_mut(b) { - *c += 1; - } - } - let f = Fmt::new(&s.number_format); - let labels: Vec = (0..bins) - .map(|b| { - let a = lo + b as f64 * width; - let e = edges.get(b).copied().unwrap_or(a); - let open = if b == 0 { '[' } else { '(' }; - format!("{open}{}, {}]", f.format(a, Some(width / 10.0)), f.format(e, Some(width / 10.0))) - }) - .collect(); - let maxc = counts.iter().copied().max().unwrap_or(0) as f64; - let ys = nice_scale(0.0, maxc, true); - let fc = Fmt::new("General"); - let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &fc }, None, false, chart.gridlines); - if !plot.ok() { - return; - } - let cw = plot.w / bins as f32; - for (b, c) in counts.iter().enumerate() { - let top = plot.bottom() - ys.frac(*c as f64) * plot.h; - let x = plot.x + b as f32 * cw; - ctx.rect(x, top, cw, plot.bottom() - top, s.color, Some((WHITE, 0.75))); - if chart.data_labels { - ctx.text(x + cw / 2.0, top - 2.0, &c.to_string(), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); - } - } -} - -// ---------------------------------------------------------------- waterfall - -fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { - let Some(s) = data.series.first() else { return }; - let mut ncat = data.categories.len().max(s.values.len()).min(MAX_CATS); - if ncat == 0 { - return; - } - let mut labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); - let mut vals: Vec = (0..ncat).map(|j| s.values.get(j).copied().flatten().unwrap_or(0.0)).collect(); - let mut is_total = vec![false; ncat]; - let last_total = labels.last().is_some_and(|l| l.to_lowercase().contains("total")); - if let Some(t) = is_total.last_mut() { - *t = last_total; - } - if !last_total { - labels.push("Total".into()); - vals.push(0.0); - is_total.push(true); - ncat += 1; - } - let mut bars = Vec::with_capacity(ncat); - let mut cum = 0.0f64; - for (j, v) in vals.iter().enumerate() { - if is_total.get(j).copied().unwrap_or(false) { - let total = if last_total && j + 1 == ncat && *v != 0.0 { *v } else { cum }; - bars.push((0.0, total, 0u8)); - cum = total; - } else { - let top = clean(cum + v); - bars.push((cum, top, if *v >= 0.0 { 1 } else { 2 })); - cum = top; - } - } - let (lo, hi) = value_range(bars.iter().flat_map(|(b, t, _)| [*b, *t])).unwrap_or((0.0, 0.0)); - let ys = nice_scale(lo, hi, true); - let f = Fmt::new(&s.number_format); - let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); - if !plot.ok() { - return; - } - let cw = plot.w / ncat as f32; - let bw = cw / 1.5; - let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h; - for (j, (b, t, kind)) in bars.iter().enumerate() { - let color = match kind { - 1 => UP, - 2 => DOWN, - _ => s.color, - }; - let x = plot.x + (j as f32 + 0.5) * cw - bw / 2.0; - let (yb, yt) = (vy(*b), vy(*t)); - ctx.rect(x, yb.min(yt), bw, (yb - yt).abs().max(0.5), color, None); - if j + 1 < bars.len() && ncat <= 500 { - ctx.out.push(Prim::Line { pts: vec![[x + bw, yt], [x + cw, yt]], color: AXIS, width: 0.75, dash: true }); - } - if chart.data_labels && ncat <= MAX_LABELS { - let v = if *kind == 0 { *t } else { t - b }; - ctx.text(x + bw / 2.0, yb.min(yt) - 2.0, &f.format(v, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); - } - } -} - -// ---------------------------------------------------------------- funnel - -fn funnel(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { - let Some(s) = data.series.first() else { return }; - let n = data.categories.len().max(s.values.len()).min(MAX_CATS); - if n == 0 { - return; - } - let vals: Vec = (0..n).map(|j| s.values.get(j).copied().flatten().unwrap_or(0.0).abs()).collect(); - let max = vals.iter().copied().fold(0.0f64, f64::max); - if max.is_nan() || max <= 0.0 { - return; - } - let labels: Vec = (0..n).map(|j| cat_label(data, j)).collect(); - let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, LABEL_SIZE)).fold(0.0f32, f32::max) + 8.0).min(area.w * 0.35); - let plot = Bx { x: area.x + lw, w: area.w - lw, ..area }; - if !plot.ok() { - return; - } - let slot = plot.h / n as f32; - let bh = slot * 0.85; - let f = Fmt::new(&s.number_format); - let show = slot >= LABEL_SIZE * 0.9; - for (j, v) in vals.iter().enumerate() { - let bw = (*v / max) as f32 * plot.w; - let x = plot.x + (plot.w - bw) / 2.0; - let y = plot.y + j as f32 * slot + (slot - bh) / 2.0; - if bw > 0.0 { - ctx.rect(x, y, bw, bh, s.color, None); - } - if show { - let yc = y + bh / 2.0; - if chart.data_labels { - let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None); - let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT }; - ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, HAlign::Center, VAlign::Middle); - } - let l = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw - 8.0); - ctx.text(plot.x - 6.0, yc, &l, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle); - } - } -} - -// ---------------------------------------------------------------- pie, doughnut, sunburst - -fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inner: f32, names: bool) { - let Some(s) = data.series.first() else { return }; - let vals: Vec = s.values.iter().take(MAX_CATS).map(|v| v.map(f64::abs).unwrap_or(0.0)).collect(); - let total: f64 = vals.iter().sum(); - if !(total.is_finite() && total > 0.0) { - return; - } - let (cx, cy) = (area.cx(), area.cy()); - let r = area.w.min(area.h) / 2.0 * 0.92; - if r < 2.0 { - return; - } - let ri = r * inner; - let f = Fmt::new(&s.number_format); - let mut a = 0.0f32; - let mut labels = Vec::new(); - for (j, v) in vals.iter().enumerate() { - let sweep = (*v / total) as f32 * TAU; - if sweep > 1e-4 { - let fill = point_color(data, j); - ctx.out.push(Prim::Wedge { cx, cy, r_outer: r, r_inner: ri, a0: a, a1: (a + sweep).min(TAU), fill, stroke: Some((WHITE, 1.5)) }); - if (chart.data_labels || names) && vals.len() <= MAX_LABELS { - let t = if names { cat_label(data, j) } else { f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None) }; - let mid = a + sweep / 2.0; - let rr = if ri > 0.0 { (r + ri) / 2.0 } else { r * 0.65 }; - let room = sweep * rr; - if ctx.tw(&t, LABEL_SIZE) <= room.max(r - ri) && room >= LABEL_SIZE { - labels.push((cx + rr * mid.sin(), cy - rr * mid.cos(), t, on(fill))); - } - } - } - a += sweep; - } - for (x, y, t, c) in labels { - ctx.text(x, y, &t, LABEL_SIZE, c, HAlign::Center, VAlign::Middle); - } -} - -// ---------------------------------------------------------------- treemap - -fn worst(row: &[f64], side: f64) -> f64 { - let s: f64 = row.iter().sum(); - if s <= 0.0 || side <= 0.0 { - return f64::INFINITY; - } - let (mn, mx) = row.iter().fold((f64::INFINITY, 0.0f64), |(a, b), v| (a.min(*v), b.max(*v))); - let s2 = s * s; - let w2 = side * side; - (w2 * mx / s2).max(s2 / (w2 * mn)) -} - -/// Squarified treemap layout of (index, area) items, sorted descending, into `r`. -fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> { - let total: f64 = items.iter().map(|i| i.1).sum(); - let area = (r.w as f64) * (r.h as f64); - if !(total > 0.0 && area > 0.0) { - return vec![]; - } - let scale = area / total; - let mut out = Vec::with_capacity(items.len()); - let mut rem = r; - let mut row: Vec<(usize, f64)> = Vec::new(); - let mut i = 0; - let layout = |row: &[(usize, f64)], rem: &mut Bx, out: &mut Vec<(usize, Bx)>| { - let s: f64 = row.iter().map(|x| x.1).sum(); - if s <= 0.0 { - return; - } - if rem.w >= rem.h { - let cw = (s / rem.h.max(1e-6) as f64) as f32; - let mut y = rem.y; - for (j, a) in row { - let h = (*a / cw.max(1e-6) as f64) as f32; - out.push((*j, Bx { x: rem.x, y, w: cw, h })); - y += h; - } - rem.x += cw; - rem.w = (rem.w - cw).max(0.0); - } else { - let rh = (s / rem.w.max(1e-6) as f64) as f32; - let mut x = rem.x; - for (j, a) in row { - let w = (*a / rh.max(1e-6) as f64) as f32; - out.push((*j, Bx { x, y: rem.y, w, h: rh })); - x += w; - } - rem.y += rh; - rem.h = (rem.h - rh).max(0.0); - } - }; - while let Some(&(j, v)) = items.get(i) { - let a = v * scale; - let side = rem.w.min(rem.h) as f64; - let cur: Vec = row.iter().map(|x| x.1).collect(); - let mut with = cur.clone(); - with.push(a); - if row.is_empty() || worst(&with, side) <= worst(&cur, side) { - row.push((j, a)); - i += 1; - } else { - layout(&row, &mut rem, &mut out); - row.clear(); - } - } - if !row.is_empty() { - layout(&row, &mut rem, &mut out); - } - out -} - -fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { - let Some(s) = data.series.first() else { return }; - let mut items: Vec<(usize, f64)> = - s.values.iter().take(MAX_MARKERS).enumerate().filter_map(|(j, v)| v.filter(|v| *v > 0.0).map(|v| (j, v))).collect(); - items.sort_by(|a, b| b.1.total_cmp(&a.1)); - let f = Fmt::new(&s.number_format); - for (j, b) in squarify(&items, area) { - let fill = point_color(data, j); - ctx.rect(b.x, b.y, b.w, b.h, fill, Some((WHITE, 1.5))); - if b.w > 24.0 && b.h > LABEL_SIZE * 1.6 { - let name = ctx.fit(&cat_label(data, j), LABEL_SIZE, b.w - 8.0); - ctx.text(b.x + 4.0, b.y + 4.0, &name, LABEL_SIZE, on(fill), HAlign::Left, VAlign::Top); - if chart.data_labels && b.h > LABEL_SIZE * 3.2 { - let v = items.iter().find(|x| x.0 == j).map(|x| x.1).unwrap_or(0.0); - let t = ctx.fit(&f.format(v, None), LABEL_SIZE, b.w - 8.0); - ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), HAlign::Left, VAlign::Top); - } - } - } -} - -// ---------------------------------------------------------------- radar - -fn radar(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { - let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(1000); - if ncat == 0 { - return; - } - let labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); - let lw = labels.iter().map(|t| ctx.tw(t, LABEL_SIZE)).fold(0.0f32, f32::max).min(area.w * 0.2); - let r = ((area.w - 2.0 * (lw + 8.0)).min(area.h - 2.0 * (LABEL_SIZE * 1.4 + 6.0)) / 2.0).max(0.0); - if r < 5.0 { - return; - } - let (cx, cy) = (area.cx(), area.cy()); - let (lo, hi) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())).unwrap_or((0.0, 0.0)); - let sc = nice_scale(lo, hi, true); - let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); - let ang = |j: usize| j as f32 / ncat as f32 * TAU; - let pt = |a: f32, rr: f32| [cx + rr * a.sin(), cy - rr * a.cos()]; - let ticks = sc.ticks(); - let spacing = r / ticks.len().saturating_sub(1).max(1) as f32; - let every = ((LABEL_SIZE * 1.3 / spacing.max(1e-3)).ceil() as usize).max(1); - for (ti, t) in ticks.into_iter().enumerate() { - let rr = sc.frac(t) * r; - if rr > 0.5 && chart.gridlines { - let mut ring: Vec<[f32; 2]> = (0..ncat).map(|j| pt(ang(j), rr)).collect(); - if ncat < 3 { - ring = (0..=48).map(|k| pt(k as f32 / 48.0 * TAU, rr)).collect(); - } else if let Some(p) = ring.first().copied() { - ring.push(p); - } - ctx.line(ring, GRID, 0.75); - } - if ti % every == 0 { - ctx.text(cx - 3.0, cy - rr, &f.format(t, Some(sc.step)), LABEL_SIZE * 0.9, TEXT, HAlign::Right, VAlign::Middle); - } - } - let step = ncat.div_ceil(72).max(1); - for j in (0..ncat).step_by(step) { - let a = ang(j); - ctx.line(vec![[cx, cy], pt(a, r)], GRID, 0.75); - let p = pt(a, r + 6.0); - let (sa, ca) = (a.sin(), a.cos()); - let align = if sa > 0.1 { - HAlign::Left - } else if sa < -0.1 { - HAlign::Right - } else { - HAlign::Center - }; - let valign = if ca > 0.1 { - VAlign::Bottom - } else if ca < -0.1 { - VAlign::Top - } else { - VAlign::Middle - }; - let t = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw.max(20.0)); - ctx.text(p[0], p[1], &t, LABEL_SIZE, TEXT, align, valign); - } - for s in &data.series { - let mut pts: Vec<[f32; 2]> = (0..ncat).map(|j| pt(ang(j), sc.frac(s.values.get(j).copied().flatten().unwrap_or(sc.min)) * r)).collect(); - if let Some(p) = pts.first().copied() { - pts.push(p); - } - ctx.line(pts, s.color, 2.0); - } -} - -// ---------------------------------------------------------------- box & whisker - -fn quantile(sorted: &[f64], q: f64, method: QuartileMethod) -> f64 { - let n = sorted.len(); - if n == 0 { - return 0.0; - } - // Clamp exclusive ranks for short samples so one or two points remain drawable. - let pos = match method { - QuartileMethod::Inclusive => q * (n - 1) as f64, - QuartileMethod::Exclusive => (q * (n as f64 + 1.0) - 1.0).clamp(0.0, (n - 1) as f64), - }; - let i = pos.floor() as usize; - let frac = pos - i as f64; - let a = sorted.get(i).copied().unwrap_or(0.0); - let b = sorted.get(i + 1).copied().unwrap_or(a); - a + (b - a) * frac -} - -fn box_whisker(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { - let labels: Vec = data.series.iter().map(|s| s.name.clone()).collect(); - let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; - let ys = nice_scale(lo, hi, false); - let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); - let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); - if !plot.ok() { - return; - } - let n = data.series.len().max(1); - let cw = plot.w / n as f32; - let bw = (cw * 0.4).min(80.0); - let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h; - for (i, s) in data.series.iter().enumerate() { - let mut v: Vec = s.values.iter().flatten().copied().collect(); - if v.is_empty() { - continue; - } - v.sort_by(f64::total_cmp); - let method = chart.series.get(i).map(|s| s.quartiles).unwrap_or_default(); - let (q1, med, q3) = (quantile(&v, 0.25, method), quantile(&v, 0.5, method), quantile(&v, 0.75, method)); - let iqr = q3 - q1; - let (flo, fhi) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr); - let wlo = v.iter().copied().find(|x| *x >= flo).unwrap_or(q1); - let whi = v.iter().rev().copied().find(|x| *x <= fhi).unwrap_or(q3); - let mean = v.iter().sum::() / v.len() as f64; - let c = plot.x + (i as f32 + 0.5) * cw; - ctx.vline(c, vy(whi), vy(q3), s.color, 1.25); - ctx.vline(c, vy(q1), vy(wlo), s.color, 1.25); - ctx.hline(c - bw / 4.0, c + bw / 4.0, vy(whi), s.color, 1.25); - ctx.hline(c - bw / 4.0, c + bw / 4.0, vy(wlo), s.color, 1.25); - let (yt, yb) = (vy(q3), vy(q1)); - ctx.rect(c - bw / 2.0, yt, bw, (yb - yt).max(0.5), s.color, None); - ctx.hline(c - bw / 2.0, c + bw / 2.0, vy(med), WHITE, 1.5); - let (mx, my, d) = (c, vy(mean), 3.0); - ctx.line(vec![[mx - d, my - d], [mx + d, my + d]], WHITE, 1.25); - ctx.line(vec![[mx - d, my + d], [mx + d, my - d]], WHITE, 1.25); - for o in v.iter().filter(|x| **x < flo || **x > fhi).take(MAX_MARKERS) { - ctx.out.push(Prim::Circle { cx: c, cy: vy(*o), r: 2.5, fill: None, stroke: Some((s.color, 1.0)) }); - } - } -} - -// ---------------------------------------------------------------- stock - -fn stock(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { - let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS); - if ncat == 0 { - return; - } - let labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); - let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; - let ys = nice_scale(lo, hi, false); - let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); - let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); - if !plot.ok() { - return; - } - let ser = |k: usize| data.series.get(k); - let (open, high, low, close) = match data.series.len() { - 0 => return, - 1 => (None, None, None, ser(0)), - 2 => (None, ser(0), ser(1), None), - 3 => (None, ser(0), ser(1), ser(2)), - _ => (ser(0), ser(1), ser(2), ser(3)), - }; - let get = |s: Option<&SeriesData>, j: usize| s.and_then(|s| s.values.get(j).copied().flatten()); - let cw = plot.w / ncat as f32; - let tick = (cw * 0.25).max(1.0); - let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h; - for j in 0..ncat { - let c = plot.x + (j as f32 + 0.5) * cw; - if let (Some(h), Some(l)) = (get(high, j), get(low, j)) { - ctx.vline(c, vy(h), vy(l), STOCK, 1.25); - } - match (get(open, j), get(close, j)) { - (Some(o), Some(cl)) => { - let (yo, yc) = (vy(o), vy(cl)); - let bw = (cw * 0.5).max(1.0); - let fill = if cl >= o { WHITE } else { STOCK }; - ctx.rect(c - bw / 2.0, yo.min(yc), bw, (yo - yc).abs().max(0.75), fill, Some((STOCK, 1.0))); - } - (None, Some(cl)) => { - if high.is_some() { - ctx.hline(c, c + tick, vy(cl), STOCK, 1.25); - } else { - ctx.out.push(Prim::Circle { cx: c, cy: vy(cl), r: 2.5, fill: Some(STOCK), stroke: None }); - } - } - _ => {} - } - } -} - // ---------------------------------------------------------------- final safety pass fn fin(v: f32) -> bool { @@ -1829,60 +253,3 @@ pub(crate) fn finalize(prims: Vec, w: f32, h: f32) -> Vec { }) .collect() } - -#[cfg(test)] -mod smooth_tests { - use super::*; - - #[test] - fn long_runs_are_smoothed_uniformly_within_the_cap() { - let plot = Bx { x: 0.0, y: 0.0, w: 3000.0, h: 100.0 }; - let pts: Vec<[f32; 2]> = (0..1500).map(|i| [i as f32 * 2.0, if i % 2 == 0 { 20.0 } else { 80.0 }]).collect(); - let out = smooth_run(pts, plot); - assert!(out.len() <= MAX_LINE_PTS * 2 + 1, "{}", out.len()); - // Two sub-segments per segment all the way to the end, not 100 each and then a straight tail. - assert_eq!(out.len(), 1499 * 2 + 1); - assert_eq!(out.last().copied(), Some([2998.0, 80.0])); - } - - #[test] - fn spline_overshoot_is_clamped_to_the_plot() { - let plot = Bx { x: 0.0, y: 10.0, w: 300.0, h: 100.0 }; - // A step right at the plot edges: Catmull-Rom overshoots past both. - let pts = vec![[0.0, 110.0], [100.0, 110.0], [110.0, 10.0], [200.0, 10.0], [300.0, 10.0]]; - let out = smooth_run(pts, plot); - assert!(out.len() > 5); - assert!(out.iter().all(|p| (0.0..=300.0).contains(&p[0]) && (10.0..=110.0).contains(&p[1]))); - } -} - -#[cfg(test)] -mod quartile_tests { - use super::*; - - #[test] - fn box_whisker_default_quartiles_are_exclusive() { - let values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; - assert_eq!(quantile(&values, 0.25, QuartileMethod::default()), 1.75); - assert_eq!(quantile(&values, 0.5, QuartileMethod::default()), 3.5); - assert_eq!(quantile(&values, 0.75, QuartileMethod::default()), 5.25); - } - - #[test] - fn quartile_methods_and_short_samples() { - for (values, exclusive, inclusive) in [ - (vec![1., 2., 3.], (1., 3.), (1.5, 2.5)), - (vec![1., 2., 3., 4.], (1.25, 3.75), (1.75, 3.25)), - (vec![1., 2., 3., 4., 5.], (1.5, 4.5), (2., 4.)), - (vec![1., 2., 3., 4., 5., 6., 7.], (2., 6.), (2.5, 5.5)), - (vec![1., 1., 2., 3., 5., 8.], (1., 5.75), (1.25, 4.5)), - (vec![1., 2.], (1., 2.), (1.25, 1.75)), - (vec![1.], (1., 1.), (1., 1.)), - (vec![], (0., 0.), (0., 0.)), - ] { - for (method, expected) in [(QuartileMethod::Exclusive, exclusive), (QuartileMethod::Inclusive, inclusive)] { - assert_eq!((quantile(&values, 0.25, method), quantile(&values, 0.75, method)), expected); - } - } - } -} diff --git a/crates/chart/src/render/cartesian.rs b/crates/chart/src/render/cartesian.rs new file mode 100644 index 000000000..85a577b08 --- /dev/null +++ b/crates/chart/src/render/cartesian.rs @@ -0,0 +1,431 @@ +use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range}; +use super::legend::{cat_label, plot_kind}; +use super::lines::{decimate, draw_markers, draw_runs}; +use super::trend::{draw_trend_labels, draw_trendlines, max_backward, max_forward, max_forward_slots, supports_trendline}; +use super::*; + +// ---------------------------------------------------------------- cartesian (column/bar/line/area/combo) + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Fam { + Col, + ColStack, + Col100, + Line, + LineStack, + Area, + AreaStack, + Area100, +} + +fn family(k: ChartKind) -> Fam { + use ChartKind::*; + match k { + ColumnStacked | BarStacked => Fam::ColStack, + ColumnStacked100 | BarStacked100 => Fam::Col100, + Line | LineMarkers | Scatter | ScatterLines | Radar | Stock => Fam::Line, + LineStacked => Fam::LineStack, + Area => Fam::Area, + AreaStacked => Fam::AreaStack, + AreaStacked100 => Fam::Area100, + _ => Fam::Col, + } +} + +/// Families stacked to 100%: each category's values are shares of its total. +fn is_100(f: Fam) -> bool { + matches!(f, Fam::Col100 | Fam::Area100) +} + +fn is_col(f: Fam) -> bool { + matches!(f, Fam::Col | Fam::ColStack | Fam::Col100) +} + +fn is_line(f: Fam) -> bool { + matches!(f, Fam::Line | Fam::LineStack) +} + +pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizontal: bool) { + let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS); + if ncat == 0 { + return; + } + let kinds: Vec = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).collect(); + let fams: Vec = kinds.iter().map(|k| family(*k)).collect(); + let any_primary = data.series.iter().any(|s| !s.secondary); + let sec: Vec = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect(); + + // Totals for 100% stacking, per axis and family (100% columns and 100% areas stack apart). + let mut totals: Vec<((bool, Fam), Vec)> = Vec::new(); + for (i, s) in data.series.iter().enumerate() { + let fam = fams.get(i).copied().unwrap_or(Fam::Col); + if is_100(fam) { + let key = (sec.get(i).copied().unwrap_or(false), fam); + let p = match totals.iter().position(|(k, _)| *k == key) { + Some(p) => p, + None => { + totals.push((key, vec![0.0; ncat])); + totals.len() - 1 + } + }; + for (j, v) in s.values.iter().take(ncat).enumerate() { + if let (Some(v), Some(t)) = (v, totals.get_mut(p).and_then(|(_, t)| t.get_mut(j))) { + *t += v.abs(); + } + } + } + } + // A value's share of its category's total, for the 100% families. + let share = |key: (bool, Fam), j: usize, v: f64| { + let t = totals.iter().find(|(k, _)| *k == key).and_then(|(_, t)| t.get(j)).copied().unwrap_or(0.0); + if t > 0.0 { v / t } else { 0.0 } + }; + // Stack accumulators keyed by (axis, family): positive and negative running sums. + let mut stacks: Vec<((bool, Fam), Vec, Vec)> = Vec::new(); + let mut spans: Vec>> = Vec::with_capacity(data.series.len()); + for (i, s) in data.series.iter().enumerate() { + let fam = fams.get(i).copied().unwrap_or(Fam::Col); + let a = sec.get(i).copied().unwrap_or(false); + let stacked = matches!(fam, Fam::ColStack | Fam::Col100 | Fam::LineStack | Fam::AreaStack | Fam::Area100); + let key = (a, fam); + let si = if stacked { + match stacks.iter().position(|(k, _, _)| *k == key) { + Some(p) => Some(p), + None => { + stacks.push((key, vec![0.0; ncat], vec![0.0; ncat])); + Some(stacks.len() - 1) + } + } + } else { + None + }; + let mut row = Vec::with_capacity(ncat); + for j in 0..ncat { + let v = s.values.get(j).copied().flatten(); + let span = match (si.and_then(|p| stacks.get_mut(p)), fam) { + (Some((_, pos, neg)), Fam::ColStack | Fam::Col100) => v.and_then(|v| { + let v = if fam == Fam::Col100 { share(key, j, v) } else { v }; + let acc = if v >= 0.0 { pos.get_mut(j) } else { neg.get_mut(j) }?; + let base = *acc; + *acc += v; + Some((base, *acc)) + }), + (Some((_, pos, _)), _) => { + let v = v.unwrap_or(0.0); + let v = if fam == Fam::Area100 { share(key, j, v) } else { v }; + pos.get_mut(j).map(|acc| { + let base = *acc; + *acc = clean(*acc + v); + (base, *acc) + }) + } + (None, _) => v.map(|v| (0.0, v)), + }; + row.push(span); + } + spans.push(row); + } + + // Axis scales. + let mut axes: Vec<(Scale, Fmt)> = Vec::new(); + for axis in [false, true] { + let members: Vec = (0..data.series.len()).filter(|i| sec.get(*i).copied().unwrap_or(false) == axis).collect(); + if members.is_empty() { + continue; + } + let force_zero = members.iter().any(|i| fams.get(*i).is_some_and(|f| !is_line(*f))); + let all100 = members.iter().all(|i| fams.get(*i).is_some_and(|f| is_100(*f))); + let range = value_range(members.iter().flat_map(|i| { + let line = fams.get(*i).is_some_and(|f| is_line(*f)); + spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| if line { vec![*t] } else { vec![*b, *t] }) + })); + let (lo, hi) = range.unwrap_or((0.0, 0.0)); + let (scale, fmt) = if all100 { + (fixed_scale(if lo < -1e-9 { -1.0 } else { 0.0 }, if hi > 1e-9 || lo >= -1e-9 { 1.0 } else { 0.0 }, 0.2), Fmt::new("0%")) + } else { + let code = members.first().and_then(|i| data.series.get(*i)).map(|s| s.number_format.as_str()).unwrap_or("General"); + (nice_scale(lo, hi, force_zero), Fmt::new(code)) + }; + axes.push((scale, fmt)); + if !axis && sec.iter().all(|s| !s) { + break; + } + } + let Some((s1, f1)) = axes.first() else { return }; + let y1 = ValAxis { scale: *s1, fmt: f1 }; + let y2 = axes.get(1).map(|(s, f)| ValAxis { scale: *s, fmt: f }); + // A forward forecast on a category axis adds empty category slots after the data. + let fwd = data + .series + .iter() + .enumerate() + .filter(|(i, _)| kinds.get(*i).is_some_and(|k| supports_trendline(*k))) + .map(|(_, s)| max_forward(s)) + .fold(0.0, f64::max); + let ncat_slots = ncat.saturating_add(fwd.min(max_forward_slots(ncat)).ceil() as usize).min(MAX_CATS.max(ncat)); + let labels: Vec = (0..ncat_slots).map(|j| if j < ncat { cat_label(data, j) } else { String::new() }).collect(); + // Outer levels padded to the slots (forecast slots stay in the last group). + let outer: Vec> = + data.category_levels.iter().map(|l| (0..ncat_slots).map(|j| l.get(j).cloned().unwrap_or_default()).collect()).collect(); + let plot = frame(ctx, area, CatAxis::Labels(&labels, &outer), &y1, y2.as_ref(), horizontal, chart.gridlines); + if !plot.ok() { + return; + } + let scale_of = |i: usize| -> Scale { if sec.get(i).copied().unwrap_or(false) { y2.as_ref().map(|a| a.scale).unwrap_or(*s1) } else { *s1 } }; + let cat_len = if horizontal { plot.h } else { plot.w }; + let cw = cat_len / ncat_slots as f32; + let center = |j: usize| if horizontal { plot.bottom() - (j as f32 + 0.5) * cw } else { plot.x + (j as f32 + 0.5) * cw }; + let vpos = |v: f64, sc: &Scale| if horizontal { plot.x + sc.frac(v) * plot.w } else { plot.bottom() - sc.frac(v) * plot.h }; + + // Column slots: each clustered series gets one, each stack group shares one. + let mut slot_keys: Vec<(bool, Fam, usize)> = Vec::new(); + let mut slot_of = vec![0usize; data.series.len()]; + for (i, f) in fams.iter().enumerate() { + if !is_col(*f) { + continue; + } + let a = sec.get(i).copied().unwrap_or(false); + let key = if *f == Fam::Col { (a, *f, i) } else { (a, *f, usize::MAX) }; + let k = match slot_keys.iter().position(|k| *k == key) { + Some(p) => p, + None => { + slot_keys.push(key); + slot_keys.len() - 1 + } + }; + if let Some(s) = slot_of.get_mut(i) { + *s = k; + } + } + let nslots = slot_keys.len().max(1) as f32; + let bw = cw / (nslots + 1.5); + let labels_on = chart.data_labels && ncat <= MAX_LABELS; + // Area labels wait until every area is filled, so a later (stacked) area can't paint over them. + let mut area_labels = Vec::new(); + + // Areas first, then columns, then lines on top. + for pass in 0..3 { + for (i, s) in data.series.iter().enumerate() { + let fam = fams.get(i).copied().unwrap_or(Fam::Col); + let want = match pass { + 0 => matches!(fam, Fam::Area | Fam::AreaStack | Fam::Area100), + 1 => is_col(fam), + _ => is_line(fam), + }; + if !want { + continue; + } + let sc = scale_of(i); + let fmt = Fmt::new(&s.number_format); + let row = match spans.get(i) { + Some(r) => r, + None => continue, + }; + match pass { + 0 => { + let mut top = Vec::with_capacity(ncat * 2); + let mut base = Vec::with_capacity(ncat); + for (j, sp) in row.iter().enumerate() { + let (b, t) = sp.unwrap_or((0.0, 0.0)); + let c = center(j); + top.push([c, vpos(t, &sc)]); + base.push([c, vpos(b, &sc)]); + // Source values (not stack totals), just above the top edge like the line labels. + if labels_on + && sp.is_some() + && let Some(v) = s.values.get(j).copied().flatten() + { + area_labels.push((c, vpos(t, &sc) - 5.0, fmt.format(v, None))); + } + } + if top.len() == 1 { + // A single point: give it some width. + if let (Some(t), Some(b)) = (top.first().copied(), base.first().copied()) { + top = vec![[t[0] - cw * 0.3, t[1]], [t[0] + cw * 0.3, t[1]]]; + base = vec![[b[0] - cw * 0.3, b[1]], [b[0] + cw * 0.3, b[1]]]; + } + } + let top = decimate(top, MAX_LINE_PTS * 2); + let base = decimate(base, MAX_LINE_PTS * 2); + let mut poly = top; + poly.extend(base.into_iter().rev()); + if poly.len() >= 3 { + ctx.out.push(Prim::Polygon { pts: poly, fill: s.color, stroke: None }); + } + } + 1 => { + let k = slot_of.get(i).copied().unwrap_or(0) as f32; + for (j, sp) in row.iter().enumerate() { + let Some((b, t)) = sp else { continue }; + let (pb, pt) = (vpos(*b, &sc), vpos(*t, &sc)); + let c = center(j); + let lo = pb.min(pt); + let len = (pb - pt).abs(); + if horizontal { + let y = c + nslots * bw / 2.0 - (k + 1.0) * bw; + ctx.rect(lo, y, len, bw, s.color, None); + } else { + let x = c - nslots * bw / 2.0 + k * bw; + ctx.rect(x, lo, bw, len, s.color, None); + } + if labels_on && let Some(v) = s.values.get(j).copied().flatten() { + let t = fmt.format(v, None); + let stacked = fam != Fam::Col; + let mid = (pb + pt) / 2.0; + if horizontal { + let y = c + nslots * bw / 2.0 - (k + 0.5) * bw; + if stacked { + ctx.text(mid, y, &t, LABEL_SIZE, on(s.color), HAlign::Center, VAlign::Middle); + } else if v >= 0.0 { + ctx.text(pb.max(pt) + 3.0, y, &t, LABEL_SIZE, TEXT, HAlign::Left, VAlign::Middle); + } else { + ctx.text(lo - 3.0, y, &t, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle); + } + } else { + let x = c - nslots * bw / 2.0 + (k + 0.5) * bw; + if stacked { + ctx.text(x, mid, &t, LABEL_SIZE, on(s.color), HAlign::Center, VAlign::Middle); + } else if v >= 0.0 { + ctx.text(x, lo - 2.0, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + } else { + ctx.text(x, lo + len + 2.0, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Top); + } + } + } + } + } + _ => { + let pts: Vec> = row.iter().enumerate().map(|(j, sp)| sp.map(|(_, t)| [center(j), vpos(t, &sc)])).collect(); + draw_runs(ctx, &pts, s.color, 2.25, s.smooth.then_some(plot)); + let k = kinds.get(i).copied().unwrap_or(ChartKind::Line); + if k == ChartKind::LineMarkers && cw >= 3.0 { + draw_markers(ctx, pts.iter().flatten().copied(), 3.5, s.color); + } + if labels_on { + for (j, p) in pts.iter().enumerate() { + if let (Some(p), Some(v)) = (p, s.values.get(j).copied().flatten()) { + ctx.text(p[0], p[1] - 5.0, &fmt.format(v, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + } + } + } + } + } + } + for (x, y, t) in area_labels.drain(..) { + ctx.text(x, y, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + } + } + // Trendlines on top: x is the category number 1..n. + let mut trend_labels = Vec::new(); + for (i, s) in data.series.iter().enumerate() { + if s.trendlines.is_empty() || !kinds.get(i).is_some_and(|k| supports_trendline(*k)) { + continue; + } + let sc = scale_of(i); + let pts: Vec<(f64, f64)> = s.values.iter().take(ncat).enumerate().filter_map(|(j, v)| v.map(|v| ((j + 1) as f64, v))).collect(); + let frac = |x: f64, y: f64| ((x - 0.5) / ncat_slots as f64, (y - sc.min) / (sc.max - sc.min)); + let px = |(fx, fy): (f64, f64)| { + if horizontal { + [plot.x + fy as f32 * plot.w, plot.bottom() - fx as f32 * plot.h] + } else { + [plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h] + } + }; + draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (0.5, ncat_slots as f64 + 0.5)); + } + draw_trend_labels(ctx, plot, &trend_labels); +} + +// ---------------------------------------------------------------- scatter & bubble + +pub(super) fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let pts_of = |s: &SeriesData| -> Vec> { + s.values + .iter() + .enumerate() + .map(|(j, v)| { + let x = match &s.x { + Some(xs) => xs.get(j).copied().flatten(), + None => Some((j + 1) as f64), + }; + match (x, v) { + (Some(x), Some(y)) => Some((x, *y)), + _ => None, + } + }) + .collect() + }; + let all: Vec>> = data.series.iter().map(pts_of).collect(); + let Some((mut xlo, mut xhi)) = value_range(all.iter().flatten().flatten().map(|p| p.0)) else { return }; + // Forecasts extend the x axis to the trendline's ends. + for (s, pts) in data.series.iter().zip(&all) { + if let Some((lo, hi)) = value_range(pts.iter().flatten().map(|p| p.0)).filter(|_| !s.trendlines.is_empty()) { + xlo = xlo.min(lo - max_backward(s)); + xhi = xhi.max(hi + max_forward(s)); + } + } + let Some((ylo, yhi)) = value_range(all.iter().flatten().flatten().map(|p| p.1)) else { return }; + let xs = nice_scale(xlo, xhi, false); + let ys = nice_scale(ylo, yhi, false); + let fx = Fmt::new("General"); + let fy = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); + let plot = frame(ctx, area, CatAxis::Numeric(xs, &fx), &ValAxis { scale: ys, fmt: &fy }, None, false, chart.gridlines); + if !plot.ok() { + return; + } + let map = |(x, y): (f64, f64)| [plot.x + xs.frac(x) * plot.w, plot.bottom() - ys.frac(y) * plot.h]; + let bubble = chart.kind == ChartKind::Bubble; + let rmax = plot.w.min(plot.h) * 0.12; + for (s, pts) in data.series.iter().zip(&all) { + let px: Vec> = pts.iter().map(|p| p.map(map)).collect(); + if bubble { + let sizes = s.sizes.as_deref().unwrap_or(&[]); + let smax = sizes.iter().flatten().fold(0.0f64, |a, b| a.max(*b)); + let mut bubbles: Vec<([f32; 2], f64)> = px + .iter() + .enumerate() + .filter_map(|(j, p)| { + let sz = if sizes.is_empty() { Some(1.0) } else { sizes.get(j).copied().flatten() }; + match (p, sz) { + (Some(p), Some(sz)) if sz > 0.0 => Some((*p, sz)), + _ => None, + } + }) + .take(MAX_MARKERS) + .collect(); + bubbles.sort_by(|a, b| b.1.total_cmp(&a.1)); + let smax = if sizes.is_empty() { 1.0 } else { smax }; + let fill = [s.color[0], s.color[1], s.color[2], 0xC0]; + for (p, sz) in bubbles { + let r = ((sz / smax).sqrt() as f32 * rmax).max(1.0); + ctx.out.push(Prim::Circle { cx: p[0], cy: p[1], r, fill: Some(fill), stroke: Some((WHITE, 0.75)) }); + } + } else { + let lines = chart.kind == ChartKind::ScatterLines || s.kind == ChartKind::ScatterLines; + if lines { + draw_runs(ctx, &px, s.color, 2.0, s.smooth.then_some(plot)); + } + if !lines || px.len() <= 500 { + draw_markers(ctx, px.iter().flatten().copied(), 3.5, s.color); + } + } + if chart.data_labels && pts.len() <= MAX_LABELS { + for (p, raw) in px.iter().zip(pts) { + if let (Some(p), Some((_, y))) = (p, raw) { + ctx.text(p[0], p[1] - 6.0, &fy.format(*y, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + } + } + } + } + let mut trend_labels = Vec::new(); + for (s, pts) in data.series.iter().zip(&all) { + if s.trendlines.is_empty() { + continue; + } + let pts: Vec<(f64, f64)> = pts.iter().flatten().copied().collect(); + let frac = |x: f64, y: f64| ((x - xs.min) / (xs.max - xs.min), (y - ys.min) / (ys.max - ys.min)); + let px = |(fx, fy): (f64, f64)| [plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h]; + draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (xs.min, xs.max)); + } + draw_trend_labels(ctx, plot, &trend_labels); +} diff --git a/crates/chart/src/render/frame.rs b/crates/chart/src/render/frame.rs new file mode 100644 index 000000000..7fa2a54a1 --- /dev/null +++ b/crates/chart/src/render/frame.rs @@ -0,0 +1,245 @@ +use super::*; + +#[derive(Clone, Copy, Debug)] +pub(super) struct Bx { + pub(super) x: f32, + pub(super) y: f32, + pub(super) w: f32, + pub(super) h: f32, +} + +impl Bx { + pub(super) fn right(&self) -> f32 { + self.x + self.w + } + pub(super) fn bottom(&self) -> f32 { + self.y + self.h + } + pub(super) fn ok(&self) -> bool { + self.w.is_finite() && self.h.is_finite() && self.w > 4.0 && self.h > 4.0 + } + pub(super) fn cx(&self) -> f32 { + self.x + self.w / 2.0 + } + pub(super) fn cy(&self) -> f32 { + self.y + self.h / 2.0 + } +} + +/// Text colour readable on `fill`. +pub(super) fn on(fill: Rgba) -> Rgba { + let l = 0.299 * fill[0] as f32 + 0.587 * fill[1] as f32 + 0.114 * fill[2] as f32; + if l < 150.0 { WHITE } else { DARK_TEXT } +} + +pub(super) fn chart_title(chart: &Chart, data: &ChartData) -> Option { + match &chart.title { + Some(t) => Some(t.trim().to_string()).filter(|t| !t.is_empty()), + None if data.series.len() == 1 => data.series.first().map(|s| s.name.clone()).filter(|t| !t.is_empty()), + None => None, + } +} + +pub(super) fn axis_titles(ctx: &mut Ctx, chart: &Chart, mut area: Bx) -> Bx { + let lh = AXIS_TITLE_SIZE * 1.4 + 2.0; + if let Some(t) = chart.x_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) { + let t = ctx.fit(t, AXIS_TITLE_SIZE, area.w); + ctx.text(area.cx(), area.bottom(), &t, AXIS_TITLE_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + area.h -= lh; + } + if let Some(t) = chart.y_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) { + let t = ctx.fit(t, AXIS_TITLE_SIZE, area.h); + if !t.is_empty() { + ctx.out.push(Prim::Text { + x: area.x, + y: area.cy(), + text: t, + size: AXIS_TITLE_SIZE, + color: TEXT, + bold: false, + align: HAlign::Center, + valign: VAlign::Top, + rotation: -PI / 2.0, + }); + } + area.x += lh; + area.w -= lh; + } + area +} + +// ---------------------------------------------------------------- axes frame + +pub(super) enum CatAxis<'a> { + /// Category labels and, for a multi-level axis, the outer levels (nearest the labels first; + /// one entry per category, non-empty where a group starts). + Labels(&'a [String], &'a [Vec]), + Numeric(Scale, &'a Fmt), +} + +/// Groups of an outer category level: `(first, end)` index pairs, a group starting at every +/// non-empty entry (and at the first category). +fn level_groups(level: &[String], n: usize) -> Vec<(usize, usize)> { + let mut starts: Vec = (0..n).filter(|&j| j == 0 || level.get(j).is_some_and(|t| !t.is_empty())).collect(); + starts.push(n); + starts.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect() +} + +pub(super) struct ValAxis<'a> { + pub(super) scale: Scale, + pub(super) fmt: &'a Fmt, +} + +impl ValAxis<'_> { + fn labels(&self) -> Vec<(f64, String)> { + self.scale.ticks().into_iter().map(|v| (v, self.fmt.format(v, Some(self.scale.step)))).collect() + } +} + +/// Draws gridlines, axis labels and the category axis line; returns the plot rectangle. +pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis>, horizontal: bool, grid: bool) -> Bx { + let ls = LABEL_SIZE; + let lh = ls * 1.4; + let ylabels = y.labels(); + let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw(t, ls)).fold(0.0f32, f32::max); + if !horizontal { + let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4); + let y2labels = y2.map(ValAxis::labels); + let right = match (&y2labels, &cat) { + (Some(l), _) => (maxw(ctx, l) + 6.0).min(area.w * 0.3), + (None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2), + _ => 4.0, + }; + let levels = match &cat { + CatAxis::Labels(_, o) => o.len() as f32, + CatAxis::Numeric(..) => 0.0, + }; + let plot = Bx { x: area.x + left, y: area.y + lh * 0.5, w: area.w - left - right, h: area.h - lh * 1.5 - 4.0 - lh * levels }; + if !plot.ok() { + return plot; + } + for (v, t) in &ylabels { + let yy = plot.bottom() - y.scale.frac(*v) * plot.h; + if grid { + ctx.hline(plot.x, plot.right(), yy, GRID, 0.75); + } + ctx.text(plot.x - 4.0, yy, t, ls, TEXT, HAlign::Right, VAlign::Middle); + } + if let (Some(a), Some(l)) = (y2, &y2labels) { + for (v, t) in l { + let yy = plot.bottom() - a.scale.frac(*v) * plot.h; + ctx.text(plot.right() + 4.0, yy, t, ls, TEXT, HAlign::Left, VAlign::Middle); + } + } + let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min }; + let y0 = plot.bottom() - y.scale.frac(zero) * plot.h; + ctx.hline(plot.x, plot.right(), y0, AXIS, 0.75); + match cat { + CatAxis::Labels(l, outer) => { + let n = l.len().max(1); + let cw = plot.w / n as f32; + let lw = l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max); + let k = (((lw + 6.0) / cw.max(1e-3)).ceil() as usize).clamp(1, n); + for (j, t) in l.iter().enumerate().step_by(k) { + let t = ctx.fit(t, ls, cw * k as f32 - 2.0); + ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, HAlign::Center, VAlign::Top); + } + // Outer levels, one row each below the labels: a group's name centred under its + // categories, and a separator at each group boundary down through the rows. + for (i, level) in outer.iter().enumerate() { + let top = plot.bottom() + 3.0 + lh * (i as f32 + 1.0); + for (first, end) in level_groups(level, n) { + let (x0, x1) = (plot.x + first as f32 * cw, plot.x + end as f32 * cw); + if let Some(t) = level.get(first).filter(|t| !t.is_empty()) { + let t = ctx.fit(t, ls, x1 - x0 - 2.0); + ctx.text((x0 + x1) / 2.0, top, &t, ls, TEXT, HAlign::Center, VAlign::Top); + } + if first > 0 { + ctx.vline(x0, plot.bottom(), top + lh - 2.0, AXIS, 0.75); + } + } + ctx.vline(plot.right(), plot.bottom(), top + lh - 2.0, AXIS, 0.75); + } + } + CatAxis::Numeric(s, f) => { + let ticks = s.ticks(); + let labels: Vec = ticks.iter().map(|v| f.format(*v, Some(s.step))).collect(); + let lw = labels.iter().map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max); + let slot = plot.w / ticks.len().max(1) as f32; + let k = (((lw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1); + for (v, t) in ticks.iter().zip(&labels).step_by(k) { + let xx = plot.x + s.frac(*v) * plot.w; + ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top); + } + if s.min <= 0.0 && s.max >= 0.0 { + let xx = plot.x + s.frac(0.0) * plot.w; + ctx.vline(xx, plot.y, plot.bottom(), AXIS, 0.75); + } + } + } + plot + } else { + let (labels, outer): (Vec, &[Vec]) = match cat { + CatAxis::Labels(l, o) => (l.to_vec(), o), + CatAxis::Numeric(..) => (vec![], &[]), + }; + let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.35); + // Outer levels stand in columns left of the labels, each as wide as its longest name. + let level_w: Vec = + outer.iter().map(|l| (l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.15)).collect(); + let levels_w: f32 = level_w.iter().sum(); + let right = ylabels.last().map(|(_, t)| ctx.tw(t, ls) / 2.0).unwrap_or(0.0).min(area.w * 0.2) + 2.0; + let plot = Bx { x: area.x + lw + levels_w, y: area.y + 2.0, w: area.w - lw - levels_w - right, h: area.h - lh - 6.0 }; + if !plot.ok() { + return plot; + } + let vw = maxw(ctx, &ylabels); + let slot = plot.w / ylabels.len().max(1) as f32; + let k = (((vw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1); + for (i, (v, t)) in ylabels.iter().enumerate() { + let xx = plot.x + y.scale.frac(*v) * plot.w; + if grid { + ctx.vline(xx, plot.y, plot.bottom(), GRID, 0.75); + } + if i % k == 0 { + ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, HAlign::Center, VAlign::Top); + } + } + let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min }; + let x0 = plot.x + y.scale.frac(zero) * plot.w; + ctx.vline(x0, plot.y, plot.bottom(), AXIS, 0.75); + let n = labels.len().max(1); + let ch = plot.h / n as f32; + let k = ((lh / ch.max(1e-3)).ceil() as usize).clamp(1, n); + for (j, t) in labels.iter().enumerate().step_by(k) { + let t = ctx.fit(t, ls, lw - 6.0); + ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, HAlign::Right, VAlign::Middle); + } + // Outer levels: a group's name centred beside its categories, separators at the + // boundaries running right to the axis. + let mut x1 = plot.x - lw; + for (level, w) in outer.iter().zip(&level_w) { + let x0 = x1 - w; + for (first, end) in level_groups(level, n) { + let (y0, y1) = (plot.bottom() - end as f32 * ch, plot.bottom() - first as f32 * ch); + if let Some(t) = level.get(first).filter(|t| !t.is_empty()) { + let t = ctx.fit(t, ls, w - 4.0); + ctx.text((x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, ls, TEXT, HAlign::Center, VAlign::Middle); + } + if first > 0 { + ctx.hline(x0, plot.x, y1, AXIS, 0.75); + } + } + ctx.hline(x0, plot.x, plot.y, AXIS, 0.75); + x1 = x0; + } + plot + } +} + +pub(super) fn value_range(it: impl Iterator) -> Option<(f64, f64)> { + it.fold(None, |acc, v| match acc { + None => Some((v, v)), + Some((a, b)) => Some((a.min(v), b.max(v))), + }) +} diff --git a/crates/chart/src/render/kinds.rs b/crates/chart/src/render/kinds.rs new file mode 100644 index 000000000..2ffbfa3dd --- /dev/null +++ b/crates/chart/src/render/kinds.rs @@ -0,0 +1,505 @@ +use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range}; +use super::legend::{cat_label, point_color}; +use super::*; + +// ---------------------------------------------------------------- histogram + +pub(super) fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let Some(s) = data.series.first() else { return }; + let vals: Vec = s.values.iter().flatten().copied().collect(); + let Some((lo, hi)) = value_range(vals.iter().copied()) else { return }; + let n = vals.len(); + let span = hi - lo; + let mut bins = ((n as f64).sqrt().ceil() as usize).clamp(1, 100); + if !(span.is_finite() && span > 0.0) { + bins = 1; + } + let width = if bins == 1 && span.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) { 1.0 } else { span / bins as f64 }; + // Upper edge of each bin, exactly as shown in the labels below. Bins are right-inclusive (the first is closed on both sides), so a value + // equal to an edge belongs to the bin that ends there. + let edges: Vec = (0..bins).map(|b| if b + 1 == bins { hi.max(lo + b as f64 * width) } else { lo + (b + 1) as f64 * width }).collect(); + let mut counts = vec![0usize; bins]; + for v in &vals { + let b = edges.partition_point(|e| e < v).min(bins - 1); + if let Some(c) = counts.get_mut(b) { + *c += 1; + } + } + let f = Fmt::new(&s.number_format); + let labels: Vec = (0..bins) + .map(|b| { + let a = lo + b as f64 * width; + let e = edges.get(b).copied().unwrap_or(a); + let open = if b == 0 { '[' } else { '(' }; + format!("{open}{}, {}]", f.format(a, Some(width / 10.0)), f.format(e, Some(width / 10.0))) + }) + .collect(); + let maxc = counts.iter().copied().max().unwrap_or(0) as f64; + let ys = nice_scale(0.0, maxc, true); + let fc = Fmt::new("General"); + let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &fc }, None, false, chart.gridlines); + if !plot.ok() { + return; + } + let cw = plot.w / bins as f32; + for (b, c) in counts.iter().enumerate() { + let top = plot.bottom() - ys.frac(*c as f64) * plot.h; + let x = plot.x + b as f32 * cw; + ctx.rect(x, top, cw, plot.bottom() - top, s.color, Some((WHITE, 0.75))); + if chart.data_labels { + ctx.text(x + cw / 2.0, top - 2.0, &c.to_string(), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + } + } +} + +// ---------------------------------------------------------------- waterfall + +pub(super) fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let Some(s) = data.series.first() else { return }; + let mut ncat = data.categories.len().max(s.values.len()).min(MAX_CATS); + if ncat == 0 { + return; + } + let mut labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); + let mut vals: Vec = (0..ncat).map(|j| s.values.get(j).copied().flatten().unwrap_or(0.0)).collect(); + let mut is_total = vec![false; ncat]; + let last_total = labels.last().is_some_and(|l| l.to_lowercase().contains("total")); + if let Some(t) = is_total.last_mut() { + *t = last_total; + } + if !last_total { + labels.push("Total".into()); + vals.push(0.0); + is_total.push(true); + ncat += 1; + } + let mut bars = Vec::with_capacity(ncat); + let mut cum = 0.0f64; + for (j, v) in vals.iter().enumerate() { + if is_total.get(j).copied().unwrap_or(false) { + let total = if last_total && j + 1 == ncat && *v != 0.0 { *v } else { cum }; + bars.push((0.0, total, 0u8)); + cum = total; + } else { + let top = clean(cum + v); + bars.push((cum, top, if *v >= 0.0 { 1 } else { 2 })); + cum = top; + } + } + let (lo, hi) = value_range(bars.iter().flat_map(|(b, t, _)| [*b, *t])).unwrap_or((0.0, 0.0)); + let ys = nice_scale(lo, hi, true); + let f = Fmt::new(&s.number_format); + let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); + if !plot.ok() { + return; + } + let cw = plot.w / ncat as f32; + let bw = cw / 1.5; + let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h; + for (j, (b, t, kind)) in bars.iter().enumerate() { + let color = match kind { + 1 => UP, + 2 => DOWN, + _ => s.color, + }; + let x = plot.x + (j as f32 + 0.5) * cw - bw / 2.0; + let (yb, yt) = (vy(*b), vy(*t)); + ctx.rect(x, yb.min(yt), bw, (yb - yt).abs().max(0.5), color, None); + if j + 1 < bars.len() && ncat <= 500 { + ctx.out.push(Prim::Line { pts: vec![[x + bw, yt], [x + cw, yt]], color: AXIS, width: 0.75, dash: true }); + } + if chart.data_labels && ncat <= MAX_LABELS { + let v = if *kind == 0 { *t } else { t - b }; + ctx.text(x + bw / 2.0, yb.min(yt) - 2.0, &f.format(v, None), LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom); + } + } +} + +// ---------------------------------------------------------------- funnel + +pub(super) fn funnel(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let Some(s) = data.series.first() else { return }; + let n = data.categories.len().max(s.values.len()).min(MAX_CATS); + if n == 0 { + return; + } + let vals: Vec = (0..n).map(|j| s.values.get(j).copied().flatten().unwrap_or(0.0).abs()).collect(); + let max = vals.iter().copied().fold(0.0f64, f64::max); + if max.is_nan() || max <= 0.0 { + return; + } + let labels: Vec = (0..n).map(|j| cat_label(data, j)).collect(); + let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, LABEL_SIZE)).fold(0.0f32, f32::max) + 8.0).min(area.w * 0.35); + let plot = Bx { x: area.x + lw, w: area.w - lw, ..area }; + if !plot.ok() { + return; + } + let slot = plot.h / n as f32; + let bh = slot * 0.85; + let f = Fmt::new(&s.number_format); + let show = slot >= LABEL_SIZE * 0.9; + for (j, v) in vals.iter().enumerate() { + let bw = (*v / max) as f32 * plot.w; + let x = plot.x + (plot.w - bw) / 2.0; + let y = plot.y + j as f32 * slot + (slot - bh) / 2.0; + if bw > 0.0 { + ctx.rect(x, y, bw, bh, s.color, None); + } + if show { + let yc = y + bh / 2.0; + if chart.data_labels { + let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None); + let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT }; + ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, HAlign::Center, VAlign::Middle); + } + let l = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw - 8.0); + ctx.text(plot.x - 6.0, yc, &l, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle); + } + } +} + +// ---------------------------------------------------------------- pie, doughnut, sunburst + +pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inner: f32, names: bool) { + let Some(s) = data.series.first() else { return }; + let vals: Vec = s.values.iter().take(MAX_CATS).map(|v| v.map(f64::abs).unwrap_or(0.0)).collect(); + let total: f64 = vals.iter().sum(); + if !(total.is_finite() && total > 0.0) { + return; + } + let (cx, cy) = (area.cx(), area.cy()); + let r = area.w.min(area.h) / 2.0 * 0.92; + if r < 2.0 { + return; + } + let ri = r * inner; + let f = Fmt::new(&s.number_format); + let mut a = 0.0f32; + let mut labels = Vec::new(); + for (j, v) in vals.iter().enumerate() { + let sweep = (*v / total) as f32 * TAU; + if sweep > 1e-4 { + let fill = point_color(data, j); + ctx.out.push(Prim::Wedge { cx, cy, r_outer: r, r_inner: ri, a0: a, a1: (a + sweep).min(TAU), fill, stroke: Some((WHITE, 1.5)) }); + if (chart.data_labels || names) && vals.len() <= MAX_LABELS { + let t = if names { cat_label(data, j) } else { f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None) }; + let mid = a + sweep / 2.0; + let rr = if ri > 0.0 { (r + ri) / 2.0 } else { r * 0.65 }; + let room = sweep * rr; + if ctx.tw(&t, LABEL_SIZE) <= room.max(r - ri) && room >= LABEL_SIZE { + labels.push((cx + rr * mid.sin(), cy - rr * mid.cos(), t, on(fill))); + } + } + } + a += sweep; + } + for (x, y, t, c) in labels { + ctx.text(x, y, &t, LABEL_SIZE, c, HAlign::Center, VAlign::Middle); + } +} + +// ---------------------------------------------------------------- treemap + +fn worst(row: &[f64], side: f64) -> f64 { + let s: f64 = row.iter().sum(); + if s <= 0.0 || side <= 0.0 { + return f64::INFINITY; + } + let (mn, mx) = row.iter().fold((f64::INFINITY, 0.0f64), |(a, b), v| (a.min(*v), b.max(*v))); + let s2 = s * s; + let w2 = side * side; + (w2 * mx / s2).max(s2 / (w2 * mn)) +} + +/// Squarified treemap layout of (index, area) items, sorted descending, into `r`. +fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> { + let total: f64 = items.iter().map(|i| i.1).sum(); + let area = (r.w as f64) * (r.h as f64); + if !(total > 0.0 && area > 0.0) { + return vec![]; + } + let scale = area / total; + let mut out = Vec::with_capacity(items.len()); + let mut rem = r; + let mut row: Vec<(usize, f64)> = Vec::new(); + let mut i = 0; + let layout = |row: &[(usize, f64)], rem: &mut Bx, out: &mut Vec<(usize, Bx)>| { + let s: f64 = row.iter().map(|x| x.1).sum(); + if s <= 0.0 { + return; + } + if rem.w >= rem.h { + let cw = (s / rem.h.max(1e-6) as f64) as f32; + let mut y = rem.y; + for (j, a) in row { + let h = (*a / cw.max(1e-6) as f64) as f32; + out.push((*j, Bx { x: rem.x, y, w: cw, h })); + y += h; + } + rem.x += cw; + rem.w = (rem.w - cw).max(0.0); + } else { + let rh = (s / rem.w.max(1e-6) as f64) as f32; + let mut x = rem.x; + for (j, a) in row { + let w = (*a / rh.max(1e-6) as f64) as f32; + out.push((*j, Bx { x, y: rem.y, w, h: rh })); + x += w; + } + rem.y += rh; + rem.h = (rem.h - rh).max(0.0); + } + }; + while let Some(&(j, v)) = items.get(i) { + let a = v * scale; + let side = rem.w.min(rem.h) as f64; + let cur: Vec = row.iter().map(|x| x.1).collect(); + let mut with = cur.clone(); + with.push(a); + if row.is_empty() || worst(&with, side) <= worst(&cur, side) { + row.push((j, a)); + i += 1; + } else { + layout(&row, &mut rem, &mut out); + row.clear(); + } + } + if !row.is_empty() { + layout(&row, &mut rem, &mut out); + } + out +} + +pub(super) fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let Some(s) = data.series.first() else { return }; + let mut items: Vec<(usize, f64)> = + s.values.iter().take(MAX_MARKERS).enumerate().filter_map(|(j, v)| v.filter(|v| *v > 0.0).map(|v| (j, v))).collect(); + items.sort_by(|a, b| b.1.total_cmp(&a.1)); + let f = Fmt::new(&s.number_format); + for (j, b) in squarify(&items, area) { + let fill = point_color(data, j); + ctx.rect(b.x, b.y, b.w, b.h, fill, Some((WHITE, 1.5))); + if b.w > 24.0 && b.h > LABEL_SIZE * 1.6 { + let name = ctx.fit(&cat_label(data, j), LABEL_SIZE, b.w - 8.0); + ctx.text(b.x + 4.0, b.y + 4.0, &name, LABEL_SIZE, on(fill), HAlign::Left, VAlign::Top); + if chart.data_labels && b.h > LABEL_SIZE * 3.2 { + let v = items.iter().find(|x| x.0 == j).map(|x| x.1).unwrap_or(0.0); + let t = ctx.fit(&f.format(v, None), LABEL_SIZE, b.w - 8.0); + ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), HAlign::Left, VAlign::Top); + } + } + } +} + +// ---------------------------------------------------------------- radar + +pub(super) fn radar(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(1000); + if ncat == 0 { + return; + } + let labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); + let lw = labels.iter().map(|t| ctx.tw(t, LABEL_SIZE)).fold(0.0f32, f32::max).min(area.w * 0.2); + let r = ((area.w - 2.0 * (lw + 8.0)).min(area.h - 2.0 * (LABEL_SIZE * 1.4 + 6.0)) / 2.0).max(0.0); + if r < 5.0 { + return; + } + let (cx, cy) = (area.cx(), area.cy()); + let (lo, hi) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())).unwrap_or((0.0, 0.0)); + let sc = nice_scale(lo, hi, true); + let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); + let ang = |j: usize| j as f32 / ncat as f32 * TAU; + let pt = |a: f32, rr: f32| [cx + rr * a.sin(), cy - rr * a.cos()]; + let ticks = sc.ticks(); + let spacing = r / ticks.len().saturating_sub(1).max(1) as f32; + let every = ((LABEL_SIZE * 1.3 / spacing.max(1e-3)).ceil() as usize).max(1); + for (ti, t) in ticks.into_iter().enumerate() { + let rr = sc.frac(t) * r; + if rr > 0.5 && chart.gridlines { + let mut ring: Vec<[f32; 2]> = (0..ncat).map(|j| pt(ang(j), rr)).collect(); + if ncat < 3 { + ring = (0..=48).map(|k| pt(k as f32 / 48.0 * TAU, rr)).collect(); + } else if let Some(p) = ring.first().copied() { + ring.push(p); + } + ctx.line(ring, GRID, 0.75); + } + if ti % every == 0 { + ctx.text(cx - 3.0, cy - rr, &f.format(t, Some(sc.step)), LABEL_SIZE * 0.9, TEXT, HAlign::Right, VAlign::Middle); + } + } + let step = ncat.div_ceil(72).max(1); + for j in (0..ncat).step_by(step) { + let a = ang(j); + ctx.line(vec![[cx, cy], pt(a, r)], GRID, 0.75); + let p = pt(a, r + 6.0); + let (sa, ca) = (a.sin(), a.cos()); + let align = if sa > 0.1 { + HAlign::Left + } else if sa < -0.1 { + HAlign::Right + } else { + HAlign::Center + }; + let valign = if ca > 0.1 { + VAlign::Bottom + } else if ca < -0.1 { + VAlign::Top + } else { + VAlign::Middle + }; + let t = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw.max(20.0)); + ctx.text(p[0], p[1], &t, LABEL_SIZE, TEXT, align, valign); + } + for s in &data.series { + let mut pts: Vec<[f32; 2]> = (0..ncat).map(|j| pt(ang(j), sc.frac(s.values.get(j).copied().flatten().unwrap_or(sc.min)) * r)).collect(); + if let Some(p) = pts.first().copied() { + pts.push(p); + } + ctx.line(pts, s.color, 2.0); + } +} + +// ---------------------------------------------------------------- box & whisker + +fn quantile(sorted: &[f64], q: f64, method: QuartileMethod) -> f64 { + let n = sorted.len(); + if n == 0 { + return 0.0; + } + // Clamp exclusive ranks for short samples so one or two points remain drawable. + let pos = match method { + QuartileMethod::Inclusive => q * (n - 1) as f64, + QuartileMethod::Exclusive => (q * (n as f64 + 1.0) - 1.0).clamp(0.0, (n - 1) as f64), + }; + let i = pos.floor() as usize; + let frac = pos - i as f64; + let a = sorted.get(i).copied().unwrap_or(0.0); + let b = sorted.get(i + 1).copied().unwrap_or(a); + a + (b - a) * frac +} + +pub(super) fn box_whisker(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let labels: Vec = data.series.iter().map(|s| s.name.clone()).collect(); + let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; + let ys = nice_scale(lo, hi, false); + let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); + let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); + if !plot.ok() { + return; + } + let n = data.series.len().max(1); + let cw = plot.w / n as f32; + let bw = (cw * 0.4).min(80.0); + let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h; + for (i, s) in data.series.iter().enumerate() { + let mut v: Vec = s.values.iter().flatten().copied().collect(); + if v.is_empty() { + continue; + } + v.sort_by(f64::total_cmp); + let method = chart.series.get(i).map(|s| s.quartiles).unwrap_or_default(); + let (q1, med, q3) = (quantile(&v, 0.25, method), quantile(&v, 0.5, method), quantile(&v, 0.75, method)); + let iqr = q3 - q1; + let (flo, fhi) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr); + let wlo = v.iter().copied().find(|x| *x >= flo).unwrap_or(q1); + let whi = v.iter().rev().copied().find(|x| *x <= fhi).unwrap_or(q3); + let mean = v.iter().sum::() / v.len() as f64; + let c = plot.x + (i as f32 + 0.5) * cw; + ctx.vline(c, vy(whi), vy(q3), s.color, 1.25); + ctx.vline(c, vy(q1), vy(wlo), s.color, 1.25); + ctx.hline(c - bw / 4.0, c + bw / 4.0, vy(whi), s.color, 1.25); + ctx.hline(c - bw / 4.0, c + bw / 4.0, vy(wlo), s.color, 1.25); + let (yt, yb) = (vy(q3), vy(q1)); + ctx.rect(c - bw / 2.0, yt, bw, (yb - yt).max(0.5), s.color, None); + ctx.hline(c - bw / 2.0, c + bw / 2.0, vy(med), WHITE, 1.5); + let (mx, my, d) = (c, vy(mean), 3.0); + ctx.line(vec![[mx - d, my - d], [mx + d, my + d]], WHITE, 1.25); + ctx.line(vec![[mx - d, my + d], [mx + d, my - d]], WHITE, 1.25); + for o in v.iter().filter(|x| **x < flo || **x > fhi).take(MAX_MARKERS) { + ctx.out.push(Prim::Circle { cx: c, cy: vy(*o), r: 2.5, fill: None, stroke: Some((s.color, 1.0)) }); + } + } +} + +// ---------------------------------------------------------------- stock + +pub(super) fn stock(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS); + if ncat == 0 { + return; + } + let labels: Vec = (0..ncat).map(|j| cat_label(data, j)).collect(); + let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return }; + let ys = nice_scale(lo, hi, false); + let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General")); + let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines); + if !plot.ok() { + return; + } + let ser = |k: usize| data.series.get(k); + let (open, high, low, close) = match data.series.len() { + 0 => return, + 1 => (None, None, None, ser(0)), + 2 => (None, ser(0), ser(1), None), + 3 => (None, ser(0), ser(1), ser(2)), + _ => (ser(0), ser(1), ser(2), ser(3)), + }; + let get = |s: Option<&SeriesData>, j: usize| s.and_then(|s| s.values.get(j).copied().flatten()); + let cw = plot.w / ncat as f32; + let tick = (cw * 0.25).max(1.0); + let vy = |v: f64| plot.bottom() - ys.frac(v) * plot.h; + for j in 0..ncat { + let c = plot.x + (j as f32 + 0.5) * cw; + if let (Some(h), Some(l)) = (get(high, j), get(low, j)) { + ctx.vline(c, vy(h), vy(l), STOCK, 1.25); + } + match (get(open, j), get(close, j)) { + (Some(o), Some(cl)) => { + let (yo, yc) = (vy(o), vy(cl)); + let bw = (cw * 0.5).max(1.0); + let fill = if cl >= o { WHITE } else { STOCK }; + ctx.rect(c - bw / 2.0, yo.min(yc), bw, (yo - yc).abs().max(0.75), fill, Some((STOCK, 1.0))); + } + (None, Some(cl)) => { + if high.is_some() { + ctx.hline(c, c + tick, vy(cl), STOCK, 1.25); + } else { + ctx.out.push(Prim::Circle { cx: c, cy: vy(cl), r: 2.5, fill: Some(STOCK), stroke: None }); + } + } + _ => {} + } + } +} + +#[cfg(test)] +mod quartile_tests { + use super::*; + + #[test] + fn box_whisker_default_quartiles_are_exclusive() { + let values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; + assert_eq!(quantile(&values, 0.25, QuartileMethod::default()), 1.75); + assert_eq!(quantile(&values, 0.5, QuartileMethod::default()), 3.5); + assert_eq!(quantile(&values, 0.75, QuartileMethod::default()), 5.25); + } + + #[test] + fn quartile_methods_and_short_samples() { + for (values, exclusive, inclusive) in [ + (vec![1., 2., 3.], (1., 3.), (1.5, 2.5)), + (vec![1., 2., 3., 4.], (1.25, 3.75), (1.75, 3.25)), + (vec![1., 2., 3., 4., 5.], (1.5, 4.5), (2., 4.)), + (vec![1., 2., 3., 4., 5., 6., 7.], (2., 6.), (2.5, 5.5)), + (vec![1., 1., 2., 3., 5., 8.], (1., 5.75), (1.25, 4.5)), + (vec![1., 2.], (1., 2.), (1.25, 1.75)), + (vec![1.], (1., 1.), (1., 1.)), + (vec![], (0., 0.), (0., 0.)), + ] { + for (method, expected) in [(QuartileMethod::Exclusive, exclusive), (QuartileMethod::Inclusive, inclusive)] { + assert_eq!((quantile(&values, 0.25, method), quantile(&values, 0.75, method)), expected); + } + } + } +} diff --git a/crates/chart/src/render/legend.rs b/crates/chart/src/render/legend.rs new file mode 100644 index 000000000..95cb39d48 --- /dev/null +++ b/crates/chart/src/render/legend.rs @@ -0,0 +1,161 @@ +use super::frame::Bx; +use super::trend::supports_trendline; +use super::*; + +/// How a legend entry's swatch is drawn. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Swatch { + Box, + Line, + LineMarker, + Marker, +} + +/// The kind a series is drawn as (per-series kinds only apply to combo charts). +pub(super) fn plot_kind(chart: &Chart, s: &SeriesData) -> ChartKind { + if chart.kind == ChartKind::Combo { if s.kind == ChartKind::Combo { ChartKind::Line } else { s.kind } } else { chart.kind } +} + +/// Colour of point `j` for charts that colour by point (pie, doughnut, treemap, sunburst). +pub(super) fn point_color(data: &ChartData, j: usize) -> Rgba { + match (j, data.series.first()) { + (0, Some(s)) => s.color, + _ => j.checked_rem(data.palette.len()).and_then(|k| data.palette.get(k)).copied().unwrap_or_else(|| series_color(&Theme::default(), j)), + } +} + +fn first_len(data: &ChartData) -> usize { + data.series.first().map(|s| s.values.len()).unwrap_or(0) +} + +pub(super) fn cat_label(data: &ChartData, j: usize) -> String { + data.categories.get(j).cloned().unwrap_or_else(|| (j + 1).to_string()) +} + +/// Legend entries (text, colour, swatch) `render` draws for this chart; empty when hidden. +pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swatch)> { + if chart.legend == LegendPos::None { + return vec![]; + } + match chart.kind { + ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst => { + let n = first_len(data).min(MAX_CATS); + (0..n).map(|j| (cat_label(data, j), point_color(data, j), Swatch::Box)).collect() + } + ChartKind::Histogram | ChartKind::Funnel | ChartKind::Stock => vec![], + ChartKind::Waterfall => { + if data.series.is_empty() { + return vec![]; + } + let total = data.series.first().map(|s| s.color).unwrap_or(TEXT); + vec![("Increase".into(), UP, Swatch::Box), ("Decrease".into(), DOWN, Swatch::Box), ("Total".into(), total, Swatch::Box)] + } + _ => { + let series = data.series.iter().take(MAX_SERIES); + let mut out: Vec<(String, Rgba, Swatch)> = series + .clone() + .map(|s| { + let sw = match plot_kind(chart, s) { + ChartKind::Line | ChartKind::LineStacked | ChartKind::Radar | ChartKind::ScatterLines => Swatch::Line, + ChartKind::LineMarkers => Swatch::LineMarker, + ChartKind::Scatter | ChartKind::Bubble => Swatch::Marker, + _ => Swatch::Box, + }; + (s.name.clone(), s.color, sw) + }) + .collect(); + // Trendlines follow the series, named "Linear (Sales)" unless given a name. + for s in series.filter(|s| supports_trendline(plot_kind(chart, s))) { + for t in s.trendlines.iter().take(MAX_TRENDLINES) { + out.push((t.spec.name.clone().unwrap_or_else(|| auto_name(t.spec.kind, &s.name)), t.color, Swatch::Line)); + } + } + out + } + } +} + +// ---------------------------------------------------------------- legend + +fn swatch_w(sw: Swatch, s: f32) -> f32 { + if sw == Swatch::Box { s } else { s * 2.0 } +} + +fn draw_swatch(ctx: &mut Ctx, sw: Swatch, x: f32, yc: f32, s: f32, color: Rgba) { + match sw { + Swatch::Box => ctx.rect(x, yc - s / 2.0, s, s, color, None), + Swatch::Line => ctx.hline(x, x + 2.0 * s, yc, color, 2.0), + Swatch::LineMarker => { + ctx.hline(x, x + 2.0 * s, yc, color, 2.0); + ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.35, fill: Some(color), stroke: None }); + } + Swatch::Marker => ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.4, fill: Some(color), stroke: None }), + } +} + +pub(super) fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos: LegendPos, area: Bx) -> Bx { + let size = LABEL_SIZE; + let row_h = size * 1.6; + let s = size * 0.8; + let gap = 4.0; + let spacing = size * 1.3; + let item_w = |ctx: &Ctx, e: &(String, Rgba, Swatch)| swatch_w(e.2, s) + gap + ctx.tw(&e.0, size); + match pos { + LegendPos::None => area, + LegendPos::Top | LegendPos::Bottom => { + let max_rows = ((area.h * 0.3 / row_h).floor() as usize).max(1); + let mut rows: Vec> = vec![vec![]]; + let mut cur = 0.0f32; + for (i, e) in entries.iter().enumerate() { + let iw = item_w(ctx, e).min(area.w); + let need = if cur > 0.0 { spacing + iw } else { iw }; + if cur > 0.0 && cur + need > area.w { + if rows.len() >= max_rows { + break; + } + rows.push(vec![]); + cur = 0.0; + } + cur += if cur > 0.0 { spacing + iw } else { iw }; + if let Some(r) = rows.last_mut() { + r.push((i, iw)); + } + } + let lh = rows.len() as f32 * row_h; + let y0 = if pos == LegendPos::Bottom { area.bottom() - lh } else { area.y }; + for (ri, row) in rows.iter().enumerate() { + let total: f32 = row.iter().map(|(_, w)| w).sum::() + spacing * row.len().saturating_sub(1) as f32; + let mut x = area.x + (area.w - total).max(0.0) / 2.0; + let yc = y0 + ri as f32 * row_h + row_h / 2.0; + for &(i, iw) in row { + if let Some(e) = entries.get(i) { + draw_swatch(ctx, e.2, x, yc, s, e.1); + let sx = swatch_w(e.2, s) + gap; + let t = ctx.fit(&e.0, size, (iw - sx).max(0.0) + 0.5); + ctx.text(x + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle); + } + x += iw + spacing; + } + } + let used = lh + 4.0; + if pos == LegendPos::Bottom { Bx { h: area.h - used, ..area } } else { Bx { y: area.y + used, h: area.h - used, ..area } } + } + LegendPos::Left | LegendPos::Right => { + let colw = entries.iter().map(|e| item_w(ctx, e)).fold(0.0f32, f32::max).min(area.w * 0.35); + let max_rows = ((area.h / row_h).floor() as usize).max(1); + let n = entries.len().min(max_rows); + let lh = n as f32 * row_h; + let y0 = area.y + (area.h - lh).max(0.0) / 2.0; + let x0 = if pos == LegendPos::Right { area.right() - colw } else { area.x }; + for (i, e) in entries.iter().take(n).enumerate() { + let yc = y0 + i as f32 * row_h + row_h / 2.0; + draw_swatch(ctx, e.2, x0, yc, s, e.1); + let sx = swatch_w(e.2, s) + gap; + let t = ctx.fit(&e.0, size, (colw - sx).max(0.0) + 0.5); + ctx.text(x0 + sx, yc, &t, size, TEXT, HAlign::Left, VAlign::Middle); + } + let used = colw + 8.0; + if pos == LegendPos::Right { Bx { w: area.w - used, ..area } } else { Bx { x: area.x + used, w: area.w - used, ..area } } + } + } +} diff --git a/crates/chart/src/render/lines.rs b/crates/chart/src/render/lines.rs new file mode 100644 index 000000000..9cef4608c --- /dev/null +++ b/crates/chart/src/render/lines.rs @@ -0,0 +1,157 @@ +use super::frame::Bx; +use super::*; + +// ---------------------------------------------------------------- helpers + +/// Reduces a long polyline to first/min/max/last per pixel column. +pub(super) fn decimate(pts: Vec<[f32; 2]>, max: usize) -> Vec<[f32; 2]> { + if pts.len() <= max { + return pts; + } + let mut out = Vec::with_capacity(max * 2); + let mut i = 0; + while let Some(p) = pts.get(i) { + let col = p[0].floor(); + let (start, mut mn, mut mx) = (i, i, i); + let mut k = i + 1; + while let Some(q) = pts.get(k) { + if q[0].floor() != col { + break; + } + if pts.get(mn).is_some_and(|m| q[1] < m[1]) { + mn = k; + } + if pts.get(mx).is_some_and(|m| q[1] > m[1]) { + mx = k; + } + k += 1; + } + let mut idx = [start, mn, mx, k - 1]; + idx.sort_unstable(); + let mut last = usize::MAX; + for ix in idx { + if ix != last + && let Some(q) = pts.get(ix) + { + out.push(*q); + last = ix; + } + } + i = k; + } + if out.len() > max * 4 { + let step = out.len() / (max * 2) + 1; + let last = out.last().copied(); + let mut s: Vec<[f32; 2]> = out.into_iter().step_by(step).collect(); + if let Some(l) = last + && s.last() != Some(&l) + { + s.push(l); + } + return s; + } + out +} + +/// Draws a polyline split at gaps (`None`), decimated when long. With `smooth` (the plot box), +/// each run is interpolated with a Catmull-Rom spline so the line curves through the data points. +pub(super) fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32, smooth: Option) { + let mut run: Vec<[f32; 2]> = Vec::new(); + for p in pts.iter().chain(std::iter::once(&None)) { + match p { + Some(p) => run.push(*p), + None => { + if run.len() >= 2 { + let r = decimate(std::mem::take(&mut run), MAX_LINE_PTS); + let r = match smooth { + Some(plot) => smooth_run(r, plot), + None => r, + }; + ctx.line(r, color, width); + } else { + run.clear(); + } + } + } + } +} + +/// Catmull-Rom spline through `pts`: every segment gets the same number of sub-segments (up to +/// 100), chosen so the whole curve stays within `MAX_LINE_PTS * 2` points. Endpoints are +/// duplicated so the curve passes through the first and last points, and the curve is clamped +/// to the `plot` box (a spline overshoots its data near sharp turns). +fn smooth_run(pts: Vec<[f32; 2]>, plot: Bx) -> Vec<[f32; 2]> { + let n = pts.len(); + if n < 3 { + return pts; + } + let cap = MAX_LINE_PTS * 2; + let steps = (cap / n).clamp(1, 100); + let mut out: Vec<[f32; 2]> = Vec::with_capacity(n.saturating_mul(steps).min(cap) + 1); + let at = |i: isize| -> [f32; 2] { + let j = i.clamp(0, n as isize - 1) as usize; + pts.get(j).copied().unwrap_or([0.0, 0.0]) + }; + let (x0, x1) = (plot.x.min(plot.right()), plot.x.max(plot.right())); + let (y0, y1) = (plot.y.min(plot.bottom()), plot.y.max(plot.bottom())); + let clip = |p: [f32; 2]| [p[0].clamp(x0, x1), p[1].clamp(y0, y1)]; + for i in 0..n.saturating_sub(1) { + let (p0, p1, p2, p3) = (at(i as isize - 1), at(i as isize), at(i as isize + 1), at(i as isize + 2)); + for s in 0..steps { + let t = s as f32 / steps as f32; + out.push(clip(catmull(p0, p1, p2, p3, t))); + } + } + out.push(clip(at(n as isize - 1))); + out +} + +/// One Catmull-Rom point between `p1` and `p2` (uniform parameterisation), tangent-scaled by 0.5. +fn catmull(p0: [f32; 2], p1: [f32; 2], p2: [f32; 2], p3: [f32; 2], t: f32) -> [f32; 2] { + let t2 = t * t; + let t3 = t2 * t; + let mut o = [0.0f32; 2]; + for k in 0..2 { + let (a0, a1, a2, a3) = (p0[k], p1[k], p2[k], p3[k]); + o[k] = 0.5 * ((2.0 * a1) + (-a0 + a2) * t + (2.0 * a0 - 5.0 * a1 + 4.0 * a2 - a3) * t2 + (-a0 + 3.0 * a1 - 3.0 * a2 + a3) * t3); + } + o +} + +pub(super) fn draw_markers(ctx: &mut Ctx, pts: impl Iterator, r: f32, color: Rgba) { + let mut seen = HashSet::new(); + for p in pts { + if seen.len() >= MAX_MARKERS { + break; + } + if seen.insert(((p[0] * 0.5) as i32, (p[1] * 0.5) as i32)) { + ctx.out.push(Prim::Circle { cx: p[0], cy: p[1], r, fill: Some(color), stroke: Some((WHITE, 0.75)) }); + } + } +} + +#[cfg(test)] +mod smooth_tests { + use super::*; + + #[test] + fn long_runs_are_smoothed_uniformly_within_the_cap() { + let plot = Bx { x: 0.0, y: 0.0, w: 3000.0, h: 100.0 }; + let pts: Vec<[f32; 2]> = (0..1500).map(|i| [i as f32 * 2.0, if i % 2 == 0 { 20.0 } else { 80.0 }]).collect(); + let out = smooth_run(pts, plot); + assert!(out.len() <= MAX_LINE_PTS * 2 + 1, "{}", out.len()); + // Two sub-segments per segment all the way to the end, not 100 each and then a straight tail. + assert_eq!(out.len(), 1499 * 2 + 1); + assert_eq!(out.last().copied(), Some([2998.0, 80.0])); + } + + #[test] + fn spline_overshoot_is_clamped_to_the_plot() { + let plot = Bx { x: 0.0, y: 10.0, w: 300.0, h: 100.0 }; + // A step right at the plot edges: Catmull-Rom overshoots past both. + let pts = vec![[0.0, 110.0], [100.0, 110.0], [110.0, 10.0], [200.0, 10.0], [300.0, 10.0]]; + let out = smooth_run(pts, plot); + assert!(out.len() > 5); + assert!(out.iter().all(|p| (0.0..=300.0).contains(&p[0]) && (10.0..=110.0).contains(&p[1]))); + } +} diff --git a/crates/chart/src/render/trend.rs b/crates/chart/src/render/trend.rs new file mode 100644 index 000000000..38e435a47 --- /dev/null +++ b/crates/chart/src/render/trend.rs @@ -0,0 +1,166 @@ +use super::frame::{Bx, value_range}; +use super::lines::decimate; +use super::*; + +// ---------------------------------------------------------------- trendlines + +/// Samples along a regression trendline. +const TREND_STEPS: usize = 200; + +/// Whether series drawn as `k` show trendlines (Excel offers them on 2-D unstacked column, +/// bar, line, area, scatter and bubble charts). +pub fn supports_trendline(k: ChartKind) -> bool { + use ChartKind::*; + matches!(k, ColumnClustered | BarClustered | Line | LineMarkers | Area | Scatter | ScatterLines | Bubble) +} + +/// A forecast distance as drawn: finite, non-negative and bounded. +fn forecast(v: f64) -> f64 { + if v.is_finite() && v > 0.0 { v.min(1e9) } else { 0.0 } +} + +/// Most empty category slots a forward forecast may add after `ncat` categories: four times the +/// data (at least 32), and never past `MAX_CATS` in total. A larger forecast would shrink every +/// column to nothing. +pub(super) fn max_forward_slots(ncat: usize) -> f64 { + (ncat.saturating_mul(4).max(32)).min(MAX_CATS.saturating_sub(ncat)) as f64 +} + +/// Longest forward forecast among `s`'s regression trendlines. +pub(super) fn max_forward(s: &SeriesData) -> f64 { + s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.forward)).fold(0.0, f64::max) +} + +/// Longest backward forecast among `s`'s regression trendlines. +pub(super) fn max_backward(s: &SeriesData) -> f64 { + s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.backward)).fold(0.0, f64::max) +} + +/// Liang–Barsky: the part of segment `a`–`b` inside the unit square, with whether the start and +/// end were cut. +#[allow(clippy::type_complexity)] +fn clip_segment(a: (f64, f64), b: (f64, f64)) -> Option<((f64, f64), (f64, f64), bool, bool)> { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let (mut t0, mut t1) = (0.0f64, 1.0f64); + for (p, q) in [(-dx, a.0), (dx, 1.0 - a.0), (-dy, a.1), (dy, 1.0 - a.1)] { + if p == 0.0 { + if q < 0.0 { + return None; + } + } else { + let r = q / p; + if p < 0.0 { + if r > t1 { + return None; + } + t0 = t0.max(r); + } else { + if r < t0 { + return None; + } + t1 = t1.min(r); + } + } + } + Some(((a.0 + t0 * dx, a.1 + t0 * dy), (a.0 + t1 * dx, a.1 + t1 * dy), t0 > 0.0, t1 < 1.0)) +} + +/// Clips a sampled polyline in plot fractions (`None` = gap) to the plot box (the unit +/// square), returning the visible runs. +fn clip_unit(pts: &[Option<(f64, f64)>]) -> Vec> { + const LIM: f64 = 1e9; + let ok = |p: &(f64, f64)| p.0.is_finite() && p.1.is_finite(); + let cl = |p: (f64, f64)| (p.0.clamp(-LIM, LIM), p.1.clamp(-LIM, LIM)); + let mut runs: Vec> = Vec::new(); + let mut run: Vec<(f64, f64)> = Vec::new(); + for w in pts.windows(2) { + let seg = match w { + [Some(a), Some(b)] if ok(a) && ok(b) => clip_segment(cl(*a), cl(*b)), + _ => None, + }; + match seg { + Some((p, q, cut_start, cut_end)) => { + if cut_start || run.is_empty() { + if run.len() >= 2 { + runs.push(std::mem::take(&mut run)); + } + run.clear(); + run.push(p); + } + run.push(q); + if cut_end { + runs.push(std::mem::take(&mut run)); + } + } + None => { + if run.len() >= 2 { + runs.push(std::mem::take(&mut run)); + } + run.clear(); + } + } + } + if run.len() >= 2 { + runs.push(run); + } + runs +} + +/// Draws `s`'s trendlines fitted to its plotted points `pts` (data x, y). `frac` maps data to +/// fractions of the plot box (0..1 inside, along the x and y axes), `px` fractions to pixels. +/// Equation and R² label lines are appended to `labels`. Degenerate fits draw nothing. +pub(super) fn draw_trendlines( + ctx: &mut Ctx, + s: &SeriesData, + pts: &[(f64, f64)], + frac: &dyn Fn(f64, f64) -> (f64, f64), + px: &dyn Fn((f64, f64)) -> [f32; 2], + labels: &mut Vec, + window: (f64, f64), +) { + let Some((xlo, xhi)) = value_range(pts.iter().map(|p| p.0)) else { return }; + for t in s.trendlines.iter().take(MAX_TRENDLINES) { + let spec = &t.spec; + let samples: Vec> = match spec.kind { + TrendlineKind::MovingAverage(p) => moving_average(pts, p).into_iter().map(|(x, y)| Some(frac(x, y))).collect(), + _ => { + let Some(fit) = fit_trendline(spec, pts) else { continue }; + // Sample only the part of the forecast that is on the plot. + let (lo, hi) = ((xlo - forecast(spec.backward)).max(window.0), (xhi + forecast(spec.forward)).min(window.1)); + if spec.display_equation { + labels.push(fit.equation()); + } + if spec.display_r_squared { + labels.push(fit.r_squared()); + } + (0..=TREND_STEPS) + .map(|i| { + let x = lo + (hi - lo) * i as f64 / TREND_STEPS as f64; + fit.eval(x).map(|y| frac(x, y)) + }) + .collect() + } + }; + let width = spec.width.filter(|w| w.is_finite()).unwrap_or(1.5).clamp(0.25, 12.0); + let dash = spec.dash != Some(LineDash::Solid); + for run in clip_unit(&samples) { + let line = decimate(run.into_iter().map(px).collect(), MAX_LINE_PTS); + if line.len() >= 2 { + ctx.out.push(Prim::Line { pts: line, color: t.color, width, dash }); + } + } + } +} + +/// Trendline equation / R² labels, stacked in the plot's top-right corner. +pub(super) fn draw_trend_labels(ctx: &mut Ctx, plot: Bx, labels: &[String]) { + let lh = LABEL_SIZE * 1.3; + for (i, t) in labels.iter().enumerate() { + let y = plot.y + 4.0 + i as f32 * lh; + if y + lh > plot.bottom() { + break; + } + let t = ctx.fit(t, LABEL_SIZE, plot.w - 8.0); + ctx.text(plot.right() - 4.0, y, &t, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Top); + } +} From 6a40610bb448c5f91b177be493b8572043742bfa Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:44:03 +0000 Subject: [PATCH 46/76] refactor(ui-egui): move grid.rs items to grid/ --- crates/ui-egui/src/grid.rs | 4272 +------------------------ crates/ui-egui/src/grid/cell_paint.rs | 310 ++ crates/ui-egui/src/grid/cursor.rs | 138 + crates/ui-egui/src/grid/editor.rs | 161 + crates/ui-egui/src/grid/geo.rs | 237 ++ crates/ui-egui/src/grid/interact.rs | 904 ++++++ crates/ui-egui/src/grid/keyboard.rs | 155 + crates/ui-egui/src/grid/menus.rs | 551 ++++ crates/ui-egui/src/grid/quadrant.rs | 474 +++ crates/ui-egui/src/grid/selection.rs | 419 +++ crates/ui-egui/src/grid/show.rs | 861 +++++ crates/ui-egui/src/grid/tables.rs | 87 + 12 files changed, 4328 insertions(+), 4241 deletions(-) create mode 100644 crates/ui-egui/src/grid/cell_paint.rs create mode 100644 crates/ui-egui/src/grid/cursor.rs create mode 100644 crates/ui-egui/src/grid/editor.rs create mode 100644 crates/ui-egui/src/grid/geo.rs create mode 100644 crates/ui-egui/src/grid/interact.rs create mode 100644 crates/ui-egui/src/grid/keyboard.rs create mode 100644 crates/ui-egui/src/grid/menus.rs create mode 100644 crates/ui-egui/src/grid/quadrant.rs create mode 100644 crates/ui-egui/src/grid/selection.rs create mode 100644 crates/ui-egui/src/grid/show.rs create mode 100644 crates/ui-egui/src/grid/tables.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 970ab61dd..e3715a6de 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -15,6 +15,37 @@ use crate::SheetApp; use crate::editor::EditState; use crate::theme::{self, Tokens}; +mod cell_paint; +mod cursor; +mod editor; +mod geo; +mod interact; +mod keyboard; +mod menus; +mod quadrant; +mod selection; +mod show; +mod tables; + +pub(crate) use cell_paint::paint_border; +#[cfg(test)] +pub(crate) use cell_paint::{cell_text_y, rotated_text_pos, wrapped_cell_text_y}; +#[cfg(test)] +use cell_paint::{center_across_end, display_text, look, numfmt_color, painted_halign}; +pub use editor::validation_arrow; +#[cfg(test)] +use editor::{editor_slack, editor_width, list_picker_height}; +pub use geo::Geo; +pub use interact::{follow_link, freeze_handles, point_move}; +pub(crate) use quadrant::paint_quadrant; +pub(crate) use selection::paint_selection; +pub use selection::selection_fill; +pub(crate) use show::horizontal_scrollbar; +pub use show::show; +#[cfg(test)] +use tables::filter_table_range; +pub use tables::{TableHeading, filter_button_rect, heading_table, table_headings}; + pub const COL_HEADER_H: f32 = 21.0; /// Sheet units are 96-dpi pixels (as in XLSX); on screen one unit is drawn at this many points, /// which matches desktop spreadsheet density (a default 20 px row is 16 pt tall). @@ -238,3962 +269,6 @@ impl WheelZoom { } } -/// Geometry of the grid for one frame (handles zoom and frozen panes). -#[derive(Clone)] -pub struct Geo { - pub rect: Rect, - pub cells: Rect, - pub z: f32, - pub header_w: f32, - pub header_h: f32, - /// The outline bands (grouped rows / columns): left of the row headers and above the column - /// headers; 0 without an outline (see `outline`). - pub band_w: f32, - pub band_h: f32, - pub row_outline: Arc, - pub col_outline: Arc, - pub fr: u32, - pub fc: u32, - pub frozen_w: f32, - pub frozen_h: f32, - pub scroll: egui::Vec2, - /// Sheet Right-to-Left: everything is laid out left-to-right as usual (these "logical" - /// coordinates are what `x`, `cell_rect`, `col_at`… return) and mirrored across `rect`'s - /// vertical centre line on screen: painted shapes by `mirror_painted`, widgets by - /// `mirror_rect`/`mirror_pos`, and pointer input back by `mirror_pos` (an involution). - pub rtl: bool, - /// Prefix sums over custom row heights / column widths, built once per frame. - ri: std::sync::Arc, - ci: std::sync::Arc, - /// Page Layout view: cells sit on pages (`scroll` is then in page-layout units). - pub paper: Option>, -} - -impl Geo { - pub fn new(sh: &Sheet, rect: Rect, scroll: egui::Vec2) -> Geo { - let zoom = (sh.zoom as f32 / 100.0).clamp(0.1, 4.0); - let z = zoom * DISPLAY_SCALE; - let header_h = if sh.show_headings { (COL_HEADER_H * zoom.max(0.75)).round() } else { 0.0 }; - let ri = std::sync::Arc::new(LineIndex::new(&sh.rows, sh.default_row_height, MAX_ROWS)); - let ci = std::sync::Arc::new(LineIndex::new(&sh.cols, sh.default_col_width, MAX_COLS)); - let last_row = ri.at((scroll.y + rect.height() / z) as f64 + 200.0) + 1; - let digits = (last_row as f32).log10().floor() as i32 + 1; - let header_w = if sh.show_headings { ((digits.max(2) as f32 * 7.0 + 14.0) * zoom.max(0.75)).round() } else { 0.0 }; - let (fr, fc) = sh.freeze.unwrap_or((0, 0)); - let frozen_w = (ci.start(fc) as f32) * z; - let frozen_h = (ri.start(fr) as f32) * z; - let row_outline = Arc::new(crate::outline::Axis::of(sh, true)); - let col_outline = Arc::new(crate::outline::Axis::of(sh, false)); - // Show Outline Symbols off: no bands (the outline's hidden lines stay hidden). - let symbols = sh.show_outline_symbols; - let mut band_w = if symbols { crate::outline::band(row_outline.levels, zoom) } else { 0.0 }; - let mut band_h = if symbols { crate::outline::band(col_outline.levels, zoom) } else { 0.0 }; - // Without headings the level buttons still need a corner to sit in. - if symbols && row_outline.levels > 0 && header_h == 0.0 { - band_h = band_h.max(crate::outline::lane(zoom) + 3.0); - } - if symbols && col_outline.levels > 0 && header_w == 0.0 { - band_w = band_w.max(crate::outline::lane(zoom) + 3.0); - } - let cells = Rect::from_min_max(pos2(rect.left() + band_w + header_w, rect.top() + band_h + header_h), rect.max); - Geo { - rect, - cells, - z, - header_w, - header_h, - band_w, - band_h, - row_outline, - col_outline, - fr, - fc, - frozen_w, - frozen_h, - scroll, - rtl: sh.right_to_left, - ri, - ci, - paper: None, - } - } - /// Left edge of the row headers (right of the outline band). - pub fn head_left(&self) -> f32 { - self.cells.left() - self.header_w - } - /// Top edge of the column headers (below the outline band). - pub fn head_top(&self) -> f32 { - self.cells.top() - self.header_h - } - /// Page Layout view geometry: `rect` excludes the rulers; cells are drawn at the print scale - /// on pages laid out by `grid`; frozen panes don't apply (as in Excel). - pub fn paged(sh: &Sheet, rect: Rect, scroll: egui::Vec2, grid: Arc) -> Geo { - let mut g = Geo::new(sh, rect, scroll); - let zoom = (sh.zoom as f32 / 100.0).clamp(0.1, 4.0); - g.z = zoom * DISPLAY_SCALE * grid.scale.clamp(0.1, 4.0); - (g.fr, g.fc, g.frozen_w, g.frozen_h) = (0, 0, 0.0, 0.0); - g.paper = Some(grid); - g - } - /// The point is over cells (in Page Layout view: on a page, inside its margins). - pub fn in_cells(&self, p: Pos2) -> bool { - if !self.cells.contains(p) { - return false; - } - match &self.paper { - Some(pg) => pg.on_cells( - self.scroll.x as f64 + ((p.x - self.cells.left()) / self.z) as f64, - self.scroll.y as f64 + ((p.y - self.cells.top()) / self.z) as f64, - ), - None => true, - } - } - /// Scroll offset (in `scroll` units) at which `row` reaches the top of the scrolled area. - pub fn scroll_top_of(&self, sh: &Sheet, row: u32) -> f32 { - match &self.paper { - Some(pg) => pg.row_y(row) as f32, - None => (sh.row_top(row) - sh.row_top(self.fr)) as f32, - } - } - /// Scroll offset (in `scroll` units) at which `col` reaches the left of the scrolled area. - pub fn scroll_left_of(&self, sh: &Sheet, col: u32) -> f32 { - match &self.paper { - Some(pg) => pg.col_x(col) as f32, - None => (sh.col_left(col) - sh.col_left(self.fc)) as f32, - } - } - /// Screen x for a logical x (and back): identity unless the sheet is right-to-left. - pub fn mirror_x(&self, x: f32) -> f32 { - if self.rtl { self.rect.left() + self.rect.right() - x } else { x } - } - pub fn mirror_pos(&self, p: Pos2) -> Pos2 { - pos2(self.mirror_x(p.x), p.y) - } - pub fn mirror_rect(&self, r: Rect) -> Rect { - if self.rtl { Rect::from_min_max(pos2(self.mirror_x(r.right()), r.top()), pos2(self.mirror_x(r.left()), r.bottom())) } else { r } - } - /// On-screen rectangle of a cell (`cell_rect` mirrored for right-to-left sheets). - pub fn screen_cell_rect(&self, sh: &Sheet, c: CellRef) -> Rect { - self.mirror_rect(self.cell_rect(sh, c)) - } - pub fn x(&self, _sh: &Sheet, col: u32) -> f32 { - if let Some(pg) = &self.paper { - return self.cells.left() + (pg.col_x(col) - self.scroll.x as f64) as f32 * self.z; - } - if col < self.fc { - self.cells.left() + self.ci.start(col) as f32 * self.z - } else { - self.cells.left() + self.frozen_w + (self.ci.start(col) as f32 - self.ci.start(self.fc) as f32 - self.scroll.x) * self.z - } - } - pub fn y(&self, _sh: &Sheet, row: u32) -> f32 { - if let Some(pg) = &self.paper { - return self.cells.top() + (pg.row_y(row) - self.scroll.y as f64) as f32 * self.z; - } - if row < self.fr { - self.cells.top() + self.ri.start(row) as f32 * self.z - } else { - self.cells.top() + self.frozen_h + (self.ri.start(row) as f32 - self.ri.start(self.fr) as f32 - self.scroll.y) * self.z - } - } - pub fn cell_rect(&self, sh: &Sheet, c: CellRef) -> Rect { - Rect::from_min_max( - pos2(self.x(sh, c.col), self.y(sh, c.row)), - pos2(self.x(sh, c.col) + sh.col_width(c.col) * self.z, self.y(sh, c.row) + sh.row_height(c.row) * self.z), - ) - } - pub fn range_rect(&self, sh: &Sheet, r: RangeRef) -> Rect { - let a = self.cell_rect(sh, r.start); - let b = self.cell_rect(sh, r.end); - Rect::from_min_max(a.min, b.max) - } - pub fn col_at(&self, _sh: &Sheet, x: f32) -> u32 { - if let Some(pg) = &self.paper { - return pg.col_at(self.scroll.x as f64 + ((x - self.cells.left()) / self.z) as f64); - } - let dx = (x - self.cells.left()) / self.z; - if dx < self.frozen_w / self.z { - self.ci.at(dx.max(0.0) as f64) - } else { - self.ci.at(self.ci.start(self.fc) + self.scroll.x as f64 + (dx - self.frozen_w / self.z) as f64) - } - } - pub fn row_at(&self, _sh: &Sheet, y: f32) -> u32 { - if let Some(pg) = &self.paper { - return pg.row_at(self.scroll.y as f64 + ((y - self.cells.top()) / self.z) as f64); - } - let dy = (y - self.cells.top()) / self.z; - if dy < self.frozen_h / self.z { - self.ri.at(dy.max(0.0) as f64) - } else { - self.ri.at(self.ri.start(self.fr) + self.scroll.y as f64 + (dy - self.frozen_h / self.z) as f64) - } - } - pub fn cell_at(&self, sh: &Sheet, p: Pos2) -> CellRef { - CellRef::new(self.row_at(sh, p.y), self.col_at(sh, p.x)) - } - /// Visible scrolled rows (from the first visible row after the frozen ones). - fn scroll_rows(&self, _sh: &Sheet) -> (u32, u32) { - let top = self.ri.start(self.fr) + self.scroll.y as f64; - let r0 = self.ri.at(top).max(self.fr); - let r1 = self.ri.at(top + ((self.cells.height() - self.frozen_h) / self.z) as f64).min(MAX_ROWS - 1); - (r0, r1) - } - fn scroll_cols(&self, _sh: &Sheet) -> (u32, u32) { - let left = self.ci.start(self.fc) + self.scroll.x as f64; - let c0 = self.ci.at(left).max(self.fc); - let c1 = self.ci.at(left + ((self.cells.width() - self.frozen_w) / self.z) as f64).min(MAX_COLS - 1); - (c0, c1) - } - /// Screen areas of the panes (frozen rows/columns or the scrolling part) that hold part of `r`. - /// The panes share one coordinate system, so a range that is not clipped to these is also drawn - /// over the frozen rows/columns when it is scrolled under them. - pub fn range_panes(&self, r: RangeRef) -> Vec { - let split = pos2(self.cells.left() + self.frozen_w, self.cells.top() + self.frozen_h); - let mut xs = Vec::new(); - if r.start.col < self.fc { - xs.push((self.cells.left(), split.x)); - } - if r.end.col >= self.fc { - xs.push((split.x, self.cells.right())); - } - let mut ys = Vec::new(); - if r.start.row < self.fr { - ys.push((self.cells.top(), split.y)); - } - if r.end.row >= self.fr { - ys.push((split.y, self.cells.bottom())); - } - ys.iter().flat_map(|&(y0, y1)| xs.iter().map(move |&(x0, x1)| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)))).collect() - } - /// True when cell `c` is in the scrolling pane but scrolled entirely under the frozen rows or columns. - pub fn hidden_under_frozen(&self, sh: &Sheet, c: CellRef) -> bool { - let r = self.cell_rect(sh, c); - (self.fc > 0 && c.col >= self.fc && r.right() <= self.cells.left() + self.frozen_w) - || (self.fr > 0 && c.row >= self.fr && r.bottom() <= self.cells.top() + self.frozen_h) - } -} - -/// Resolved look of a cell for painting. -struct Look { - style: Style, - fill: Option, - text_color: Color32, -} - -fn col32(c: Color, wb: &Workbook, default: Color32) -> Color32 { - c.resolve(&wb.theme).map(theme::color32).unwrap_or(default) -} - -fn numfmt_color(c: gridcraft_engine::numfmt::FormatColor) -> Color32 { - c.rgb().map(|[r, g, b]| Color32::from_rgb(r, g, b)).unwrap_or(Color32::BLACK) -} - -/// Last column of a Center Across Selection run starting at `start`: it extends over following columns (up to `limit`) while `continues` holds. -fn center_across_end(start: u32, limit: u32, continues: impl Fn(u32) -> bool) -> u32 { - let mut end = start; - while end < limit && end + 1 < MAX_COLS && continues(end + 1) { - end += 1; - } - end -} - -fn look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut CfCache) -> (Look, Option) { - let mut style = wb.styles.get(sh.style_id(c)).clone(); - if let Some(t) = sh.table_at(c) { - style = gridcraft_engine::tables::table_cell_style(wb, si, t, c); - } - let cfl = cf.look(wb, si, c); - if let Some(l) = &cfl - && let Some(st) = &l.style - { - if st.fill.pattern != PatternType::None { - style.fill = st.fill; - } - if st.font.color != Color::Auto { - style.font.color = st.font.color; - } - if st.font.bold { - style.font.bold = true; - } - if st.font.italic { - style.font.italic = true; - } - if st.font.underline != Underline::None { - style.font.underline = st.font.underline; - } - if st.font.strike { - style.font.strike = true; - } - // In a differential style General means "not set" (#1163). - if st.num_fmt.as_str() != "General" { - style.num_fmt = st.num_fmt.clone(); - } - for (dst, src) in [ - (&mut style.borders.left, st.borders.left), - (&mut style.borders.right, st.borders.right), - (&mut style.borders.top, st.borders.top), - (&mut style.borders.bottom, st.borders.bottom), - ] { - if !src.is_none() { - *dst = src; - } - } - } - let mut fill = match style.fill.pattern { - PatternType::None => None, - PatternType::Solid => style.fill.fg.resolve(&wb.theme).map(theme::color32), - _ => { - // Patterns: approximate with a blend of foreground and background. - let fg = style.fill.fg.resolve(&wb.theme).unwrap_or([0, 0, 0]); - let bg = style.fill.bg.resolve(&wb.theme).unwrap_or([255, 255, 255]); - Some(Color32::from_rgb( - ((fg[0] as u16 + bg[0] as u16 * 3) / 4) as u8, - ((fg[1] as u16 + bg[1] as u16 * 3) / 4) as u8, - ((fg[2] as u16 + bg[2] as u16 * 3) / 4) as u8, - )) - } - }; - if let Some(l) = &cfl - && let Some(rgb) = l.scale_fill - { - fill = Some(theme::color32(rgb)); - } - let text_color = col32(style.font.color, wb, Color32::BLACK); - (Look { style, fill, text_color }, cfl) -} - -/// Text for a value with General narrowing to the available width. -fn display_text( - wb: &Workbook, - sh: &Sheet, - c: CellRef, - v: &Value, - st: &Style, - width_px: f32, - char_w: f32, -) -> (String, Option, bool, Option<(char, usize)>) { - if sh.show_formulas - && let Some(f) = sh.cell(c).and_then(|x| x.formula.as_ref()) - { - return (format!("={}", f.text), None, false, None); - } - if !sh.show_zeros && v.as_f64() == Some(0.0) { - return (String::new(), None, false, None); - } - let code = st.num_fmt.as_str(); - // A checkbox cell draws TRUE/FALSE (and empty) as a box; other values show as General. - if code == "checkbox" && matches!(v, Value::Bool(_) | Value::Empty) { - return (String::new(), None, false, None); - } - if code == "General" - && let Value::Number(n) = v - { - let max_chars = ((width_px - 4.0) / char_w).floor().max(1.0) as usize; - return match gridcraft_engine::numfmt::format_general_fit(*n, max_chars.min(11)) { - Some(t) => (t, None, true, None), - None => ("#".repeat(max_chars.max(1)), None, true, None), - }; - } - let f = gridcraft_engine::display::format(v, code, wb); - (f.text, f.color.map(numfmt_color), f.numeric, f.fill) -} - -pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); - let available = ui.available_rect_before_wrap(); - // A right-to-left sheet has its vertical scrollbar on the left. - let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left); - let bar = 14.0_f32.min(available.width()); - let rect = if rtl { - Rect::from_min_max(pos2(available.left() + bar, available.top()), available.max) - } else { - Rect::from_min_max(available.min, pos2(available.right() - bar, available.bottom())) - }; - let resp = ui.allocate_rect(rect, Sense::click_and_drag()); - app.grid.rect = Some(rect); - let Some(d) = app.session.active() else { return }; - let wb: Arc = d.wb.clone(); - let si = wb.active_sheet(); - let Some(sh) = wb.sheet(si) else { return }; - let sel = d.selection.clone(); - let place = (d.uid, si); - let mut view = app.view(); - // Page Layout view: pages on a desk, rulers above and left of the headings. - let paper = sh.view == gridcraft_engine::model::SheetViewMode::PageLayout; - let ruler = if paper && !app.session.hide_ruler { crate::paper_view::RULER } else { 0.0 }; - // Right-to-left sheets carry the vertical ruler on the right, beside the row headings. - let grid_rect = if rtl { - Rect::from_min_max(pos2(rect.left(), (rect.top() + ruler).min(rect.bottom())), pos2((rect.right() - ruler).max(rect.left()), rect.bottom())) - } else { - Rect::from_min_max(pos2((rect.left() + ruler).min(rect.right()), (rect.top() + ruler).min(rect.bottom())), rect.max) - }; - let make_geo = |app: &mut SheetApp, scroll: egui::Vec2| -> Geo { - if paper { - let probe = Geo::new(sh, grid_rect, scroll); - if let Some(g) = crate::paper_view::grid_for(app, &wb, si, sh, probe.cells.size(), scroll) { - let g = crate::paper_view::with_dragged_margin(app, &wb, si, g); - return Geo::paged(sh, grid_rect, scroll, g); - } - } - Geo::new(sh, rect, scroll) - }; - // Scrolling. - let (scroll_delta, zoom_delta, modifiers, now) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers, i.time)); - if (resp.hovered() || resp.contains_pointer()) && !crate::text_box::contains_pointer(app, ui.ctx()) { - if zoom_delta != 1.0 { - // egui's zoom factor is e^(speed × points); one notch is `line_scroll_speed` points. - let notch = ui.ctx().options(|o| o.input_options.scroll_zoom_speed * o.input_options.line_scroll_speed); - let turned = zoom_delta.ln() / notch; - let mut zooming = WheelZoom::at(app, place, sh.zoom, now); - if now - zooming.time > WHEEL_ZOOM_IDLE { - zooming.notches = 0.0; - } - if turned.is_finite() { - zooming.notches = (zooming.notches + turned).clamp(-32.0, 32.0); - } - zooming.time = now; - // A notch steps once 60% of it has turned, and the rest of it brings `notches` back to - // about 0. (Past half, so what's left after a step can't step back.) Steps count from - // where a glide is going, not from where it has got to. - let from = zooming.glide.map_or(sh.zoom, |(to, _)| to); - let mut z = from; - while zooming.notches >= 0.6 { - z = wheel_zoom_step(z, true); - zooming.notches -= 1.0; - } - while zooming.notches <= -0.6 { - z = wheel_zoom_step(z, false); - zooming.notches += 1.0; - } - if z != from { - zooming.glide = Some((z, sh.zoom)); - } - app.grid.wheel_zoom = Some(zooming); - } else if scroll_delta != egui::Vec2::ZERO { - let z = if paper { make_geo(app, view.scroll).z } else { sh.zoom as f32 / 100.0 * DISPLAY_SCALE }; - let mut d = scroll_delta; - // A horizontal swipe moves the content the way the fingers go, which is towards - // higher columns on a mirrored sheet. - if sh.right_to_left { - d.x = -d.x; - } - if modifiers.shift && d.x == 0.0 { - d = vec2(d.y, 0.0); - } - view.scroll -= d / z; - } - } - // A one-finger touch drag pans the sheet, the content following the finger; it doesn't select. - // (Two fingers pinch-zoom through `zoom_delta` above.) - if resp.drag_started() && ui.input(|i| i.any_touches()) { - app.grid.touch_pan = true; - } - if app.grid.touch_pan && resp.dragged() && ui.input(|i| i.multi_touch().is_none()) { - let z = if paper { make_geo(app, view.scroll).z } else { sh.zoom as f32 / 100.0 * DISPLAY_SCALE }; - let mut d = resp.drag_delta(); - if sh.right_to_left { - d.x = -d.x; - } - view.scroll -= d / z; - } - // The zoom glides to its step like egui eases a wheel notch (90% of the way in 0.1 s), or goes - // there at once without animations (as for the selection outline below: View › Animations - // off, egui's off in the headless harness, or a screenshot on its way). - let mut zooming = WheelZoom::at(app, place, sh.zoom, now); - if let Some((to, shown)) = zooming.glide { - let glides = app.ui.animations && ui.ctx().global_style().animation_time > 0.0 && !app.shots.in_flight(); - let share = egui::emath::exponential_smooth_factor(0.9, 0.1, ui.input(|i| i.stable_dt).min(0.1)); - let z = if glides { zoom_glide_step(shown, to, share) } else { to }; - let _ = app.session.run("view.zoom", json!({"percent": z})); - zooming.glide = (z != to).then_some((to, z)); - ui.ctx().request_repaint(); - } - // Store a glide's progress, or that it stopped. - if app.grid.wheel_zoom.is_some_and(|w| w.glide.is_some()) { - app.grid.wheel_zoom = Some(zooming); - } - view.scroll = view.scroll.max(egui::Vec2::ZERO); - let (mut max_x, mut max_y) = (sh.col_left(MAX_COLS - 20) as f32, sh.row_top(MAX_ROWS - 40) as f32); - if paper { - // Page layout units run past sheet units (margins and gaps); the end is known once the - // pages reach the last row / column. - let g = make_geo(app, view.scroll); - if let Some(pg) = &g.paper { - let (ex, ey) = pg.extent(); - max_x = if pg.cols_complete { (ex as f32 - g.cells.width() / g.z).max(0.0) } else { 1.0e9 }; - max_y = if pg.rows_complete { (ey as f32 - g.cells.height() / g.z).max(0.0) } else { 1.0e9 }; - } - } - view.scroll.x = view.scroll.x.min(max_x); - view.scroll.y = view.scroll.y.min(max_y); - let mut geo = make_geo(app, view.scroll); - // Keep the active cell (or the moving end of an extension) visible after keyboard moves. - if app.grid.ensure_visible { - app.grid.ensure_visible = false; - let moving = std::mem::take(&mut app.grid.reveal_moving_end); - let cell = if moving { sel.moving_end() } else { sel.active }; - let from = app.grid.reveal_from.take(); - let target = app.editor.as_ref().and_then(|e| e.point_cell).unwrap_or(cell); - // A whole column's moving end sits on the sheet's last row (a whole row's on its last - // column): when an extension moves it only across, the view keeps its place the other way. - // An extension that reaches the last row itself (Ctrl+Shift+Down past the data) still - // follows it. - let extended = (moving && target == cell).then(|| sel.current()); - let before = view.scroll; - if paper { - crate::paper_view::ensure_covers(app, &wb, si, target); - geo = make_geo(app, view.scroll); - } - let cr = geo.cell_rect(sh, target); - let area = Rect::from_min_max(pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h), geo.cells.max); - if target.row >= geo.fr { - if cr.top() < area.top() { - view.scroll.y -= (area.top() - cr.top()) / geo.z; - } else if cr.bottom() > area.bottom() - 2.0 { - view.scroll.y += (cr.bottom() - area.bottom() + 2.0) / geo.z; - } - } else if from.is_some_and(|f| f.row >= geo.fr) { - // A move from the scrolled rows into the frozen ones (Page Up or Ctrl+Up onto a frozen - // row 1) brings the scrolled rows back to their top; the rows it passed over would - // otherwise stay out of sight. - view.scroll.y = 0.0; - } - if target.col >= geo.fc { - if cr.left() < area.left() { - view.scroll.x -= (area.left() - cr.left()) / geo.z; - } else if cr.right() > area.right() - 2.0 { - view.scroll.x += ((cr.right() - area.right() + 2.0) / geo.z).min(cr.left() - area.left()); - } - } else if from.is_some_and(|f| f.col >= geo.fc) { - view.scroll.x = 0.0; - } - if extended.is_some_and(|r| r.is_full_cols()) && from.is_some_and(|f| f.row == cell.row) { - view.scroll.y = before.y; - } - if extended.is_some_and(|r| r.is_full_rows()) && from.is_some_and(|f| f.col == cell.col) { - view.scroll.x = before.x; - } - view.scroll = view.scroll.max(egui::Vec2::ZERO); - geo = make_geo(app, view.scroll); - } - // Snap scrolling to whole rows/columns at rest like Excel? Keep smooth (better on trackpads). - if let Some(v) = app.view_mut() { - *v = view; - } - app.grid.cells_rect = Some(geo.mirror_rect(geo.cells)); - // The selection outline glides to a new selection. It stays on its cells when Animations is - // off, when egui's animations are (the headless harness), and while a screenshot is on its way. - // A mouse button held down (a click or a drag selecting cells) moves it at once, as in Excel: - // a gliding outline trails the pointer and reads as lag. - let pointer_selecting = ui.input(|i| i.pointer.any_down()); - let outline = app.grid.glide.frame(crate::selection_glide::Frame { - place, - range: sel.current(), - target: geo.range_rect(sh, sel.current()), - view: geo.cells, - now, - frame_nr: ui.ctx().cumulative_frame_nr(), - animate: app.ui.animations && ui.ctx().global_style().animation_time > 0.0 && !app.shots.in_flight() && !pointer_selecting, - }); - if app.grid.glide.is_gliding() { - ui.ctx().request_repaint(); - } - app.grid.geo = Some(geo.clone()); - let painter = ui.painter_at(rect); - painter.rect_filled(rect, 0.0, t.grid_bg); - // Everything up to the headers is painted in logical (left-to-right) coordinates. - let mirror_from = painter.add(egui::Shape::Noop); - let mut cf = CfCache::new(); - let quads = if geo.paper.is_some() { - crate::paper_view::paint_pages(app, &painter, &geo, &wb, si, sh, &sel, outline, &mut cf, &t) - } else { - paint_panes(app, &painter, &geo, &wb, si, sh, &sel, outline, &mut cf, &t) - }; - // Objects above cells. A hidden object can't stay selected: Delete or the Format pane would act - // on something the user can't see. - if crate::chartview::objects_hidden(&wb) { - app.selected_chart = None; - } - let objects = crate::chartview::paint_objects(app, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh); - paint_overlays(app, ui, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh, &sel); - if sh.view == gridcraft_engine::model::SheetViewMode::PageBreakPreview { - paint_pages(app, &painter.with_clip_rect(geo.cells), &geo, sh); - } - if geo.paper.is_some() { - crate::paper_view::paint_headers_footers(app, ui, &painter, &geo, &wb, sh); - } - // Headers. - if sh.show_headings { - let headings = table_headings(&geo, sh, sel.active); - paint_headers(&painter, &geo, sh, &sel, &t, &quads, &headings); - paint_freeze_handles(&painter, &geo, sh, &app.grid.drag, &t); - } - crate::outline::paint(&painter, &geo, sh, &t); - if geo.rtl { - let mirror_to = painter.add(egui::Shape::Noop); - mirror_painted(ui.ctx(), painter.layer_id(), mirror_from.0..mirror_to.0, &geo, &objects); - } - if geo.paper.is_some() { - crate::paper_view::paint_rulers(app, &painter, rect, &geo, &t); - } - // Interaction. - let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu, app.grid.fill_menu); - // A pan the grid never saw end (it was hidden mid-drag) must not swallow the next press. - if app.grid.touch_pan && !resp.dragged() && !resp.drag_stopped() { - app.grid.touch_pan = false; - } - if app.grid.touch_pan { - // Skip the frames of a touch pan, its release included. - if !resp.dragged() { - app.grid.touch_pan = false; - } - } else { - crate::paper_view::interact(app, ui, &resp, &geo, sh, &wb); - interact(app, ui, &resp, &geo, sh, &wb); - } - update_cursor(app, ui, &resp, &geo, sh); - crate::text_box::show(app, ui, &geo, sh); - if app.editor.as_ref().is_some_and(|e| !e.from_formula_bar) { - in_cell_editor(app, ui, &geo, sh, &wb); - } - filter_menu(app, ui, &geo, previous_popups.0 == app.grid.filter_menu); - list_picker(app, ui, &geo, previous_popups.1 == app.grid.list_picker); - header_menu(app, ui, previous_popups.2 == app.grid.header_menu); - context_menu(app, ui); - fill_menu(app, ui, previous_popups.3 == app.grid.fill_menu); - fill_options_tag(app, ui, &geo, sh); - object_menu(app, ui); - error_check_tag(app, ui, &geo, sh); - let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) }; - // The track starts below the column headings and frozen rows; the bar's column beside them - // takes its colour too instead of showing a light square in dark mode. - ui.painter().rect_filled(Rect::from_min_max(pos2(track_x.0, rect.top()), pos2(track_x.1, rect.bottom())), 0.0, t.tab_bar); - vertical_scrollbar(app, ui, sh, &geo, track_x, max_y, &t); - if app.session.clipboard.is_some() { - // By the clock, not per frame: a gliding outline repaints more often than the ants step. - app.grid.marching_phase = crate::selection_glide::ants_phase(now); - ui.ctx().request_repaint_after(std::time::Duration::from_millis(60)); - } -} - -/// Normal view: up to four panes (frozen rows/columns), each painted with its own clip. -#[allow(clippy::too_many_arguments)] -fn paint_panes( - app: &SheetApp, - painter: &Painter, - geo: &Geo, - wb: &Workbook, - si: usize, - sh: &Sheet, - sel: &gridcraft_engine::Selection, - outline: Rect, - cf: &mut CfCache, - t: &Tokens, -) -> Vec<((u32, u32), (u32, u32), Rect)> { - // Quadrants: (rows, cols, clip). - let (sr0, sr1) = geo.scroll_rows(sh); - let (sc0, sc1) = geo.scroll_cols(sh); - let fr_rows = (0, geo.fr.saturating_sub(1)); - let fr_cols = (0, geo.fc.saturating_sub(1)); - let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h); - let mut quads = vec![((sr0, sr1), (sc0, sc1), Rect::from_min_max(split, geo.cells.max))]; - if geo.fr > 0 { - quads.push((fr_rows, (sc0, sc1), Rect::from_min_max(pos2(split.x, geo.cells.top()), pos2(geo.cells.right(), split.y)))); - } - if geo.fc > 0 { - quads.push(((sr0, sr1), fr_cols, Rect::from_min_max(pos2(geo.cells.left(), split.y), pos2(split.x, geo.cells.bottom())))); - } - if geo.fr > 0 && geo.fc > 0 { - quads.push((fr_rows, fr_cols, Rect::from_min_max(geo.cells.min, split))); - } - for (rows, cols, clip) in &quads { - if rows.0 > rows.1 || cols.0 > cols.1 { - continue; - } - let p = painter.with_clip_rect(*clip); - paint_quadrant(&p, geo, wb, si, sh, *rows, *cols, cf, t); - paint_selection(&p, geo, sh, sel, outline, t, app, *rows, *cols); - } - // Freeze lines. - if geo.fr > 0 { - painter.line_segment([pos2(geo.cells.left(), split.y), pos2(geo.cells.right(), split.y)], Stroke::new(1.0, Color32::from_gray(150))); - } - if geo.fc > 0 { - painter.line_segment([pos2(split.x, geo.cells.top()), pos2(split.x, geo.cells.bottom())], Stroke::new(1.0, Color32::from_gray(150))); - } - quads -} - -/// The worksheet is painted virtually, so it needs its own scrollbar rather than a -/// ScrollArea containing all million rows. Keep the scroll extent stable during a drag. -fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &Geo, track_x: (f32, f32), limit: f32, t: &Tokens) { - let track = Rect::from_min_max(pos2(track_x.0, geo.cells.top() + geo.frozen_h), pos2(track_x.1, geo.cells.bottom())); - if track.height() <= 0.0 || track.width() <= 0.0 { - return; - } - let visible = track.height() / geo.z; - let scroll = app.view().scroll.y; - let last = sh.used_range().map(|r| r.end.row.saturating_add(10)).unwrap_or(100).clamp(100, MAX_ROWS - 1); - let content = geo.scroll_top_of(sh, last); - let extent = (content - visible).max(scroll).max(0.0).min(limit); - let id = ui.scope_id().with("vscroll"); - let resp = ui.interact(track, id, Sense::click_and_drag()); - let drag_id = id.with("extent"); - if resp.drag_started() { - ui.ctx().data_mut(|d| d.insert_temp(drag_id, extent)); - } - let extent = if resp.dragged() { ui.ctx().data(|d| d.get_temp::(drag_id)).unwrap_or(extent) } else { extent }; - let thumb_h = (track.height() * visible / (extent + visible)).clamp(24.0_f32.min(track.height()), track.height()); - let travel = track.height() - thumb_h; - let thumb_top = track.top() + if extent > 0.0 { travel * (scroll / extent).clamp(0.0, 1.0) } else { 0.0 }; - let thumb = Rect::from_min_size(pos2(track.left() + 3.0, thumb_top), vec2((track.width() - 6.0).max(0.0), thumb_h)); - ui.painter().rect_filled(track, 0.0, t.tab_bar); - ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled }); - let mut next = scroll; - if resp.dragged() && travel > 0.0 { - next += resp.drag_delta().y / travel * extent; - } else if resp.clicked() - && let Some(p) = resp.interact_pointer_pos() - { - if p.y < thumb.top() { - next -= visible; - } else if p.y > thumb.bottom() { - next += visible; - } - } - if resp.hovered() { - next -= ui.input(|i| i.smooth_scroll_delta.y) / geo.z; - } - next = next.clamp(0.0, limit); - if next != scroll { - if let Some(v) = app.view_mut() { - v.scroll.y = next; - } - ui.ctx().request_repaint(); - } -} - -/// The horizontal counterpart of `vertical_scrollbar`, drawn in the sheet tab bar as in Excel. The tab -/// bar is laid out before the grid, so this reads the last frame's geometry and leaves clamping the -/// far end to the grid, which runs later in the same frame. -pub(crate) fn horizontal_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, track: Rect, t: &Tokens) { - app.grid.hscroll = Some(track); - if track.height() <= 0.0 || track.width() <= 0.0 { - return; - } - let Some(geo) = app.grid.geo.clone() else { return }; - let Some(wb) = app.session.active().map(|d| d.wb.clone()) else { return }; - let Some(sh) = wb.active() else { return }; - let visible = ((geo.cells.width() - geo.frozen_w) / geo.z).max(0.0); - let scroll = app.view().scroll.x; - let last = sh.used_range().map(|r| r.end.col.saturating_add(10)).unwrap_or(26).clamp(26, MAX_COLS - 1); - let content = geo.scroll_left_of(sh, last); - let extent = (content - visible).max(scroll).max(0.0); - let id = ui.scope_id().with("hscroll"); - let resp = ui.interact(track, id, Sense::click_and_drag()); - let drag_id = id.with("extent"); - if resp.drag_started() { - ui.ctx().data_mut(|d| d.insert_temp(drag_id, extent)); - } - let extent = if resp.dragged() { ui.ctx().data(|d| d.get_temp::(drag_id)).unwrap_or(extent) } else { extent }; - let thumb_w = (track.width() * visible / (extent + visible).max(1.0)).clamp(24.0_f32.min(track.width()), track.width()); - let travel = track.width() - thumb_w; - let offset = if extent > 0.0 { travel * (scroll / extent).clamp(0.0, 1.0) } else { 0.0 }; - // A right-to-left sheet scrolls towards the left, so its thumb starts at the right end. - let dir = if geo.rtl { -1.0 } else { 1.0 }; - let thumb_left = if geo.rtl { track.right() - thumb_w - offset } else { track.left() + offset }; - let thumb = Rect::from_min_size(pos2(thumb_left, track.top() + 3.0), vec2(thumb_w, (track.height() - 6.0).max(0.0))); - ui.painter().rect_filled(track, 0.0, t.tab_bar); - ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled }); - let mut next = scroll; - if resp.dragged() && travel > 0.0 { - next += dir * resp.drag_delta().x / travel * extent; - } else if resp.clicked() - && let Some(p) = resp.interact_pointer_pos() - { - if p.x < thumb.left() { - next -= dir * visible; - } else if p.x > thumb.right() { - next += dir * visible; - } - } - if resp.hovered() { - let d = ui.input(|i| i.smooth_scroll_delta); - next -= (if d.x != 0.0 { dir * d.x } else { d.y }) / geo.z; - } - next = next.max(0.0); - if next != scroll { - if let Some(v) = app.view_mut() { - v.scroll.x = next; - } - ui.ctx().request_repaint(); - } -} - -pub(crate) fn wrapped_cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 { - let pad_v = 1.5 * z; - let y_top = rect.top() + pad_v; - // Not clamped to `y_top`: text taller than the row overflows upward (Bottom) or both ways - // (Center), and the cell's clip rect trims it, so Middle and Bottom stay distinct from Top. - let y_bottom = rect.bottom() - gh - pad_v; - match valign { - VAlign::Top => y_top, - VAlign::Center | VAlign::Justify | VAlign::Distributed => (y_top + y_bottom) / 2.0, - VAlign::Bottom => y_bottom, - } -} - -pub(crate) fn cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 { - let top_inset = 1.0 * z; - let y_top = rect.top() - top_inset; - let y_bottom = (rect.bottom() - gh - 0.5 * z).max(y_top); - match valign { - VAlign::Top => y_top, - VAlign::Center | VAlign::Justify | VAlign::Distributed => (y_top + y_bottom) / 2.0, - VAlign::Bottom => y_bottom, - } -} - -/// Where to put a `TextShape` holding a galley of `size` turned by `angle` (egui radians, so -/// negative for upward text) for it to sit in `rect` per the cell's alignment. egui turns the -/// galley about `pos`, its top-left corner, so the turned box is placed first and `pos` follows. -/// General keeps the text at the left. Bottom (the default) puts upward text at the bottom, but -/// downward text at the top, where it starts reading. -pub(crate) fn rotated_text_pos(rect: Rect, size: egui::Vec2, angle: f32, h: HAlign, v: VAlign, pad: f32, indent: f32) -> Pos2 { - let rot = egui::emath::Rot2::from_angle(angle); - let turned = Rect::from_points(&[vec2(0.0, 0.0), vec2(size.x, 0.0), vec2(0.0, size.y), size].map(|c| (rot * c).to_pos2())); - let x = match h { - HAlign::Center | HAlign::CenterAcross | HAlign::Distributed => rect.center().x - turned.width() / 2.0, - HAlign::Right => rect.right() - pad - indent - turned.width(), - _ => rect.left() + pad + indent, - }; - let y = match v { - VAlign::Center | VAlign::Justify | VAlign::Distributed => rect.center().y - turned.height() / 2.0, - VAlign::Bottom if angle < 0.0 => rect.bottom() - pad - turned.height(), - _ => rect.top() + pad, - }; - pos2(x, y) - turned.min.to_vec2() -} - -/// Mirror what the grid painted into `layer` (the shape indices in `range`) for a right-to-left -/// sheet. Geometry is reflected; text and meshes (glyphs, pictures) keep their reading direction -/// and only move so that their box lands mirrored. Floating objects (`objects`: shape index -/// ranges with their logical rect) move as a whole, so a chart keeps its axes and a picture -/// isn't flipped. -fn mirror_painted(ctx: &egui::Context, layer: egui::LayerId, range: std::ops::Range, geo: &Geo, objects: &[crate::chartview::PaintedObject]) { - let axis = geo.rect.left() + geo.rect.right(); - ctx.graphics_mut(|g| { - let list = g.entry(layer); - for i in range { - let object = objects.iter().find(|o| o.shapes.contains(&i)); - list.mutate_shape(egui::layers::ShapeIdx(i), |cs| { - cs.clip_rect = geo.mirror_rect(cs.clip_rect); - match object { - Some(o) => cs.shape.translate(vec2(axis - o.rect.right() - o.rect.left(), 0.0)), - None => mirror_shape(&mut cs.shape, axis), - } - }); - } - }); -} - -/// Reflect one shape across the vertical line `x = axis / 2`. -fn mirror_shape(shape: &mut egui::Shape, axis: f32) { - use egui::Shape; - let flip = |p: &mut Pos2| p.x = axis - p.x; - match shape { - Shape::Noop | Shape::Callback(_) => {} - Shape::Vec(v) => v.iter_mut().for_each(|s| mirror_shape(s, axis)), - Shape::Circle(c) => flip(&mut c.center), - Shape::Ellipse(e) => flip(&mut e.center), - Shape::LineSegment { points, .. } => points.iter_mut().for_each(flip), - Shape::Path(p) => { - p.points.iter_mut().for_each(flip); - // Keep the preferred (clockwise) winding for filled polygons. - p.points.reverse(); - } - Shape::Rect(r) => { - r.rect = Rect::from_min_max(pos2(axis - r.rect.right(), r.rect.top()), pos2(axis - r.rect.left(), r.rect.bottom())); - let c = r.corner_radius; - r.corner_radius = egui::CornerRadius { nw: c.ne, ne: c.nw, sw: c.se, se: c.sw }; - } - Shape::QuadraticBezier(b) => b.points.iter_mut().for_each(flip), - Shape::CubicBezier(b) => b.points.iter_mut().for_each(flip), - _ => { - let b = shape.visual_bounding_rect(); - if b.is_finite() { - shape.translate(vec2(axis - b.right() - b.left(), 0.0)); - } - } - } -} - -#[allow(clippy::too_many_arguments)] -pub(crate) fn paint_quadrant( - p: &Painter, - geo: &Geo, - wb: &Workbook, - si: usize, - sh: &Sheet, - rows: (u32, u32), - cols: (u32, u32), - cf: &mut CfCache, - t: &Tokens, -) { - let z = geo.z; - let (r0, r1) = rows; - let (c0, c1) = cols; - let range = RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)); - // Merges are looked up 20 columns beyond the quadrant too: text overflowing in from off-screen cells stops at them. - let near = RangeRef::new(CellRef::new(r0, c0.saturating_sub(20)), CellRef::new(r1, c1.saturating_add(20).min(MAX_COLS - 1))); - let merges: Vec = sh.merges.iter().filter(|m| m.intersects(&near)).copied().collect(); - let in_merge = |c: CellRef| merges.iter().find(|m| m.contains(c)).copied(); - // Gridlines. - if sh.show_gridlines { - let stroke = Stroke::new(1.0, t.gridline); - let top = geo.y(sh, r0); - let bottom = geo.y(sh, r1 + 1); - let left = geo.x(sh, c0); - let right = geo.x(sh, c1 + 1); - for c in c0..=c1 + 1 { - if c <= c1 && sh.col_width(c) == 0.0 { - continue; - } - let x = geo.x(sh, c).round() - 0.5; - p.line_segment([pos2(x, top), pos2(x, bottom)], stroke); - } - for r in r0..=r1 + 1 { - if r <= r1 && sh.row_height(r) == 0.0 { - continue; - } - let y = geo.y(sh, r).round() - 0.5; - p.line_segment([pos2(left, y), pos2(right, y)], stroke); - } - } - // Merged areas: blank them (no inner gridlines) and paint as one cell. - let mut cells: Vec = Vec::new(); - for r in r0..=r1 { - if sh.row_height(r) == 0.0 { - continue; - } - for c in c0..=c1 { - if sh.col_width(c) == 0.0 { - continue; - } - cells.push(CellRef::new(r, c)); - } - } - let empty_ok = |c: CellRef| -> bool { sh.value_ref(c).is_none_or(Value::is_empty) && in_merge(c).is_none() }; - // Backgrounds. - let mut looks: Vec<(CellRef, Rect, Look, Option)> = Vec::with_capacity(cells.len().min(20_000)); - for &c in &cells { - let (rect, anchor) = match in_merge(c) { - Some(m) if m.start != c && !(c.row == r0.max(m.start.row) && c.col == c0.max(m.start.col)) => continue, - Some(m) => (geo.range_rect(sh, m), m.start), - None => (geo.cell_rect(sh, c), c), - }; - let (lk, cfl) = look(wb, si, sh, anchor, cf); - if let Some(f) = lk.fill { - p.rect_filled(rect.expand(0.0), 0.0, f); - } else if in_merge(c).is_some() { - p.rect_filled(rect.shrink(0.5), 0.0, Color32::WHITE); - } - looks.push((anchor, rect, lk, cfl)); - } - // Text cells just outside the quadrant whose text may overflow into it: the nearest non-empty - // cell left of `c0` and right of `c1` in each row (within the same frozen / scrolled pane). - let mut outside: Vec<(CellRef, Rect, Look, Option)> = Vec::new(); - let same_pane = |col: u32| (col < geo.fc) == (c0 < geo.fc); - for r in r0..=r1 { - if sh.row_height(r) == 0.0 { - continue; - } - let left = (c0.saturating_sub(20)..c0).rev().map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n)); - let right = (c1.saturating_add(1)..=c1.saturating_add(20).min(MAX_COLS - 1)).map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n)); - for n in [left, right].into_iter().flatten() { - if same_pane(n.col) && sh.col_width(n.col) > 0.0 && in_merge(n).is_none() { - let (lk, cfl) = look(wb, si, sh, n, cf); - outside.push((n, geo.cell_rect(sh, n), lk, cfl)); - } - } - } - // Text and per-cell decorations. - for (c, rect, lk, cfl) in looks.iter().chain(&outside) { - let c = *c; - if let Some(picture) = sh.cell_pictures.get(&c) { - crate::cell_pictures::paint(p, *rect, picture); - continue; - } - let v = sh.value(c); - if let Some(l) = cfl - && let Some((frac, rgb, gradient)) = l.bar - { - let bar = Rect::from_min_size(pos2(rect.left() + 2.0, rect.top() + 2.0), vec2((rect.width() - 4.0) * frac, rect.height() - 4.0)); - let col = theme::color32(rgb); - if gradient { - let mut mesh = egui::Mesh::default(); - let light = Color32::from_rgb( - ((rgb[0] as u16 + 255 * 3) / 4) as u8, - ((rgb[1] as u16 + 255 * 3) / 4) as u8, - ((rgb[2] as u16 + 255 * 3) / 4) as u8, - ); - let i = mesh.vertices.len() as u32; - for (pt, cc) in [(bar.left_top(), col), (bar.right_top(), light), (bar.right_bottom(), light), (bar.left_bottom(), col)] { - mesh.colored_vertex(pt, cc); - } - mesh.add_triangle(i, i + 1, i + 2); - mesh.add_triangle(i, i + 2, i + 3); - p.add(mesh); - p.rect_stroke(bar, 0.0, Stroke::new(1.0, col), StrokeKind::Inside); - } else { - p.rect_filled(bar, 0.0, col); - } - } - let mut text_left = rect.left(); - if let Some(l) = cfl - && let Some((set, idx)) = &l.icon - { - let r = Rect::from_min_size(pos2(rect.left() + 2.0, rect.center().y - 6.0 * z), vec2(12.0 * z, 12.0 * z)); - paint_cf_icon(p, r, set, *idx); - text_left += 15.0 * z; - } - if lk.style.num_fmt.as_str() == "checkbox" && matches!(v, Value::Bool(_) | Value::Empty) { - let s = (11.0 * z * 1.2).clamp(8.0, 24.0); - let b = Rect::from_center_size(rect.center(), vec2(s, s)); - let on = v == Value::Bool(true); - p.rect_filled(b, 2.0, if on { Color32::from_rgb(0x10, 0x7C, 0x41) } else { Color32::WHITE }); - p.rect_stroke( - b, - 2.0, - Stroke::new(1.0, if on { Color32::from_rgb(0x10, 0x7C, 0x41) } else { Color32::from_gray(110) }), - StrokeKind::Inside, - ); - if on { - crate::icons::paint_on_sheet(p, b.shrink(1.5), crate::icons::Icon::Check, Color32::WHITE); - } - continue; - } - if cfl.as_ref().is_some_and(|l| l.hide_value) || v.is_empty() { - continue; - } - // Align and clip header text inside the content area, away from the dropdown. - let filter_space = sh.filter_button_space(c) * z; - let text_rect = Rect::from_min_max(rect.min, pos2((rect.right() - filter_space).max(rect.left()), rect.bottom())); - let rect = &text_rect; - let st = &lk.style; - let size = st.font.size * 96.0 / 72.0 * z; - let fam = theme::cell_family(&st.font.name, st.font.bold, st.font.italic); - let font = FontId::new(size, fam); - // Digits fit by the cell's own font and size, not the default 11pt (9pt marks in narrow columns). - let char_w = p.layout_no_wrap("0".to_string(), font.clone(), Color32::PLACEHOLDER).size().x.max(1.0); - let avail_w = rect.right() - text_left; - let (mut text, ncolor, numeric, fill_char) = display_text(wb, sh, c, &v, st, avail_w / z.max(0.1) * z, char_w); - if sh.show_formulas && sh.cell(c).is_some_and(|cell| cell.formula.is_some()) { - text = crate::formula_locale::display(&text, crate::i18n::current(p.ctx()).formula_locale(), wb, si); - } - if text.is_empty() { - continue; - } - let color = ncolor.unwrap_or(lk.text_color); - let halign = match st.align.h { - HAlign::General => match v { - Value::Number(_) => HAlign::Right, - Value::Bool(_) | Value::Error(_) => HAlign::Center, - _ if numeric => HAlign::Right, - _ => HAlign::Left, - }, - h => painted_halign(h, geo.rtl), - }; - let indent = st.align.indent as f32 * 9.0 * z; - let pad = 2.5 * z; - let rotated = st.align.rotation != 0 && st.align.rotation != 255; - // Rotated text wraps along its own direction (below), not across the column width. - if !rotated && (st.align.wrap || st.align.h == HAlign::Justify) { - let wrap_w = (rect.width() - 2.0 * pad - indent).max(4.0); - let mut job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, wrap_w); - job.halign = match halign { - HAlign::Center | HAlign::CenterAcross => egui::Align::Center, - HAlign::Right => egui::Align::RIGHT, - _ => egui::Align::LEFT, - }; - let galley = p.layout_job(job); - let x = match halign { - HAlign::Center | HAlign::CenterAcross => rect.center().x, - HAlign::Right => rect.right() - pad, - _ => text_left + pad + indent, - }; - let y = wrapped_cell_text_y(st.align.v, *rect, galley.size().y, z); - p.with_clip_rect(rect.intersect(p.clip_rect())).galley(pos2(x, y), galley, color); - continue; - } - // Wrap Text off: Excel ignores the line breaks and draws the whole string on one line. - let first_line: String = text.chars().filter(|&ch| ch != '\n' && ch != '\r').collect(); - // The fill position indexes `text`; move it back by the breaks removed before it. - let fill_char = fill_char.map(|(ch, pos)| (ch, pos.saturating_sub(text.get(..pos).map_or(0, |s| s.matches(['\n', '\r']).count())))); - // Shrink to fit: too-wide content is drawn in a smaller font so it fits the cell (or the - // merged area): it neither spills, nor is clipped, nor turns into ####. - let small = (st.align.shrink && !rotated && st.align.h != HAlign::Fill && fill_char.is_none()) - .then(|| shrink_font(p, &first_line, &font, avail_w - 2.0 * pad - indent)) - .flatten(); - let shrunk = small.is_some(); - let font = small.unwrap_or(font); - let mut galley = p.layout_no_wrap(first_line.clone(), font.clone(), color); - let mut w = galley.size().x; - // Numbers and dates never overflow: they show #### when too narrow. - let hashed = !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill; - if hashed { - // Measured on `#` itself: it is wider than `0` in some fonts, and #### must fit its cell. - let hash_w = p.layout_no_wrap("#".to_string(), font.clone(), color).size().x.max(1.0); - let n = ((avail_w - 2.0 * pad) / hash_w).floor().max(1.0) as usize; - galley = p.layout_no_wrap("#".repeat(n), font.clone(), color); - w = galley.size().x; - } - // Repeat fill (`*` in number formats) / Fill alignment. A number already shown as #### keeps - // it: rebuilding it from the text would overflow and clip its leading digits. - if let Some((ch, pos)) = fill_char - && !hashed - && pos <= first_line.len() - { - let base_w = w; - let cw = p.layout_no_wrap(ch.to_string(), font.clone(), color).size().x.max(1.0); - let n = (((avail_w - 2.0 * pad) - base_w) / cw).floor().max(0.0) as usize; - let mut s = first_line.clone(); - if first_line.is_char_boundary(pos) { - s.insert_str(pos, &ch.to_string().repeat(n)); - } - galley = p.layout_no_wrap(s, font.clone(), color); - w = galley.size().x; - } - // Center Across Selection: the text is centred over this cell and the empty cells to its right that also use it. - let across_end = if halign == HAlign::CenterAcross && !rotated && in_merge(c).is_none() { - center_across_end(c.col, c1 + 20, |col| { - let n = CellRef::new(c.row, col); - empty_ok(n) && wb.styles.get(sh.style_id(n)).align.h == HAlign::CenterAcross - }) - } else { - c.col - }; - // Overflow into empty neighbours for text (not upright text, whose width is its line height). - let mut clip = *rect; - let overflows = !numeric && !matches!(v, Value::Number(_)) && st.align.rotation.abs() != 90 && w > rect.width() - 2.0 * pad; - if filter_space == 0.0 && (overflows || across_end > c.col) && in_merge(c).is_none() { - let mut right = rect.right(); - let mut left = rect.left(); - if across_end > c.col { - right = geo.x(sh, across_end + 1); - } - if matches!(halign, HAlign::Left | HAlign::Center | HAlign::Justify) { - let mut cc = c.col + 1; - while right - rect.left() < w + 2.0 * pad && cc < MAX_COLS && empty_ok(CellRef::new(c.row, cc)) && cc <= c1 + 20 { - right = geo.x(sh, cc + 1); - cc += 1; - } - } - if matches!(halign, HAlign::Right | HAlign::Center) { - let mut cc = c.col; - while rect.right() - left < w + 2.0 * pad && cc > 0 && empty_ok(CellRef::new(c.row, cc - 1)) && cc + 20 > c0 { - cc -= 1; - left = geo.x(sh, cc); - } - } - clip = Rect::from_min_max(pos2(left, rect.top()), pos2(right, rect.bottom())); - // Paint over the gridlines the text passes through. - if lk.fill.is_none() { - let inner = Rect::from_min_max(pos2(clip.left() + 1.0, clip.top() + 0.5), pos2(clip.right() - 1.0, clip.bottom() - 1.0)); - let extra_l = Rect::from_min_max(inner.min, pos2(rect.left() + 1.0, inner.bottom())); - let extra_r = Rect::from_min_max(pos2(rect.right() - 1.0, inner.top()), inner.max); - if extra_l.width() > 0.0 { - p.rect_filled(extra_l, 0.0, t.grid_bg); - } - if extra_r.width() > 0.0 { - p.rect_filled(extra_r, 0.0, t.grid_bg); - } - } - } - let x = match halign { - HAlign::CenterAcross => clip.center().x - w / 2.0, - HAlign::Center | HAlign::Distributed => rect.center().x - w / 2.0, - HAlign::Right => rect.right() - pad - w - indent, - _ => text_left + pad + indent, - }; - let gh = galley.size().y; - let y = cell_text_y(st.align.v, *rect, gh, z); - let cp = p.with_clip_rect(clip.intersect(p.clip_rect())); - if rotated { - let angle = -(st.align.rotation as f32).to_radians(); - // Upright (±90°) wrapped text wraps to the row height: lines then stack across the column. - let galley = if st.align.wrap && st.align.rotation.abs() == 90 { - let job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, (rect.height() - 2.0 * pad).max(4.0)); - p.layout_job(job) - } else { - galley.clone() - }; - let pos = rotated_text_pos(*rect, galley.size(), angle, painted_halign(st.align.h, geo.rtl), st.align.v, pad, indent); - let mut shape = egui::epaint::TextShape::new(pos, galley, color); - shape.angle = angle; - cp.add(shape); - } else { - cp.galley(pos2(x, y), galley.clone(), color); - } - let baseline = y + gh * 0.82; - match st.font.underline { - Underline::None => {} - Underline::Double | Underline::DoubleAccounting => { - cp.line_segment([pos2(x, baseline + 1.0), pos2(x + w, baseline + 1.0)], Stroke::new(1.0, color)); - cp.line_segment([pos2(x, baseline + 3.0), pos2(x + w, baseline + 3.0)], Stroke::new(1.0, color)); - } - _ => { - cp.line_segment([pos2(x, baseline + 1.0), pos2(x + w, baseline + 1.0)], Stroke::new(1.0, color)); - } - } - if st.font.strike { - cp.line_segment([pos2(x, y + gh * 0.55), pos2(x + w, y + gh * 0.55)], Stroke::new(1.0, color)); - } - } - // Borders (over fills and text). - for (c, rect, lk, _) in &looks { - let b = &lk.style.borders; - let merge = merges.iter().find(|m| m.start == *c); - let edges = match merge { - // A merged area's outline lives on the cells along each of its edges (Excel writes the - // right edge on the last column's cells and the bottom edge on the last row's), so each - // edge is drawn cell by cell from the cell it belongs to. - Some(m) => merged_edges(wb, si, sh, geo, *m, *rect, (r0, r1), (c0, c1), cf), - None => vec![ - (b.top, [rect.left_top(), rect.right_top()]), - (b.bottom, [rect.left_bottom(), rect.right_bottom()]), - (b.left, [rect.left_top(), rect.left_bottom()]), - (b.right, [rect.right_top(), rect.right_bottom()]), - ], - }; - for (line, pts) in edges { - if line.is_none() { - continue; - } - let col = col32(line.color, wb, Color32::BLACK); - paint_border(p, pts, line.style, col); - } - if !b.diag_down.is_none() { - paint_border(p, [rect.left_top(), rect.right_bottom()], b.diag_down.style, col32(b.diag_down.color, wb, Color32::BLACK)); - } - if !b.diag_up.is_none() { - paint_border(p, [rect.left_bottom(), rect.right_top()], b.diag_up.style, col32(b.diag_up.color, wb, Color32::BLACK)); - } - // Comment / note indicator. - if let Some(cm) = sh.comments.get(c) { - let tri_col = if cm.threaded { Color32::from_rgb(0x88, 0x55, 0xC8) } else { Color32::from_rgb(0xD0, 0x2E, 0x2E) }; - let s = 6.0 * z.max(0.7); - p.add(egui::Shape::convex_polygon( - vec![rect.right_top(), pos2(rect.right(), rect.top() + s), pos2(rect.right() - s, rect.top())], - tri_col, - Stroke::NONE, - )); - } - // Background error checking: a number stored as text gets Excel's green corner (#1994). - if !sh.show_formulas && sh.cell(*c).and_then(gridcraft_engine::cmd::formulas::number_stored_as_text).is_some() { - let s = 6.0 * z.max(0.7); - p.add(egui::Shape::convex_polygon( - vec![rect.left_top(), pos2(rect.left() + s, rect.top()), pos2(rect.left(), rect.top() + s)], - t.error_check_green, - Stroke::NONE, - )); - } - } - // Sparklines. - for sp in &sh.sparklines { - if !range.contains(sp.cell) { - continue; - } - let rect = geo.cell_rect(sh, sp.cell).shrink(3.0); - let vals: Vec> = match gridcraft_engine::calc::evaluate(wb, si, sp.cell, &sp.source) { - Value::Array(a) => a.data.iter().map(Value::as_f64).collect(), - v => vec![v.as_f64()], - }; - let col = sp.color.resolve(&wb.theme).unwrap_or([0x2B, 0x7C, 0xD3]); - let prims = gridcraft_chart::render_sparkline(sp.kind, &vals, [col[0], col[1], col[2], 255], sp.markers, rect.width(), rect.height()); - crate::chartview::paint_prims(p, rect.min, &prims, 1.0); - } - // Filter buttons. - let mut buttons: Vec<(CellRef, bool)> = Vec::new(); - if let Some(af) = &sh.autofilter { - for col in af.range.start.col..=af.range.end.col { - let active = af.criteria.iter().any(|(o, _)| af.range.start.col + o == col); - buttons.push((CellRef::new(af.range.start.row, col), active)); - } - } - for tb in &sh.tables { - if tb.filter_button && tb.header_row { - for col in tb.range.start.col..=tb.range.end.col { - buttons.push((CellRef::new(tb.range.start.row, col), false)); - } - } - } - for (c, active) in buttons { - if !range.contains(c) { - continue; - } - paint_filter_button(p, filter_button_rect(geo, sh, c), active); - } -} - -pub fn filter_button_rect(geo: &Geo, sh: &Sheet, c: CellRef) -> Rect { - let rect = geo.cell_rect(sh, c); - // Keep the button within the same header slot that AutoFit reserves. - let pad = 2.0 * geo.z; - let s = (rect.height() - 4.0).clamp(10.0, 18.0).min((sh.filter_button_space(c) * geo.z - pad).max(0.0)); - Rect::from_min_size(pos2(rect.right() - s - pad, rect.bottom() - s - pad), vec2(s, s)) -} - -/// A Table column heading that shows the column's header name in place of its letters. -#[derive(Clone, Debug)] -pub struct TableHeading { - pub col: u32, - /// The table's header row, which the filter menu is keyed by. - pub header_row: u32, - pub name: String, - /// The heading slot over the column (logical coordinates). - pub rect: Rect, - /// The filter button, when the table shows filter buttons. - pub button: Option, - /// The column is filtered (the button shows the funnel). - pub filtered: bool, -} - -/// The Table whose header names replace the column letters: the one holding the active cell, -/// with its header row scrolled out of view and some of its rows still in view (Excel, #1423). -pub fn heading_table<'a>(geo: &Geo, sh: &'a Sheet, active: CellRef) -> Option<&'a gridcraft_engine::model::Table> { - // Page Layout view keeps the letters. - if geo.paper.is_some() || geo.header_h <= 0.0 { - return None; - } - let tb = sh.tables.iter().find(|t| t.header_row && t.range.contains(active))?; - let header = tb.range.start.row; - // A header row in the frozen rows is always on screen. - if header < geo.fr { - return None; - } - let top = geo.cells.top() + geo.frozen_h; - let bottom = |r: u32| geo.y(sh, r) + sh.row_height(r) * geo.z; - (bottom(header) <= top + 0.5 && bottom(tb.range.end.row) > top + 0.5).then_some(tb) -} - -/// The headings of `heading_table`'s visible columns. -pub fn table_headings(geo: &Geo, sh: &Sheet, active: CellRef) -> Vec { - let Some(tb) = heading_table(geo, sh, active) else { return Vec::new() }; - let af = sh.autofilter.as_ref().filter(|af| af.range == tb.range); - let mut out = Vec::new(); - for col in tb.range.start.col..=tb.range.end.col { - let w = sh.col_width(col) * geo.z; - let off = col - tb.range.start.col; - let Some(column) = tb.columns.get(off as usize) else { continue }; - if w <= 0.0 { - continue; - } - let rect = Rect::from_min_size(pos2(geo.x(sh, col), geo.head_top()), vec2(w, geo.header_h)); - // Clear of the 4-point column resize edge, and never wider than the column. - let s = (rect.height() - 6.0).clamp(10.0, 16.0).min(w - 7.0); - let button = (tb.filter_button && s > 0.0).then(|| Rect::from_min_size(pos2(rect.right() - s - 5.0, rect.center().y - s / 2.0), vec2(s, s))); - let filtered = tb.filter.iter().any(|(o, _)| *o == off) || af.is_some_and(|af| af.criteria.iter().any(|(o, _)| *o == off)); - out.push(TableHeading { col, header_row: tb.range.start.row, name: column.name.clone(), rect, button, filtered }); - } - out -} - -fn paint_filter_button(p: &Painter, r: Rect, filtered: bool) { - let t = Tokens::get(p.ctx()); - p.rect_filled(r, 2.0, t.sheet_control_bg); - p.rect_stroke(r, 2.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Inside); - crate::icons::paint_on_sheet( - p, - r.shrink(1.5), - if filtered { crate::icons::Icon::Filter } else { crate::icons::Icon::Chevron }, - t.sheet_control_text, - ); -} - -/// The outline segments of merged area `m` drawn at `rect`: the top and left edges come from the -/// first row's and first column's cells, the bottom and right edges from the last row's and last -/// column's cells, one segment per visible cell (limited to the visible `rows` × `cols`). -#[allow(clippy::too_many_arguments)] -fn merged_edges( - wb: &Workbook, - si: usize, - sh: &Sheet, - geo: &Geo, - m: RangeRef, - rect: Rect, - rows: (u32, u32), - cols: (u32, u32), - cf: &mut CfCache, -) -> Vec<(BorderLine, [Pos2; 2])> { - let mut out = Vec::new(); - let (cs, ce) = (m.start.col.max(cols.0), m.end.col.min(cols.1)); - for col in cs..=ce { - if sh.col_width(col) == 0.0 { - continue; - } - let (x0, x1) = (geo.x(sh, col), geo.x(sh, col.saturating_add(1))); - let top = look(wb, si, sh, CellRef::new(m.start.row, col), cf).0.style.borders.top; - let bottom = look(wb, si, sh, CellRef::new(m.end.row, col), cf).0.style.borders.bottom; - out.push((top, [pos2(x0, rect.top()), pos2(x1, rect.top())])); - out.push((bottom, [pos2(x0, rect.bottom()), pos2(x1, rect.bottom())])); - } - let (rs, re) = (m.start.row.max(rows.0), m.end.row.min(rows.1)); - for row in rs..=re { - if sh.row_height(row) == 0.0 { - continue; - } - let (y0, y1) = (geo.y(sh, row), geo.y(sh, row.saturating_add(1))); - let left = look(wb, si, sh, CellRef::new(row, m.start.col), cf).0.style.borders.left; - let right = look(wb, si, sh, CellRef::new(row, m.end.col), cf).0.style.borders.right; - out.push((left, [pos2(rect.left(), y0), pos2(rect.left(), y1)])); - out.push((right, [pos2(rect.right(), y0), pos2(rect.right(), y1)])); - } - out -} - -/// The smaller font that makes `text` fit in `room` points of width (Shrink to fit), or `None` -/// when it already fits at its own size. -fn shrink_font(p: &Painter, text: &str, font: &FontId, room: f32) -> Option { - let natural = p.layout_no_wrap(text.to_string(), font.clone(), Color32::PLACEHOLDER).size().x; - let room = room.max(1.0); - if natural.is_nan() || natural <= room { - return None; - } - // Glyph advances don't scale exactly linearly (rounding), so leave a little slack. - let size = (font.size * room / natural * 0.97).max(1.0); - Some(FontId::new(size, font.family.clone())) -} - -pub(crate) fn paint_border(p: &Painter, pts: [Pos2; 2], style: BorderStyle, col: Color32) { - let w = style.width(); - let (a, b) = (pts[0], pts[1]); - match style { - BorderStyle::Double => { - let horiz = (a.y - b.y).abs() < 0.5; - let off = if horiz { vec2(0.0, 1.5) } else { vec2(1.5, 0.0) }; - p.line_segment([a - off, b - off], Stroke::new(1.0, col)); - p.line_segment([a + off, b + off], Stroke::new(1.0, col)); - } - BorderStyle::Dashed - | BorderStyle::MediumDashed - | BorderStyle::DashDot - | BorderStyle::MediumDashDot - | BorderStyle::DashDotDot - | BorderStyle::MediumDashDotDot - | BorderStyle::SlantDashDot => { - p.extend(egui::Shape::dashed_line(&[a, b], Stroke::new(w, col), 4.0, 2.0)); - } - BorderStyle::Dotted | BorderStyle::Hair => { - p.extend(egui::Shape::dotted_line(&[a, b], col, 2.0, w * 0.5)); - } - _ => { - p.line_segment([a, b], Stroke::new(w, col)); - } - } -} - -fn paint_cf_icon(p: &Painter, r: Rect, set: &str, idx: usize) { - let n = gridcraft_engine::cf::icon_count(set); - let palette3 = [Color32::from_rgb(0xD1, 0x3B, 0x2F), Color32::from_rgb(0xF2, 0xB8, 0x2E), Color32::from_rgb(0x2E, 0x9E, 0x4F)]; - let col = if n <= 3 { - palette3.get(idx).copied().unwrap_or(Color32::GRAY) - } else { - let t = idx as f32 / (n - 1).max(1) as f32; - Color32::from_rgb( - (0xD1 as f32 * (1.0 - t) + 0x2E as f32 * t) as u8, - (0x3B as f32 * (1.0 - t) + 0x9E as f32 * t) as u8, - (0x2F as f32 * (1.0 - t) + 0x4F as f32 * t) as u8, - ) - }; - let lower = set.to_ascii_lowercase(); - if lower.contains("arrow") { - let c = r.center(); - let s = r.width() / 2.0; - let pts = if idx + 1 == n { - vec![pos2(c.x, c.y - s), pos2(c.x + s, c.y + 0.1 * s), pos2(c.x - s, c.y + 0.1 * s)] - } else if idx == 0 { - vec![pos2(c.x, c.y + s), pos2(c.x - s, c.y - 0.1 * s), pos2(c.x + s, c.y - 0.1 * s)] - } else { - vec![pos2(c.x + s, c.y), pos2(c.x - 0.1 * s, c.y - s), pos2(c.x - 0.1 * s, c.y + s)] - }; - p.add(egui::Shape::convex_polygon(pts, col, Stroke::NONE)); - } else if lower.contains("rating") || lower.contains("quarter") { - p.circle_stroke(r.center(), r.width() / 2.0 - 0.5, Stroke::new(1.0, Color32::GRAY)); - let frac = idx as f32 / (n - 1).max(1) as f32; - p.rect_filled( - Rect::from_min_size(pos2(r.left() + 2.0, r.bottom() - 2.0 - (r.height() - 4.0) * frac), vec2(r.width() - 4.0, (r.height() - 4.0) * frac)), - 1.0, - Color32::from_rgb(0x2B, 0x7C, 0xD3), - ); - } else { - p.circle_filled(r.center(), r.width() / 2.0 - 0.5, col); - } -} - -/// Where the active cell is on screen: the whole merged cell when it is part of one. -fn active_rect(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection) -> Rect { - match sh.merge_at(sel.active) { - Some(m) => geo.range_rect(sh, m), - None => geo.cell_rect(sh, sel.active), - } -} - -/// The rectangles shaded for a selection: every area except the active cell, which stays clear so -/// it is plain where typing goes. A single selected cell has no fill. `outline` is where the -/// current area is drawn this frame: it glides to a new selection, and its fill with it. -pub fn selection_fill(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, outline: Rect) -> Vec { - selection_fill_where(geo, sh, sel, outline, |_| true) -} - -/// `selection_fill` for the areas `keep` accepts (a frozen pane draws only the areas it shows). -fn selection_fill_where(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, outline: Rect, keep: impl Fn(&RangeRef) -> bool) -> Vec { - if sel.is_single_cell() { - return vec![]; - } - let active = active_rect(geo, sh, sel); - let area = |r: &RangeRef| if *r == sel.current() { outline } else { geo.range_rect(sh, *r) }; - sel.ranges.iter().filter(|r| keep(r)).flat_map(|r| rect_around(area(r), active)).collect() -} - -/// `area` without `hole`: the strips above and below the hole, then what is left beside it. The -/// selection fill is translucent, so the active cell can't be cleared by painting over it; the -/// pieces never overlap for the same reason. Pieces with no size, or whose numbers aren't finite, -/// are dropped. -fn rect_around(area: Rect, hole: Rect) -> impl Iterator { - // `intersect` takes the sane side of a NaN, which would make a broken hole cover the area. - let hole = if hole.is_finite() { area.intersect(hole) } else { Rect::NOTHING }; - let parts = if hole.is_positive() { - [ - Rect::from_min_max(area.min, pos2(area.max.x, hole.min.y)), - Rect::from_min_max(pos2(area.min.x, hole.max.y), area.max), - Rect::from_min_max(pos2(area.min.x, hole.min.y), pos2(hole.min.x, hole.max.y)), - Rect::from_min_max(pos2(hole.max.x, hole.min.y), pos2(area.max.x, hole.max.y)), - ] - } else { - [area, Rect::NOTHING, Rect::NOTHING, Rect::NOTHING] - }; - parts.into_iter().filter(|r| r.is_positive() && r.is_finite()) -} - -/// True when `r` covers at least one cell of the pane that shows `rows` x `cols` (inclusive). -/// The panes of a frozen sheet share one coordinate system, so a range that lives only in the -/// scrolling pane would otherwise be drawn over the frozen pane as well. -fn range_in_pane(r: RangeRef, rows: (u32, u32), cols: (u32, u32)) -> bool { - r.start.row <= rows.1 && r.end.row >= rows.0 && r.start.col <= cols.1 && r.end.col >= cols.0 -} - -/// The four sides of a `width` wide outline centred on `rect`, cut to `clip` (a little beyond it). -/// -/// A stroked rectangle that spans a whole column (20 million points tall) loses its precision in -/// the tessellator and its left and right sides vanish; sides filled from rectangles that stay -/// near the viewport do not. Sides wholly outside `clip` are left out. -pub(crate) fn outline_bands(rect: Rect, width: f32, clip: Rect) -> Vec { - if !rect.is_finite() || !clip.is_finite() { - return Vec::new(); - } - let h = width / 2.0; - let clip = clip.expand(width * 2.0); - let sides = [ - Rect::from_min_max(pos2(rect.left() - h, rect.top() - h), pos2(rect.left() + h, rect.bottom() + h)), - Rect::from_min_max(pos2(rect.right() - h, rect.top() - h), pos2(rect.right() + h, rect.bottom() + h)), - Rect::from_min_max(pos2(rect.left() - h, rect.top() - h), pos2(rect.right() + h, rect.top() + h)), - Rect::from_min_max(pos2(rect.left() - h, rect.bottom() - h), pos2(rect.right() + h, rect.bottom() + h)), - ]; - sides.into_iter().map(|s| s.intersect(clip)).filter(|s| s.is_finite() && s.width() > 0.0 && s.height() > 0.0).collect() -} - -/// `outline` is where the current area's border is drawn this frame (see `selection_glide`). -#[allow(clippy::too_many_arguments)] -pub(crate) fn paint_selection( - p: &Painter, - geo: &Geo, - sh: &Sheet, - sel: &gridcraft_engine::Selection, - outline: Rect, - t: &Tokens, - app: &SheetApp, - rows: (u32, u32), - cols: (u32, u32), -) { - // Fill all areas except the active cell. - for rect in selection_fill_where(geo, sh, sel, outline, |r| range_in_pane(*r, rows, cols)) { - p.rect_filled(rect, 0.0, t.sel_fill); - } - let active_range = sh.merge_at(sel.active).unwrap_or_else(|| RangeRef::cell(sel.active)); - let active = active_rect(geo, sh, sel); - let active_here = range_in_pane(active_range, rows, cols); - let cur_here = range_in_pane(sel.current(), rows, cols); - // Editing a formula: outline its references instead of the selection. - if let Some(ed) = &app.editor - && ed.is_formula() - && app.session.active().is_some_and(|d| d.uid == ed.doc) - { - let origin = app.session.active().and_then(|d| d.wb.sheet(ed.sheet)).map_or(sh.name.as_str(), |o| o.name.as_str()); - for (_, _, r, sheet, idx) in crate::editor::formula_refs(&ed.text) { - if !crate::editor::ref_is_on_sheet(sheet.as_deref(), origin, &sh.name) { - continue; - } - if !range_in_pane(r, rows, cols) { - continue; - } - let rr = geo.range_rect(sh, r); - let col = Tokens::get(p.ctx()).ref_color(idx, true); - p.rect_filled(rr, 0.0, col.gamma_multiply(0.12)); - p.rect_stroke(rr, 0.0, Stroke::new(1.5, col), StrokeKind::Inside); - for corner in [rr.left_top(), rr.right_top(), rr.left_bottom(), rr.right_bottom()] { - p.rect_filled(Rect::from_center_size(corner, vec2(4.0, 4.0)), 0.0, col); - } - } - } - if cur_here { - for side in outline_bands(outline.expand(0.5), 2.0, p.clip_rect()) { - p.rect_filled(side, 0.0, t.sel_border); - } - } - if active_here && sel.ranges.len() > 1 { - p.rect_stroke(active, 0.0, Stroke::new(1.0, t.sel_border), StrokeKind::Inside); - } - // Fill handle (bottom-right of the current area). - if app.editor.is_none() && range_in_pane(RangeRef::cell(sel.current().end), rows, cols) { - let h = Rect::from_center_size(outline.right_bottom(), vec2(6.0, 6.0)); - p.rect_filled(h.expand(1.0), 0.0, Color32::WHITE); - p.rect_filled(h, 0.0, t.sel_border); - } - // Fill-handle drag preview. - if let crate::grid::Drag::Fill { target: Some(tr), .. } = &app.grid.drag - && range_in_pane(*tr, rows, cols) - { - let rr = geo.range_rect(sh, *tr); - p.extend(egui::Shape::dashed_line( - &[rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()], - Stroke::new(1.5, Color32::from_gray(90)), - 4.0, - 3.0, - )); - } - if let crate::grid::Drag::Move { to: Some(to), .. } = &app.grid.drag { - let r = sel.current(); - let moved = RangeRef::new(*to, to.offset_clamped(r.height() as i64 - 1, r.width() as i64 - 1)); - if range_in_pane(moved, rows, cols) { - let rr = geo.range_rect(sh, moved); - p.rect_stroke(rr, 0.0, Stroke::new(2.0, Color32::from_gray(110)), StrokeKind::Middle); - } - } - if let crate::grid::Drag::MoveCols { before: Some(before), .. } = &app.grid.drag { - let x = geo.x(sh, *before); - p.line_segment([pos2(x, geo.rect.top()), pos2(x, geo.rect.bottom())], Stroke::new(3.0, t.accent)); - } - // Marching ants around the copied range. - if let Some(clip) = &app.session.clipboard - && app.session.active().is_some_and(|d| d.uid == clip.doc_uid && d.wb.active_sheet() == clip.sheet) - && range_in_pane(clip.range, rows, cols) - { - // A multi-area copy marks each area as selected. - let areas = if clip.areas.is_empty() { std::slice::from_ref(&clip.range) } else { clip.areas.as_slice() }; - for area in areas { - let rr = geo.range_rect(sh, *area).expand(0.5); - let phase = app.grid.marching_phase; - let pts = [rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()]; - p.extend(egui::Shape::dashed_line_with_offset(&pts, Stroke::new(2.0, t.sel_border), &[4.0], &[4.0], phase)); - } - } -} - -pub(crate) fn paint_headers( - p: &Painter, - geo: &Geo, - sh: &Sheet, - sel: &gridcraft_engine::Selection, - t: &Tokens, - quads: &[((u32, u32), (u32, u32), Rect)], - headings: &[TableHeading], -) { - let font = theme::ui_font(heading_font_size(geo.z)); - // The headers' own area: inside the outline bands. - let r = Rect::from_min_max(pos2(geo.head_left(), geo.head_top()), geo.rect.max); - let top = Rect::from_min_max(pos2(geo.cells.left(), r.top()), pos2(r.right(), geo.cells.top())); - let left = Rect::from_min_max(pos2(r.left(), geo.cells.top()), pos2(geo.cells.left(), r.bottom())); - p.rect_filled(top, 0.0, t.header_bg); - p.rect_filled(left, 0.0, t.header_bg); - let bounds = sel.bounds(); - let col_sel = |c: u32| sel.ranges.iter().any(|x| c >= x.start.col && c <= x.end.col); - let row_sel = |rr: u32| sel.ranges.iter().any(|x| rr >= x.start.row && rr <= x.end.row); - let full_col = |c: u32| sel.ranges.iter().any(|x| x.is_full_cols() && c >= x.start.col && c <= x.end.col); - let full_row = |rr: u32| sel.ranges.iter().any(|x| x.is_full_rows() && rr >= x.start.row && rr <= x.end.row); - let _ = bounds; - // Hidden columns and rows leave a double divider where they sit, as in Excel. - let hidden_mark = Stroke::new(1.0, t.header_text.gamma_multiply(0.6)); - let mut done_cols = std::collections::HashSet::new(); - let mut done_rows = std::collections::HashSet::new(); - for (rows, cols, clip) in quads { - let pc = p.with_clip_rect(Rect::from_min_max(pos2(clip.left(), top.top()), pos2(clip.right(), top.bottom()))); - if cols.0 <= cols.1 { - for c in cols.0..=cols.1 { - if !done_cols.insert((c, clip.left() as i32)) { - continue; - } - let w = sh.col_width(c) * geo.z; - if w <= 0.0 { - continue; - } - let x = geo.x(sh, c); - let cr = Rect::from_min_size(pos2(x, top.top()), vec2(w, top.height())); - if col_sel(c) { - pc.rect_filled(cr, 0.0, if full_col(c) { t.header_all_bg } else { t.header_sel_bg }); - pc.line_segment([pos2(cr.left(), cr.bottom() - 1.0), pos2(cr.right(), cr.bottom() - 1.0)], Stroke::new(2.0, t.accent)); - } - pc.line_segment([pos2(cr.right() - 0.5, cr.top() + 3.0), pos2(cr.right() - 0.5, cr.bottom())], Stroke::new(1.0, t.header_line)); - let (before, after) = hidden_around(c, |i| sh.col_width(i)); - for x in [before.then_some(cr.left()), after.then_some(cr.right())].into_iter().flatten() { - for dx in [-1.5, 1.5] { - pc.line_segment([pos2(x + dx, cr.top() + 3.0), pos2(x + dx, cr.bottom())], hidden_mark); - } - } - let color = if col_sel(c) { t.header_sel_text } else { t.header_text }; - match headings.iter().find(|h| h.col == c) { - Some(h) => { - // The name is cut off at the button, as a cell's text is at its neighbour. - let end = h.button.map_or(cr.right(), |b| b.left()) - 2.0; - let room = Rect::from_min_max(cr.min, pos2(end.max(cr.left()), cr.bottom())).intersect(pc.clip_rect()); - let pad = 4.0 * (geo.z / DISPLAY_SCALE).max(0.75); - pc.with_clip_rect(room).text(pos2(cr.left() + pad, cr.center().y), Align2::LEFT_CENTER, &h.name, font.clone(), color); - if let Some(b) = h.button { - paint_filter_button(&pc, b, h.filtered); - } - } - None => { - pc.text(cr.center(), Align2::CENTER_CENTER, col_to_letters(c), font.clone(), color); - } - } - } - } - let pr = p.with_clip_rect(Rect::from_min_max(pos2(left.left(), clip.top()), pos2(left.right(), clip.bottom()))); - if rows.0 <= rows.1 { - for rr in rows.0..=rows.1 { - if !done_rows.insert((rr, clip.top() as i32)) { - continue; - } - let h = sh.row_height(rr) * geo.z; - if h <= 0.0 { - continue; - } - let y = geo.y(sh, rr); - let cr = Rect::from_min_size(pos2(left.left(), y), vec2(left.width(), h)); - if row_sel(rr) { - pr.rect_filled(cr, 0.0, if full_row(rr) { t.header_all_bg } else { t.header_sel_bg }); - pr.line_segment([pos2(cr.right() - 1.0, cr.top()), pos2(cr.right() - 1.0, cr.bottom())], Stroke::new(2.0, t.accent)); - } - pr.line_segment([pos2(cr.left() + 3.0, cr.bottom() - 0.5), pos2(cr.right(), cr.bottom() - 0.5)], Stroke::new(1.0, t.header_line)); - let (before, after) = hidden_around(rr, |i| sh.row_height(i)); - for y in [before.then_some(cr.top()), after.then_some(cr.bottom())].into_iter().flatten() { - for dy in [-1.5, 1.5] { - pr.line_segment([pos2(cr.left() + 3.0, y + dy), pos2(cr.right(), y + dy)], hidden_mark); - } - } - let color = if row_sel(rr) { t.header_sel_text } else { t.header_text }; - pr.text(cr.center(), Align2::CENTER_CENTER, (rr as u64 + 1).to_string(), font.clone(), color); - } - } - } - // Corner (select all) and edges. - let corner = Rect::from_min_max(r.min, geo.cells.min); - p.rect_filled(corner, 0.0, t.header_bg); - let tri = vec![ - pos2(corner.right() - 3.0, corner.top() + 5.0), - pos2(corner.right() - 3.0, corner.bottom() - 3.0), - pos2(corner.left() + 7.0, corner.bottom() - 3.0), - ]; - p.add(egui::Shape::convex_polygon(tri, Color32::from_gray(190), Stroke::NONE)); - p.line_segment([pos2(r.left(), geo.cells.top() - 0.5), pos2(r.right(), geo.cells.top() - 0.5)], Stroke::new(1.0, t.header_line)); - p.line_segment([pos2(geo.cells.left() - 0.5, r.top()), pos2(geo.cells.left() - 0.5, r.bottom())], Stroke::new(1.0, t.header_line)); -} - -/// Whether the line before / after visible line `i` is hidden (zero size), so its header edges -/// get the hidden marker. Line 0 has nothing before it. -fn hidden_around(i: u32, size: impl Fn(u32) -> f32) -> (bool, bool) { - (i > 0 && size(i - 1) <= 0.0, i < u32::MAX && size(i + 1) <= 0.0) -} - -// ------------------------------------------------------------------ interaction - -/// How close (in points) a press must be to an object's bottom-right corner to resize it instead of moving it. -const OBJECT_RESIZE_RADIUS: f32 = 10.0; - -/// Decide after interaction so drag start and release use the new state in the same frame. -fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet) { - if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() { - return; - } - let ctx = ui.ctx(); - let pointer = ctx.input(|i| i.pointer.clone()); - let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p)); - let in_cells = pos.is_some_and(|p| geo.in_cells(p)); - let (freeze_cols, freeze_rows) = freeze_handles(geo); - // Hover bookkeeping also serves cell notes, independently of who owns the cursor. - if resp.hovered() - && let Some(p) = pos - && !freeze_cols.contains(p) - && !freeze_rows.contains(p) - && !matches!(app.grid.drag, Drag::Freeze { .. }) - { - app.grid.hover_cell = in_cells.then(|| geo.cell_at(sh, p)); - } - // Draw tools own the cursor over cells, including while recording a stroke. - if !app.session.draw_tool.is_empty() && in_cells { - ctx.set_cursor_icon(CursorIcon::Crosshair); - return; - } - // A held drag owns the cursor even outside the grid; a press below egui's threshold still - // has Drag::None and uses hover. A click can briefly leave a drag state after release. - if resp.dragged() - && pointer.primary_down() - && let Some(cursor) = drag_cursor(&app.grid.drag) - { - ctx.set_cursor_icon(cursor); - return; - } - if !resp.hovered() { - return; - } - let Some(p) = pos else { return }; - hover_cursor(app, ctx, resp, &pointer, geo, sh, p); -} - -/// The cursor a held drag shows, or `None` when the drag leaves it to hover. -fn drag_cursor(drag: &Drag) -> Option { - match drag { - Drag::None => None, - Drag::Select => Some(CursorIcon::Cell), - Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default), - Drag::Fill { .. } => Some(CursorIcon::Crosshair), - Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { resize: false, .. } => Some(CursorIcon::Move), - Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn), - Drag::ResizeRow { .. } => Some(CursorIcon::ResizeRow), - Drag::Freeze { cols, .. } => Some(if *cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }), - Drag::Object { resize: true, .. } => Some(CursorIcon::ResizeNwSe), - } -} - -/// The cursor over the grid when no drag owns it: freeze bars, header edges, the fill handle, cells. -fn hover_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, pointer: &egui::PointerState, geo: &Geo, sh: &Sheet, p: Pos2) { - let (freeze_cols, freeze_rows) = freeze_handles(geo); - if freeze_cols.contains(p) || freeze_rows.contains(p) { - freeze_cursor(app, ctx, resp, pointer, freeze_cols.contains(p)); - return; - } - let in_cells = geo.in_cells(p); - let in_col_header = p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left(); - let in_row_header = p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top(); - let near_col_edge = in_col_header && col_edge(geo, sh, p.x).is_some(); - let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default(); - let cur_rect = geo.range_rect(sh, sel.current()); - let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0)); - let handle_panes = geo.range_panes(RangeRef::cell(sel.current().end)); - let border_panes = geo.range_panes(sel.current()); - let on_handle = handle.contains(p) && handle_panes.iter().any(|r| r.contains(p)); - let on_cur_border = on_border(cur_rect, p) && border_panes.iter().any(|r| r.contains(p)); - let selected_object = crate::chartview::hit(app, geo, sh, p).filter(|&(_, id, _)| app.selected_chart == Some(id)); - let near_corner = selected_object.is_some_and(|(_, _, rect)| (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS); - if app.grid.drag == Drag::None && near_corner { - ctx.set_cursor_icon(CursorIcon::ResizeNwSe); - } else if near_col_edge || (in_row_header && row_edge(geo, sh, p.y).is_some()) { - header_edge_cursor(app, ctx, pointer, geo, sh, p, near_col_edge); - } else if on_handle && app.editor.is_none() { - ctx.set_cursor_icon(CursorIcon::Crosshair); - if app.grid.drag == Drag::None && !pointer.any_down() { - resp.clone().on_hover_text_at_pointer("Drag to fill cells. Drag from inside a cell to select a range."); - } - } else if in_cells && app.editor.is_none() && on_cur_border { - ctx.set_cursor_icon(CursorIcon::Move); - } else if in_cells { - ctx.set_cursor_icon(CursorIcon::Cell); - } else if in_col_header { - let col = geo.col_at(sh, p.x); - let selected = sel.ranges.len() == 1 && sel.current().is_full_cols() && sel.current().contains(CellRef::new(0, col)); - ctx.set_cursor_icon(if selected { CursorIcon::Move } else { CursorIcon::Default }); - } -} - -/// The resize cursor for dragging a freeze bar. -fn freeze_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, pointer: &egui::PointerState, cols: bool) { - ctx.set_cursor_icon(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }); - if app.grid.drag == Drag::None && !pointer.any_down() { - let tip = if cols { "Drag to freeze columns" } else { "Drag to freeze rows" }; - resp.clone().on_hover_text_at_pointer(crate::i18n::current(ctx).tr(tip)); - } -} - -/// The resize cursor over a column or row header edge. Just past a hidden column or row, Excel -/// shows a split cursor: dragging or double-clicking there unhides it. -fn header_edge_cursor(app: &SheetApp, ctx: &egui::Context, pointer: &egui::PointerState, geo: &Geo, sh: &Sheet, p: Pos2, near_col_edge: bool) { - let hidden = - app.grid.drag == Drag::None && if near_col_edge { hidden_col_edge(geo, sh, p.x).is_some() } else { hidden_row_edge(geo, sh, p.y).is_some() }; - match pointer.hover_pos() { - Some(at) if hidden => paint_split_cursor(ctx, at, near_col_edge), - _ => ctx.set_cursor_icon(if near_col_edge { CursorIcon::ResizeColumn } else { CursorIcon::ResizeRow }), - } -} - -fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) { - // Text selection and editing must not also move the object or edit worksheet cells. - if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() { - return; - } - let ctx = ui.ctx().clone(); - // Enter/Space synthesize egui clicks; only real pointer clicks use sheet coordinates. - let primary_clicked = resp.clicked_by(egui::PointerButton::Primary); - let pointer = ctx.input(|i| i.pointer.clone()); - let mods = ctx.input(|i| i.modifiers); - // Hit-testing works in logical coordinates (see `Geo::rtl`). - let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p)); - let in_cells = pos.is_some_and(|p| geo.in_cells(p)); - let in_col_header = pos.is_some_and(|p| p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left()); - let in_row_header = pos.is_some_and(|p| p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top()); - let in_corner = pos.is_some_and(|p| p.x < geo.cells.left() && p.y < geo.cells.top() && p.x >= geo.head_left() && p.y >= geo.head_top()); - let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default(); - let cur_rect = geo.range_rect(sh, sel.current()); - let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0)); - // The fill handle and the move border only react in the panes where they are painted, not - // over the frozen rows/columns that a scrolled selection lies under. - let handle_panes = geo.range_panes(RangeRef::cell(sel.current().end)); - let border_panes = geo.range_panes(sel.current()); - let on_handle = |p: Pos2| handle.contains(p) && handle_panes.iter().any(|r| r.contains(p)); - let on_cur_border = |p: Pos2| on_border(cur_rect, p) && border_panes.iter().any(|r| r.contains(p)); - - let (freeze_cols, freeze_rows) = freeze_handles(geo); - // Draw tab tools: the pen records a stroke, the eraser deletes ink under the pointer. - if !app.session.draw_tool.is_empty() && in_cells { - ctx.set_cursor_icon(CursorIcon::Crosshair); - if app.session.draw_tool == "pen" { - if let Some(p) = pos - && resp.dragged() - { - // Through the cell under the pointer, so frozen panes and pages map right. - let c = geo.cell_at(sh, p); - let sx = sh.col_left(c.col) as f32 + (p.x - geo.x(sh, c.col)) / geo.z; - let sy = sh.row_top(c.row) as f32 + (p.y - geo.y(sh, c.row)) / geo.z; - app.grid.ink.push([sx, sy]); - } - if !app.grid.ink.is_empty() { - // Live preview. - let pts: Vec = app - .grid - .ink - .iter() - .map(|q| { - let (col, row) = (sh.col_at(q[0].max(0.0) as f64), sh.row_at(q[1].max(0.0) as f64)); - geo.mirror_pos(pos2( - geo.x(sh, col) + (q[0] - sh.col_left(col) as f32) * geo.z, - geo.y(sh, row) + (q[1] - sh.row_top(row) as f32) * geo.z, - )) - }) - .collect(); - ui.painter().add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(2.0 * geo.z, Color32::from_rgb(0x1F, 0x5F, 0xC9)))); - } - if resp.drag_stopped() && app.grid.ink.len() >= 2 { - let pts: Vec<[f32; 2]> = std::mem::take(&mut app.grid.ink); - let _ = app.run("draw.stroke", json!({"points": pts})); - } - return; - } - if app.session.draw_tool == "eraser" && (primary_clicked || resp.dragged()) { - if let Some(p) = pos - && let Some(("shape", id, _)) = crate::chartview::hit(app, geo, sh, p) - && sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::Ink) - { - let _ = app.run("object.delete", json!({"kind": "shape", "id": id})); - } - return; - } - } - // A plain click on a slicer value tile toggles it (Ctrl/Cmd for multi-select); the clear - // button resets it. Runs before the generic object select/drag so tiles stay clickable. - if primary_clicked - && !resp.drag_started() - && let Some(p) = pos - && let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet(), p) - { - app.selected_chart = Some(id); - match act { - crate::chartview::SlicerAction::Toggle(value) => { - let _ = app.run("slicer.toggle", json!({"slicer": id, "value": value, "multi": mods.command || mods.ctrl})); - } - crate::chartview::SlicerAction::Clear => { - let _ = app.run("slicer.clear", json!({"slicer": id})); - } - } - return; - } - // Objects (charts, pictures, shapes, slicers) take clicks first. Only the primary button moves - // them: a right-click that wobbles into a drag still opens the object's menu (#2067). - let primary_drag = resp.drag_started_by(egui::PointerButton::Primary); - if primary_drag || primary_clicked { - // A drag is recognised only after the pointer has moved, so grab the object where the press began. - let at = if primary_drag { pointer.press_origin().or(pos) } else { pos }; - if let Some(p) = at - && let Some((kind, id, rect)) = crate::chartview::hit(app, geo, sh, p) - { - app.selected_chart = Some(id); - if kind == "shape" - && resp.double_clicked() - && sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::TextBox) - { - app.begin_text_box_edit(id); - return; - } - if kind == "chart" && resp.double_clicked() { - app.grid.pane = Some("formatChart".into()); - } - if primary_drag { - let a = object_anchor(sh, kind, id); - let resize = (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS; - if let Some(a) = a { - app.grid.drag = Drag::Object { kind, id, start: p, orig: (a.cell, a.dx, a.dy, a.width, a.height), resize }; - } - } - return; - } - app.selected_chart = None; - } - // A right-click on a chart, picture, shape or slicer opens the object's menu, not the cell's - // under it; a right press that wobbles into a drag counts too (#2067). - let secondary_drag = resp.drag_started_by(egui::PointerButton::Secondary); - if (resp.secondary_clicked() || secondary_drag) - && let Some(p) = if secondary_drag { pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) } else { pos } - && let Some((kind, id, _)) = crate::chartview::hit(app, geo, sh, p) - { - app.selected_chart = Some(id); - app.grid.object_menu = Some((geo.mirror_pos(p), kind, id)); - return; - } - - // A double-click is also a click: handle header AutoFit before resize setup returns. - if resp.double_clicked() - && let Some(p) = pos - { - let unhide = if in_col_header { - hidden_col_edge(geo, sh, p.x).map(|col| ("home.unhideColumns", json!({"cols": RangeRef::cols(col, col).a1()}))) - } else if in_row_header { - hidden_row_edge(geo, sh, p.y).map(|row| ("home.unhideRows", json!({"rows": RangeRef::rows(row, row).a1()}))) - } else { - None - }; - let autofit = if unhide.is_some() { - unhide - } else if in_col_header { - col_edge(geo, sh, p.x).map(|(col, _)| { - let selected = sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col); - ("home.autofitColumnWidth", if selected { json!({}) } else { json!({"cols": RangeRef::cols(col, col).a1()}) }) - }) - } else if in_row_header { - row_edge(geo, sh, p.y).map(|(row, _)| { - let selected = sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row); - ("home.autofitRowHeight", if selected { json!({}) } else { json!({"rows": RangeRef::rows(row, row).a1()}) }) - }) - } else { - None - }; - if let Some((command, params)) = autofit { - app.grid.drag = Drag::None; - app.grid.drag_select = false; - app.run_or_alert(command, params); - resp.request_focus(); - return; - } - } - - // Double-click the fill handle: a double-click is also a click, so run it before the handle press returns. - if resp.double_clicked() - && let Some(p) = pos - && on_handle(p) - { - // Fill down as far as the neighbouring column goes. - let src = sel.current(); - let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 }; - let cap = src.end.row.saturating_add(1_000_000).min(MAX_ROWS - 1); - let last = if src.end.row < cap { sh.last_contiguous_nonempty(neighbour, src.end.row + 1, cap) } else { src.end.row }; - if last > src.end.row { - let target = RangeRef::new(src.start, CellRef::new(last, src.end.col)); - let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()})); - } - app.grid.drag = Drag::None; - app.grid.drag_select = false; - resp.request_focus(); - return; - } - - // Press: decide the drag mode. - if resp.drag_started() || (primary_clicked && app.grid.drag == Drag::None) { - // Where the press began (a drag is only recognised after the pointer has moved). - let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) else { return }; - // The outline band's − / + and level buttons act on release, like buttons. - if let Some(action) = crate::outline::hit(geo, sh, p) { - if primary_clicked { - let (command, params) = action.command(); - app.run_or_alert(command, params); - } - return; - } - // Freeze handles sit on the header edges (and the corner's edges when nothing is frozen). - if freeze_cols.contains(p) || freeze_rows.contains(p) { - app.grid.drag = Drag::Freeze { cols: freeze_cols.contains(p), to: None }; - return; - } - if in_corner { - let _ = app.session.run("selection.set", json!({"range": RangeRef::all().a1()})); - return; - } - if in_col_header { - if let Some((c, x)) = col_edge(geo, sh, p.x) { - app.grid.drag = Drag::ResizeCol { col: c, start: p.x, orig: sh.col_width(c).max(0.0) * 0.0 + (x - geo.x(sh, c)) / geo.z }; - return; - } - let c = geo.col_at(sh, p.x); - // A Table heading's filter button, while the header names stand in for the letters. - if let Some(h) = table_headings(geo, sh, sel.active).into_iter().find(|h| h.col == c && h.button.is_some_and(|b| b.contains(p))) { - app.grid.filter_menu = Some((c, h.header_row, geo.mirror_pos(p))); - return; - } - if point_header(app, wb, sh, RangeRef::cols(c, c), mods.shift, mods.command) { - return; - } - // An armed Format Painter paints the columns dragged over instead of moving them. - if resp.drag_started() - && !mods.any() - && app.session.format_painter.is_none() - && sel.ranges.len() == 1 - && sel.current().is_full_cols() - && sel.current().contains(CellRef::new(0, c)) - { - app.grid.drag = Drag::MoveCols { source: sel.current(), before: None }; - return; - } - let range = if mods.shift { RangeRef::cols(sel.anchor.col.min(c), sel.anchor.col.max(c)) } else { RangeRef::cols(c, c) }; - select_ranges(app, range, mods.command, CellRef::new(geo.row_at(sh, geo.cells.top() + 1.0), c)); - app.grid.drag = Drag::Cols(if mods.shift { sel.anchor.col } else { c }); - return; - } - if in_row_header { - if let Some((r, y)) = row_edge(geo, sh, p.y) { - app.grid.drag = Drag::ResizeRow { row: r, start: p.y, orig: (y - geo.y(sh, r)) / geo.z }; - return; - } - let r = geo.row_at(sh, p.y); - if point_header(app, wb, sh, RangeRef::rows(r, r), mods.shift, mods.command) { - return; - } - let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) }; - select_ranges(app, range, mods.command, CellRef::new(r, geo.col_at(sh, geo.cells.left() + 1.0))); - app.grid.drag = Drag::Rows(if mods.shift { sel.anchor.row } else { r }); - return; - } - if !in_cells { - return; - } - let c = geo.cell_at(sh, p); - // Data validation list arrow next to the active cell. - if let Some(r) = validation_arrow(sh, geo, sel.active) - && r.contains(p) - { - app.grid.list_picker = Some(sel.active); - return; - } - // Filter dropdown buttons. - if is_filter_button(sh, geo, p) { - app.grid.filter_menu = Some((c.col, c.row, geo.mirror_pos(p))); - return; - } - // A dialog's formula box in point mode (New Formatting Rule) takes the reference; the - // selection, which the dialog acts on, stays. - if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) { - d.pick_reference(c, &sh.name, false); - app.grid.drag = Drag::Select; - app.grid.drag_select = true; - if resp.drag_started() - && let Some(p) = pos - { - select_to_pointer(app, geo, sh, wb, p); - } - return; - } - // Point mode while editing a formula: clicking inserts a reference. - if let Some(ed) = app.editor.as_mut() - && ed.can_point() - { - let anchor = if mods.shift && ed.point_at_caret() { - ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet()).map(|(_, cell)| cell).unwrap_or(c) - } else { - c - }; - let range = RangeRef::new(anchor, c); - let text = if range.is_single() { c.a1() } else { range.a1() }; - let sheet_name = (ed.sheet != wb.active_sheet()).then(|| sh.name.clone()); - let r = match sheet_name { - Some(n) => format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&n)), - None => text, - }; - if mods.command && !mods.shift { - ed.append_ref(&r); - } else { - ed.insert_ref(&r); - } - ed.request_focus = true; - ed.point_cell = Some(c); - ed.point_anchor = Some((wb.active_sheet(), anchor)); - app.grid.drag = Drag::Select; - app.grid.drag_select = true; - if resp.drag_started() - && let Some(p) = pos - { - select_to_pointer(app, geo, sh, wb, p); - } - return; - } - if app.editor.is_some() && !app.commit_edit(0, 0, false, false) { - return; - } - if on_handle(p) { - app.grid.drag = Drag::Fill { target: None, menu: resp.drag_started_by(egui::PointerButton::Secondary) }; - return; - } - if on_cur_border(p) && resp.drag_started() { - app.grid.drag = Drag::Move { grab: c, to: None }; - return; - } - // Double-click edits. - let now = crate::now_ms(); - if let Some((last, at)) = app.grid.last_click - && last == c - && now - at < 450.0 - && !mods.shift - { - app.grid.last_click = None; - // A PivotTable's value cell shows its source records on a new sheet (Excel's Show - // Details); its labels and headers still edit. - if sh.pivots.iter().any(|pt| pt.last_range.is_some_and(|r| r.contains(c))) { - let not_a_value_cell = - gridcraft_engine::EngineError::Disabled("pivot.showDetails".into(), gridcraft_engine::pivot::NOT_A_VALUE_CELL.into()).to_string(); - match app.run("pivot.showDetails", json!({"cell": c.a1()})) { - Ok(_) => return, - Err(e) if e == not_a_value_cell => {} - Err(e) => { - app.message = Some(("GridCraft".into(), crate::clean_error(&e))); - return; - } - } - } - // Following a hyperlink requires a single click in Excel; double-click edits. - app.begin_edit(None, false); - return; - } - app.grid.last_click = Some((c, now)); - if mods.shift { - let _ = app.session.run("selection.set", json!({"range": RangeRef::new(sel.anchor, c).a1(), "active": sel.active.a1()})); - if let Some(d) = app.session.active_mut() { - d.selection.anchor = sel.anchor; - } - } else if mods.command { - // Ctrl+click (without Alt) on a hyperlink's text follows it instead of adding the cell to the selection. - if let Some(h) = sh.hyperlinks.get(&c).cloned() - && primary_clicked - && !mods.alt - && p.x < geo.cell_rect(sh, c).left() + geo.cell_rect(sh, c).width() * 0.8 - { - follow_link(app, &h.target); - } else { - select_ranges(app, RangeRef::cell(c), true, c); - } - } else { - let _ = app.session.run("selection.set", json!({"cell": c.a1()})); - if primary_clicked && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" { - let _ = app.run("cell.toggleCheckbox", json!({"cell": c.a1()})); - } - // A click on a hyperlink's text follows it (like Excel); elsewhere in the cell selects. - if let Some(h) = sh.hyperlinks.get(&c).cloned() - && primary_clicked - && !mods.any() - { - let r = geo.cell_rect(sh, c); - if p.x < r.left() + r.width() * 0.8 { - follow_link(app, &h.target); - } - } - } - app.grid.drag = Drag::Select; - app.grid.drag_select = true; - if resp.drag_started() - && let Some(p) = pos - { - // The first recognised drag already includes movement away from the press. - select_to_pointer(app, geo, sh, wb, p); - } - return; - } - - // Dragging. - if resp.dragged() - && let Some(p) = pos - { - // Auto-scroll near edges. - let edge = 24.0; - let mut auto = egui::Vec2::ZERO; - if p.y > geo.cells.bottom() - edge { - auto.y = 20.0; - } - if p.y < geo.cells.top() + edge * 0.5 && !matches!(app.grid.drag, Drag::ResizeRow { .. }) { - auto.y = -20.0; - } - if p.x > geo.cells.right() - edge { - auto.x = 30.0; - } - if p.x < geo.cells.left() + edge * 0.5 && !matches!(app.grid.drag, Drag::ResizeCol { .. }) { - auto.x = -30.0; - } - let pc = CellRef::new( - geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)), - geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)), - ); - match app.grid.drag.clone() { - Drag::Select => { - select_to_pointer(app, geo, sh, wb, p); - scroll_by(app, auto); - } - Drag::Rows(anchor) => { - let r = geo.row_at(sh, p.y); - let _ = - app.session.run("selection.set", json!({"range": RangeRef::rows(anchor.min(r), anchor.max(r)).a1(), "active": sel.active.a1()})); - scroll_by(app, vec2(0.0, auto.y)); - } - Drag::Cols(anchor) => { - let c = geo.col_at(sh, p.x); - let _ = - app.session.run("selection.set", json!({"range": RangeRef::cols(anchor.min(c), anchor.max(c)).a1(), "active": sel.active.a1()})); - scroll_by(app, vec2(auto.x, 0.0)); - } - Drag::MoveCols { source, .. } => { - let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)); - let midpoint = geo.x(sh, col) + sh.col_width(col) * geo.z * 0.5; - let before = (col + u32::from(p.x >= midpoint)).min(MAX_COLS - 1); - let before = (!(source.start.col..=source.end.col + 1).contains(&before)).then_some(before); - app.grid.drag = Drag::MoveCols { source, before }; - scroll_by(app, vec2(auto.x, 0.0)); - } - Drag::ResizeCol { col, start, orig } => { - let w = (orig + (p.x - start) / geo.z).max(0.0); - app.toast = - Some((format!("Width: {:.2} ({} pixels)", gridcraft_engine::cmd::format::points_to_chars(w as f64), w.round()), crate::now_ms())); - // Inside a selection of whole columns, every selected column takes the new width - // (Ctrl-selected areas included); otherwise only the dragged one. - let cols = if sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col) { - sel.ranges.iter().filter(|r| r.is_full_cols()).map(|r| r.a1()).collect::>().join(",") - } else { - RangeRef::cols(col, col).a1() - }; - let _ = app.session.run("home.columnWidth", json!({"cols": cols, "width": w.round()})); - coalesce_last_undo(app); - } - Drag::ResizeRow { row, start, orig } => { - let h = (orig + (p.y - start) / geo.z).max(0.0); - app.toast = Some((format!("Height: {:.2} ({} pixels)", h * 0.75, h.round()), crate::now_ms())); - let rows = if sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row) { - sel.ranges - .iter() - .filter(|r| r.is_full_rows()) - .map(|r| format!("{}:{}", r.start.row + 1, r.end.row + 1)) - .collect::>() - .join(",") - } else { - format!("{}:{}", row + 1, row + 1) - }; - let _ = app.session.run("home.rowHeight", json!({"rows": rows, "height": h.round()})); - coalesce_last_undo(app); - } - Drag::Freeze { cols, .. } => { - app.grid.drag = Drag::Freeze { cols, to: Some(freeze_boundary(geo, sh, p, cols)) }; - } - Drag::Fill { menu, .. } => { - let src = sel.current(); - let target = fill_target(src, pc); - app.grid.drag = Drag::Fill { target: Some(target), menu }; - scroll_by(app, auto); - } - Drag::Move { grab, .. } => { - let src = sel.current(); - let dr = pc.row as i64 - grab.row as i64; - let dc = pc.col as i64 - grab.col as i64; - app.grid.drag = Drag::Move { grab, to: Some(src.start.offset_clamped(dr, dc)) }; - scroll_by(app, auto); - } - Drag::Object { kind, id, start, orig, resize } => { - let d = (p - start) / geo.z; - let (cell, dx, dy, w, h) = orig; - let params = if resize { - json!({"kind": kind, "id": id, "width": (w + d.x).max(20.0), "height": (h + d.y).max(20.0)}) - } else { - // Re-anchor at the cell under the new top-left. - let x0 = sh.col_left(cell.col) as f32 + dx + d.x; - let y0 = sh.row_top(cell.row) as f32 + dy + d.y; - let nc = CellRef::new(sh.row_at(y0.max(0.0) as f64), sh.col_at(x0.max(0.0) as f64)); - json!({"kind": kind, "id": id, "at": nc.a1(), "dx": x0.max(0.0) - sh.col_left(nc.col) as f32, "dy": y0.max(0.0) - sh.row_top(nc.row) as f32}) - }; - let _ = app.session.run("object.move", params); - coalesce_last_undo(app); - } - Drag::None => {} - } - } - - // Release. - if resp.drag_stopped() || (!pointer.any_down() && app.grid.drag != Drag::None && !resp.dragged()) { - match std::mem::take(&mut app.grid.drag) { - Drag::Select => { - if resp.drag_stopped() - && let Some(p) = pos - { - select_to_pointer(app, geo, sh, wb, p); - } - format_painter_apply(app); - } - // Whole rows or columns clicked or dragged over by their headers. - Drag::Rows(_) | Drag::Cols(_) => format_painter_apply(app), - // Esc mid-drag lands here too (egui ends the drag on Esc): no menu, and the key is - // spent on the drag so the grid's Esc doesn't also drop the copy. - Drag::Fill { target: Some(t), menu: true } => { - if let Some(p) = pos - && t != sel.current() - && !ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) - { - app.grid.fill_menu = Some((sel.current(), t, geo.mirror_pos(p))); - } - } - Drag::Fill { target: Some(t), menu: false } => { - let src = sel.current(); - if t != src { - // Ctrl (Cmd on macOS) swaps copying and extending, as in Excel. - let mode = if mods.command { "alternate" } else { "series" }; - match app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) { - Ok(_) => set_fill_tag(app, src, t), - Err(e) => app.message = Some(("GridCraft".into(), crate::clean_error(&e))), - } - } - } - Drag::Freeze { cols, to: Some(to) } => { - let (rows_now, cols_now) = (geo.fr, geo.fc); - let (rows, cols) = if cols { (rows_now, to) } else { (to, cols_now) }; - if (rows, cols) != (rows_now, cols_now) { - // A1 (nothing left frozen) unfreezes. - app.run_or_alert("view.freezePanes", json!({"cell": CellRef::new(rows, cols).a1()})); - } - } - Drag::Move { to: Some(to), .. } => { - let src = sel.current(); - if to != src.start { - let _ = app.session.run("edit.cut", json!({"range": src.a1()})); - let _ = app.run("edit.paste", json!({"at": to.a1()})); - } - } - Drag::MoveCols { source, before: Some(before) } => { - if let Err(e) = app.run("sheet.moveColumns", json!({"cols": source.a1(), "before": before})) { - app.message = Some(("GridCraft".into(), crate::clean_error(&e))); - } - } - _ => {} - } - app.grid.drag_select = false; - } - - // Right-click: context menu. - if resp.secondary_clicked() - && let Some(p) = pos - { - if in_cells && !sel.contains(geo.cell_at(sh, p)) { - let _ = app.session.run("selection.set", json!({"cell": geo.cell_at(sh, p).a1()})); - } - if in_col_header || in_row_header { - let (rows, idx) = if in_row_header { (true, geo.row_at(sh, p.y)) } else { (false, geo.col_at(sh, p.x)) }; - let inside = sel.ranges.iter().any(|r| { - if rows { - r.is_full_rows() && idx >= r.start.row && idx <= r.end.row - } else { - r.is_full_cols() && idx >= r.start.col && idx <= r.end.col - } - }); - if !inside { - let range = if rows { RangeRef::rows(idx, idx) } else { RangeRef::cols(idx, idx) }; - let _ = app.session.run("selection.set", json!({"range": range.a1()})); - } - app.grid.header_menu = Some((geo.mirror_pos(p), rows)); - } else { - app.grid.context_menu = Some(geo.mirror_pos(p)); - app.grid.context_menu_frame = ui.ctx().cumulative_frame_nr(); - } - } - - if primary_clicked || resp.drag_started() { - resp.request_focus(); - } - keyboard(app, &ctx, resp, geo, sh); -} - -/// An armed Format Painter pastes onto the cells just clicked or dragged over (not while -/// pointing a formula reference, which also drags a Select). -fn format_painter_apply(app: &mut SheetApp) { - if app.session.format_painter.is_some() - && !app.editor.as_ref().is_some_and(|ed| ed.can_point()) - && !app.dialog.as_ref().is_some_and(|d| d.picks_references()) - { - let dest = app.session.active().map(|d| d.selection.current().a1()); - if let Some(dest) = dest - && let Err(e) = app.run("edit.formatPainter", json!({"apply": dest})) - { - app.message = Some(("GridCraft".into(), crate::clean_error(&e))); - } - } -} - -/// Point mode on a header: inserts a whole-row or whole-column reference instead of selecting. -fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef, extend: bool, add: bool) -> bool { - let Some(ed) = app.editor.as_mut().filter(|ed| ed.can_point()) else { return false }; - let edit_sheet = ed.sheet; - let active_sheet = wb.active_sheet(); - let range = if extend { - ed.point - .and_then(|point| crate::editor::formula_refs(&ed.text).into_iter().find(|(start, end, ..)| (*start, *end) == point)) - .and_then(|(_, _, previous, previous_sheet, _)| { - let previous_sheet = previous_sheet.as_deref().map(|name| wb.sheet_index(name)).unwrap_or(Some(edit_sheet)); - if previous_sheet != Some(active_sheet) { - return None; - } - if range.is_full_cols() && previous.is_full_cols() { - Some(RangeRef::cols(previous.start.col.min(range.start.col), previous.end.col.max(range.end.col))) - } else if range.is_full_rows() && previous.is_full_rows() { - Some(RangeRef::rows(previous.start.row.min(range.start.row), previous.end.row.max(range.end.row))) - } else { - None - } - }) - .unwrap_or(range) - } else { - range - }; - let reference = range.a1(); - let reference = - if ed.sheet != wb.active_sheet() { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference }; - if add { - ed.append_ref(&reference); - } else { - ed.insert_ref(&reference); - } - ed.point_anchor = None; - ed.point_cell = Some(range.start); - ed.request_focus = true; - true -} - -fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) { - let cell = CellRef::new( - geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)), - geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)), - ); - if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) { - d.pick_reference(cell, &sh.name, true); - } else if let Some(ed) = app.editor.as_mut() - && ed.point.is_some() - { - if let Some(start) = ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet()).map(|(_, cell)| cell).or(ed.point_cell) { - let range = RangeRef::new(start, cell); - let text = if range.is_single() { range.start.a1() } else { range.a1() }; - let text = if ed.sheet != wb.active_sheet() { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { text }; - ed.insert_ref(&text); - ed.point_cell = Some(cell); - ed.point_anchor = Some((wb.active_sheet(), start)); - } - } else if let Some(d) = app.session.active() { - // Read the current anchor: starting a drag may have just replaced the selection. - let anchor = d.selection.anchor; - let active = d.selection.active; - let mut ranges: Vec = d.selection.ranges.iter().map(|r| r.a1()).collect(); - ranges.pop(); - ranges.push(RangeRef::new(anchor, cell).a1()); - let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": active.a1()})); - if let Some(d) = app.session.active_mut() { - d.selection.anchor = anchor; - } - } -} - -fn coalesce_last_undo(app: &mut SheetApp) { - // Merge consecutive drag steps into one undo entry. - if let Some(d) = app.session.active_mut() - && d.undo.len() >= 2 - { - let n = d.undo.len(); - if d.undo[n - 1].label == d.undo[n - 2].label { - d.undo.remove(n - 1); - } - } -} - -fn scroll_by(app: &mut SheetApp, d: egui::Vec2) { - if d != egui::Vec2::ZERO - && let Some(v) = app.view_mut() - { - v.scroll = (v.scroll + d).max(egui::Vec2::ZERO); - } -} - -fn fill_target(src: RangeRef, pc: CellRef) -> RangeRef { - let below = pc.row as i64 - src.end.row as i64; - let above = src.start.row as i64 - pc.row as i64; - let right = pc.col as i64 - src.end.col as i64; - let left = src.start.col as i64 - pc.col as i64; - let vert = below.max(above); - let horiz = right.max(left); - if vert <= 0 && horiz <= 0 { - return src; - } - if vert >= horiz { - if below > 0 { - RangeRef::new(src.start, CellRef::new(pc.row, src.end.col)) - } else { - RangeRef::new(CellRef::new(pc.row, src.start.col), src.end) - } - } else if right > 0 { - RangeRef::new(src.start, CellRef::new(src.end.row, pc.col)) - } else { - RangeRef::new(CellRef::new(src.start.row, pc.col), src.end) - } -} - -fn on_border(r: Rect, p: Pos2) -> bool { - let outer = r.expand(3.0); - let inner = r.shrink(3.0); - outer.contains(p) && !inner.contains(p) -} - -/// The freeze handles, as in Excel for the web: a bar in the upper part of the column-header strip -/// on the frozen columns' right edge, and one in the left part of the row-header strip on the -/// frozen rows' bottom edge. With nothing frozen they sit on the corner's edges. The rest of the -/// header edge keeps resizing the column or row. -pub fn freeze_handles(geo: &Geo) -> (Rect, Rect) { - let x = geo.cells.left() + geo.frozen_w; - let y = geo.cells.top() + geo.frozen_h; - let cols = Rect::from_min_max(pos2(x - 3.0, geo.head_top()), pos2(x + 3.0, geo.head_top() + geo.header_h * 0.4)); - let rows = Rect::from_min_max(pos2(geo.head_left(), y - 3.0), pos2(geo.head_left() + geo.header_w * 0.4, y + 3.0)); - (cols, rows) -} - -/// How many columns (or rows) a freeze handle dropped at `p` freezes: the line boundary nearest -/// the pointer, limited so the frozen pane leaves room for the scrolling one. -fn freeze_boundary(geo: &Geo, sh: &Sheet, p: Pos2, cols: bool) -> u32 { - if cols { - if p.x <= geo.cells.left() { - return 0; - } - let c = geo.col_at(sh, p.x); - let (l, r) = (geo.x(sh, c), geo.x(sh, c.saturating_add(1).min(MAX_COLS - 1))); - let b = if p.x - l > r - p.x { c.saturating_add(1) } else { c }; - let room = geo.ci.at((((geo.cells.width() - 40.0) / geo.z).max(0.0)) as f64); - b.min(room).min(MAX_COLS - 1) - } else { - if p.y <= geo.cells.top() { - return 0; - } - let rr = geo.row_at(sh, p.y); - let (t, b2) = (geo.y(sh, rr), geo.y(sh, rr.saturating_add(1).min(MAX_ROWS - 1))); - let b = if p.y - t > b2 - p.y { rr.saturating_add(1) } else { rr }; - let room = geo.ri.at((((geo.cells.height() - 40.0) / geo.z).max(0.0)) as f64); - b.min(room).min(MAX_ROWS - 1) - } -} - -/// Paints the freeze handles and, while one is dragged, where the freeze line will go. -fn paint_freeze_handles(p: &Painter, geo: &Geo, sh: &Sheet, drag: &Drag, t: &Tokens) { - let (cols, rows) = freeze_handles(geo); - let bar = Color32::from_gray(150); - p.rect_filled(Rect::from_center_size(cols.center(), vec2(3.0, cols.height())), 1.0, bar); - p.rect_filled(Rect::from_center_size(rows.center(), vec2(rows.width(), 3.0)), 1.0, bar); - if let Drag::Freeze { cols, to: Some(to) } = *drag { - let stroke = Stroke::new(2.0, t.accent); - if cols { - let x = geo.cells.left() + sh.col_left(to) as f32 * geo.z; - p.line_segment([pos2(x, geo.head_top()), pos2(x, geo.cells.bottom())], stroke); - } else { - let y = geo.cells.top() + sh.row_top(to) as f32 * geo.z; - p.line_segment([pos2(geo.head_left(), y), pos2(geo.cells.right(), y)], stroke); - } - } -} - -fn col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option<(u32, f32)> { - let c = geo.col_at(sh, x); - for cc in [c, c.saturating_sub(1)] { - let edge = geo.x(sh, cc) + sh.col_width(cc) * geo.z; - if (x - edge).abs() <= 4.0 { - return Some((cc, edge)); - } - } - None -} - -fn row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option<(u32, f32)> { - let r = geo.row_at(sh, y); - for rr in [r, r.saturating_sub(1)] { - let edge = geo.y(sh, rr) + sh.row_height(rr) * geo.z; - if (y - edge).abs() <= 3.0 { - return Some((rr, edge)); - } - } - None -} - -/// The hidden column whose (zero-width) edge is at `x`: only just right of the boundary, as the -/// edge just left of it belongs to the visible column there. -fn hidden_col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option { - col_edge(geo, sh, x).map(|(c, _)| c).filter(|&c| sh.col_width(c) <= 0.0) -} - -fn hidden_row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option { - row_edge(geo, sh, y).map(|(r, _)| r).filter(|&r| sh.row_height(r) <= 0.0) -} - -/// A split cursor (two bars with arrows either side) painted in place of the OS cursor, which -/// has no such shape. Black on a white outline, like the system cursors. -fn paint_split_cursor(ctx: &egui::Context, at: Pos2, cols: bool) { - ctx.set_cursor_icon(CursorIcon::None); - let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("gridcraft.split_cursor"))); - // Drawn for columns (bars vertical); rows swap the axes. - let pt = |x: f32, y: f32| if cols { pos2(at.x + x, at.y + y) } else { pos2(at.x + y, at.y + x) }; - let arrow = |dir: f32| vec![pt(dir * 10.0, 0.0), pt(dir * 6.0, -4.0), pt(dir * 6.0, 4.0)]; - for (width, color) in [(3.0, Color32::WHITE), (1.0, Color32::BLACK)] { - let stroke = Stroke::new(width, color); - for bx in [-1.5, 1.5] { - p.line_segment([pt(bx, -8.0), pt(bx, 8.0)], stroke); - } - p.line_segment([pt(-7.0, 0.0), pt(7.0, 0.0)], stroke); - for dir in [-1.0, 1.0] { - p.add(egui::Shape::convex_polygon(arrow(dir), color, Stroke::new(width - 1.0, color))); - } - } -} - -fn is_filter_button(sh: &Sheet, geo: &Geo, p: Pos2) -> bool { - let c = geo.cell_at(sh, p); - sh.has_filter_button(c) && filter_button_rect(geo, sh, c).contains(p) -} - -fn object_anchor(sh: &Sheet, kind: &str, id: u32) -> Option { - match kind { - "chart" => sh.charts.iter().find(|c| c.id == id).map(|c| c.anchor), - "image" => sh.images.iter().find(|c| c.id == id).map(|c| c.anchor), - "slicer" => sh.slicers.iter().find(|c| c.id == id).map(|c| c.anchor), - _ => sh.shapes.iter().find(|c| c.id == id).map(|c| c.anchor), - } -} - -fn delete_selected_object(app: &mut SheetApp, sh: &Sheet) -> bool { - let Some(id) = app.selected_chart else { return false }; - let kind = if sh.charts.iter().any(|object| object.id == id) { - "chart" - } else if sh.images.iter().any(|object| object.id == id) { - "image" - } else if sh.shapes.iter().any(|object| object.id == id) { - "shape" - } else { - return false; - }; - app.run_or_alert("object.delete", json!({"kind": kind, "id": id})); - app.selected_chart = None; - true -} - -/// A click on a hyperlink. References select; web and e-mail addresses ask first (the dialog's -/// OK runs `link.open`); every other kind is refused with a message. The decision is the -/// engine's ([`gridcraft_engine::links::classify`]), the same one `link.open` applies. -pub fn follow_link(app: &mut SheetApp, target: &str) { - use gridcraft_engine::links::{LinkKind, classify}; - match classify(target) { - LinkKind::Internal(r) => { - let _ = app.session.run("edit.goTo", json!({"reference": r.trim_start_matches('#')})); - app.grid.ensure_visible = true; - } - LinkKind::External(url) => app.open_dialog("openLink", json!({"url": url})), - LinkKind::Refused(why) => app.message = Some(("Hyperlink".into(), why)), - } -} - -/// The areas after a click: with `add` (Ctrl) the clicked area is added, or removed when it is already -/// selected (unless it is the only one, so the selection never ends up empty). -fn toggled_ranges(mut ranges: Vec, clicked: String, add: bool) -> Vec { - if !add { - return vec![clicked]; - } - if ranges.len() > 1 && ranges.contains(&clicked) { - ranges.retain(|x| *x != clicked); - } else if !ranges.contains(&clicked) { - ranges.push(clicked); - } - ranges -} - -fn select_ranges(app: &mut SheetApp, r: RangeRef, add: bool, active: CellRef) { - let current: Vec = - if add { app.session.active().map(|d| d.selection.ranges.iter().map(|x| x.a1()).collect()).unwrap_or_default() } else { vec![] }; - let clicked = r.a1(); - let ranges = toggled_ranges(current, clicked.clone(), add); - // A removed area takes the active cell with it; `selection.set` then puts it at the start of the last area. - let params = - if ranges.contains(&clicked) { json!({"range": ranges.join(","), "active": active.a1()}) } else { json!({"range": ranges.join(",")}) }; - let _ = app.session.run("selection.set", params); -} - -/// Grid keyboard handling when no text field has focus. -fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: &Geo, sh: &Sheet) { - // Focus left on an editor that no longer exists goes back to the grid. - let stale = [egui::Id::unique("gridcraft.cell_editor"), egui::Id::unique("gridcraft.formula_bar"), crate::text_box::editor_id()]; - let editing = app.editor.is_some() || app.text_box_editor.is_some(); - if !editing && ctx.memory(|m| m.focused()).is_some_and(|f| stale.contains(&f)) { - resp.request_focus(); - } - let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && (editing || !stale.contains(&f))); - // An open filter menu owns the keyboard even when a click inside it left focus on nothing (#1516). - // So do the fill menu (its Esc closes it, not the copy) and the right-drag that opens it (an - // arrow key would move the selection the menu fills from). - if editing - || app.dialog.is_some() - || other_focus - || app.message.is_some() - || app.grid.filter_menu.is_some() - || app.grid.fill_menu.is_some() - || matches!(app.grid.drag, Drag::Fill { menu: true, .. }) - { - return; - } - if ctx.memory(|m| m.focused()).is_none() { - resp.request_focus(); - } - // The grid owns arrows, Tab and Escape (egui would otherwise move focus between widgets). - ctx.memory_mut(|m| { - m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }) - }); - let events = ctx.input(|i| i.events.clone()); - // A shortcut key that also types a character (Shift+Space sends Text(" ")) must not start an edit. - let shortcut_pressed = events.iter().any(|e| { - matches!(e, egui::Event::Key { pressed: true, modifiers: m, key, .. } - if !m.alt && (m.ctrl || m.command || (m.shift && *key == egui::Key::Space))) - }); - let page = ((geo.cells.height() / geo.z) / sh.default_row_height).floor().max(1.0) as i64; - // Alt shortcuts type their character too on Windows and Linux (Alt+= sends Text("=")): once - // a shortcut ran, that text must not start an edit over what it just did. - let mut shortcut_ran = false; - for ev in events { - match ev { - egui::Event::Copy => { - app.copy_to_clipboard(ctx, "edit.copy"); - } - egui::Event::Cut => { - app.copy_to_clipboard(ctx, "edit.cut"); - } - egui::Event::Paste(text) => app.paste_external(text), - // GridCraft has no image-clipboard pipeline and no cell-picture insert-from-clipboard - // command. Choosing to ignore `PasteImage` is explicit: inserting the bytes as a - // floating picture without a chosen anchor/size would be a new feature, not parity. - // A text representation, when the clipboard has one, still arrives as `Paste` above. - egui::Event::PasteImage(_) => {} - // PRIMARY middle-click paste belongs to egui's TextEdit; the grid has no caret to - // insert text at, so a middle click on a cell is deliberately not a paste. - egui::Event::MiddleClickPaste { .. } => {} - egui::Event::Text(_) if shortcut_pressed || shortcut_ran => {} - egui::Event::Text(text) if text.chars().all(|c| !c.is_control()) && !text.is_empty() => { - app.begin_edit(Some(text), false); - if let Some(ed) = app.editor.as_mut() { - ed.caret = ed.text.chars().count(); - } - return; - } - egui::Event::Key { key, physical_key, pressed: true, modifiers: m, .. } => { - use egui::Key; - if crate::keymap::dispatch(app, ctx, key, physical_key, m, geo, sh) { - shortcut_ran = true; - continue; - } - let cmd = m.command; - let shift = m.shift; - let mv = |app: &mut SheetApp, dr: i64, dc: i64| { - let from = app.session.active().map(|d| if shift { d.selection.moving_end() } else { d.selection.active }); - let _ = app.session.run("selection.move", json!({"dr": dr, "dc": dc, "extend": shift, "jump": cmd})); - app.grid.ensure_visible = true; - app.grid.reveal_moving_end = shift; - app.grid.reveal_from = from; - }; - match key { - Key::ArrowUp => mv(app, -1, 0), - Key::ArrowDown => mv(app, 1, 0), - // Arrows follow the screen: on a right-to-left sheet Left goes to the next column. - Key::ArrowLeft => mv(app, 0, if sh.right_to_left { 1 } else { -1 }), - Key::ArrowRight => mv(app, 0, if sh.right_to_left { -1 } else { 1 }), - Key::PageDown if !cmd => mv(app, page, 0), - Key::PageUp if !cmd => mv(app, -page, 0), - Key::PageDown => app.run_or_alert("sheet.next", json!({})), - Key::PageUp => app.run_or_alert("sheet.previous", json!({})), - Key::Home if cmd => { - let r = sh.freeze.map(|f| f.0).unwrap_or(0); - let c = sh.freeze.map(|f| f.1).unwrap_or(0); - let _ = app.session.run("selection.set", json!({"cell": CellRef::new(r, c).a1()})); - app.grid.ensure_visible = true; - } - Key::End if cmd => { - let end = sh.used_range().map(|u| u.end).unwrap_or_default(); - let _ = app.session.run("selection.set", json!({"cell": end.a1()})); - app.grid.ensure_visible = true; - } - Key::Home => { - let a = app.session.active().map(|d| d.selection.active).unwrap_or_default(); - let _ = app.session.run("selection.set", json!({"cell": CellRef::new(a.row, 0).a1()})); - app.grid.ensure_visible = true; - } - // Excel: Enter while a copied range is marked pastes it and ends copy mode. - Key::Enter if !m.alt && !m.ctrl && !shift && !cmd && app.session.clipboard.is_some() => { - app.run_or_alert("edit.paste", json!({})); - app.session.clipboard = None; - app.grid.ensure_visible = true; - } - Key::Enter => { - if m.alt || m.ctrl { - app.begin_edit(None, false); - } else { - app.move_after_enter(if shift { -1 } else { 1 }, 0); - } - } - Key::Tab => app.move_after_enter(0, if shift { -1 } else { 1 }), - Key::F2 => app.begin_edit(None, false), - Key::Delete if delete_selected_object(app, sh) => {} - Key::Delete => app.run_or_alert("edit.clearContents", json!({})), - Key::Backspace if delete_selected_object(app, sh) => {} - Key::Backspace => { - // Excel for Mac: Delete clears the cell and starts editing it. - app.run_or_alert("edit.clearContents", json!({})); - if app.session.active().is_some_and(|d| d.selection.is_single_cell()) { - app.begin_edit(Some(String::new()), false); - } - } - Key::Escape => { - app.session.clipboard = None; - app.session.format_painter = None; - } - Key::Space if !cmd && !shift && is_checkbox(sh, app) => app.run_or_alert("cell.toggleCheckbox", json!({})), - Key::F8 if m.alt => { - app.open_dialog("macros", json!({})); - } - Key::F9 => app.run_or_alert("formulas.calculateNow", json!({})), - Key::F4 if !cmd => {} - _ if cmd && !crate::keymap::altgr_text(ctx, m, ctx.os() == egui::os::OperatingSystem::Mac) => { - crate::ribbon::shortcut(app, key, m) - } - _ => {} - } - } - _ => {} - } - } -} - -/// Room the in-cell editor keeps right of its text. It is sized from the text of the last frame, so -/// the next typed character must still fit: a fixed 14 px was less than one glyph at high zoom, -/// and the field scrolled and snapped back on every key (#2336). `size` is the font size in px. -fn editor_slack(size: f32) -> f32 { - 14.0 + size -} - -/// Width of the one-line in-cell editor. While the text fits the cell (with the usual 14 px margin) the -/// editor is exactly the cell wide, as in Excel; once it overflows, the extra `editor_slack` applies. -fn editor_width(text_w: f32, cell_w: f32, size: f32) -> f32 { - if text_w + 14.0 <= cell_w { cell_w } else { text_w + editor_slack(size) } -} - -/// Draws the in-cell editor, growing and wrapping with its text, mirroring for right-to-left sheets, choosing contrasting caret ink and clipping cells hidden under frozen panes. -fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, wb: &Workbook) { - let Some(ed) = app.editor.as_ref() else { return }; - if app.view_key() != Some((ed.doc, ed.sheet)) { - return; - } - let cell = ed.cell; - let rect = match sh.merge_at(cell) { - Some(m) => geo.range_rect(sh, m), - None => geo.cell_rect(sh, cell), - }; - let st = wb.styles.get(sh.style_id(cell)).clone(); - let size = st.font.size * 96.0 / 72.0 * geo.z; - let font = FontId::new(size, theme::cell_family(&st.font.name, st.font.bold, st.font.italic)); - let lines = ed.text.lines().count().max(1) as f32 + if ed.text.ends_with('\n') { 1.0 } else { 0.0 }; - // Grows with the text: the in-cell editor shows every line and never scrolls (the formula bar - // is the one that stops at five lines). - let shown = lines; - // Wrap Text: the editor keeps the column's width and wraps, growing down as Excel's does. - // Otherwise it widens to the text's one-line width. - let wrap = st.align.wrap; - let (w, h) = if wrap { - // The field wraps at the width it is given (the editor's less its 2 px inset a side): its - // frame is `Frame::NONE`, so no text margin is taken off. - let wrapped = ui.painter().layout(ed.text.clone(), font.clone(), Color32::BLACK, (rect.width() - 4.0).max(1.0)); - (rect.width(), (wrapped.size().y.max(size * 1.3 * shown) + 4.0).max(rect.height())) - } else { - let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x; - // Excel keeps the cell's height for one line and only grows for extra lines. - let h = if lines > 1.0 { (size * 1.3 * lines + 4.0).max(rect.height()) } else { rect.height() }; - (editor_width(text_w, rect.width(), size).min(geo.cells.right() - rect.left()), h) - }; - // Grows to the left on a right-to-left sheet. - let erect = geo.mirror_rect(Rect::from_min_size(rect.min, vec2(w, h))); - let fill = match st.fill.pattern { - PatternType::Solid => st.fill.fg.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::WHITE), - _ => Color32::WHITE, - }; - let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h); - let quadrant = Rect::from_min_max( - pos2(if cell.col >= geo.fc { split.x } else { geo.cells.left() }, if cell.row >= geo.fr { split.y } else { geo.cells.top() }), - pos2(if cell.col >= geo.fc { geo.cells.right() } else { split.x }, if cell.row >= geo.fr { geo.cells.bottom() } else { split.y }), - ); - // A cell scrolled entirely under a frozen row/column keeps its edit (Escape still works), - // but neither the cell nor its helper may paint. Test the cell's own visible quadrant and - // give the shared editor an empty clip when the cell is hidden: a one-line cell editor's - // field can be larger than the cell (egui 0.37 gives text fields a minimum height), so its - // own max_rect is not a reliable "is the cell on screen" test at the frozen boundary. The - // pre-wrap cell rect must also be positive: a hidden row/column has zero height/width, but - // Wrap Text can expand the editor below/right of it, which must not paint over the next line. - let cell_visible = rect.width() > 0.0 && rect.height() > 0.0; - // `erect` and `ui.clip_rect()` are in screen coordinates; `quadrant` is logical until mirrored. - let screen_quadrant = geo.mirror_rect(quadrant); - let on_screen = cell_visible && erect.intersect(screen_quadrant).is_positive() && !geo.hidden_under_frozen(sh, cell); - let clip = if on_screen { ui.clip_rect().intersect(screen_quadrant) } else { Rect::NOTHING }; - let painter = ui.painter().with_clip_rect(clip); - painter.rect_filled(erect, 0.0, fill); - painter.rect_stroke(erect.expand(1.0), 0.0, Stroke::new(2.0, Tokens::get(ui.ctx()).sel_border), StrokeKind::Middle); - let id = egui::Id::unique("gridcraft.cell_editor"); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(erect.shrink2(vec2(2.0, 1.0)))); - child.set_clip_rect(clip); - // The theme's caret is meant for the UI chrome and vanishes on some fills (and, in the dark - // theme, on the white paper): pick black or white against the fill, like Excel. - child.visuals_mut().text_cursor.stroke.color = ink_on(fill); - crate::formula_bar::editor_widget(app, &mut child, id, font, wrap, erect.width() - 4.0, erect.height() - 2.0); - ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("gridcraft.cell_editor.rect"), erect)); -} - -/// Test the popup itself, not whether the pointer is over any part of the app. -/// A trigger can sit outside its popup, so its opening click must be ignored. -fn popup_dismissed(response: &egui::Response, was_open: bool) -> bool { - response.ctx.input(|i| !i.focused || i.key_pressed(egui::Key::Escape)) || (was_open && response.clicked_elsewhere()) -} - -/// Black or white, whichever reads on `fill` (the threshold chart labels use). -fn ink_on(fill: Color32) -> Color32 { - let l = 0.299 * fill.r() as f32 + 0.587 * fill.g() as f32 + 0.114 * fill.b() as f32; - if l < 150.0 { Color32::WHITE } else { Color32::BLACK } -} - -/// The table whose header cell (`row`, `col`) was clicked, not just the first table in the column. -fn filter_table_range(sh: &Sheet, col: u32, row: u32) -> Option { - sh.tables.iter().find(|t| t.range.start.row == row && col >= t.range.start.col && col <= t.range.end.col).map(|t| t.range) -} - -/// Draws the filter-header popup: sort, search/select-all, apply/clear, a lazily installed table autofilter, new-open search focus and dismissal. -fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) { - let Some((col, row, at)) = app.grid.filter_menu else { return }; - let Some(d) = app.session.active() else { return }; - let wb = d.wb.clone(); - let si = wb.active_sheet(); - let Some(sh) = wb.sheet(si) else { return }; - let _ = geo; - // Tables get an autofilter on demand. - let table_range = filter_table_range(sh, col, row); - if sh.autofilter.is_none() - && let Some(r) = table_range - { - let _ = app.session.run("data.filter", json!({"range": r.a1()})); - let _ = app.session.run("data.filter", json!({"range": r.a1()})); - if let Some(sh2) = app.session.active().and_then(|d| d.wb.active()).map(|s| s.autofilter.is_some()) - && !sh2 - { - let d = app.session.active_mut(); - if let Some(d) = d { - let wbm = Arc::make_mut(&mut d.wb); - if let Some(s) = wbm.sheet_mut(si) { - s.autofilter = Some(gridcraft_engine::model::AutoFilter { range: r, criteria: vec![] }); - } - } - } - } - let wb = app.session.active().map(|d| d.wb.clone()).unwrap_or(wb); - let values = gridcraft_engine::cmd::data::filter_values(&wb, si, col); - let key = egui::Id::unique(("filter_sel", col)); - let search_key = egui::Id::unique(("filter_search", col)); - let buttons_key = egui::Id::unique(("filter_buttons", col)); - // Enter is the menu's OK, as in Excel: taken before the search box (which would only drop its - // focus) and the value checkboxes (which would toggle) see it. A focused button keeps it as its click. - let buttons: Vec = ui.ctx().data_mut(|d| d.get_temp(buttons_key)).unwrap_or_default(); - let on_button = ui.ctx().memory(|m| m.focused()).is_some_and(|f| buttons.contains(&f)); - let enter = !on_button && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); - let mut close = false; - let area = egui::Area::new(egui::Id::unique("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground); - let response = area.show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_width(230.0); - let ascending = ui.button("↑ Sort Ascending"); - if ascending.clicked() { - let _ = app.run("data.sortAscending", json!({"column": col_to_letters(col)})); - close = true; - } - let descending = ui.button("↓ Sort Descending"); - if descending.clicked() { - let _ = app.run("data.sortDescending", json!({"column": col_to_letters(col)})); - close = true; - } - ui.separator(); - ui.label(egui::RichText::new("Filter").strong()); - let mut checks: Vec<(String, bool)> = ui.ctx().data_mut(|d| d.get_temp::>(key)).unwrap_or(values.clone()); - // A search narrows the list; Select All and Apply then only cover the matches. - let mut search: String = ui.ctx().data_mut(|d| d.get_temp(search_key)).unwrap_or_default(); - let field = ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search").desired_width(f32::INFINITY)); - if !was_open { - field.request_focus(); - } - let needle = search.trim().to_lowercase(); - let matches = |v: &str| needle.is_empty() || v.to_lowercase().contains(&needle); - let all = checks.iter().filter(|(v, _)| matches(v)).all(|(_, b)| *b); - let mut all_new = all; - let label = if needle.is_empty() { "(Select All)" } else { "(Select All Search Results)" }; - if ui.checkbox(&mut all_new, label).changed() { - for c in checks.iter_mut().filter(|(v, _)| matches(v)) { - c.1 = all_new; - } - } - // Full width: shrunk to the longest value, the scroll bar sat mid-menu and the wheel only - // worked over the values themselves (#432). - egui::ScrollArea::vertical().max_height(220.0).auto_shrink([false, true]).show(ui, |ui| { - for (v, on) in checks.iter_mut().filter(|(v, _)| matches(v)) { - ui.checkbox(on, v.as_str()); - } - }); - ui.ctx().data_mut(|d| { - d.insert_temp(key, checks.clone()); - d.insert_temp(search_key, search.clone()); - }); - let checks: Vec<(String, bool)> = checks.iter().map(|(v, on)| (v.clone(), *on && matches(v))).collect(); - ui.separator(); - ui.horizontal(|ui| { - let clear = ui.button("Clear Filter"); - if clear.clicked() { - let _ = app.run("data.filterBy", json!({"column": col_to_letters(col), "clear": true})); - close = true; - } - // Nothing checked would hide every row, so Enter (unlike a click on Apply) does nothing then. - let any = checks.iter().any(|(_, on)| *on); - let apply = ui.button("Apply"); - ui.ctx().data_mut(|d| d.insert_temp(buttons_key, vec![ascending.id, descending.id, clear.id, apply.id])); - if apply.clicked() || (enter && any) { - let blanks = checks.iter().any(|(v, on)| v == "(Blanks)" && *on); - let vals: Vec = checks.iter().filter(|(v, on)| *on && v != "(Blanks)").map(|(v, _)| v.clone()).collect(); - let p = if checks.iter().all(|(_, b)| *b) { - json!({"column": col_to_letters(col), "clear": true}) - } else { - json!({"column": col_to_letters(col), "values": vals, "blanks": blanks}) - }; - let _ = app.run("data.filterBy", p); - close = true; - } - }); - }); - }); - if close || popup_dismissed(&response.response, was_open) { - ui.ctx().data_mut(|d| { - d.remove::>(key); - d.remove::(search_key); - d.remove::>(buttons_key); - }); - app.grid.filter_menu = None; - } -} - -/// Draws and dispatches the cell context menu, including Show Details when offered, clipboard and insert/format actions; closes on Esc or an outside press. -fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { - let Some(at) = app.grid.context_menu else { return }; - let mut close = false; - let resp = egui::Area::new(egui::Id::unique("cell_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(200.0); - let insert_copied = insert_copied_item(app); - let mut items: Vec<(&str, &str)> = vec![ - ("Cut", "edit.cut"), - ("Copy", "edit.copy"), - ("Paste", "edit.paste"), - ("Paste Special…", "ui:pasteSpecial"), - ("-", ""), - ("Insert…", "ui:insertCells"), - ("Delete…", "ui:deleteCells"), - ("Clear Contents", "edit.clearContents"), - ("-", ""), - ("Filter by Selected Cell's Value", "ui:filterValue"), - ("Sort A to Z", "data.sortAscending"), - ("Sort Z to A", "data.sortDescending"), - ("-", ""), - ("New Comment", "ui:newComment"), - ("New Note", "ui:newNote"), - ("-", ""), - ("Format Cells…", "ui:formatCells"), - ("Pick From Drop-down List…", "ui:pickList"), - ("Define Name…", "ui:defineName"), - ("Link…", "ui:insertLink"), - ]; - if let Some(item) = insert_copied { - items.insert(6, item); - } - if show_details_offered(app) - && let Some(i) = items.iter().position(|(_, id)| *id == "data.sortDescending") - { - items.insert(i + 1, ("-", "")); - items.insert(i + 2, ("Show Details", "pivot.showDetails")); - } - let lang = crate::i18n::current(ui.ctx()); - for (label, id) in items { - if label == "-" { - ui.separator(); - continue; - } - if ui.add(egui::Button::new(lang.tr(label)).frame_when_inactive(false).min_size(vec2(190.0, 20.0))).clicked() { - close = true; - match id { - "ui:pasteSpecial" => app.open_dialog("pasteSpecial", json!({})), - "ui:insertPaste" => app.open_dialog("insertPaste", json!({})), - "ui:insertCells" => app.open_dialog("insertCells", json!({})), - "ui:deleteCells" => app.open_dialog("deleteCells", json!({})), - "ui:formatCells" => app.open_dialog("formatCells", json!({})), - "ui:newComment" => app.open_dialog("comment", json!({"threaded": true})), - "ui:newNote" => app.open_dialog("comment", json!({"threaded": false})), - "ui:defineName" => app.open_dialog("defineName", json!({})), - "ui:insertLink" => app.open_dialog("insertLink", json!({})), - "ui:pickList" => app.open_dialog("pickList", json!({})), - "ui:filterValue" => { - if let Some(d) = app.session.active() { - let a = d.selection.active; - let text = d.wb.active().map(|sh| gridcraft_engine::display::cell_text(&d.wb, sh, a)).unwrap_or_default(); - let has = d.wb.active().is_some_and(|sh| sh.autofilter.is_some()); - if !has { - let _ = app.run("data.filter", json!({})); - } - app.run_or_alert("data.filterBy", json!({"column": col_to_letters(a.col), "values": [text]})); - } - } - "edit.copy" | "edit.cut" => { - app.copy_to_clipboard(ui.ctx(), id); - } - "edit.paste" => app.paste_from_clipboard(), - other => app.run_or_alert(other, json!({})), - } - } - } - }); - }); - // A quick right-click presses and releases in one frame, and the menu's corner snaps to the - // nearest physical pixel, which can put the pointer just outside it: that press opened the - // menu, so it doesn't count as a click elsewhere. - let opened_now = app.grid.context_menu_frame == ui.ctx().cumulative_frame_nr(); - if close - || ui.input(|i| i.key_pressed(egui::Key::Escape)) - || (!opened_now - && ui.input(|i| i.pointer.any_pressed()) - && !resp.response.contains_pointer() - && !resp.response.hovered() - && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !resp.response.rect.contains(p))) - { - app.grid.context_menu = None; - } -} - -/// Whether the cell menu offers Show Details (Excel's right-click › Show Details): the active cell -/// is a value, subtotal or grand total cell of a PivotTable, or a cell of a report that is out of -/// date, where running the command says so. Labels and headers of a current report don't offer it, -/// and no cell does while the PivotTable's Enable show details option is off. -fn show_details_offered(app: &mut SheetApp) -> bool { - let Some(d) = app.session.active() else { return false }; - let (si, at) = (d.wb.active_sheet(), d.selection.active); - let Some((pi, pt)) = d.wb.active().and_then(|sh| sh.pivots.iter().enumerate().find(|(_, pt)| pt.last_range.is_some_and(|r| r.contains(at)))) - else { - return false; - }; - if !pt.enable_show_details { - return false; - } - let origin = pt.last_range.map(|r| r.start).unwrap_or_default(); - let cell = (at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)); - let key = (d.uid, d.revision, si, pi); - if let Some((k, cells)) = &app.grid.show_details_cells - && *k == key - { - return cells.as_ref().is_none_or(|c| c.contains(&cell)); - } - let cells = gridcraft_engine::pivot::detail_cells(&d.wb, pt, si).ok(); - let on = cells.as_ref().is_none_or(|c| c.contains(&cell)); - app.grid.show_details_cells = Some((key, cells)); - on -} - -/// The ways to fill a dragged range, as the fill menu and the Auto Fill Options button list them. -const FILL_MODES: [(&str, &str); 4] = - [("Copy Cells", "copy"), ("Fill Series", "extend"), ("Fill Formatting Only", "formats"), ("Fill Without Formatting", "values")]; - -/// The ways to fill from a date, which both menus add after [`FILL_MODES`] when the source starts -/// with one. -const DATE_FILL_MODES: [(&str, &str); 4] = [("Fill Days", "days"), ("Fill Weekdays", "weekdays"), ("Fill Months", "months"), ("Fill Years", "years")]; - -/// The fill modes the menus list for a fill from `src` on the active sheet. -fn fill_modes(app: &SheetApp, src: RangeRef) -> Vec<(&'static str, &'static str)> { - let dates = app.session.active().is_some_and(|d| gridcraft_engine::fill::starts_with_date(&d.wb, d.wb.active_sheet(), src)); - FILL_MODES.into_iter().chain(DATE_FILL_MODES.into_iter().filter(|_| dates)).collect() -} - -/// Remembers the fill just made so the Auto Fill Options button can offer the other ways. -fn set_fill_tag(app: &mut SheetApp, src: RangeRef, target: RangeRef) { - app.grid.fill_tag = - app.session.active().map(|d| FillTag { src, target, uid: d.uid, sheet: d.wb.active_sheet(), revision: d.revision, undo_len: d.undo.len() }); -} - -/// The Auto Fill Options button below the bottom-right corner of cells a left-drag of the fill -/// handle just filled. Its menu redoes that fill as Copy Cells, Fill Series, Fill Formatting Only -/// or Fill Without Formatting, and from a date also as Fill Days, Fill Weekdays, Fill Months or -/// Fill Years: it undoes the drag's fill, then runs `edit.autoFill` in that mode. -/// The button goes away as soon as anything else changes the workbook or the selection moves. -fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) { - let Some(tag) = app.grid.fill_tag.clone() else { return }; - let current = app.session.active().is_some_and(|d| { - d.uid == tag.uid - && d.wb.active_sheet() == tag.sheet - && d.revision == tag.revision - && d.undo.len() == tag.undo_len - && d.selection.ranges.len() == 1 - && d.selection.ranges.first().is_some_and(|r| *r == tag.target) - }); - if !current { - app.grid.fill_tag = None; - return; - } - if app.editor.is_some() || app.dialog.is_some() || app.grid.drag != Drag::None { - return; - } - let size = vec2(18.0, 18.0); - let corner = geo.range_rect(sh, tag.target).right_bottom(); - let button = geo.mirror_rect(Rect::from_min_size(corner + vec2(2.0, 2.0), size)); - // `button` is in screen space, so compare it with the mirrored cells area. - if !geo.mirror_rect(geo.cells).contains_rect(button) { - return; - } - let lang = crate::i18n::current(ui.ctx()); - let resp = ui.interact(button, egui::Id::unique("gridcraft.fill_options_tag"), Sense::click()).on_hover_text(lang.tr("Auto Fill Options")); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, lang.tr("Auto Fill Options"))); - let t = Tokens::get(ui.ctx()); - let p = ui.painter(); - p.rect_filled(button, 3.0, if resp.hovered() { t.hover } else { t.window }); - p.rect_stroke(button, 3.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); - // Two small cells and a drop-down arrow. - let c = button.center(); - p.rect_stroke(Rect::from_min_size(pos2(c.x - 6.0, c.y - 6.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); - p.rect_stroke(Rect::from_min_size(pos2(c.x - 3.0, c.y - 3.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); - p.add(egui::Shape::convex_polygon( - vec![pos2(c.x + 1.0, c.y + 5.0), pos2(c.x + 7.0, c.y + 5.0), pos2(c.x + 4.0, c.y + 8.0)], - t.text, - Stroke::NONE, - )); - let mut chosen = None; - let modes = fill_modes(app, tag.src); - egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(190.0); - for (label, mode) in modes { - if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() { - chosen = Some(mode); - ui.close(); - } - } - }); - if let Some(mode) = chosen { - if let Err(e) = app.run("edit.undo", json!({})) { - app.grid.fill_tag = None; - app.message = Some(("GridCraft".into(), crate::clean_error(&e))); - return; - } - match app.run("edit.autoFill", json!({"source": tag.src.a1(), "target": tag.target.a1(), "mode": mode})) { - Ok(_) => set_fill_tag(app, tag.src, tag.target), - Err(e) => { - app.grid.fill_tag = None; - app.message = Some(("GridCraft".into(), crate::clean_error(&e))); - } - } - } -} - -/// The menu a right-drag of the fill handle opens on release, as in Excel. -fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { - let Some((src, target, at)) = app.grid.fill_menu else { return }; - let mut close = false; - let mut items = fill_modes(app, src); - items.extend([("-", ""), ("Series…", "ui:series")]); - let response = egui::Area::new(egui::Id::unique("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(190.0); - let lang = crate::i18n::current(ui.ctx()); - for (label, mode) in items { - if label == "-" { - ui.separator(); - continue; - } - if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() { - close = true; - if mode == "ui:series" { - let _ = app.session.run("selection.set", json!({"range": target.union(&src).a1(), "active": src.start.a1()})); - let direction = if target.width() != src.width() { "rows" } else { "columns" }; - app.open_dialog("series", json!({"direction": direction})); - } else if let Err(e) = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1(), "mode": mode})) { - app.message = Some(("GridCraft".into(), crate::clean_error(&e))); - } - } - } - }); - }); - if close || popup_dismissed(&response.response, was_open) { - app.grid.fill_menu = None; - } -} - -/// The right-click menu of a chart, picture, shape or slicer. -fn object_menu(app: &mut SheetApp, ui: &mut egui::Ui) { - let Some((at, kind, id)) = app.grid.object_menu else { return }; - let mut close = false; - let resp = egui::Area::new(egui::Id::unique("object_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(200.0); - let mut items = vec![("Delete", "object.delete")]; - if kind == "chart" { - items.extend([("-", ""), ("Select Data…", "ui:selectData"), ("Format Chart Area…", "ui:formatChart")]); - } - let lang = crate::i18n::current(ui.ctx()); - for (label, action) in items { - if label == "-" { - ui.separator(); - continue; - } - if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(190.0, 20.0))).clicked() { - close = true; - match action { - "ui:selectData" => app.open_dialog("selectData", json!({"chart": id})), - "ui:formatChart" => app.grid.pane = Some("formatChart".into()), - _ => { - app.run_or_alert("object.delete", json!({"kind": kind, "id": id})); - app.selected_chart = None; - } - } - } - } - }); - }); - if close - || ui.input(|i| i.key_pressed(egui::Key::Escape)) - || (ui.input(|i| i.pointer.any_pressed()) - && !resp.response.contains_pointer() - && !resp.response.hovered() - && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !resp.response.rect.contains(p))) - { - app.grid.object_menu = None; - } -} - -/// The warning button beside an active cell holding a number stored as text, as in Excel: its -/// menu names the problem and offers Convert to Number for the selection (#1994). -fn error_check_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) { - if app.editor.is_some() || app.dialog.is_some() || sh.show_formulas || app.grid.drag != Drag::None { - return; - } - let Some(active) = app.session.active().map(|d| d.selection.active) else { return }; - if sh.cell(active).and_then(gridcraft_engine::cmd::formulas::number_stored_as_text).is_none() { - return; - } - let cell = geo.cell_rect(sh, active); - let size = vec2(18.0, 18.0); - // Left of the cell, or right of it when that would cover the row headings. - let left = if cell.left() - size.x - 2.0 >= geo.cells.left() { cell.left() - size.x - 2.0 } else { cell.right() + 2.0 }; - let tag = geo.mirror_rect(Rect::from_min_size(pos2(left, cell.top()), size)); - if !geo.cells.intersects(tag) { - return; - } - let lang = crate::i18n::current(ui.ctx()); - let resp = ui.interact(tag, egui::Id::unique("gridcraft.error_check_tag"), Sense::click()).on_hover_text(lang.tr("Number Stored as Text")); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, lang.tr("Error checking options"))); - let t = Tokens::get(ui.ctx()); - let p = ui.painter(); - p.rect_filled(tag, 3.0, if resp.hovered() { t.hover } else { t.window }); - p.rect_stroke(tag, 3.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); - // A warning sign: an amber diamond with an exclamation mark. - let c = tag.center(); - let r = 6.0; - p.add(egui::Shape::convex_polygon( - vec![pos2(c.x, c.y - r), pos2(c.x + r, c.y), pos2(c.x, c.y + r), pos2(c.x - r, c.y)], - t.warning_fill, - Stroke::NONE, - )); - p.text(c, egui::Align2::CENTER_CENTER, "!", egui::FontId::proportional(10.0), t.warning_ink); - egui::Popup::menu(&resp).show(|ui| { - ui.label(egui::RichText::new(lang.tr("Number Stored as Text")).strong()); - ui.separator(); - if ui.button(lang.tr("Convert to Number")).clicked() { - app.run_or_alert("formulas.convertToNumber", json!({})); - ui.close(); - } - }); -} - -/// Used by `EditState` users to keep the active cell visible while pointing. -pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, extend: bool) { - let Some(d) = app.session.active() else { return }; - let from = ed.point_cell.unwrap_or(ed.cell); - let to = from.offset_clamped(dr, dc); - let anchor = if extend { - // The anchor is the corner opposite the moving end: the written range is normalized, so - // after extending up or left its first cell is the moving end, not the anchor. - let pointed = ed.point.and_then(|(s, e)| ed.text.get(s..e)).and_then(RangeRef::parse); - let inferred_anchor = pointed - .map(|r| { - CellRef::new( - if from.row == r.start.row { r.end.row } else { r.start.row }, - if from.col == r.start.col { r.end.col } else { r.start.col }, - ) - }) - .unwrap_or(from); - ed.point_anchor.filter(|(sheet, _)| *sheet == d.wb.active_sheet()).map(|(_, cell)| cell).unwrap_or(inferred_anchor) - } else { - to - }; - let text = if extend { RangeRef::new(anchor, to).a1() } else { to.a1() }; - let sheet = d.wb.active_sheet(); - ed.insert_ref(&text); - ed.point_cell = Some(to); - ed.point_anchor = Some((sheet, anchor)); - app.grid.ensure_visible = true; -} - -/// The dropdown arrow beside a cell with an in-cell list validation. -pub fn validation_arrow(sh: &Sheet, geo: &Geo, c: CellRef) -> Option { - let dv = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(c)))?; - if dv.kind != gridcraft_engine::model::ValidationKind::List || !dv.in_cell_dropdown || geo.hidden_under_frozen(sh, c) { - return None; - } - let r = geo.cell_rect(sh, c); - let s = r.height().clamp(14.0, 22.0); - Some(Rect::from_min_size(pos2(r.right() + 1.0, r.bottom() - s), vec2(s, s))) -} - -#[allow(clippy::too_many_arguments)] -/// Paints the validation arrow and input message, notes, the hyperlink tooltip and trace precedent/dependent arrows. -pub(crate) fn paint_overlays( - app: &mut SheetApp, - ui: &egui::Ui, - p: &Painter, - geo: &Geo, - wb: &Workbook, - si: usize, - sh: &Sheet, - sel: &gridcraft_engine::Selection, -) { - // Validation dropdown arrow for the active cell. - if app.editor.is_none() - && let Some(r) = validation_arrow(sh, geo, sel.active) - { - let t = Tokens::get(p.ctx()); - p.rect_filled(r, 2.0, t.sheet_control_bg); - p.rect_stroke(r, 2.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Inside); - crate::icons::paint_on_sheet(p, r.shrink(3.0), crate::icons::Icon::Chevron, t.sheet_control_text); - } - // Input message of the active cell's validation, hidden while its list is open: it would cover the items. - if app.editor.is_none() - && app.grid.list_picker != Some(sel.active) - && let Some(dv) = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(sel.active))) - && dv.show_input - && !(dv.input_title.is_empty() && dv.input_message.is_empty()) - && !geo.hidden_under_frozen(sh, sel.active) - { - let r = geo.cell_rect(sh, sel.active); - let (at, pivot) = (geo.mirror_pos(pos2(r.left() + 8.0, r.bottom() + 6.0)), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP }); - egui::Area::new(egui::Id::unique("dv_input")).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show( - ui.ctx(), - |ui| { - egui::Frame::popup(ui.style()).fill(Color32::from_rgb(0xFF, 0xFF, 0xE1)).show(ui, |ui| { - ui.set_max_width(220.0); - if !dv.input_title.is_empty() { - ui.label(egui::RichText::new(&dv.input_title).strong().color(Color32::BLACK)); - } - ui.label(egui::RichText::new(&dv.input_message).color(Color32::BLACK)); - }); - }, - ); - } - // Notes: shown on hover (or always when marked visible). - let hover = app.grid.hover_cell; - let all_notes = app.session.show_all_notes; - for (c, cm) in &sh.comments { - let show = cm.visible || (all_notes && !cm.threaded) || hover == Some(*c); - if !show || app.grid.drag != Drag::None { - continue; - } - let r = geo.cell_rect(sh, *c); - if !p.clip_rect().intersects(r) || geo.hidden_under_frozen(sh, *c) { - continue; - } - let at = pos2(r.right() + 10.0, r.top()); - p.line_segment([r.right_top(), at + vec2(0.0, 6.0)], Stroke::new(1.0, Color32::from_gray(90))); - let (at, pivot) = (geo.mirror_pos(at), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP }); - egui::Area::new(egui::Id::unique(("note", c.row, c.col))).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show( - ui.ctx(), - |ui| { - egui::Frame::popup(ui.style()).fill(if cm.threaded { Color32::WHITE } else { Color32::from_rgb(0xFF, 0xFF, 0xE1) }).show(ui, |ui| { - ui.set_max_width(240.0); - ui.label(egui::RichText::new(format!("{}:", cm.author)).strong().color(Color32::BLACK)); - ui.label(egui::RichText::new(&cm.text).color(Color32::BLACK)); - for (a, t) in &cm.replies { - ui.separator(); - ui.label(egui::RichText::new(format!("{a}: {t}")).color(Color32::BLACK)); - } - }); - }, - ); - } - // Hyperlink tooltip. - if let Some(h) = hover.and_then(|c| sh.hyperlinks.get(&c)) - && app.editor.is_none() - { - let tip = h.tooltip.clone().unwrap_or_else(|| format!("{}\nClick once to follow. Click and hold to select this cell.", h.target)); - egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique("link_tip"), egui::PopupAnchor::Pointer).show(|ui| { - ui.label(tip); - }); - } - // Trace precedents / dependents arrows. - if let Some(trace) = &app.grid.trace - && app.session.active().is_some_and(|d| d.uid == trace.uid && d.revision == trace.revision) - && trace.sheet == si - && let Some(items) = trace.items.as_array() - { - let blue = Color32::from_rgb(0x1F, 0x5F, 0xC9); - let active = geo.cell_rect(sh, trace.cell).center(); - for it in items { - if it["sheet"].as_str().is_some_and(|s| !s.eq_ignore_ascii_case(&sh.name)) { - continue; - } - let target = it["range"].as_str().or(it["cell"].as_str()).and_then(RangeRef::parse); - let Some(t) = target else { continue }; - let tr = geo.range_rect(sh, t); - if !t.is_single() { - for pane in geo.range_panes(t) { - p.with_clip_rect(pane.intersect(p.clip_rect())).rect_stroke(tr, 0.0, Stroke::new(1.5, blue), StrokeKind::Inside); - } - } - let (from, to) = if it.get("range").is_some() { (tr.center(), active) } else { (active, tr.center()) }; - p.line_segment([from, to], Stroke::new(1.5, blue)); - p.circle_filled(from, 3.0, blue); - let d = (to - from).normalized(); - let n = vec2(-d.y, d.x); - p.add(egui::Shape::convex_polygon(vec![to, to - d * 9.0 + n * 4.0, to - d * 9.0 - n * 4.0], blue, Stroke::NONE)); - } - } - let _ = (wb, si); -} - -/// Rows the list-validation dropdown shows before it scrolls (Excel shows 8). -const LIST_PICKER_MAX_ROWS: usize = 8; -const LIST_PICKER_ROW_H: f32 = 18.0; -/// Popup frame around the rows: 2 px inner margin and the window stroke on each side, rounded up. -const LIST_PICKER_CHROME: f32 = 8.0; - -/// Popup height for `n` items: every item up to `LIST_PICKER_MAX_ROWS`, then a scrollbar. -fn list_picker_height(n: usize, row_h: f32, gap: f32) -> f32 { - let rows = n.clamp(1, LIST_PICKER_MAX_ROWS) as f32; - rows * row_h + (rows - 1.0) * gap -} - -/// Draws the validation list popup, choosing above/below and left/right placement for RTL, and applies the clicked item with `cell.set`. -fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) { - let Some(c) = app.grid.list_picker else { return }; - let Some(d) = app.session.active() else { return }; - let wb = d.wb.clone(); - let si = wb.active_sheet(); - let Some(sh) = wb.sheet(si) else { return }; - let Some(dv) = sh.validations.iter().find(|x| x.ranges.iter().any(|r| r.contains(c))).cloned() else { - app.grid.list_picker = None; - return; - }; - let items = gridcraft_engine::cmd::data::list_items(&wb, si, &dv); - let r = geo.cell_rect(sh, c); - // Fit the popup to its rows (up to LIST_PICKER_MAX_ROWS); without a minimum the scroll area collapses to its 64 px default. - let height = list_picker_height(items.len(), LIST_PICKER_ROW_H, ui.spacing().item_spacing.y); - // Open below the cell, or above it when the rows would run past the bottom of the window (as Excel does). - let screen = ui.ctx().content_rect(); - let above = r.bottom() + height + LIST_PICKER_CHROME > screen.bottom() && r.top() - height - LIST_PICKER_CHROME >= screen.top(); - let (at, pivot) = match (above, geo.rtl) { - (false, false) => (geo.mirror_pos(r.left_bottom()), Align2::LEFT_TOP), - (false, true) => (geo.mirror_pos(r.left_bottom()), Align2::RIGHT_TOP), - (true, false) => (geo.mirror_pos(r.left_top()), Align2::LEFT_BOTTOM), - (true, true) => (geo.mirror_pos(r.left_top()), Align2::RIGHT_BOTTOM), - }; - let mut close = false; - let response = egui::Area::new(egui::Id::unique("dv_list")).fixed_pos(at).pivot(pivot).order(egui::Order::Foreground).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).inner_margin(2.0).show(ui, |ui| { - ui.set_min_width(r.width().max(100.0)); - egui::ScrollArea::vertical().max_height(height).min_scrolled_height(height).show(ui, |ui| { - for it in &items { - if ui - .add(egui::Button::new(it.as_str()).frame_when_inactive(false).min_size(vec2(r.width().max(100.0), LIST_PICKER_ROW_H))) - .clicked() - { - app.run_or_alert("cell.set", json!({"cell": c.a1(), "input": it})); - close = true; - } - } - }); - }); - }); - if close || popup_dismissed(&response.response, was_open) { - app.grid.list_picker = None; - } -} - -/// Draws row/column header context menus with insert/delete, format, size, hide/unhide and group actions, plus Insert Copied Cells when available. -fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { - let Some((at, rows)) = app.grid.header_menu else { return }; - let mut close = false; - let response = egui::Area::new(egui::Id::unique("header_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(190.0); - let insert_copied = insert_copied_item(app); - let mut items: Vec<(&str, &str)> = if rows { - vec![ - ("Cut", "edit.cut"), - ("Copy", "edit.copy"), - ("Paste", "edit.paste"), - ("-", ""), - ("Insert", "home.insertRows"), - ("Delete", "home.deleteRows"), - ("Clear Contents", "edit.clearContents"), - ("-", ""), - ("Format Cells…", "ui:formatCells"), - ("Row Height…", "ui:rowHeight"), - ("AutoFit Row Height", "home.autofitRowHeight"), - ("Hide", "home.hideRows"), - ("Unhide", "home.unhideRows"), - ("Group", "data.group"), - ] - } else { - vec![ - ("Cut", "edit.cut"), - ("Copy", "edit.copy"), - ("Paste", "edit.paste"), - ("-", ""), - ("Insert", "home.insertColumns"), - ("Delete", "home.deleteColumns"), - ("Clear Contents", "edit.clearContents"), - ("-", ""), - ("Format Cells…", "ui:formatCells"), - ("Column Width…", "ui:columnWidth"), - ("AutoFit Column Width", "home.autofitColumnWidth"), - ("Hide", "home.hideColumns"), - ("Unhide", "home.unhideColumns"), - ("Sort A to Z", "data.sortAscending"), - ("Group", "data.group"), - ] - }; - if let Some(item) = insert_copied { - items.insert(5, item); - } - let lang = crate::i18n::current(ui.ctx()); - for (label, id) in items { - if label == "-" { - ui.separator(); - continue; - } - if ui.add(egui::Button::new(lang.tr(label)).frame_when_inactive(false).min_size(vec2(180.0, 20.0))).clicked() { - close = true; - match id { - "ui:insertPaste" => app.open_dialog("insertPaste", json!({})), - "ui:rowHeight" => app.open_dialog("rowHeight", json!({})), - "ui:formatCells" => app.open_dialog("formatCells", json!({})), - "ui:columnWidth" => app.open_dialog("columnWidth", json!({})), - "edit.copy" | "edit.cut" => { - app.copy_to_clipboard(ui.ctx(), id); - } - "edit.paste" => app.paste_from_clipboard(), - other => app.run_or_alert(other, json!({})), - } - } - } - }); - }); - if close || popup_dismissed(&response.response, was_open) { - app.grid.header_menu = None; - } -} - -/// The context-menu entry for Insert Copied Cells / Insert Cut Cells, when the clipboard holds a -/// copy or cut of this app: whole rows or columns insert at once, a block asks which way to shift -/// (Insert Paste). A cut block can only be pasted, so it gets no entry. -fn insert_copied_item(app: &SheetApp) -> Option<(&'static str, &'static str)> { - let clip = app.session.clipboard.as_ref()?; - let lines = clip.selected.is_full_rows() != clip.selected.is_full_cols(); - let cut = clip.cut && app.session.active().is_some_and(|d| d.uid == clip.doc_uid); - match (cut, lines) { - (true, true) => Some(("Insert Cut Cells", "home.insertCopiedCells")), - (true, false) => None, - (false, true) => Some(("Insert Copied Cells", "home.insertCopiedCells")), - (false, false) => Some(("Insert Copied Cells…", "ui:insertPaste")), - } -} - -fn is_checkbox(sh: &Sheet, app: &SheetApp) -> bool { - let Some(d) = app.session.active() else { return false }; - d.wb.styles.get(sh.style_id(d.selection.active)).num_fmt.as_str() == "checkbox" -} - -fn paint_pages(app: &mut SheetApp, p: &Painter, geo: &Geo, sh: &Sheet) { - let Some(d) = app.session.active() else { return }; - let key = (d.uid, d.revision, d.wb.active_sheet()); - if app.grid.pages.as_ref().is_none_or(|(k, _)| *k != key) { - let pages: Vec<(RangeRef, u32)> = app - .session - .run("file.printPreview", json!({})) - .ok() - .and_then(|v| { - v["pageList"].as_array().map(|a| { - a.iter() - .filter(|pg| pg["sheet"].as_str() == Some(sh.name.as_str())) - .filter_map(|pg| Some((RangeRef::parse(pg["range"].as_str()?)?, pg["page"].as_u64()? as u32))) - .collect() - }) - }) - .unwrap_or_default(); - app.grid.pages = Some((key, pages)); - } - let Some((_, pages)) = &app.grid.pages else { return }; - if pages.is_empty() { - return; - } - // Grey everything, then clear the printed pages. - let mut printed = pages[0].0; - for (r, _) in pages { - printed = printed.union(r); - } - let pr = geo.range_rect(sh, printed); - let grey = Color32::from_black_alpha(70); - let c = p.clip_rect(); - for r in [ - Rect::from_min_max(c.min, pos2(c.right(), pr.top())), - Rect::from_min_max(pos2(c.left(), pr.bottom()), c.max), - Rect::from_min_max(pos2(c.left(), pr.top()), pos2(pr.left(), pr.bottom())), - Rect::from_min_max(pos2(pr.right(), pr.top()), pos2(c.right(), pr.bottom())), - ] { - if r.is_positive() { - p.rect_filled(r, 0.0, grey); - } - } - let blue = Color32::from_rgb(0x2E, 0x6F, 0xD8); - for (r, n) in pages { - let rr = geo.range_rect(sh, *r); - if !rr.intersects(c) { - continue; - } - p.extend(egui::Shape::dashed_line( - &[rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()], - Stroke::new(2.0, blue), - 6.0, - 3.0, - )); - p.text( - rr.center(), - Align2::CENTER_CENTER, - format!("Page {n}"), - theme::ui_bold((rr.height().min(rr.width()) / 6.0).clamp(14.0, 72.0)), - Color32::from_black_alpha(45), - ); - } - p.rect_stroke(pr, 0.0, Stroke::new(3.0, blue), StrokeKind::Outside); -} - -#[cfg(test)] -mod selection_fill_tests { - use super::*; - - #[test] - fn rect_around_leaves_the_hole_and_never_overlaps() { - let area = Rect::from_min_max(pos2(100.0, 50.0), pos2(400.0, 250.0)); - let check = |hole: Rect, pieces: usize| { - let parts: Vec = rect_around(area, hole).collect(); - assert_eq!(parts.len(), pieces, "{hole:?}: {parts:?}"); - let cut = area.intersect(hole); - let cut_area = if cut.is_positive() { cut.area() } else { 0.0 }; - let shaded: f32 = parts.iter().map(Rect::area).sum(); - assert!((shaded - (area.area() - cut_area)).abs() < 0.5, "everything but the hole is shaded: {hole:?}"); - for (i, a) in parts.iter().enumerate() { - assert!(area.contains_rect(*a), "nothing outside the area: {a:?}"); - assert!(!a.shrink(0.01).intersects(hole), "the hole stays clear: {a:?} vs {hole:?}"); - // The fill is translucent: a part shaded twice would show darker. - assert!(parts.iter().skip(i + 1).all(|b| !a.shrink(0.01).intersects(*b)), "{parts:?}"); - } - }; - let cell = |x: f32, y: f32| Rect::from_min_size(pos2(x, y), vec2(64.0, 16.0)); - // In the middle, in each corner (where a drag began), along an edge. - check(cell(200.0, 100.0), 4); - check(cell(100.0, 50.0), 2); - check(cell(336.0, 50.0), 2); - check(cell(100.0, 234.0), 2); - check(cell(336.0, 234.0), 2); - check(cell(100.0, 100.0), 3); - // A merged cell as wide, or as tall, as the area; one that is all of it, or more. - check(Rect::from_min_max(pos2(100.0, 100.0), pos2(400.0, 132.0)), 2); - check(Rect::from_min_max(pos2(200.0, 50.0), pos2(264.0, 250.0)), 2); - check(area, 0); - check(area.expand(30.0), 0); - // The active cell is in another area, touches this one, or is only partly inside it. - check(cell(500.0, 300.0), 1); - check(cell(400.0, 50.0), 1); - check(cell(380.0, 240.0), 2); - } - - #[test] - fn rect_around_survives_hostile_numbers() { - let sane = Rect::from_min_max(pos2(0.0, 0.0), pos2(64.0, 16.0)); - let bad = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN, -1.0, 0.0]; - for a in bad { - for b in bad { - let weird = Rect::from_min_max(pos2(a, b), pos2(b, a)); - for (area, hole) in [(sane, weird), (weird, sane), (weird, weird)] { - for part in rect_around(area, hole) { - assert!(part.is_positive() && part.is_finite(), "{area:?} around {hole:?}: {part:?}"); - } - } - // A hole that makes no sense leaves the whole area shaded. - if !weird.intersects(sane) { - assert_eq!(rect_around(sane, weird).collect::>(), vec![sane]); - } - } - } - } -} - -/// The horizontal alignment to paint with. Cells are painted left to right and a right-to-left -/// sheet is mirrored afterwards, so an explicit Left or Right is swapped first to end up on the -/// side the user chose: Align Right stays on the right in either sheet direction, as in Excel. -/// General alignment is resolved from the value before this and follows the sheet direction. -fn painted_halign(h: HAlign, rtl: bool) -> HAlign { - match h { - HAlign::Left if rtl => HAlign::Right, - HAlign::Right if rtl => HAlign::Left, - h => h, - } -} - /// Row and column heading labels grow with the zoom as the cells do, as in Excel (at 400% the /// labels are four times their 100% size), and never get smaller than a readable 8.5 points. pub(crate) fn heading_font_size(zoom: f32) -> f32 { @@ -4202,288 +277,3 @@ pub(crate) fn heading_font_size(zoom: f32) -> f32 { #[cfg(test)] mod tests; - -#[cfg(test)] -mod pane_tests { - use super::*; - - fn rng(r0: u32, c0: u32, r1: u32, c1: u32) -> RangeRef { - RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)) - } - - #[test] - fn selection_in_scrolling_pane_is_not_drawn_in_frozen_pane() { - // Frozen: 2 rows and 2 columns; the scrolling pane shows rows 2..=20 and columns 2..=10. - let frozen_cols = ((2, 20), (0, 1)); - let frozen_rows = ((0, 1), (2, 10)); - let corner = ((0, 1), (0, 1)); - let scrolled = ((2, 20), (2, 10)); - let sel = rng(5, 4, 7, 6); - assert!(range_in_pane(sel, scrolled.0, scrolled.1)); - assert!(!range_in_pane(sel, frozen_cols.0, frozen_cols.1)); - assert!(!range_in_pane(sel, frozen_rows.0, frozen_rows.1)); - assert!(!range_in_pane(sel, corner.0, corner.1)); - } - - #[test] - fn ctrl_click_on_a_selected_header_deselects_it() { - let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::>(); - assert_eq!(toggled_ranges(s(&["A:A", "C:C"]), "C:C".into(), true), s(&["A:A"])); - assert_eq!(toggled_ranges(s(&["A:A"]), "C:C".into(), true), s(&["A:A", "C:C"])); - assert_eq!(toggled_ranges(s(&["A:A"]), "A:A".into(), true), s(&["A:A"])); - assert_eq!(toggled_ranges(s(&["A1", "B2", "3:3"]), "B2".into(), true), s(&["A1", "3:3"])); - assert_eq!(toggled_ranges(s(&["A:A", "C:C"]), "C:C".into(), false), s(&["C:C"])); - } - - #[test] - fn selection_across_the_freeze_line_is_drawn_in_both_panes() { - let sel = rng(5, 0, 7, 6); - assert!(range_in_pane(sel, (2, 20), (0, 1))); - assert!(range_in_pane(sel, (2, 20), (2, 10))); - } - - #[test] - fn hidden_outline_symbols_take_no_room() { - let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); - let mut sh = Sheet::new("S"); - sh.rows.entry(3).or_default().outline = 1; - sh.cols.entry(2).or_default().outline = 2; - let shown = Geo::new(&sh, rect, vec2(0.0, 0.0)); - assert!(shown.band_w > 0.0 && shown.band_h > 0.0); - sh.show_outline_symbols = false; - let hidden = Geo::new(&sh, rect, vec2(0.0, 0.0)); - assert_eq!((hidden.band_w, hidden.band_h), (0.0, 0.0)); - assert!(hidden.cells.left() < shown.cells.left() && hidden.cells.top() < shown.cells.top()); - // Without headings too. - sh.show_headings = false; - let bare = Geo::new(&sh, rect, vec2(0.0, 0.0)); - assert_eq!((bare.band_w, bare.band_h), (0.0, 0.0)); - // No button answers a click on the empty bands' edges (the grid's left and top), where the - // level buttons would sit with no headings to hold them. - for geo in [&hidden, &bare] { - for i in 0..400 { - let t = i as f32 * 0.5; - for p in [pos2(geo.rect.left(), geo.rect.top() + t), pos2(geo.rect.left() + t, geo.rect.top())] { - assert_eq!(crate::outline::hit(geo, &sh, p), None, "{p:?}"); - } - } - } - } - - /// Two frozen rows and columns, scrolled right so that columns C:E lie under the frozen columns. - fn frozen_geo(sh: &mut Sheet) -> Geo { - sh.freeze = Some((2, 2)); - let scroll = vec2(3.0 * sh.default_col_width + 8.0, 0.0); - Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), scroll) - } - - #[test] - fn the_edge_just_past_a_hidden_line_unhides_it() { - let mut sh = Sheet::new("S"); - sh.cols.entry(11).or_default().hidden = true; // L - sh.rows.entry(26).or_default().hidden = true; // 27 - let geo = Geo::new(&sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(2000.0, 1200.0)), vec2(0.0, 0.0)); - let x = geo.x(&sh, 12); // between K and M - assert_eq!(hidden_col_edge(&geo, &sh, x + 2.0), Some(11)); - assert_eq!(hidden_col_edge(&geo, &sh, x - 2.0), None); // K's own edge - assert_eq!(hidden_col_edge(&geo, &sh, geo.x(&sh, 5)), None); - let y = geo.y(&sh, 27); // between 26 and 28 - assert_eq!(hidden_row_edge(&geo, &sh, y + 2.0), Some(26)); - assert_eq!(hidden_row_edge(&geo, &sh, y - 2.0), None); - } - - #[test] - fn headers_mark_the_edges_next_to_hidden_lines() { - let mut sh = Sheet::new("S"); - sh.cols.entry(0).or_default().hidden = true; // A - sh.cols.entry(11).or_default().hidden = true; // L - sh.rows.entry(26).or_default().hidden = true; // 27 - let col = |c| hidden_around(c, |i| sh.col_width(i)); - assert_eq!(col(1), (true, false)); // B, after hidden A - assert_eq!(col(10), (false, true)); // K - assert_eq!(col(12), (true, false)); // M - assert_eq!(col(5), (false, false)); - let row = |r| hidden_around(r, |i| sh.row_height(i)); - assert_eq!(row(25), (false, true)); - assert_eq!(row(27), (true, false)); - assert_eq!(row(0), (false, false)); - } - - #[test] - fn overlays_skip_cells_scrolled_under_the_frozen_columns() { - let mut sh = Sheet::new("S"); - let geo = frozen_geo(&mut sh); - assert!(geo.hidden_under_frozen(&sh, CellRef::new(5, 2))); - assert!(geo.hidden_under_frozen(&sh, CellRef::new(5, 4))); - assert!(!geo.hidden_under_frozen(&sh, CellRef::new(5, 5))); - assert!(!geo.hidden_under_frozen(&sh, CellRef::new(5, 0))); - let unscrolled = Geo::new(&sh, geo.rect, vec2(0.0, 0.0)); - assert!(!unscrolled.hidden_under_frozen(&sh, CellRef::new(5, 2))); - } - - #[test] - fn ranges_are_clipped_to_the_panes_that_hold_them() { - let mut sh = Sheet::new("S"); - let geo = frozen_geo(&mut sh); - let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h); - assert_eq!(geo.range_panes(rng(5, 3, 7, 6)), [Rect::from_min_max(split, geo.cells.max)]); - assert_eq!(geo.range_panes(rng(5, 0, 7, 6)).len(), 2); - assert_eq!(geo.range_panes(rng(0, 0, 7, 6)).len(), 4); - assert_eq!(geo.range_panes(rng(0, 0, 1, 1)), [Rect::from_min_max(geo.cells.min, split)]); - // The fill handle of D6:E7 sits over the frozen columns, where nothing of it is shown. - let handle = geo.range_rect(&sh, rng(5, 3, 6, 4)).right_bottom(); - assert!(handle.x < split.x); - assert!(!geo.range_panes(rng(6, 4, 6, 4)).iter().any(|r| r.contains(handle))); - } -} - -#[cfg(test)] -mod outline_band_tests { - use super::*; - - #[test] - fn whole_column_outline_keeps_left_and_right_sides_near_viewport() { - let clip = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0)); - let col = Rect::from_min_max(pos2(100.0, 40.0), pos2(180.0, 20_971_540.0)); - let sides = outline_bands(col, 2.0, clip); - // Left, right and top (in view); the bottom is 20 million points away. - assert_eq!(sides.len(), 3); - assert!(sides.iter().all(|s| s.bottom() <= clip.bottom() + 4.0 + f32::EPSILON)); - assert!(sides.iter().any(|s| s.left() < 100.0 && s.right() > 100.0 && s.height() > 500.0)); - assert!(sides.iter().any(|s| s.left() < 180.0 && s.right() > 180.0 && s.height() > 500.0)); - } - - #[test] - fn outline_outside_the_view_and_non_finite_draw_nothing() { - let clip = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0)); - assert!(outline_bands(Rect::from_min_max(pos2(900.0, 0.0), pos2(950.0, 20.0)), 2.0, clip).is_empty()); - assert!(outline_bands(Rect::from_min_max(pos2(f32::NAN, 0.0), pos2(1.0, 1.0)), 2.0, clip).is_empty()); - } -} - -#[cfg(test)] -mod trace_arrow_tests { - use super::*; - - fn app() -> SheetApp { - let mut s = gridcraft_engine::Session::new(); - s.new_workbook(); - SheetApp::new(s, Default::default()) - } - - fn select(app: &mut SheetApp, cell: &str) { - app.run("selection.set", json!({"cell": cell})).unwrap(); - } - - /// What the ribbon's Trace Precedents / Trace Dependents buttons do. - fn trace(app: &mut SheetApp, id: &str) { - let r = app.run(id, json!({})).unwrap(); - app.grid.trace = Trace::new(app, r); - } - - /// The arrows `paint_overlays` draws on the active sheet, as (tail, head) points. - fn arrows(app: &mut SheetApp) -> (Geo, Vec<(Pos2, Pos2)>) { - let d = app.session.active().unwrap(); - let (wb, si, sel) = (d.wb.clone(), d.wb.active_sheet(), d.selection.clone()); - let sh = wb.sheet(si).unwrap(); - let geo = Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0)), vec2(0.0, 0.0)); - let ctx = egui::Context::default(); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - let p = ui.painter().clone(); - paint_overlays(app, ui, &p, &geo, &wb, si, sh, &sel); - }); - // No renderer consumes the font atlas upload here. - out.textures_delta.clear(); - let blue = Color32::from_rgb(0x1F, 0x5F, 0xC9); - let mut lines = Vec::new(); - for s in out.shapes { - if let egui::Shape::LineSegment { points, stroke } = s.shape - && stroke.color == blue - { - lines.push((points[0], points[1])); - } - } - (geo, lines) - } - - fn center(app: &SheetApp, geo: &Geo, cell: &str) -> Pos2 { - let d = app.session.active().unwrap(); - let sh = d.wb.sheet(d.wb.active_sheet()).unwrap(); - geo.cell_rect(sh, CellRef::parse(cell).unwrap()).center() - } - - #[test] - fn precedent_arrows_stay_on_the_traced_cell_when_the_selection_moves() { - let mut app = app(); - for (c, v) in [("B2", "1"), ("B3", "2"), ("D2", "1"), ("D3", "2"), ("F7", "=B2+B3+SUM(D2:D3)")] { - app.run("cell.set", json!({"cell": c, "input": v})).unwrap(); - } - select(&mut app, "F7"); - trace(&mut app, "formulas.tracePrecedents"); - select(&mut app, "H7"); - let (geo, lines) = arrows(&mut app); - assert_eq!(lines.len(), 3, "B2, B3 and D2:D3: {lines:?}"); - let f7 = center(&app, &geo, "F7"); - assert!(lines.iter().all(|&(_, head)| head == f7), "every arrow points at F7, not at the active H7: {lines:?}"); - } - - #[test] - fn dependent_arrows_stay_on_the_traced_cell_when_the_selection_moves() { - let mut app = app(); - for (c, v) in [("B2", "1"), ("D4", "=B2*2"), ("E6", "=B2+1")] { - app.run("cell.set", json!({"cell": c, "input": v})).unwrap(); - } - select(&mut app, "B2"); - trace(&mut app, "formulas.traceDependents"); - select(&mut app, "C9"); - let (geo, lines) = arrows(&mut app); - assert_eq!(lines.len(), 2, "D4 and E6: {lines:?}"); - let b2 = center(&app, &geo, "B2"); - assert!(lines.iter().all(|&(tail, _)| tail == b2), "every arrow starts at B2, not at the active C9: {lines:?}"); - } - - #[test] - fn arrows_stay_on_the_sheet_of_the_traced_cell() { - let mut app = app(); - app.run("home.insertSheet", json!({"name": "Two"})).unwrap(); - app.run("cell.set", json!({"sheet": "Sheet1", "cell": "B2", "input": "1"})).unwrap(); - app.run("sheet.activate", json!({"sheet": "Two"})).unwrap(); - app.run("cell.set", json!({"cell": "F7", "input": "=Sheet1!B2"})).unwrap(); - select(&mut app, "F7"); - trace(&mut app, "formulas.tracePrecedents"); - // The precedent lives on Sheet1, but the arrow belongs to Two's F7. - app.run("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); - assert!(arrows(&mut app).1.is_empty(), "no arrow on Sheet1 from B2 to where F7 would be"); - } - - #[test] - fn inserting_a_row_removes_the_arrows() { - let mut app = app(); - app.run("cell.set", json!({"cell": "B2", "input": "1"})).unwrap(); - app.run("cell.set", json!({"cell": "F7", "input": "=B2"})).unwrap(); - select(&mut app, "F7"); - trace(&mut app, "formulas.tracePrecedents"); - assert_eq!(arrows(&mut app).1.len(), 1); - app.run("home.insertRows", json!({"rows": "4"})).unwrap(); - assert!(arrows(&mut app).1.is_empty(), "F7 is now F8: the old arrows would point at the wrong cell"); - } - - #[test] - fn arrows_stay_in_their_workbook() { - let mut app = app(); - app.session.new_workbook(); - // Two books with the same cells and the same revision: only the document tells them apart. - for book in [1, 0] { - app.session.set_active(book); - app.run("cell.set", json!({"cell": "B2", "input": "1"})).unwrap(); - app.run("cell.set", json!({"cell": "F7", "input": "=B2"})).unwrap(); - } - select(&mut app, "F7"); - trace(&mut app, "formulas.tracePrecedents"); - assert_eq!(arrows(&mut app).1.len(), 1); - app.session.set_active(1); - let revisions: Vec = app.session.documents().iter().map(|d| d.revision).collect(); - assert_eq!(revisions[0], revisions[1]); - assert!(arrows(&mut app).1.is_empty(), "Book1's arrows are not drawn on Book2"); - } -} diff --git a/crates/ui-egui/src/grid/cell_paint.rs b/crates/ui-egui/src/grid/cell_paint.rs new file mode 100644 index 000000000..99ff2320e --- /dev/null +++ b/crates/ui-egui/src/grid/cell_paint.rs @@ -0,0 +1,310 @@ +use super::geo::Geo; +use super::*; + +/// Resolved look of a cell for painting. +pub(super) struct Look { + pub(super) style: Style, + pub(super) fill: Option, + pub(super) text_color: Color32, +} + +pub(super) fn col32(c: Color, wb: &Workbook, default: Color32) -> Color32 { + c.resolve(&wb.theme).map(theme::color32).unwrap_or(default) +} + +pub(super) fn numfmt_color(c: gridcraft_engine::numfmt::FormatColor) -> Color32 { + c.rgb().map(|[r, g, b]| Color32::from_rgb(r, g, b)).unwrap_or(Color32::BLACK) +} + +/// Last column of a Center Across Selection run starting at `start`: it extends over following columns (up to `limit`) while `continues` holds. +pub(super) fn center_across_end(start: u32, limit: u32, continues: impl Fn(u32) -> bool) -> u32 { + let mut end = start; + while end < limit && end + 1 < MAX_COLS && continues(end + 1) { + end += 1; + } + end +} + +pub(super) fn look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut CfCache) -> (Look, Option) { + let mut style = wb.styles.get(sh.style_id(c)).clone(); + if let Some(t) = sh.table_at(c) { + style = gridcraft_engine::tables::table_cell_style(wb, si, t, c); + } + let cfl = cf.look(wb, si, c); + if let Some(l) = &cfl + && let Some(st) = &l.style + { + if st.fill.pattern != PatternType::None { + style.fill = st.fill; + } + if st.font.color != Color::Auto { + style.font.color = st.font.color; + } + if st.font.bold { + style.font.bold = true; + } + if st.font.italic { + style.font.italic = true; + } + if st.font.underline != Underline::None { + style.font.underline = st.font.underline; + } + if st.font.strike { + style.font.strike = true; + } + // In a differential style General means "not set" (#1163). + if st.num_fmt.as_str() != "General" { + style.num_fmt = st.num_fmt.clone(); + } + for (dst, src) in [ + (&mut style.borders.left, st.borders.left), + (&mut style.borders.right, st.borders.right), + (&mut style.borders.top, st.borders.top), + (&mut style.borders.bottom, st.borders.bottom), + ] { + if !src.is_none() { + *dst = src; + } + } + } + let mut fill = match style.fill.pattern { + PatternType::None => None, + PatternType::Solid => style.fill.fg.resolve(&wb.theme).map(theme::color32), + _ => { + // Patterns: approximate with a blend of foreground and background. + let fg = style.fill.fg.resolve(&wb.theme).unwrap_or([0, 0, 0]); + let bg = style.fill.bg.resolve(&wb.theme).unwrap_or([255, 255, 255]); + Some(Color32::from_rgb( + ((fg[0] as u16 + bg[0] as u16 * 3) / 4) as u8, + ((fg[1] as u16 + bg[1] as u16 * 3) / 4) as u8, + ((fg[2] as u16 + bg[2] as u16 * 3) / 4) as u8, + )) + } + }; + if let Some(l) = &cfl + && let Some(rgb) = l.scale_fill + { + fill = Some(theme::color32(rgb)); + } + let text_color = col32(style.font.color, wb, Color32::BLACK); + (Look { style, fill, text_color }, cfl) +} + +/// Text for a value with General narrowing to the available width. +pub(super) fn display_text( + wb: &Workbook, + sh: &Sheet, + c: CellRef, + v: &Value, + st: &Style, + width_px: f32, + char_w: f32, +) -> (String, Option, bool, Option<(char, usize)>) { + if sh.show_formulas + && let Some(f) = sh.cell(c).and_then(|x| x.formula.as_ref()) + { + return (format!("={}", f.text), None, false, None); + } + if !sh.show_zeros && v.as_f64() == Some(0.0) { + return (String::new(), None, false, None); + } + let code = st.num_fmt.as_str(); + // A checkbox cell draws TRUE/FALSE (and empty) as a box; other values show as General. + if code == "checkbox" && matches!(v, Value::Bool(_) | Value::Empty) { + return (String::new(), None, false, None); + } + if code == "General" + && let Value::Number(n) = v + { + let max_chars = ((width_px - 4.0) / char_w).floor().max(1.0) as usize; + return match gridcraft_engine::numfmt::format_general_fit(*n, max_chars.min(11)) { + Some(t) => (t, None, true, None), + None => ("#".repeat(max_chars.max(1)), None, true, None), + }; + } + let f = gridcraft_engine::display::format(v, code, wb); + (f.text, f.color.map(numfmt_color), f.numeric, f.fill) +} + +pub(crate) fn wrapped_cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 { + let pad_v = 1.5 * z; + let y_top = rect.top() + pad_v; + // Not clamped to `y_top`: text taller than the row overflows upward (Bottom) or both ways + // (Center), and the cell's clip rect trims it, so Middle and Bottom stay distinct from Top. + let y_bottom = rect.bottom() - gh - pad_v; + match valign { + VAlign::Top => y_top, + VAlign::Center | VAlign::Justify | VAlign::Distributed => (y_top + y_bottom) / 2.0, + VAlign::Bottom => y_bottom, + } +} + +pub(crate) fn cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 { + let top_inset = 1.0 * z; + let y_top = rect.top() - top_inset; + let y_bottom = (rect.bottom() - gh - 0.5 * z).max(y_top); + match valign { + VAlign::Top => y_top, + VAlign::Center | VAlign::Justify | VAlign::Distributed => (y_top + y_bottom) / 2.0, + VAlign::Bottom => y_bottom, + } +} + +/// Where to put a `TextShape` holding a galley of `size` turned by `angle` (egui radians, so +/// negative for upward text) for it to sit in `rect` per the cell's alignment. egui turns the +/// galley about `pos`, its top-left corner, so the turned box is placed first and `pos` follows. +/// General keeps the text at the left. Bottom (the default) puts upward text at the bottom, but +/// downward text at the top, where it starts reading. +pub(crate) fn rotated_text_pos(rect: Rect, size: egui::Vec2, angle: f32, h: HAlign, v: VAlign, pad: f32, indent: f32) -> Pos2 { + let rot = egui::emath::Rot2::from_angle(angle); + let turned = Rect::from_points(&[vec2(0.0, 0.0), vec2(size.x, 0.0), vec2(0.0, size.y), size].map(|c| (rot * c).to_pos2())); + let x = match h { + HAlign::Center | HAlign::CenterAcross | HAlign::Distributed => rect.center().x - turned.width() / 2.0, + HAlign::Right => rect.right() - pad - indent - turned.width(), + _ => rect.left() + pad + indent, + }; + let y = match v { + VAlign::Center | VAlign::Justify | VAlign::Distributed => rect.center().y - turned.height() / 2.0, + VAlign::Bottom if angle < 0.0 => rect.bottom() - pad - turned.height(), + _ => rect.top() + pad, + }; + pos2(x, y) - turned.min.to_vec2() +} + +/// The outline segments of merged area `m` drawn at `rect`: the top and left edges come from the +/// first row's and first column's cells, the bottom and right edges from the last row's and last +/// column's cells, one segment per visible cell (limited to the visible `rows` × `cols`). +#[allow(clippy::too_many_arguments)] +pub(super) fn merged_edges( + wb: &Workbook, + si: usize, + sh: &Sheet, + geo: &Geo, + m: RangeRef, + rect: Rect, + rows: (u32, u32), + cols: (u32, u32), + cf: &mut CfCache, +) -> Vec<(BorderLine, [Pos2; 2])> { + let mut out = Vec::new(); + let (cs, ce) = (m.start.col.max(cols.0), m.end.col.min(cols.1)); + for col in cs..=ce { + if sh.col_width(col) == 0.0 { + continue; + } + let (x0, x1) = (geo.x(sh, col), geo.x(sh, col.saturating_add(1))); + let top = look(wb, si, sh, CellRef::new(m.start.row, col), cf).0.style.borders.top; + let bottom = look(wb, si, sh, CellRef::new(m.end.row, col), cf).0.style.borders.bottom; + out.push((top, [pos2(x0, rect.top()), pos2(x1, rect.top())])); + out.push((bottom, [pos2(x0, rect.bottom()), pos2(x1, rect.bottom())])); + } + let (rs, re) = (m.start.row.max(rows.0), m.end.row.min(rows.1)); + for row in rs..=re { + if sh.row_height(row) == 0.0 { + continue; + } + let (y0, y1) = (geo.y(sh, row), geo.y(sh, row.saturating_add(1))); + let left = look(wb, si, sh, CellRef::new(row, m.start.col), cf).0.style.borders.left; + let right = look(wb, si, sh, CellRef::new(row, m.end.col), cf).0.style.borders.right; + out.push((left, [pos2(rect.left(), y0), pos2(rect.left(), y1)])); + out.push((right, [pos2(rect.right(), y0), pos2(rect.right(), y1)])); + } + out +} + +/// The smaller font that makes `text` fit in `room` points of width (Shrink to fit), or `None` +/// when it already fits at its own size. +pub(super) fn shrink_font(p: &Painter, text: &str, font: &FontId, room: f32) -> Option { + let natural = p.layout_no_wrap(text.to_string(), font.clone(), Color32::PLACEHOLDER).size().x; + let room = room.max(1.0); + if natural.is_nan() || natural <= room { + return None; + } + // Glyph advances don't scale exactly linearly (rounding), so leave a little slack. + let size = (font.size * room / natural * 0.97).max(1.0); + Some(FontId::new(size, font.family.clone())) +} + +pub(crate) fn paint_border(p: &Painter, pts: [Pos2; 2], style: BorderStyle, col: Color32) { + let w = style.width(); + let (a, b) = (pts[0], pts[1]); + match style { + BorderStyle::Double => { + let horiz = (a.y - b.y).abs() < 0.5; + let off = if horiz { vec2(0.0, 1.5) } else { vec2(1.5, 0.0) }; + p.line_segment([a - off, b - off], Stroke::new(1.0, col)); + p.line_segment([a + off, b + off], Stroke::new(1.0, col)); + } + BorderStyle::Dashed + | BorderStyle::MediumDashed + | BorderStyle::DashDot + | BorderStyle::MediumDashDot + | BorderStyle::DashDotDot + | BorderStyle::MediumDashDotDot + | BorderStyle::SlantDashDot => { + p.extend(egui::Shape::dashed_line(&[a, b], Stroke::new(w, col), 4.0, 2.0)); + } + BorderStyle::Dotted | BorderStyle::Hair => { + p.extend(egui::Shape::dotted_line(&[a, b], col, 2.0, w * 0.5)); + } + _ => { + p.line_segment([a, b], Stroke::new(w, col)); + } + } +} + +pub(super) fn paint_cf_icon(p: &Painter, r: Rect, set: &str, idx: usize) { + let n = gridcraft_engine::cf::icon_count(set); + let palette3 = [Color32::from_rgb(0xD1, 0x3B, 0x2F), Color32::from_rgb(0xF2, 0xB8, 0x2E), Color32::from_rgb(0x2E, 0x9E, 0x4F)]; + let col = if n <= 3 { + palette3.get(idx).copied().unwrap_or(Color32::GRAY) + } else { + let t = idx as f32 / (n - 1).max(1) as f32; + Color32::from_rgb( + (0xD1 as f32 * (1.0 - t) + 0x2E as f32 * t) as u8, + (0x3B as f32 * (1.0 - t) + 0x9E as f32 * t) as u8, + (0x2F as f32 * (1.0 - t) + 0x4F as f32 * t) as u8, + ) + }; + let lower = set.to_ascii_lowercase(); + if lower.contains("arrow") { + let c = r.center(); + let s = r.width() / 2.0; + let pts = if idx + 1 == n { + vec![pos2(c.x, c.y - s), pos2(c.x + s, c.y + 0.1 * s), pos2(c.x - s, c.y + 0.1 * s)] + } else if idx == 0 { + vec![pos2(c.x, c.y + s), pos2(c.x - s, c.y - 0.1 * s), pos2(c.x + s, c.y - 0.1 * s)] + } else { + vec![pos2(c.x + s, c.y), pos2(c.x - 0.1 * s, c.y - s), pos2(c.x - 0.1 * s, c.y + s)] + }; + p.add(egui::Shape::convex_polygon(pts, col, Stroke::NONE)); + } else if lower.contains("rating") || lower.contains("quarter") { + p.circle_stroke(r.center(), r.width() / 2.0 - 0.5, Stroke::new(1.0, Color32::GRAY)); + let frac = idx as f32 / (n - 1).max(1) as f32; + p.rect_filled( + Rect::from_min_size(pos2(r.left() + 2.0, r.bottom() - 2.0 - (r.height() - 4.0) * frac), vec2(r.width() - 4.0, (r.height() - 4.0) * frac)), + 1.0, + Color32::from_rgb(0x2B, 0x7C, 0xD3), + ); + } else { + p.circle_filled(r.center(), r.width() / 2.0 - 0.5, col); + } +} + +/// Black or white, whichever reads on `fill` (the threshold chart labels use). +pub(super) fn ink_on(fill: Color32) -> Color32 { + let l = 0.299 * fill.r() as f32 + 0.587 * fill.g() as f32 + 0.114 * fill.b() as f32; + if l < 150.0 { Color32::WHITE } else { Color32::BLACK } +} + +/// The horizontal alignment to paint with. Cells are painted left to right and a right-to-left +/// sheet is mirrored afterwards, so an explicit Left or Right is swapped first to end up on the +/// side the user chose: Align Right stays on the right in either sheet direction, as in Excel. +/// General alignment is resolved from the value before this and follows the sheet direction. +pub(super) fn painted_halign(h: HAlign, rtl: bool) -> HAlign { + match h { + HAlign::Left if rtl => HAlign::Right, + HAlign::Right if rtl => HAlign::Left, + h => h, + } +} diff --git a/crates/ui-egui/src/grid/cursor.rs b/crates/ui-egui/src/grid/cursor.rs new file mode 100644 index 000000000..b72f9e160 --- /dev/null +++ b/crates/ui-egui/src/grid/cursor.rs @@ -0,0 +1,138 @@ +use super::geo::Geo; +use super::interact::{OBJECT_RESIZE_RADIUS, col_edge, freeze_handles, hidden_col_edge, hidden_row_edge, on_border, row_edge}; +use super::*; + +/// Decide after interaction so drag start and release use the new state in the same frame. +pub(super) fn update_cursor(app: &mut SheetApp, ui: &egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet) { + if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() { + return; + } + let ctx = ui.ctx(); + let pointer = ctx.input(|i| i.pointer.clone()); + let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p)); + let in_cells = pos.is_some_and(|p| geo.in_cells(p)); + let (freeze_cols, freeze_rows) = freeze_handles(geo); + // Hover bookkeeping also serves cell notes, independently of who owns the cursor. + if resp.hovered() + && let Some(p) = pos + && !freeze_cols.contains(p) + && !freeze_rows.contains(p) + && !matches!(app.grid.drag, Drag::Freeze { .. }) + { + app.grid.hover_cell = in_cells.then(|| geo.cell_at(sh, p)); + } + // Draw tools own the cursor over cells, including while recording a stroke. + if !app.session.draw_tool.is_empty() && in_cells { + ctx.set_cursor_icon(CursorIcon::Crosshair); + return; + } + // A held drag owns the cursor even outside the grid; a press below egui's threshold still + // has Drag::None and uses hover. A click can briefly leave a drag state after release. + if resp.dragged() + && pointer.primary_down() + && let Some(cursor) = drag_cursor(&app.grid.drag) + { + ctx.set_cursor_icon(cursor); + return; + } + if !resp.hovered() { + return; + } + let Some(p) = pos else { return }; + hover_cursor(app, ctx, resp, &pointer, geo, sh, p); +} + +/// The cursor a held drag shows, or `None` when the drag leaves it to hover. +fn drag_cursor(drag: &Drag) -> Option { + match drag { + Drag::None => None, + Drag::Select => Some(CursorIcon::Cell), + Drag::Rows(_) | Drag::Cols(_) => Some(CursorIcon::Default), + Drag::Fill { .. } => Some(CursorIcon::Crosshair), + Drag::Move { .. } | Drag::MoveCols { .. } | Drag::Object { resize: false, .. } => Some(CursorIcon::Move), + Drag::ResizeCol { .. } => Some(CursorIcon::ResizeColumn), + Drag::ResizeRow { .. } => Some(CursorIcon::ResizeRow), + Drag::Freeze { cols, .. } => Some(if *cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }), + Drag::Object { resize: true, .. } => Some(CursorIcon::ResizeNwSe), + } +} + +/// The cursor over the grid when no drag owns it: freeze bars, header edges, the fill handle, cells. +fn hover_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, pointer: &egui::PointerState, geo: &Geo, sh: &Sheet, p: Pos2) { + let (freeze_cols, freeze_rows) = freeze_handles(geo); + if freeze_cols.contains(p) || freeze_rows.contains(p) { + freeze_cursor(app, ctx, resp, pointer, freeze_cols.contains(p)); + return; + } + let in_cells = geo.in_cells(p); + let in_col_header = p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left(); + let in_row_header = p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top(); + let near_col_edge = in_col_header && col_edge(geo, sh, p.x).is_some(); + let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default(); + let cur_rect = geo.range_rect(sh, sel.current()); + let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0)); + let handle_panes = geo.range_panes(RangeRef::cell(sel.current().end)); + let border_panes = geo.range_panes(sel.current()); + let on_handle = handle.contains(p) && handle_panes.iter().any(|r| r.contains(p)); + let on_cur_border = on_border(cur_rect, p) && border_panes.iter().any(|r| r.contains(p)); + let selected_object = crate::chartview::hit(app, geo, sh, p).filter(|&(_, id, _)| app.selected_chart == Some(id)); + let near_corner = selected_object.is_some_and(|(_, _, rect)| (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS); + if app.grid.drag == Drag::None && near_corner { + ctx.set_cursor_icon(CursorIcon::ResizeNwSe); + } else if near_col_edge || (in_row_header && row_edge(geo, sh, p.y).is_some()) { + header_edge_cursor(app, ctx, pointer, geo, sh, p, near_col_edge); + } else if on_handle && app.editor.is_none() { + ctx.set_cursor_icon(CursorIcon::Crosshair); + if app.grid.drag == Drag::None && !pointer.any_down() { + resp.clone().on_hover_text_at_pointer("Drag to fill cells. Drag from inside a cell to select a range."); + } + } else if in_cells && app.editor.is_none() && on_cur_border { + ctx.set_cursor_icon(CursorIcon::Move); + } else if in_cells { + ctx.set_cursor_icon(CursorIcon::Cell); + } else if in_col_header { + let col = geo.col_at(sh, p.x); + let selected = sel.ranges.len() == 1 && sel.current().is_full_cols() && sel.current().contains(CellRef::new(0, col)); + ctx.set_cursor_icon(if selected { CursorIcon::Move } else { CursorIcon::Default }); + } +} + +/// The resize cursor for dragging a freeze bar. +fn freeze_cursor(app: &SheetApp, ctx: &egui::Context, resp: &egui::Response, pointer: &egui::PointerState, cols: bool) { + ctx.set_cursor_icon(if cols { CursorIcon::ResizeHorizontal } else { CursorIcon::ResizeVertical }); + if app.grid.drag == Drag::None && !pointer.any_down() { + let tip = if cols { "Drag to freeze columns" } else { "Drag to freeze rows" }; + resp.clone().on_hover_text_at_pointer(crate::i18n::current(ctx).tr(tip)); + } +} + +/// The resize cursor over a column or row header edge. Just past a hidden column or row, Excel +/// shows a split cursor: dragging or double-clicking there unhides it. +fn header_edge_cursor(app: &SheetApp, ctx: &egui::Context, pointer: &egui::PointerState, geo: &Geo, sh: &Sheet, p: Pos2, near_col_edge: bool) { + let hidden = + app.grid.drag == Drag::None && if near_col_edge { hidden_col_edge(geo, sh, p.x).is_some() } else { hidden_row_edge(geo, sh, p.y).is_some() }; + match pointer.hover_pos() { + Some(at) if hidden => paint_split_cursor(ctx, at, near_col_edge), + _ => ctx.set_cursor_icon(if near_col_edge { CursorIcon::ResizeColumn } else { CursorIcon::ResizeRow }), + } +} + +/// A split cursor (two bars with arrows either side) painted in place of the OS cursor, which +/// has no such shape. Black on a white outline, like the system cursors. +fn paint_split_cursor(ctx: &egui::Context, at: Pos2, cols: bool) { + ctx.set_cursor_icon(CursorIcon::None); + let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("gridcraft.split_cursor"))); + // Drawn for columns (bars vertical); rows swap the axes. + let pt = |x: f32, y: f32| if cols { pos2(at.x + x, at.y + y) } else { pos2(at.x + y, at.y + x) }; + let arrow = |dir: f32| vec![pt(dir * 10.0, 0.0), pt(dir * 6.0, -4.0), pt(dir * 6.0, 4.0)]; + for (width, color) in [(3.0, Color32::WHITE), (1.0, Color32::BLACK)] { + let stroke = Stroke::new(width, color); + for bx in [-1.5, 1.5] { + p.line_segment([pt(bx, -8.0), pt(bx, 8.0)], stroke); + } + p.line_segment([pt(-7.0, 0.0), pt(7.0, 0.0)], stroke); + for dir in [-1.0, 1.0] { + p.add(egui::Shape::convex_polygon(arrow(dir), color, Stroke::new(width - 1.0, color))); + } + } +} diff --git a/crates/ui-egui/src/grid/editor.rs b/crates/ui-egui/src/grid/editor.rs new file mode 100644 index 000000000..d87ec385a --- /dev/null +++ b/crates/ui-egui/src/grid/editor.rs @@ -0,0 +1,161 @@ +use super::cell_paint::ink_on; +use super::geo::Geo; +use super::*; + +/// Room the in-cell editor keeps right of its text. It is sized from the text of the last frame, so +/// the next typed character must still fit: a fixed 14 px was less than one glyph at high zoom, +/// and the field scrolled and snapped back on every key (#2336). `size` is the font size in px. +pub(super) fn editor_slack(size: f32) -> f32 { + 14.0 + size +} + +/// Width of the one-line in-cell editor. While the text fits the cell (with the usual 14 px margin) the +/// editor is exactly the cell wide, as in Excel; once it overflows, the extra `editor_slack` applies. +pub(super) fn editor_width(text_w: f32, cell_w: f32, size: f32) -> f32 { + if text_w + 14.0 <= cell_w { cell_w } else { text_w + editor_slack(size) } +} + +/// Draws the in-cell editor, growing and wrapping with its text, mirroring for right-to-left sheets, choosing contrasting caret ink and clipping cells hidden under frozen panes. +pub(super) fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, wb: &Workbook) { + let Some(ed) = app.editor.as_ref() else { return }; + if app.view_key() != Some((ed.doc, ed.sheet)) { + return; + } + let cell = ed.cell; + let rect = match sh.merge_at(cell) { + Some(m) => geo.range_rect(sh, m), + None => geo.cell_rect(sh, cell), + }; + let st = wb.styles.get(sh.style_id(cell)).clone(); + let size = st.font.size * 96.0 / 72.0 * geo.z; + let font = FontId::new(size, theme::cell_family(&st.font.name, st.font.bold, st.font.italic)); + let lines = ed.text.lines().count().max(1) as f32 + if ed.text.ends_with('\n') { 1.0 } else { 0.0 }; + // Grows with the text: the in-cell editor shows every line and never scrolls (the formula bar + // is the one that stops at five lines). + let shown = lines; + // Wrap Text: the editor keeps the column's width and wraps, growing down as Excel's does. + // Otherwise it widens to the text's one-line width. + let wrap = st.align.wrap; + let (w, h) = if wrap { + // The field wraps at the width it is given (the editor's less its 2 px inset a side): its + // frame is `Frame::NONE`, so no text margin is taken off. + let wrapped = ui.painter().layout(ed.text.clone(), font.clone(), Color32::BLACK, (rect.width() - 4.0).max(1.0)); + (rect.width(), (wrapped.size().y.max(size * 1.3 * shown) + 4.0).max(rect.height())) + } else { + let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x; + // Excel keeps the cell's height for one line and only grows for extra lines. + let h = if lines > 1.0 { (size * 1.3 * lines + 4.0).max(rect.height()) } else { rect.height() }; + (editor_width(text_w, rect.width(), size).min(geo.cells.right() - rect.left()), h) + }; + // Grows to the left on a right-to-left sheet. + let erect = geo.mirror_rect(Rect::from_min_size(rect.min, vec2(w, h))); + let fill = match st.fill.pattern { + PatternType::Solid => st.fill.fg.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::WHITE), + _ => Color32::WHITE, + }; + let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h); + let quadrant = Rect::from_min_max( + pos2(if cell.col >= geo.fc { split.x } else { geo.cells.left() }, if cell.row >= geo.fr { split.y } else { geo.cells.top() }), + pos2(if cell.col >= geo.fc { geo.cells.right() } else { split.x }, if cell.row >= geo.fr { geo.cells.bottom() } else { split.y }), + ); + // A cell scrolled entirely under a frozen row/column keeps its edit (Escape still works), + // but neither the cell nor its helper may paint. Test the cell's own visible quadrant and + // give the shared editor an empty clip when the cell is hidden: a one-line cell editor's + // field can be larger than the cell (egui 0.37 gives text fields a minimum height), so its + // own max_rect is not a reliable "is the cell on screen" test at the frozen boundary. The + // pre-wrap cell rect must also be positive: a hidden row/column has zero height/width, but + // Wrap Text can expand the editor below/right of it, which must not paint over the next line. + let cell_visible = rect.width() > 0.0 && rect.height() > 0.0; + // `erect` and `ui.clip_rect()` are in screen coordinates; `quadrant` is logical until mirrored. + let screen_quadrant = geo.mirror_rect(quadrant); + let on_screen = cell_visible && erect.intersect(screen_quadrant).is_positive() && !geo.hidden_under_frozen(sh, cell); + let clip = if on_screen { ui.clip_rect().intersect(screen_quadrant) } else { Rect::NOTHING }; + let painter = ui.painter().with_clip_rect(clip); + painter.rect_filled(erect, 0.0, fill); + painter.rect_stroke(erect.expand(1.0), 0.0, Stroke::new(2.0, Tokens::get(ui.ctx()).sel_border), StrokeKind::Middle); + let id = egui::Id::unique("gridcraft.cell_editor"); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(erect.shrink2(vec2(2.0, 1.0)))); + child.set_clip_rect(clip); + // The theme's caret is meant for the UI chrome and vanishes on some fills (and, in the dark + // theme, on the white paper): pick black or white against the fill, like Excel. + child.visuals_mut().text_cursor.stroke.color = ink_on(fill); + crate::formula_bar::editor_widget(app, &mut child, id, font, wrap, erect.width() - 4.0, erect.height() - 2.0); + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("gridcraft.cell_editor.rect"), erect)); +} + +/// Test the popup itself, not whether the pointer is over any part of the app. +/// A trigger can sit outside its popup, so its opening click must be ignored. +pub(super) fn popup_dismissed(response: &egui::Response, was_open: bool) -> bool { + response.ctx.input(|i| !i.focused || i.key_pressed(egui::Key::Escape)) || (was_open && response.clicked_elsewhere()) +} + +/// The dropdown arrow beside a cell with an in-cell list validation. +pub fn validation_arrow(sh: &Sheet, geo: &Geo, c: CellRef) -> Option { + let dv = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(c)))?; + if dv.kind != gridcraft_engine::model::ValidationKind::List || !dv.in_cell_dropdown || geo.hidden_under_frozen(sh, c) { + return None; + } + let r = geo.cell_rect(sh, c); + let s = r.height().clamp(14.0, 22.0); + Some(Rect::from_min_size(pos2(r.right() + 1.0, r.bottom() - s), vec2(s, s))) +} + +/// Rows the list-validation dropdown shows before it scrolls (Excel shows 8). +const LIST_PICKER_MAX_ROWS: usize = 8; + +const LIST_PICKER_ROW_H: f32 = 18.0; + +/// Popup frame around the rows: 2 px inner margin and the window stroke on each side, rounded up. +const LIST_PICKER_CHROME: f32 = 8.0; + +/// Popup height for `n` items: every item up to `LIST_PICKER_MAX_ROWS`, then a scrollbar. +pub(super) fn list_picker_height(n: usize, row_h: f32, gap: f32) -> f32 { + let rows = n.clamp(1, LIST_PICKER_MAX_ROWS) as f32; + rows * row_h + (rows - 1.0) * gap +} + +/// Draws the validation list popup, choosing above/below and left/right placement for RTL, and applies the clicked item with `cell.set`. +pub(super) fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) { + let Some(c) = app.grid.list_picker else { return }; + let Some(d) = app.session.active() else { return }; + let wb = d.wb.clone(); + let si = wb.active_sheet(); + let Some(sh) = wb.sheet(si) else { return }; + let Some(dv) = sh.validations.iter().find(|x| x.ranges.iter().any(|r| r.contains(c))).cloned() else { + app.grid.list_picker = None; + return; + }; + let items = gridcraft_engine::cmd::data::list_items(&wb, si, &dv); + let r = geo.cell_rect(sh, c); + // Fit the popup to its rows (up to LIST_PICKER_MAX_ROWS); without a minimum the scroll area collapses to its 64 px default. + let height = list_picker_height(items.len(), LIST_PICKER_ROW_H, ui.spacing().item_spacing.y); + // Open below the cell, or above it when the rows would run past the bottom of the window (as Excel does). + let screen = ui.ctx().content_rect(); + let above = r.bottom() + height + LIST_PICKER_CHROME > screen.bottom() && r.top() - height - LIST_PICKER_CHROME >= screen.top(); + let (at, pivot) = match (above, geo.rtl) { + (false, false) => (geo.mirror_pos(r.left_bottom()), Align2::LEFT_TOP), + (false, true) => (geo.mirror_pos(r.left_bottom()), Align2::RIGHT_TOP), + (true, false) => (geo.mirror_pos(r.left_top()), Align2::LEFT_BOTTOM), + (true, true) => (geo.mirror_pos(r.left_top()), Align2::RIGHT_BOTTOM), + }; + let mut close = false; + let response = egui::Area::new(egui::Id::unique("dv_list")).fixed_pos(at).pivot(pivot).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).inner_margin(2.0).show(ui, |ui| { + ui.set_min_width(r.width().max(100.0)); + egui::ScrollArea::vertical().max_height(height).min_scrolled_height(height).show(ui, |ui| { + for it in &items { + if ui + .add(egui::Button::new(it.as_str()).frame_when_inactive(false).min_size(vec2(r.width().max(100.0), LIST_PICKER_ROW_H))) + .clicked() + { + app.run_or_alert("cell.set", json!({"cell": c.a1(), "input": it})); + close = true; + } + } + }); + }); + }); + if close || popup_dismissed(&response.response, was_open) { + app.grid.list_picker = None; + } +} diff --git a/crates/ui-egui/src/grid/geo.rs b/crates/ui-egui/src/grid/geo.rs new file mode 100644 index 000000000..0c17e173a --- /dev/null +++ b/crates/ui-egui/src/grid/geo.rs @@ -0,0 +1,237 @@ +use super::*; + +/// Geometry of the grid for one frame (handles zoom and frozen panes). +#[derive(Clone)] +pub struct Geo { + pub rect: Rect, + pub cells: Rect, + pub z: f32, + pub header_w: f32, + pub header_h: f32, + /// The outline bands (grouped rows / columns): left of the row headers and above the column + /// headers; 0 without an outline (see `outline`). + pub band_w: f32, + pub band_h: f32, + pub row_outline: Arc, + pub col_outline: Arc, + pub fr: u32, + pub fc: u32, + pub frozen_w: f32, + pub frozen_h: f32, + pub scroll: egui::Vec2, + /// Sheet Right-to-Left: everything is laid out left-to-right as usual (these "logical" + /// coordinates are what `x`, `cell_rect`, `col_at`… return) and mirrored across `rect`'s + /// vertical centre line on screen: painted shapes by `mirror_painted`, widgets by + /// `mirror_rect`/`mirror_pos`, and pointer input back by `mirror_pos` (an involution). + pub rtl: bool, + /// Prefix sums over custom row heights / column widths, built once per frame. + pub(super) ri: std::sync::Arc, + pub(super) ci: std::sync::Arc, + /// Page Layout view: cells sit on pages (`scroll` is then in page-layout units). + pub paper: Option>, +} + +impl Geo { + pub fn new(sh: &Sheet, rect: Rect, scroll: egui::Vec2) -> Geo { + let zoom = (sh.zoom as f32 / 100.0).clamp(0.1, 4.0); + let z = zoom * DISPLAY_SCALE; + let header_h = if sh.show_headings { (COL_HEADER_H * zoom.max(0.75)).round() } else { 0.0 }; + let ri = std::sync::Arc::new(LineIndex::new(&sh.rows, sh.default_row_height, MAX_ROWS)); + let ci = std::sync::Arc::new(LineIndex::new(&sh.cols, sh.default_col_width, MAX_COLS)); + let last_row = ri.at((scroll.y + rect.height() / z) as f64 + 200.0) + 1; + let digits = (last_row as f32).log10().floor() as i32 + 1; + let header_w = if sh.show_headings { ((digits.max(2) as f32 * 7.0 + 14.0) * zoom.max(0.75)).round() } else { 0.0 }; + let (fr, fc) = sh.freeze.unwrap_or((0, 0)); + let frozen_w = (ci.start(fc) as f32) * z; + let frozen_h = (ri.start(fr) as f32) * z; + let row_outline = Arc::new(crate::outline::Axis::of(sh, true)); + let col_outline = Arc::new(crate::outline::Axis::of(sh, false)); + // Show Outline Symbols off: no bands (the outline's hidden lines stay hidden). + let symbols = sh.show_outline_symbols; + let mut band_w = if symbols { crate::outline::band(row_outline.levels, zoom) } else { 0.0 }; + let mut band_h = if symbols { crate::outline::band(col_outline.levels, zoom) } else { 0.0 }; + // Without headings the level buttons still need a corner to sit in. + if symbols && row_outline.levels > 0 && header_h == 0.0 { + band_h = band_h.max(crate::outline::lane(zoom) + 3.0); + } + if symbols && col_outline.levels > 0 && header_w == 0.0 { + band_w = band_w.max(crate::outline::lane(zoom) + 3.0); + } + let cells = Rect::from_min_max(pos2(rect.left() + band_w + header_w, rect.top() + band_h + header_h), rect.max); + Geo { + rect, + cells, + z, + header_w, + header_h, + band_w, + band_h, + row_outline, + col_outline, + fr, + fc, + frozen_w, + frozen_h, + scroll, + rtl: sh.right_to_left, + ri, + ci, + paper: None, + } + } + /// Left edge of the row headers (right of the outline band). + pub fn head_left(&self) -> f32 { + self.cells.left() - self.header_w + } + /// Top edge of the column headers (below the outline band). + pub fn head_top(&self) -> f32 { + self.cells.top() - self.header_h + } + /// Page Layout view geometry: `rect` excludes the rulers; cells are drawn at the print scale + /// on pages laid out by `grid`; frozen panes don't apply (as in Excel). + pub fn paged(sh: &Sheet, rect: Rect, scroll: egui::Vec2, grid: Arc) -> Geo { + let mut g = Geo::new(sh, rect, scroll); + let zoom = (sh.zoom as f32 / 100.0).clamp(0.1, 4.0); + g.z = zoom * DISPLAY_SCALE * grid.scale.clamp(0.1, 4.0); + (g.fr, g.fc, g.frozen_w, g.frozen_h) = (0, 0, 0.0, 0.0); + g.paper = Some(grid); + g + } + /// The point is over cells (in Page Layout view: on a page, inside its margins). + pub fn in_cells(&self, p: Pos2) -> bool { + if !self.cells.contains(p) { + return false; + } + match &self.paper { + Some(pg) => pg.on_cells( + self.scroll.x as f64 + ((p.x - self.cells.left()) / self.z) as f64, + self.scroll.y as f64 + ((p.y - self.cells.top()) / self.z) as f64, + ), + None => true, + } + } + /// Scroll offset (in `scroll` units) at which `row` reaches the top of the scrolled area. + pub fn scroll_top_of(&self, sh: &Sheet, row: u32) -> f32 { + match &self.paper { + Some(pg) => pg.row_y(row) as f32, + None => (sh.row_top(row) - sh.row_top(self.fr)) as f32, + } + } + /// Scroll offset (in `scroll` units) at which `col` reaches the left of the scrolled area. + pub fn scroll_left_of(&self, sh: &Sheet, col: u32) -> f32 { + match &self.paper { + Some(pg) => pg.col_x(col) as f32, + None => (sh.col_left(col) - sh.col_left(self.fc)) as f32, + } + } + /// Screen x for a logical x (and back): identity unless the sheet is right-to-left. + pub fn mirror_x(&self, x: f32) -> f32 { + if self.rtl { self.rect.left() + self.rect.right() - x } else { x } + } + pub fn mirror_pos(&self, p: Pos2) -> Pos2 { + pos2(self.mirror_x(p.x), p.y) + } + pub fn mirror_rect(&self, r: Rect) -> Rect { + if self.rtl { Rect::from_min_max(pos2(self.mirror_x(r.right()), r.top()), pos2(self.mirror_x(r.left()), r.bottom())) } else { r } + } + /// On-screen rectangle of a cell (`cell_rect` mirrored for right-to-left sheets). + pub fn screen_cell_rect(&self, sh: &Sheet, c: CellRef) -> Rect { + self.mirror_rect(self.cell_rect(sh, c)) + } + pub fn x(&self, _sh: &Sheet, col: u32) -> f32 { + if let Some(pg) = &self.paper { + return self.cells.left() + (pg.col_x(col) - self.scroll.x as f64) as f32 * self.z; + } + if col < self.fc { + self.cells.left() + self.ci.start(col) as f32 * self.z + } else { + self.cells.left() + self.frozen_w + (self.ci.start(col) as f32 - self.ci.start(self.fc) as f32 - self.scroll.x) * self.z + } + } + pub fn y(&self, _sh: &Sheet, row: u32) -> f32 { + if let Some(pg) = &self.paper { + return self.cells.top() + (pg.row_y(row) - self.scroll.y as f64) as f32 * self.z; + } + if row < self.fr { + self.cells.top() + self.ri.start(row) as f32 * self.z + } else { + self.cells.top() + self.frozen_h + (self.ri.start(row) as f32 - self.ri.start(self.fr) as f32 - self.scroll.y) * self.z + } + } + pub fn cell_rect(&self, sh: &Sheet, c: CellRef) -> Rect { + Rect::from_min_max( + pos2(self.x(sh, c.col), self.y(sh, c.row)), + pos2(self.x(sh, c.col) + sh.col_width(c.col) * self.z, self.y(sh, c.row) + sh.row_height(c.row) * self.z), + ) + } + pub fn range_rect(&self, sh: &Sheet, r: RangeRef) -> Rect { + let a = self.cell_rect(sh, r.start); + let b = self.cell_rect(sh, r.end); + Rect::from_min_max(a.min, b.max) + } + pub fn col_at(&self, _sh: &Sheet, x: f32) -> u32 { + if let Some(pg) = &self.paper { + return pg.col_at(self.scroll.x as f64 + ((x - self.cells.left()) / self.z) as f64); + } + let dx = (x - self.cells.left()) / self.z; + if dx < self.frozen_w / self.z { + self.ci.at(dx.max(0.0) as f64) + } else { + self.ci.at(self.ci.start(self.fc) + self.scroll.x as f64 + (dx - self.frozen_w / self.z) as f64) + } + } + pub fn row_at(&self, _sh: &Sheet, y: f32) -> u32 { + if let Some(pg) = &self.paper { + return pg.row_at(self.scroll.y as f64 + ((y - self.cells.top()) / self.z) as f64); + } + let dy = (y - self.cells.top()) / self.z; + if dy < self.frozen_h / self.z { + self.ri.at(dy.max(0.0) as f64) + } else { + self.ri.at(self.ri.start(self.fr) + self.scroll.y as f64 + (dy - self.frozen_h / self.z) as f64) + } + } + pub fn cell_at(&self, sh: &Sheet, p: Pos2) -> CellRef { + CellRef::new(self.row_at(sh, p.y), self.col_at(sh, p.x)) + } + /// Visible scrolled rows (from the first visible row after the frozen ones). + pub(super) fn scroll_rows(&self, _sh: &Sheet) -> (u32, u32) { + let top = self.ri.start(self.fr) + self.scroll.y as f64; + let r0 = self.ri.at(top).max(self.fr); + let r1 = self.ri.at(top + ((self.cells.height() - self.frozen_h) / self.z) as f64).min(MAX_ROWS - 1); + (r0, r1) + } + pub(super) fn scroll_cols(&self, _sh: &Sheet) -> (u32, u32) { + let left = self.ci.start(self.fc) + self.scroll.x as f64; + let c0 = self.ci.at(left).max(self.fc); + let c1 = self.ci.at(left + ((self.cells.width() - self.frozen_w) / self.z) as f64).min(MAX_COLS - 1); + (c0, c1) + } + /// Screen areas of the panes (frozen rows/columns or the scrolling part) that hold part of `r`. + /// The panes share one coordinate system, so a range that is not clipped to these is also drawn + /// over the frozen rows/columns when it is scrolled under them. + pub fn range_panes(&self, r: RangeRef) -> Vec { + let split = pos2(self.cells.left() + self.frozen_w, self.cells.top() + self.frozen_h); + let mut xs = Vec::new(); + if r.start.col < self.fc { + xs.push((self.cells.left(), split.x)); + } + if r.end.col >= self.fc { + xs.push((split.x, self.cells.right())); + } + let mut ys = Vec::new(); + if r.start.row < self.fr { + ys.push((self.cells.top(), split.y)); + } + if r.end.row >= self.fr { + ys.push((split.y, self.cells.bottom())); + } + ys.iter().flat_map(|&(y0, y1)| xs.iter().map(move |&(x0, x1)| Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)))).collect() + } + /// True when cell `c` is in the scrolling pane but scrolled entirely under the frozen rows or columns. + pub fn hidden_under_frozen(&self, sh: &Sheet, c: CellRef) -> bool { + let r = self.cell_rect(sh, c); + (self.fc > 0 && c.col >= self.fc && r.right() <= self.cells.left() + self.frozen_w) + || (self.fr > 0 && c.row >= self.fr && r.bottom() <= self.cells.top() + self.frozen_h) + } +} diff --git a/crates/ui-egui/src/grid/interact.rs b/crates/ui-egui/src/grid/interact.rs new file mode 100644 index 000000000..f59f367a7 --- /dev/null +++ b/crates/ui-egui/src/grid/interact.rs @@ -0,0 +1,904 @@ +use super::editor::validation_arrow; +use super::geo::Geo; +use super::keyboard::keyboard; +use super::menus::set_fill_tag; +use super::selection::select_ranges; +use super::tables::{is_filter_button, table_headings}; +use super::*; + +// ------------------------------------------------------------------ interaction + +/// How close (in points) a press must be to an object's bottom-right corner to resize it instead of moving it. +pub(super) const OBJECT_RESIZE_RADIUS: f32 = 10.0; + +pub(super) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) { + // Text selection and editing must not also move the object or edit worksheet cells. + if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() { + return; + } + let ctx = ui.ctx().clone(); + // Enter/Space synthesize egui clicks; only real pointer clicks use sheet coordinates. + let primary_clicked = resp.clicked_by(egui::PointerButton::Primary); + let pointer = ctx.input(|i| i.pointer.clone()); + let mods = ctx.input(|i| i.modifiers); + // Hit-testing works in logical coordinates (see `Geo::rtl`). + let pos = pointer.interact_pos().or(pointer.hover_pos()).map(|p| geo.mirror_pos(p)); + let in_cells = pos.is_some_and(|p| geo.in_cells(p)); + let in_col_header = pos.is_some_and(|p| p.y < geo.cells.top() && p.y >= geo.head_top() && p.x >= geo.cells.left()); + let in_row_header = pos.is_some_and(|p| p.x < geo.cells.left() && p.x >= geo.head_left() && p.y >= geo.cells.top()); + let in_corner = pos.is_some_and(|p| p.x < geo.cells.left() && p.y < geo.cells.top() && p.x >= geo.head_left() && p.y >= geo.head_top()); + let sel = app.session.active().map(|d| d.selection.clone()).unwrap_or_default(); + let cur_rect = geo.range_rect(sh, sel.current()); + let handle = Rect::from_center_size(cur_rect.right_bottom(), vec2(9.0, 9.0)); + // The fill handle and the move border only react in the panes where they are painted, not + // over the frozen rows/columns that a scrolled selection lies under. + let handle_panes = geo.range_panes(RangeRef::cell(sel.current().end)); + let border_panes = geo.range_panes(sel.current()); + let on_handle = |p: Pos2| handle.contains(p) && handle_panes.iter().any(|r| r.contains(p)); + let on_cur_border = |p: Pos2| on_border(cur_rect, p) && border_panes.iter().any(|r| r.contains(p)); + + let (freeze_cols, freeze_rows) = freeze_handles(geo); + // Draw tab tools: the pen records a stroke, the eraser deletes ink under the pointer. + if !app.session.draw_tool.is_empty() && in_cells { + ctx.set_cursor_icon(CursorIcon::Crosshair); + if app.session.draw_tool == "pen" { + if let Some(p) = pos + && resp.dragged() + { + // Through the cell under the pointer, so frozen panes and pages map right. + let c = geo.cell_at(sh, p); + let sx = sh.col_left(c.col) as f32 + (p.x - geo.x(sh, c.col)) / geo.z; + let sy = sh.row_top(c.row) as f32 + (p.y - geo.y(sh, c.row)) / geo.z; + app.grid.ink.push([sx, sy]); + } + if !app.grid.ink.is_empty() { + // Live preview. + let pts: Vec = app + .grid + .ink + .iter() + .map(|q| { + let (col, row) = (sh.col_at(q[0].max(0.0) as f64), sh.row_at(q[1].max(0.0) as f64)); + geo.mirror_pos(pos2( + geo.x(sh, col) + (q[0] - sh.col_left(col) as f32) * geo.z, + geo.y(sh, row) + (q[1] - sh.row_top(row) as f32) * geo.z, + )) + }) + .collect(); + ui.painter().add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(2.0 * geo.z, Color32::from_rgb(0x1F, 0x5F, 0xC9)))); + } + if resp.drag_stopped() && app.grid.ink.len() >= 2 { + let pts: Vec<[f32; 2]> = std::mem::take(&mut app.grid.ink); + let _ = app.run("draw.stroke", json!({"points": pts})); + } + return; + } + if app.session.draw_tool == "eraser" && (primary_clicked || resp.dragged()) { + if let Some(p) = pos + && let Some(("shape", id, _)) = crate::chartview::hit(app, geo, sh, p) + && sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::Ink) + { + let _ = app.run("object.delete", json!({"kind": "shape", "id": id})); + } + return; + } + } + // A plain click on a slicer value tile toggles it (Ctrl/Cmd for multi-select); the clear + // button resets it. Runs before the generic object select/drag so tiles stay clickable. + if primary_clicked + && !resp.drag_started() + && let Some(p) = pos + && let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet(), p) + { + app.selected_chart = Some(id); + match act { + crate::chartview::SlicerAction::Toggle(value) => { + let _ = app.run("slicer.toggle", json!({"slicer": id, "value": value, "multi": mods.command || mods.ctrl})); + } + crate::chartview::SlicerAction::Clear => { + let _ = app.run("slicer.clear", json!({"slicer": id})); + } + } + return; + } + // Objects (charts, pictures, shapes, slicers) take clicks first. Only the primary button moves + // them: a right-click that wobbles into a drag still opens the object's menu (#2067). + let primary_drag = resp.drag_started_by(egui::PointerButton::Primary); + if primary_drag || primary_clicked { + // A drag is recognised only after the pointer has moved, so grab the object where the press began. + let at = if primary_drag { pointer.press_origin().or(pos) } else { pos }; + if let Some(p) = at + && let Some((kind, id, rect)) = crate::chartview::hit(app, geo, sh, p) + { + app.selected_chart = Some(id); + if kind == "shape" + && resp.double_clicked() + && sh.shapes.iter().any(|s| s.id == id && s.kind == gridcraft_engine::model::ShapeKind::TextBox) + { + app.begin_text_box_edit(id); + return; + } + if kind == "chart" && resp.double_clicked() { + app.grid.pane = Some("formatChart".into()); + } + if primary_drag { + let a = object_anchor(sh, kind, id); + let resize = (p - rect.right_bottom()).length() < OBJECT_RESIZE_RADIUS; + if let Some(a) = a { + app.grid.drag = Drag::Object { kind, id, start: p, orig: (a.cell, a.dx, a.dy, a.width, a.height), resize }; + } + } + return; + } + app.selected_chart = None; + } + // A right-click on a chart, picture, shape or slicer opens the object's menu, not the cell's + // under it; a right press that wobbles into a drag counts too (#2067). + let secondary_drag = resp.drag_started_by(egui::PointerButton::Secondary); + if (resp.secondary_clicked() || secondary_drag) + && let Some(p) = if secondary_drag { pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) } else { pos } + && let Some((kind, id, _)) = crate::chartview::hit(app, geo, sh, p) + { + app.selected_chart = Some(id); + app.grid.object_menu = Some((geo.mirror_pos(p), kind, id)); + return; + } + + // A double-click is also a click: handle header AutoFit before resize setup returns. + if resp.double_clicked() + && let Some(p) = pos + { + let unhide = if in_col_header { + hidden_col_edge(geo, sh, p.x).map(|col| ("home.unhideColumns", json!({"cols": RangeRef::cols(col, col).a1()}))) + } else if in_row_header { + hidden_row_edge(geo, sh, p.y).map(|row| ("home.unhideRows", json!({"rows": RangeRef::rows(row, row).a1()}))) + } else { + None + }; + let autofit = if unhide.is_some() { + unhide + } else if in_col_header { + col_edge(geo, sh, p.x).map(|(col, _)| { + let selected = sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col); + ("home.autofitColumnWidth", if selected { json!({}) } else { json!({"cols": RangeRef::cols(col, col).a1()}) }) + }) + } else if in_row_header { + row_edge(geo, sh, p.y).map(|(row, _)| { + let selected = sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row); + ("home.autofitRowHeight", if selected { json!({}) } else { json!({"rows": RangeRef::rows(row, row).a1()}) }) + }) + } else { + None + }; + if let Some((command, params)) = autofit { + app.grid.drag = Drag::None; + app.grid.drag_select = false; + app.run_or_alert(command, params); + resp.request_focus(); + return; + } + } + + // Double-click the fill handle: a double-click is also a click, so run it before the handle press returns. + if resp.double_clicked() + && let Some(p) = pos + && on_handle(p) + { + // Fill down as far as the neighbouring column goes. + let src = sel.current(); + let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 }; + let cap = src.end.row.saturating_add(1_000_000).min(MAX_ROWS - 1); + let last = if src.end.row < cap { sh.last_contiguous_nonempty(neighbour, src.end.row + 1, cap) } else { src.end.row }; + if last > src.end.row { + let target = RangeRef::new(src.start, CellRef::new(last, src.end.col)); + let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()})); + } + app.grid.drag = Drag::None; + app.grid.drag_select = false; + resp.request_focus(); + return; + } + + // Press: decide the drag mode. + if resp.drag_started() || (primary_clicked && app.grid.drag == Drag::None) { + // Where the press began (a drag is only recognised after the pointer has moved). + let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) else { return }; + // The outline band's − / + and level buttons act on release, like buttons. + if let Some(action) = crate::outline::hit(geo, sh, p) { + if primary_clicked { + let (command, params) = action.command(); + app.run_or_alert(command, params); + } + return; + } + // Freeze handles sit on the header edges (and the corner's edges when nothing is frozen). + if freeze_cols.contains(p) || freeze_rows.contains(p) { + app.grid.drag = Drag::Freeze { cols: freeze_cols.contains(p), to: None }; + return; + } + if in_corner { + let _ = app.session.run("selection.set", json!({"range": RangeRef::all().a1()})); + return; + } + if in_col_header { + if let Some((c, x)) = col_edge(geo, sh, p.x) { + app.grid.drag = Drag::ResizeCol { col: c, start: p.x, orig: sh.col_width(c).max(0.0) * 0.0 + (x - geo.x(sh, c)) / geo.z }; + return; + } + let c = geo.col_at(sh, p.x); + // A Table heading's filter button, while the header names stand in for the letters. + if let Some(h) = table_headings(geo, sh, sel.active).into_iter().find(|h| h.col == c && h.button.is_some_and(|b| b.contains(p))) { + app.grid.filter_menu = Some((c, h.header_row, geo.mirror_pos(p))); + return; + } + if point_header(app, wb, sh, RangeRef::cols(c, c), mods.shift, mods.command) { + return; + } + // An armed Format Painter paints the columns dragged over instead of moving them. + if resp.drag_started() + && !mods.any() + && app.session.format_painter.is_none() + && sel.ranges.len() == 1 + && sel.current().is_full_cols() + && sel.current().contains(CellRef::new(0, c)) + { + app.grid.drag = Drag::MoveCols { source: sel.current(), before: None }; + return; + } + let range = if mods.shift { RangeRef::cols(sel.anchor.col.min(c), sel.anchor.col.max(c)) } else { RangeRef::cols(c, c) }; + select_ranges(app, range, mods.command, CellRef::new(geo.row_at(sh, geo.cells.top() + 1.0), c)); + app.grid.drag = Drag::Cols(if mods.shift { sel.anchor.col } else { c }); + return; + } + if in_row_header { + if let Some((r, y)) = row_edge(geo, sh, p.y) { + app.grid.drag = Drag::ResizeRow { row: r, start: p.y, orig: (y - geo.y(sh, r)) / geo.z }; + return; + } + let r = geo.row_at(sh, p.y); + if point_header(app, wb, sh, RangeRef::rows(r, r), mods.shift, mods.command) { + return; + } + let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) }; + select_ranges(app, range, mods.command, CellRef::new(r, geo.col_at(sh, geo.cells.left() + 1.0))); + app.grid.drag = Drag::Rows(if mods.shift { sel.anchor.row } else { r }); + return; + } + if !in_cells { + return; + } + let c = geo.cell_at(sh, p); + // Data validation list arrow next to the active cell. + if let Some(r) = validation_arrow(sh, geo, sel.active) + && r.contains(p) + { + app.grid.list_picker = Some(sel.active); + return; + } + // Filter dropdown buttons. + if is_filter_button(sh, geo, p) { + app.grid.filter_menu = Some((c.col, c.row, geo.mirror_pos(p))); + return; + } + // A dialog's formula box in point mode (New Formatting Rule) takes the reference; the + // selection, which the dialog acts on, stays. + if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) { + d.pick_reference(c, &sh.name, false); + app.grid.drag = Drag::Select; + app.grid.drag_select = true; + if resp.drag_started() + && let Some(p) = pos + { + select_to_pointer(app, geo, sh, wb, p); + } + return; + } + // Point mode while editing a formula: clicking inserts a reference. + if let Some(ed) = app.editor.as_mut() + && ed.can_point() + { + let anchor = if mods.shift && ed.point_at_caret() { + ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet()).map(|(_, cell)| cell).unwrap_or(c) + } else { + c + }; + let range = RangeRef::new(anchor, c); + let text = if range.is_single() { c.a1() } else { range.a1() }; + let sheet_name = (ed.sheet != wb.active_sheet()).then(|| sh.name.clone()); + let r = match sheet_name { + Some(n) => format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&n)), + None => text, + }; + if mods.command && !mods.shift { + ed.append_ref(&r); + } else { + ed.insert_ref(&r); + } + ed.request_focus = true; + ed.point_cell = Some(c); + ed.point_anchor = Some((wb.active_sheet(), anchor)); + app.grid.drag = Drag::Select; + app.grid.drag_select = true; + if resp.drag_started() + && let Some(p) = pos + { + select_to_pointer(app, geo, sh, wb, p); + } + return; + } + if app.editor.is_some() && !app.commit_edit(0, 0, false, false) { + return; + } + if on_handle(p) { + app.grid.drag = Drag::Fill { target: None, menu: resp.drag_started_by(egui::PointerButton::Secondary) }; + return; + } + if on_cur_border(p) && resp.drag_started() { + app.grid.drag = Drag::Move { grab: c, to: None }; + return; + } + // Double-click edits. + let now = crate::now_ms(); + if let Some((last, at)) = app.grid.last_click + && last == c + && now - at < 450.0 + && !mods.shift + { + app.grid.last_click = None; + // A PivotTable's value cell shows its source records on a new sheet (Excel's Show + // Details); its labels and headers still edit. + if sh.pivots.iter().any(|pt| pt.last_range.is_some_and(|r| r.contains(c))) { + let not_a_value_cell = + gridcraft_engine::EngineError::Disabled("pivot.showDetails".into(), gridcraft_engine::pivot::NOT_A_VALUE_CELL.into()).to_string(); + match app.run("pivot.showDetails", json!({"cell": c.a1()})) { + Ok(_) => return, + Err(e) if e == not_a_value_cell => {} + Err(e) => { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + return; + } + } + } + // Following a hyperlink requires a single click in Excel; double-click edits. + app.begin_edit(None, false); + return; + } + app.grid.last_click = Some((c, now)); + if mods.shift { + let _ = app.session.run("selection.set", json!({"range": RangeRef::new(sel.anchor, c).a1(), "active": sel.active.a1()})); + if let Some(d) = app.session.active_mut() { + d.selection.anchor = sel.anchor; + } + } else if mods.command { + // Ctrl+click (without Alt) on a hyperlink's text follows it instead of adding the cell to the selection. + if let Some(h) = sh.hyperlinks.get(&c).cloned() + && primary_clicked + && !mods.alt + && p.x < geo.cell_rect(sh, c).left() + geo.cell_rect(sh, c).width() * 0.8 + { + follow_link(app, &h.target); + } else { + select_ranges(app, RangeRef::cell(c), true, c); + } + } else { + let _ = app.session.run("selection.set", json!({"cell": c.a1()})); + if primary_clicked && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" { + let _ = app.run("cell.toggleCheckbox", json!({"cell": c.a1()})); + } + // A click on a hyperlink's text follows it (like Excel); elsewhere in the cell selects. + if let Some(h) = sh.hyperlinks.get(&c).cloned() + && primary_clicked + && !mods.any() + { + let r = geo.cell_rect(sh, c); + if p.x < r.left() + r.width() * 0.8 { + follow_link(app, &h.target); + } + } + } + app.grid.drag = Drag::Select; + app.grid.drag_select = true; + if resp.drag_started() + && let Some(p) = pos + { + // The first recognised drag already includes movement away from the press. + select_to_pointer(app, geo, sh, wb, p); + } + return; + } + + // Dragging. + if resp.dragged() + && let Some(p) = pos + { + // Auto-scroll near edges. + let edge = 24.0; + let mut auto = egui::Vec2::ZERO; + if p.y > geo.cells.bottom() - edge { + auto.y = 20.0; + } + if p.y < geo.cells.top() + edge * 0.5 && !matches!(app.grid.drag, Drag::ResizeRow { .. }) { + auto.y = -20.0; + } + if p.x > geo.cells.right() - edge { + auto.x = 30.0; + } + if p.x < geo.cells.left() + edge * 0.5 && !matches!(app.grid.drag, Drag::ResizeCol { .. }) { + auto.x = -30.0; + } + let pc = CellRef::new( + geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)), + geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)), + ); + match app.grid.drag.clone() { + Drag::Select => { + select_to_pointer(app, geo, sh, wb, p); + scroll_by(app, auto); + } + Drag::Rows(anchor) => { + let r = geo.row_at(sh, p.y); + let _ = + app.session.run("selection.set", json!({"range": RangeRef::rows(anchor.min(r), anchor.max(r)).a1(), "active": sel.active.a1()})); + scroll_by(app, vec2(0.0, auto.y)); + } + Drag::Cols(anchor) => { + let c = geo.col_at(sh, p.x); + let _ = + app.session.run("selection.set", json!({"range": RangeRef::cols(anchor.min(c), anchor.max(c)).a1(), "active": sel.active.a1()})); + scroll_by(app, vec2(auto.x, 0.0)); + } + Drag::MoveCols { source, .. } => { + let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)); + let midpoint = geo.x(sh, col) + sh.col_width(col) * geo.z * 0.5; + let before = (col + u32::from(p.x >= midpoint)).min(MAX_COLS - 1); + let before = (!(source.start.col..=source.end.col + 1).contains(&before)).then_some(before); + app.grid.drag = Drag::MoveCols { source, before }; + scroll_by(app, vec2(auto.x, 0.0)); + } + Drag::ResizeCol { col, start, orig } => { + let w = (orig + (p.x - start) / geo.z).max(0.0); + app.toast = + Some((format!("Width: {:.2} ({} pixels)", gridcraft_engine::cmd::format::points_to_chars(w as f64), w.round()), crate::now_ms())); + // Inside a selection of whole columns, every selected column takes the new width + // (Ctrl-selected areas included); otherwise only the dragged one. + let cols = if sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col) { + sel.ranges.iter().filter(|r| r.is_full_cols()).map(|r| r.a1()).collect::>().join(",") + } else { + RangeRef::cols(col, col).a1() + }; + let _ = app.session.run("home.columnWidth", json!({"cols": cols, "width": w.round()})); + coalesce_last_undo(app); + } + Drag::ResizeRow { row, start, orig } => { + let h = (orig + (p.y - start) / geo.z).max(0.0); + app.toast = Some((format!("Height: {:.2} ({} pixels)", h * 0.75, h.round()), crate::now_ms())); + let rows = if sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row) { + sel.ranges + .iter() + .filter(|r| r.is_full_rows()) + .map(|r| format!("{}:{}", r.start.row + 1, r.end.row + 1)) + .collect::>() + .join(",") + } else { + format!("{}:{}", row + 1, row + 1) + }; + let _ = app.session.run("home.rowHeight", json!({"rows": rows, "height": h.round()})); + coalesce_last_undo(app); + } + Drag::Freeze { cols, .. } => { + app.grid.drag = Drag::Freeze { cols, to: Some(freeze_boundary(geo, sh, p, cols)) }; + } + Drag::Fill { menu, .. } => { + let src = sel.current(); + let target = fill_target(src, pc); + app.grid.drag = Drag::Fill { target: Some(target), menu }; + scroll_by(app, auto); + } + Drag::Move { grab, .. } => { + let src = sel.current(); + let dr = pc.row as i64 - grab.row as i64; + let dc = pc.col as i64 - grab.col as i64; + app.grid.drag = Drag::Move { grab, to: Some(src.start.offset_clamped(dr, dc)) }; + scroll_by(app, auto); + } + Drag::Object { kind, id, start, orig, resize } => { + let d = (p - start) / geo.z; + let (cell, dx, dy, w, h) = orig; + let params = if resize { + json!({"kind": kind, "id": id, "width": (w + d.x).max(20.0), "height": (h + d.y).max(20.0)}) + } else { + // Re-anchor at the cell under the new top-left. + let x0 = sh.col_left(cell.col) as f32 + dx + d.x; + let y0 = sh.row_top(cell.row) as f32 + dy + d.y; + let nc = CellRef::new(sh.row_at(y0.max(0.0) as f64), sh.col_at(x0.max(0.0) as f64)); + json!({"kind": kind, "id": id, "at": nc.a1(), "dx": x0.max(0.0) - sh.col_left(nc.col) as f32, "dy": y0.max(0.0) - sh.row_top(nc.row) as f32}) + }; + let _ = app.session.run("object.move", params); + coalesce_last_undo(app); + } + Drag::None => {} + } + } + + // Release. + if resp.drag_stopped() || (!pointer.any_down() && app.grid.drag != Drag::None && !resp.dragged()) { + match std::mem::take(&mut app.grid.drag) { + Drag::Select => { + if resp.drag_stopped() + && let Some(p) = pos + { + select_to_pointer(app, geo, sh, wb, p); + } + format_painter_apply(app); + } + // Whole rows or columns clicked or dragged over by their headers. + Drag::Rows(_) | Drag::Cols(_) => format_painter_apply(app), + // Esc mid-drag lands here too (egui ends the drag on Esc): no menu, and the key is + // spent on the drag so the grid's Esc doesn't also drop the copy. + Drag::Fill { target: Some(t), menu: true } => { + if let Some(p) = pos + && t != sel.current() + && !ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) + { + app.grid.fill_menu = Some((sel.current(), t, geo.mirror_pos(p))); + } + } + Drag::Fill { target: Some(t), menu: false } => { + let src = sel.current(); + if t != src { + // Ctrl (Cmd on macOS) swaps copying and extending, as in Excel. + let mode = if mods.command { "alternate" } else { "series" }; + match app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) { + Ok(_) => set_fill_tag(app, src, t), + Err(e) => app.message = Some(("GridCraft".into(), crate::clean_error(&e))), + } + } + } + Drag::Freeze { cols, to: Some(to) } => { + let (rows_now, cols_now) = (geo.fr, geo.fc); + let (rows, cols) = if cols { (rows_now, to) } else { (to, cols_now) }; + if (rows, cols) != (rows_now, cols_now) { + // A1 (nothing left frozen) unfreezes. + app.run_or_alert("view.freezePanes", json!({"cell": CellRef::new(rows, cols).a1()})); + } + } + Drag::Move { to: Some(to), .. } => { + let src = sel.current(); + if to != src.start { + let _ = app.session.run("edit.cut", json!({"range": src.a1()})); + let _ = app.run("edit.paste", json!({"at": to.a1()})); + } + } + Drag::MoveCols { source, before: Some(before) } => { + if let Err(e) = app.run("sheet.moveColumns", json!({"cols": source.a1(), "before": before})) { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } + _ => {} + } + app.grid.drag_select = false; + } + + // Right-click: context menu. + if resp.secondary_clicked() + && let Some(p) = pos + { + if in_cells && !sel.contains(geo.cell_at(sh, p)) { + let _ = app.session.run("selection.set", json!({"cell": geo.cell_at(sh, p).a1()})); + } + if in_col_header || in_row_header { + let (rows, idx) = if in_row_header { (true, geo.row_at(sh, p.y)) } else { (false, geo.col_at(sh, p.x)) }; + let inside = sel.ranges.iter().any(|r| { + if rows { + r.is_full_rows() && idx >= r.start.row && idx <= r.end.row + } else { + r.is_full_cols() && idx >= r.start.col && idx <= r.end.col + } + }); + if !inside { + let range = if rows { RangeRef::rows(idx, idx) } else { RangeRef::cols(idx, idx) }; + let _ = app.session.run("selection.set", json!({"range": range.a1()})); + } + app.grid.header_menu = Some((geo.mirror_pos(p), rows)); + } else { + app.grid.context_menu = Some(geo.mirror_pos(p)); + app.grid.context_menu_frame = ui.ctx().cumulative_frame_nr(); + } + } + + if primary_clicked || resp.drag_started() { + resp.request_focus(); + } + keyboard(app, &ctx, resp, geo, sh); +} + +/// An armed Format Painter pastes onto the cells just clicked or dragged over (not while +/// pointing a formula reference, which also drags a Select). +fn format_painter_apply(app: &mut SheetApp) { + if app.session.format_painter.is_some() + && !app.editor.as_ref().is_some_and(|ed| ed.can_point()) + && !app.dialog.as_ref().is_some_and(|d| d.picks_references()) + { + let dest = app.session.active().map(|d| d.selection.current().a1()); + if let Some(dest) = dest + && let Err(e) = app.run("edit.formatPainter", json!({"apply": dest})) + { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } +} + +/// Point mode on a header: inserts a whole-row or whole-column reference instead of selecting. +fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef, extend: bool, add: bool) -> bool { + let Some(ed) = app.editor.as_mut().filter(|ed| ed.can_point()) else { return false }; + let edit_sheet = ed.sheet; + let active_sheet = wb.active_sheet(); + let range = if extend { + ed.point + .and_then(|point| crate::editor::formula_refs(&ed.text).into_iter().find(|(start, end, ..)| (*start, *end) == point)) + .and_then(|(_, _, previous, previous_sheet, _)| { + let previous_sheet = previous_sheet.as_deref().map(|name| wb.sheet_index(name)).unwrap_or(Some(edit_sheet)); + if previous_sheet != Some(active_sheet) { + return None; + } + if range.is_full_cols() && previous.is_full_cols() { + Some(RangeRef::cols(previous.start.col.min(range.start.col), previous.end.col.max(range.end.col))) + } else if range.is_full_rows() && previous.is_full_rows() { + Some(RangeRef::rows(previous.start.row.min(range.start.row), previous.end.row.max(range.end.row))) + } else { + None + } + }) + .unwrap_or(range) + } else { + range + }; + let reference = range.a1(); + let reference = + if ed.sheet != wb.active_sheet() { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference }; + if add { + ed.append_ref(&reference); + } else { + ed.insert_ref(&reference); + } + ed.point_anchor = None; + ed.point_cell = Some(range.start); + ed.request_focus = true; + true +} + +fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) { + let cell = CellRef::new( + geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)), + geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)), + ); + if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) { + d.pick_reference(cell, &sh.name, true); + } else if let Some(ed) = app.editor.as_mut() + && ed.point.is_some() + { + if let Some(start) = ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet()).map(|(_, cell)| cell).or(ed.point_cell) { + let range = RangeRef::new(start, cell); + let text = if range.is_single() { range.start.a1() } else { range.a1() }; + let text = if ed.sheet != wb.active_sheet() { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { text }; + ed.insert_ref(&text); + ed.point_cell = Some(cell); + ed.point_anchor = Some((wb.active_sheet(), start)); + } + } else if let Some(d) = app.session.active() { + // Read the current anchor: starting a drag may have just replaced the selection. + let anchor = d.selection.anchor; + let active = d.selection.active; + let mut ranges: Vec = d.selection.ranges.iter().map(|r| r.a1()).collect(); + ranges.pop(); + ranges.push(RangeRef::new(anchor, cell).a1()); + let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": active.a1()})); + if let Some(d) = app.session.active_mut() { + d.selection.anchor = anchor; + } + } +} + +fn coalesce_last_undo(app: &mut SheetApp) { + // Merge consecutive drag steps into one undo entry. + if let Some(d) = app.session.active_mut() + && d.undo.len() >= 2 + { + let n = d.undo.len(); + if d.undo[n - 1].label == d.undo[n - 2].label { + d.undo.remove(n - 1); + } + } +} + +fn scroll_by(app: &mut SheetApp, d: egui::Vec2) { + if d != egui::Vec2::ZERO + && let Some(v) = app.view_mut() + { + v.scroll = (v.scroll + d).max(egui::Vec2::ZERO); + } +} + +fn fill_target(src: RangeRef, pc: CellRef) -> RangeRef { + let below = pc.row as i64 - src.end.row as i64; + let above = src.start.row as i64 - pc.row as i64; + let right = pc.col as i64 - src.end.col as i64; + let left = src.start.col as i64 - pc.col as i64; + let vert = below.max(above); + let horiz = right.max(left); + if vert <= 0 && horiz <= 0 { + return src; + } + if vert >= horiz { + if below > 0 { + RangeRef::new(src.start, CellRef::new(pc.row, src.end.col)) + } else { + RangeRef::new(CellRef::new(pc.row, src.start.col), src.end) + } + } else if right > 0 { + RangeRef::new(src.start, CellRef::new(src.end.row, pc.col)) + } else { + RangeRef::new(CellRef::new(src.start.row, pc.col), src.end) + } +} + +pub(super) fn on_border(r: Rect, p: Pos2) -> bool { + let outer = r.expand(3.0); + let inner = r.shrink(3.0); + outer.contains(p) && !inner.contains(p) +} + +/// The freeze handles, as in Excel for the web: a bar in the upper part of the column-header strip +/// on the frozen columns' right edge, and one in the left part of the row-header strip on the +/// frozen rows' bottom edge. With nothing frozen they sit on the corner's edges. The rest of the +/// header edge keeps resizing the column or row. +pub fn freeze_handles(geo: &Geo) -> (Rect, Rect) { + let x = geo.cells.left() + geo.frozen_w; + let y = geo.cells.top() + geo.frozen_h; + let cols = Rect::from_min_max(pos2(x - 3.0, geo.head_top()), pos2(x + 3.0, geo.head_top() + geo.header_h * 0.4)); + let rows = Rect::from_min_max(pos2(geo.head_left(), y - 3.0), pos2(geo.head_left() + geo.header_w * 0.4, y + 3.0)); + (cols, rows) +} + +/// How many columns (or rows) a freeze handle dropped at `p` freezes: the line boundary nearest +/// the pointer, limited so the frozen pane leaves room for the scrolling one. +fn freeze_boundary(geo: &Geo, sh: &Sheet, p: Pos2, cols: bool) -> u32 { + if cols { + if p.x <= geo.cells.left() { + return 0; + } + let c = geo.col_at(sh, p.x); + let (l, r) = (geo.x(sh, c), geo.x(sh, c.saturating_add(1).min(MAX_COLS - 1))); + let b = if p.x - l > r - p.x { c.saturating_add(1) } else { c }; + let room = geo.ci.at((((geo.cells.width() - 40.0) / geo.z).max(0.0)) as f64); + b.min(room).min(MAX_COLS - 1) + } else { + if p.y <= geo.cells.top() { + return 0; + } + let rr = geo.row_at(sh, p.y); + let (t, b2) = (geo.y(sh, rr), geo.y(sh, rr.saturating_add(1).min(MAX_ROWS - 1))); + let b = if p.y - t > b2 - p.y { rr.saturating_add(1) } else { rr }; + let room = geo.ri.at((((geo.cells.height() - 40.0) / geo.z).max(0.0)) as f64); + b.min(room).min(MAX_ROWS - 1) + } +} + +/// Paints the freeze handles and, while one is dragged, where the freeze line will go. +pub(super) fn paint_freeze_handles(p: &Painter, geo: &Geo, sh: &Sheet, drag: &Drag, t: &Tokens) { + let (cols, rows) = freeze_handles(geo); + let bar = Color32::from_gray(150); + p.rect_filled(Rect::from_center_size(cols.center(), vec2(3.0, cols.height())), 1.0, bar); + p.rect_filled(Rect::from_center_size(rows.center(), vec2(rows.width(), 3.0)), 1.0, bar); + if let Drag::Freeze { cols, to: Some(to) } = *drag { + let stroke = Stroke::new(2.0, t.accent); + if cols { + let x = geo.cells.left() + sh.col_left(to) as f32 * geo.z; + p.line_segment([pos2(x, geo.head_top()), pos2(x, geo.cells.bottom())], stroke); + } else { + let y = geo.cells.top() + sh.row_top(to) as f32 * geo.z; + p.line_segment([pos2(geo.head_left(), y), pos2(geo.cells.right(), y)], stroke); + } + } +} + +pub(super) fn col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option<(u32, f32)> { + let c = geo.col_at(sh, x); + for cc in [c, c.saturating_sub(1)] { + let edge = geo.x(sh, cc) + sh.col_width(cc) * geo.z; + if (x - edge).abs() <= 4.0 { + return Some((cc, edge)); + } + } + None +} + +pub(super) fn row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option<(u32, f32)> { + let r = geo.row_at(sh, y); + for rr in [r, r.saturating_sub(1)] { + let edge = geo.y(sh, rr) + sh.row_height(rr) * geo.z; + if (y - edge).abs() <= 3.0 { + return Some((rr, edge)); + } + } + None +} + +/// The hidden column whose (zero-width) edge is at `x`: only just right of the boundary, as the +/// edge just left of it belongs to the visible column there. +pub(super) fn hidden_col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option { + col_edge(geo, sh, x).map(|(c, _)| c).filter(|&c| sh.col_width(c) <= 0.0) +} + +pub(super) fn hidden_row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option { + row_edge(geo, sh, y).map(|(r, _)| r).filter(|&r| sh.row_height(r) <= 0.0) +} + +fn object_anchor(sh: &Sheet, kind: &str, id: u32) -> Option { + match kind { + "chart" => sh.charts.iter().find(|c| c.id == id).map(|c| c.anchor), + "image" => sh.images.iter().find(|c| c.id == id).map(|c| c.anchor), + "slicer" => sh.slicers.iter().find(|c| c.id == id).map(|c| c.anchor), + _ => sh.shapes.iter().find(|c| c.id == id).map(|c| c.anchor), + } +} + +pub(super) fn delete_selected_object(app: &mut SheetApp, sh: &Sheet) -> bool { + let Some(id) = app.selected_chart else { return false }; + let kind = if sh.charts.iter().any(|object| object.id == id) { + "chart" + } else if sh.images.iter().any(|object| object.id == id) { + "image" + } else if sh.shapes.iter().any(|object| object.id == id) { + "shape" + } else { + return false; + }; + app.run_or_alert("object.delete", json!({"kind": kind, "id": id})); + app.selected_chart = None; + true +} + +/// A click on a hyperlink. References select; web and e-mail addresses ask first (the dialog's +/// OK runs `link.open`); every other kind is refused with a message. The decision is the +/// engine's ([`gridcraft_engine::links::classify`]), the same one `link.open` applies. +pub fn follow_link(app: &mut SheetApp, target: &str) { + use gridcraft_engine::links::{LinkKind, classify}; + match classify(target) { + LinkKind::Internal(r) => { + let _ = app.session.run("edit.goTo", json!({"reference": r.trim_start_matches('#')})); + app.grid.ensure_visible = true; + } + LinkKind::External(url) => app.open_dialog("openLink", json!({"url": url})), + LinkKind::Refused(why) => app.message = Some(("Hyperlink".into(), why)), + } +} + +/// Used by `EditState` users to keep the active cell visible while pointing. +pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, extend: bool) { + let Some(d) = app.session.active() else { return }; + let from = ed.point_cell.unwrap_or(ed.cell); + let to = from.offset_clamped(dr, dc); + let anchor = if extend { + // The anchor is the corner opposite the moving end: the written range is normalized, so + // after extending up or left its first cell is the moving end, not the anchor. + let pointed = ed.point.and_then(|(s, e)| ed.text.get(s..e)).and_then(RangeRef::parse); + let inferred_anchor = pointed + .map(|r| { + CellRef::new( + if from.row == r.start.row { r.end.row } else { r.start.row }, + if from.col == r.start.col { r.end.col } else { r.start.col }, + ) + }) + .unwrap_or(from); + ed.point_anchor.filter(|(sheet, _)| *sheet == d.wb.active_sheet()).map(|(_, cell)| cell).unwrap_or(inferred_anchor) + } else { + to + }; + let text = if extend { RangeRef::new(anchor, to).a1() } else { to.a1() }; + let sheet = d.wb.active_sheet(); + ed.insert_ref(&text); + ed.point_cell = Some(to); + ed.point_anchor = Some((sheet, anchor)); + app.grid.ensure_visible = true; +} diff --git a/crates/ui-egui/src/grid/keyboard.rs b/crates/ui-egui/src/grid/keyboard.rs new file mode 100644 index 000000000..6cb4a8c9c --- /dev/null +++ b/crates/ui-egui/src/grid/keyboard.rs @@ -0,0 +1,155 @@ +use super::geo::Geo; +use super::interact::delete_selected_object; +use super::menus::is_checkbox; +use super::*; + +/// Grid keyboard handling when no text field has focus. +pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: &Geo, sh: &Sheet) { + // Focus left on an editor that no longer exists goes back to the grid. + let stale = [egui::Id::unique("gridcraft.cell_editor"), egui::Id::unique("gridcraft.formula_bar"), crate::text_box::editor_id()]; + let editing = app.editor.is_some() || app.text_box_editor.is_some(); + if !editing && ctx.memory(|m| m.focused()).is_some_and(|f| stale.contains(&f)) { + resp.request_focus(); + } + let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && (editing || !stale.contains(&f))); + // An open filter menu owns the keyboard even when a click inside it left focus on nothing (#1516). + // So do the fill menu (its Esc closes it, not the copy) and the right-drag that opens it (an + // arrow key would move the selection the menu fills from). + if editing + || app.dialog.is_some() + || other_focus + || app.message.is_some() + || app.grid.filter_menu.is_some() + || app.grid.fill_menu.is_some() + || matches!(app.grid.drag, Drag::Fill { menu: true, .. }) + { + return; + } + if ctx.memory(|m| m.focused()).is_none() { + resp.request_focus(); + } + // The grid owns arrows, Tab and Escape (egui would otherwise move focus between widgets). + ctx.memory_mut(|m| { + m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }) + }); + let events = ctx.input(|i| i.events.clone()); + // A shortcut key that also types a character (Shift+Space sends Text(" ")) must not start an edit. + let shortcut_pressed = events.iter().any(|e| { + matches!(e, egui::Event::Key { pressed: true, modifiers: m, key, .. } + if !m.alt && (m.ctrl || m.command || (m.shift && *key == egui::Key::Space))) + }); + let page = ((geo.cells.height() / geo.z) / sh.default_row_height).floor().max(1.0) as i64; + // Alt shortcuts type their character too on Windows and Linux (Alt+= sends Text("=")): once + // a shortcut ran, that text must not start an edit over what it just did. + let mut shortcut_ran = false; + for ev in events { + match ev { + egui::Event::Copy => { + app.copy_to_clipboard(ctx, "edit.copy"); + } + egui::Event::Cut => { + app.copy_to_clipboard(ctx, "edit.cut"); + } + egui::Event::Paste(text) => app.paste_external(text), + // GridCraft has no image-clipboard pipeline and no cell-picture insert-from-clipboard + // command. Choosing to ignore `PasteImage` is explicit: inserting the bytes as a + // floating picture without a chosen anchor/size would be a new feature, not parity. + // A text representation, when the clipboard has one, still arrives as `Paste` above. + egui::Event::PasteImage(_) => {} + // PRIMARY middle-click paste belongs to egui's TextEdit; the grid has no caret to + // insert text at, so a middle click on a cell is deliberately not a paste. + egui::Event::MiddleClickPaste { .. } => {} + egui::Event::Text(_) if shortcut_pressed || shortcut_ran => {} + egui::Event::Text(text) if text.chars().all(|c| !c.is_control()) && !text.is_empty() => { + app.begin_edit(Some(text), false); + if let Some(ed) = app.editor.as_mut() { + ed.caret = ed.text.chars().count(); + } + return; + } + egui::Event::Key { key, physical_key, pressed: true, modifiers: m, .. } => { + use egui::Key; + if crate::keymap::dispatch(app, ctx, key, physical_key, m, geo, sh) { + shortcut_ran = true; + continue; + } + let cmd = m.command; + let shift = m.shift; + let mv = |app: &mut SheetApp, dr: i64, dc: i64| { + let from = app.session.active().map(|d| if shift { d.selection.moving_end() } else { d.selection.active }); + let _ = app.session.run("selection.move", json!({"dr": dr, "dc": dc, "extend": shift, "jump": cmd})); + app.grid.ensure_visible = true; + app.grid.reveal_moving_end = shift; + app.grid.reveal_from = from; + }; + match key { + Key::ArrowUp => mv(app, -1, 0), + Key::ArrowDown => mv(app, 1, 0), + // Arrows follow the screen: on a right-to-left sheet Left goes to the next column. + Key::ArrowLeft => mv(app, 0, if sh.right_to_left { 1 } else { -1 }), + Key::ArrowRight => mv(app, 0, if sh.right_to_left { -1 } else { 1 }), + Key::PageDown if !cmd => mv(app, page, 0), + Key::PageUp if !cmd => mv(app, -page, 0), + Key::PageDown => app.run_or_alert("sheet.next", json!({})), + Key::PageUp => app.run_or_alert("sheet.previous", json!({})), + Key::Home if cmd => { + let r = sh.freeze.map(|f| f.0).unwrap_or(0); + let c = sh.freeze.map(|f| f.1).unwrap_or(0); + let _ = app.session.run("selection.set", json!({"cell": CellRef::new(r, c).a1()})); + app.grid.ensure_visible = true; + } + Key::End if cmd => { + let end = sh.used_range().map(|u| u.end).unwrap_or_default(); + let _ = app.session.run("selection.set", json!({"cell": end.a1()})); + app.grid.ensure_visible = true; + } + Key::Home => { + let a = app.session.active().map(|d| d.selection.active).unwrap_or_default(); + let _ = app.session.run("selection.set", json!({"cell": CellRef::new(a.row, 0).a1()})); + app.grid.ensure_visible = true; + } + // Excel: Enter while a copied range is marked pastes it and ends copy mode. + Key::Enter if !m.alt && !m.ctrl && !shift && !cmd && app.session.clipboard.is_some() => { + app.run_or_alert("edit.paste", json!({})); + app.session.clipboard = None; + app.grid.ensure_visible = true; + } + Key::Enter => { + if m.alt || m.ctrl { + app.begin_edit(None, false); + } else { + app.move_after_enter(if shift { -1 } else { 1 }, 0); + } + } + Key::Tab => app.move_after_enter(0, if shift { -1 } else { 1 }), + Key::F2 => app.begin_edit(None, false), + Key::Delete if delete_selected_object(app, sh) => {} + Key::Delete => app.run_or_alert("edit.clearContents", json!({})), + Key::Backspace if delete_selected_object(app, sh) => {} + Key::Backspace => { + // Excel for Mac: Delete clears the cell and starts editing it. + app.run_or_alert("edit.clearContents", json!({})); + if app.session.active().is_some_and(|d| d.selection.is_single_cell()) { + app.begin_edit(Some(String::new()), false); + } + } + Key::Escape => { + app.session.clipboard = None; + app.session.format_painter = None; + } + Key::Space if !cmd && !shift && is_checkbox(sh, app) => app.run_or_alert("cell.toggleCheckbox", json!({})), + Key::F8 if m.alt => { + app.open_dialog("macros", json!({})); + } + Key::F9 => app.run_or_alert("formulas.calculateNow", json!({})), + Key::F4 if !cmd => {} + _ if cmd && !crate::keymap::altgr_text(ctx, m, ctx.os() == egui::os::OperatingSystem::Mac) => { + crate::ribbon::shortcut(app, key, m) + } + _ => {} + } + } + _ => {} + } + } +} diff --git a/crates/ui-egui/src/grid/menus.rs b/crates/ui-egui/src/grid/menus.rs new file mode 100644 index 000000000..b239c7eac --- /dev/null +++ b/crates/ui-egui/src/grid/menus.rs @@ -0,0 +1,551 @@ +use super::editor::popup_dismissed; +use super::geo::Geo; +use super::tables::filter_table_range; +use super::*; + +/// Draws the filter-header popup: sort, search/select-all, apply/clear, a lazily installed table autofilter, new-open search focus and dismissal. +pub(super) fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) { + let Some((col, row, at)) = app.grid.filter_menu else { return }; + let Some(d) = app.session.active() else { return }; + let wb = d.wb.clone(); + let si = wb.active_sheet(); + let Some(sh) = wb.sheet(si) else { return }; + let _ = geo; + // Tables get an autofilter on demand. + let table_range = filter_table_range(sh, col, row); + if sh.autofilter.is_none() + && let Some(r) = table_range + { + let _ = app.session.run("data.filter", json!({"range": r.a1()})); + let _ = app.session.run("data.filter", json!({"range": r.a1()})); + if let Some(sh2) = app.session.active().and_then(|d| d.wb.active()).map(|s| s.autofilter.is_some()) + && !sh2 + { + let d = app.session.active_mut(); + if let Some(d) = d { + let wbm = Arc::make_mut(&mut d.wb); + if let Some(s) = wbm.sheet_mut(si) { + s.autofilter = Some(gridcraft_engine::model::AutoFilter { range: r, criteria: vec![] }); + } + } + } + } + let wb = app.session.active().map(|d| d.wb.clone()).unwrap_or(wb); + let values = gridcraft_engine::cmd::data::filter_values(&wb, si, col); + let key = egui::Id::unique(("filter_sel", col)); + let search_key = egui::Id::unique(("filter_search", col)); + let buttons_key = egui::Id::unique(("filter_buttons", col)); + // Enter is the menu's OK, as in Excel: taken before the search box (which would only drop its + // focus) and the value checkboxes (which would toggle) see it. A focused button keeps it as its click. + let buttons: Vec = ui.ctx().data_mut(|d| d.get_temp(buttons_key)).unwrap_or_default(); + let on_button = ui.ctx().memory(|m| m.focused()).is_some_and(|f| buttons.contains(&f)); + let enter = !on_button && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + let mut close = false; + let area = egui::Area::new(egui::Id::unique("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground); + let response = area.show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_width(230.0); + let ascending = ui.button("↑ Sort Ascending"); + if ascending.clicked() { + let _ = app.run("data.sortAscending", json!({"column": col_to_letters(col)})); + close = true; + } + let descending = ui.button("↓ Sort Descending"); + if descending.clicked() { + let _ = app.run("data.sortDescending", json!({"column": col_to_letters(col)})); + close = true; + } + ui.separator(); + ui.label(egui::RichText::new("Filter").strong()); + let mut checks: Vec<(String, bool)> = ui.ctx().data_mut(|d| d.get_temp::>(key)).unwrap_or(values.clone()); + // A search narrows the list; Select All and Apply then only cover the matches. + let mut search: String = ui.ctx().data_mut(|d| d.get_temp(search_key)).unwrap_or_default(); + let field = ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search").desired_width(f32::INFINITY)); + if !was_open { + field.request_focus(); + } + let needle = search.trim().to_lowercase(); + let matches = |v: &str| needle.is_empty() || v.to_lowercase().contains(&needle); + let all = checks.iter().filter(|(v, _)| matches(v)).all(|(_, b)| *b); + let mut all_new = all; + let label = if needle.is_empty() { "(Select All)" } else { "(Select All Search Results)" }; + if ui.checkbox(&mut all_new, label).changed() { + for c in checks.iter_mut().filter(|(v, _)| matches(v)) { + c.1 = all_new; + } + } + // Full width: shrunk to the longest value, the scroll bar sat mid-menu and the wheel only + // worked over the values themselves (#432). + egui::ScrollArea::vertical().max_height(220.0).auto_shrink([false, true]).show(ui, |ui| { + for (v, on) in checks.iter_mut().filter(|(v, _)| matches(v)) { + ui.checkbox(on, v.as_str()); + } + }); + ui.ctx().data_mut(|d| { + d.insert_temp(key, checks.clone()); + d.insert_temp(search_key, search.clone()); + }); + let checks: Vec<(String, bool)> = checks.iter().map(|(v, on)| (v.clone(), *on && matches(v))).collect(); + ui.separator(); + ui.horizontal(|ui| { + let clear = ui.button("Clear Filter"); + if clear.clicked() { + let _ = app.run("data.filterBy", json!({"column": col_to_letters(col), "clear": true})); + close = true; + } + // Nothing checked would hide every row, so Enter (unlike a click on Apply) does nothing then. + let any = checks.iter().any(|(_, on)| *on); + let apply = ui.button("Apply"); + ui.ctx().data_mut(|d| d.insert_temp(buttons_key, vec![ascending.id, descending.id, clear.id, apply.id])); + if apply.clicked() || (enter && any) { + let blanks = checks.iter().any(|(v, on)| v == "(Blanks)" && *on); + let vals: Vec = checks.iter().filter(|(v, on)| *on && v != "(Blanks)").map(|(v, _)| v.clone()).collect(); + let p = if checks.iter().all(|(_, b)| *b) { + json!({"column": col_to_letters(col), "clear": true}) + } else { + json!({"column": col_to_letters(col), "values": vals, "blanks": blanks}) + }; + let _ = app.run("data.filterBy", p); + close = true; + } + }); + }); + }); + if close || popup_dismissed(&response.response, was_open) { + ui.ctx().data_mut(|d| { + d.remove::>(key); + d.remove::(search_key); + d.remove::>(buttons_key); + }); + app.grid.filter_menu = None; + } +} + +/// Draws and dispatches the cell context menu, including Show Details when offered, clipboard and insert/format actions; closes on Esc or an outside press. +pub(super) fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { + let Some(at) = app.grid.context_menu else { return }; + let mut close = false; + let resp = egui::Area::new(egui::Id::unique("cell_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(200.0); + let insert_copied = insert_copied_item(app); + let mut items: Vec<(&str, &str)> = vec![ + ("Cut", "edit.cut"), + ("Copy", "edit.copy"), + ("Paste", "edit.paste"), + ("Paste Special…", "ui:pasteSpecial"), + ("-", ""), + ("Insert…", "ui:insertCells"), + ("Delete…", "ui:deleteCells"), + ("Clear Contents", "edit.clearContents"), + ("-", ""), + ("Filter by Selected Cell's Value", "ui:filterValue"), + ("Sort A to Z", "data.sortAscending"), + ("Sort Z to A", "data.sortDescending"), + ("-", ""), + ("New Comment", "ui:newComment"), + ("New Note", "ui:newNote"), + ("-", ""), + ("Format Cells…", "ui:formatCells"), + ("Pick From Drop-down List…", "ui:pickList"), + ("Define Name…", "ui:defineName"), + ("Link…", "ui:insertLink"), + ]; + if let Some(item) = insert_copied { + items.insert(6, item); + } + if show_details_offered(app) + && let Some(i) = items.iter().position(|(_, id)| *id == "data.sortDescending") + { + items.insert(i + 1, ("-", "")); + items.insert(i + 2, ("Show Details", "pivot.showDetails")); + } + let lang = crate::i18n::current(ui.ctx()); + for (label, id) in items { + if label == "-" { + ui.separator(); + continue; + } + if ui.add(egui::Button::new(lang.tr(label)).frame_when_inactive(false).min_size(vec2(190.0, 20.0))).clicked() { + close = true; + match id { + "ui:pasteSpecial" => app.open_dialog("pasteSpecial", json!({})), + "ui:insertPaste" => app.open_dialog("insertPaste", json!({})), + "ui:insertCells" => app.open_dialog("insertCells", json!({})), + "ui:deleteCells" => app.open_dialog("deleteCells", json!({})), + "ui:formatCells" => app.open_dialog("formatCells", json!({})), + "ui:newComment" => app.open_dialog("comment", json!({"threaded": true})), + "ui:newNote" => app.open_dialog("comment", json!({"threaded": false})), + "ui:defineName" => app.open_dialog("defineName", json!({})), + "ui:insertLink" => app.open_dialog("insertLink", json!({})), + "ui:pickList" => app.open_dialog("pickList", json!({})), + "ui:filterValue" => { + if let Some(d) = app.session.active() { + let a = d.selection.active; + let text = d.wb.active().map(|sh| gridcraft_engine::display::cell_text(&d.wb, sh, a)).unwrap_or_default(); + let has = d.wb.active().is_some_and(|sh| sh.autofilter.is_some()); + if !has { + let _ = app.run("data.filter", json!({})); + } + app.run_or_alert("data.filterBy", json!({"column": col_to_letters(a.col), "values": [text]})); + } + } + "edit.copy" | "edit.cut" => { + app.copy_to_clipboard(ui.ctx(), id); + } + "edit.paste" => app.paste_from_clipboard(), + other => app.run_or_alert(other, json!({})), + } + } + } + }); + }); + // A quick right-click presses and releases in one frame, and the menu's corner snaps to the + // nearest physical pixel, which can put the pointer just outside it: that press opened the + // menu, so it doesn't count as a click elsewhere. + let opened_now = app.grid.context_menu_frame == ui.ctx().cumulative_frame_nr(); + if close + || ui.input(|i| i.key_pressed(egui::Key::Escape)) + || (!opened_now + && ui.input(|i| i.pointer.any_pressed()) + && !resp.response.contains_pointer() + && !resp.response.hovered() + && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !resp.response.rect.contains(p))) + { + app.grid.context_menu = None; + } +} + +/// Whether the cell menu offers Show Details (Excel's right-click › Show Details): the active cell +/// is a value, subtotal or grand total cell of a PivotTable, or a cell of a report that is out of +/// date, where running the command says so. Labels and headers of a current report don't offer it, +/// and no cell does while the PivotTable's Enable show details option is off. +fn show_details_offered(app: &mut SheetApp) -> bool { + let Some(d) = app.session.active() else { return false }; + let (si, at) = (d.wb.active_sheet(), d.selection.active); + let Some((pi, pt)) = d.wb.active().and_then(|sh| sh.pivots.iter().enumerate().find(|(_, pt)| pt.last_range.is_some_and(|r| r.contains(at)))) + else { + return false; + }; + if !pt.enable_show_details { + return false; + } + let origin = pt.last_range.map(|r| r.start).unwrap_or_default(); + let cell = (at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)); + let key = (d.uid, d.revision, si, pi); + if let Some((k, cells)) = &app.grid.show_details_cells + && *k == key + { + return cells.as_ref().is_none_or(|c| c.contains(&cell)); + } + let cells = gridcraft_engine::pivot::detail_cells(&d.wb, pt, si).ok(); + let on = cells.as_ref().is_none_or(|c| c.contains(&cell)); + app.grid.show_details_cells = Some((key, cells)); + on +} + +/// The ways to fill a dragged range, as the fill menu and the Auto Fill Options button list them. +const FILL_MODES: [(&str, &str); 4] = + [("Copy Cells", "copy"), ("Fill Series", "extend"), ("Fill Formatting Only", "formats"), ("Fill Without Formatting", "values")]; + +/// The ways to fill from a date, which both menus add after [`FILL_MODES`] when the source starts +/// with one. +const DATE_FILL_MODES: [(&str, &str); 4] = [("Fill Days", "days"), ("Fill Weekdays", "weekdays"), ("Fill Months", "months"), ("Fill Years", "years")]; + +/// The fill modes the menus list for a fill from `src` on the active sheet. +fn fill_modes(app: &SheetApp, src: RangeRef) -> Vec<(&'static str, &'static str)> { + let dates = app.session.active().is_some_and(|d| gridcraft_engine::fill::starts_with_date(&d.wb, d.wb.active_sheet(), src)); + FILL_MODES.into_iter().chain(DATE_FILL_MODES.into_iter().filter(|_| dates)).collect() +} + +/// Remembers the fill just made so the Auto Fill Options button can offer the other ways. +pub(super) fn set_fill_tag(app: &mut SheetApp, src: RangeRef, target: RangeRef) { + app.grid.fill_tag = + app.session.active().map(|d| FillTag { src, target, uid: d.uid, sheet: d.wb.active_sheet(), revision: d.revision, undo_len: d.undo.len() }); +} + +/// The Auto Fill Options button below the bottom-right corner of cells a left-drag of the fill +/// handle just filled. Its menu redoes that fill as Copy Cells, Fill Series, Fill Formatting Only +/// or Fill Without Formatting, and from a date also as Fill Days, Fill Weekdays, Fill Months or +/// Fill Years: it undoes the drag's fill, then runs `edit.autoFill` in that mode. +/// The button goes away as soon as anything else changes the workbook or the selection moves. +pub(super) fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) { + let Some(tag) = app.grid.fill_tag.clone() else { return }; + let current = app.session.active().is_some_and(|d| { + d.uid == tag.uid + && d.wb.active_sheet() == tag.sheet + && d.revision == tag.revision + && d.undo.len() == tag.undo_len + && d.selection.ranges.len() == 1 + && d.selection.ranges.first().is_some_and(|r| *r == tag.target) + }); + if !current { + app.grid.fill_tag = None; + return; + } + if app.editor.is_some() || app.dialog.is_some() || app.grid.drag != Drag::None { + return; + } + let size = vec2(18.0, 18.0); + let corner = geo.range_rect(sh, tag.target).right_bottom(); + let button = geo.mirror_rect(Rect::from_min_size(corner + vec2(2.0, 2.0), size)); + // `button` is in screen space, so compare it with the mirrored cells area. + if !geo.mirror_rect(geo.cells).contains_rect(button) { + return; + } + let lang = crate::i18n::current(ui.ctx()); + let resp = ui.interact(button, egui::Id::unique("gridcraft.fill_options_tag"), Sense::click()).on_hover_text(lang.tr("Auto Fill Options")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, lang.tr("Auto Fill Options"))); + let t = Tokens::get(ui.ctx()); + let p = ui.painter(); + p.rect_filled(button, 3.0, if resp.hovered() { t.hover } else { t.window }); + p.rect_stroke(button, 3.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); + // Two small cells and a drop-down arrow. + let c = button.center(); + p.rect_stroke(Rect::from_min_size(pos2(c.x - 6.0, c.y - 6.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); + p.rect_stroke(Rect::from_min_size(pos2(c.x - 3.0, c.y - 3.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); + p.add(egui::Shape::convex_polygon( + vec![pos2(c.x + 1.0, c.y + 5.0), pos2(c.x + 7.0, c.y + 5.0), pos2(c.x + 4.0, c.y + 8.0)], + t.text, + Stroke::NONE, + )); + let mut chosen = None; + let modes = fill_modes(app, tag.src); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(190.0); + for (label, mode) in modes { + if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() { + chosen = Some(mode); + ui.close(); + } + } + }); + if let Some(mode) = chosen { + if let Err(e) = app.run("edit.undo", json!({})) { + app.grid.fill_tag = None; + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + return; + } + match app.run("edit.autoFill", json!({"source": tag.src.a1(), "target": tag.target.a1(), "mode": mode})) { + Ok(_) => set_fill_tag(app, tag.src, tag.target), + Err(e) => { + app.grid.fill_tag = None; + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } + } +} + +/// The menu a right-drag of the fill handle opens on release, as in Excel. +pub(super) fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { + let Some((src, target, at)) = app.grid.fill_menu else { return }; + let mut close = false; + let mut items = fill_modes(app, src); + items.extend([("-", ""), ("Series…", "ui:series")]); + let response = egui::Area::new(egui::Id::unique("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(190.0); + let lang = crate::i18n::current(ui.ctx()); + for (label, mode) in items { + if label == "-" { + ui.separator(); + continue; + } + if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() { + close = true; + if mode == "ui:series" { + let _ = app.session.run("selection.set", json!({"range": target.union(&src).a1(), "active": src.start.a1()})); + let direction = if target.width() != src.width() { "rows" } else { "columns" }; + app.open_dialog("series", json!({"direction": direction})); + } else if let Err(e) = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1(), "mode": mode})) { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } + } + }); + }); + if close || popup_dismissed(&response.response, was_open) { + app.grid.fill_menu = None; + } +} + +/// The right-click menu of a chart, picture, shape or slicer. +pub(super) fn object_menu(app: &mut SheetApp, ui: &mut egui::Ui) { + let Some((at, kind, id)) = app.grid.object_menu else { return }; + let mut close = false; + let resp = egui::Area::new(egui::Id::unique("object_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(200.0); + let mut items = vec![("Delete", "object.delete")]; + if kind == "chart" { + items.extend([("-", ""), ("Select Data…", "ui:selectData"), ("Format Chart Area…", "ui:formatChart")]); + } + let lang = crate::i18n::current(ui.ctx()); + for (label, action) in items { + if label == "-" { + ui.separator(); + continue; + } + if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(190.0, 20.0))).clicked() { + close = true; + match action { + "ui:selectData" => app.open_dialog("selectData", json!({"chart": id})), + "ui:formatChart" => app.grid.pane = Some("formatChart".into()), + _ => { + app.run_or_alert("object.delete", json!({"kind": kind, "id": id})); + app.selected_chart = None; + } + } + } + } + }); + }); + if close + || ui.input(|i| i.key_pressed(egui::Key::Escape)) + || (ui.input(|i| i.pointer.any_pressed()) + && !resp.response.contains_pointer() + && !resp.response.hovered() + && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !resp.response.rect.contains(p))) + { + app.grid.object_menu = None; + } +} + +/// The warning button beside an active cell holding a number stored as text, as in Excel: its +/// menu names the problem and offers Convert to Number for the selection (#1994). +pub(super) fn error_check_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) { + if app.editor.is_some() || app.dialog.is_some() || sh.show_formulas || app.grid.drag != Drag::None { + return; + } + let Some(active) = app.session.active().map(|d| d.selection.active) else { return }; + if sh.cell(active).and_then(gridcraft_engine::cmd::formulas::number_stored_as_text).is_none() { + return; + } + let cell = geo.cell_rect(sh, active); + let size = vec2(18.0, 18.0); + // Left of the cell, or right of it when that would cover the row headings. + let left = if cell.left() - size.x - 2.0 >= geo.cells.left() { cell.left() - size.x - 2.0 } else { cell.right() + 2.0 }; + let tag = geo.mirror_rect(Rect::from_min_size(pos2(left, cell.top()), size)); + if !geo.cells.intersects(tag) { + return; + } + let lang = crate::i18n::current(ui.ctx()); + let resp = ui.interact(tag, egui::Id::unique("gridcraft.error_check_tag"), Sense::click()).on_hover_text(lang.tr("Number Stored as Text")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, lang.tr("Error checking options"))); + let t = Tokens::get(ui.ctx()); + let p = ui.painter(); + p.rect_filled(tag, 3.0, if resp.hovered() { t.hover } else { t.window }); + p.rect_stroke(tag, 3.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); + // A warning sign: an amber diamond with an exclamation mark. + let c = tag.center(); + let r = 6.0; + p.add(egui::Shape::convex_polygon( + vec![pos2(c.x, c.y - r), pos2(c.x + r, c.y), pos2(c.x, c.y + r), pos2(c.x - r, c.y)], + t.warning_fill, + Stroke::NONE, + )); + p.text(c, egui::Align2::CENTER_CENTER, "!", egui::FontId::proportional(10.0), t.warning_ink); + egui::Popup::menu(&resp).show(|ui| { + ui.label(egui::RichText::new(lang.tr("Number Stored as Text")).strong()); + ui.separator(); + if ui.button(lang.tr("Convert to Number")).clicked() { + app.run_or_alert("formulas.convertToNumber", json!({})); + ui.close(); + } + }); +} + +/// Draws row/column header context menus with insert/delete, format, size, hide/unhide and group actions, plus Insert Copied Cells when available. +pub(super) fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { + let Some((at, rows)) = app.grid.header_menu else { return }; + let mut close = false; + let response = egui::Area::new(egui::Id::unique("header_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(190.0); + let insert_copied = insert_copied_item(app); + let mut items: Vec<(&str, &str)> = if rows { + vec![ + ("Cut", "edit.cut"), + ("Copy", "edit.copy"), + ("Paste", "edit.paste"), + ("-", ""), + ("Insert", "home.insertRows"), + ("Delete", "home.deleteRows"), + ("Clear Contents", "edit.clearContents"), + ("-", ""), + ("Format Cells…", "ui:formatCells"), + ("Row Height…", "ui:rowHeight"), + ("AutoFit Row Height", "home.autofitRowHeight"), + ("Hide", "home.hideRows"), + ("Unhide", "home.unhideRows"), + ("Group", "data.group"), + ] + } else { + vec![ + ("Cut", "edit.cut"), + ("Copy", "edit.copy"), + ("Paste", "edit.paste"), + ("-", ""), + ("Insert", "home.insertColumns"), + ("Delete", "home.deleteColumns"), + ("Clear Contents", "edit.clearContents"), + ("-", ""), + ("Format Cells…", "ui:formatCells"), + ("Column Width…", "ui:columnWidth"), + ("AutoFit Column Width", "home.autofitColumnWidth"), + ("Hide", "home.hideColumns"), + ("Unhide", "home.unhideColumns"), + ("Sort A to Z", "data.sortAscending"), + ("Group", "data.group"), + ] + }; + if let Some(item) = insert_copied { + items.insert(5, item); + } + let lang = crate::i18n::current(ui.ctx()); + for (label, id) in items { + if label == "-" { + ui.separator(); + continue; + } + if ui.add(egui::Button::new(lang.tr(label)).frame_when_inactive(false).min_size(vec2(180.0, 20.0))).clicked() { + close = true; + match id { + "ui:insertPaste" => app.open_dialog("insertPaste", json!({})), + "ui:rowHeight" => app.open_dialog("rowHeight", json!({})), + "ui:formatCells" => app.open_dialog("formatCells", json!({})), + "ui:columnWidth" => app.open_dialog("columnWidth", json!({})), + "edit.copy" | "edit.cut" => { + app.copy_to_clipboard(ui.ctx(), id); + } + "edit.paste" => app.paste_from_clipboard(), + other => app.run_or_alert(other, json!({})), + } + } + } + }); + }); + if close || popup_dismissed(&response.response, was_open) { + app.grid.header_menu = None; + } +} + +/// The context-menu entry for Insert Copied Cells / Insert Cut Cells, when the clipboard holds a +/// copy or cut of this app: whole rows or columns insert at once, a block asks which way to shift +/// (Insert Paste). A cut block can only be pasted, so it gets no entry. +fn insert_copied_item(app: &SheetApp) -> Option<(&'static str, &'static str)> { + let clip = app.session.clipboard.as_ref()?; + let lines = clip.selected.is_full_rows() != clip.selected.is_full_cols(); + let cut = clip.cut && app.session.active().is_some_and(|d| d.uid == clip.doc_uid); + match (cut, lines) { + (true, true) => Some(("Insert Cut Cells", "home.insertCopiedCells")), + (true, false) => None, + (false, true) => Some(("Insert Copied Cells", "home.insertCopiedCells")), + (false, false) => Some(("Insert Copied Cells…", "ui:insertPaste")), + } +} + +pub(super) fn is_checkbox(sh: &Sheet, app: &SheetApp) -> bool { + let Some(d) = app.session.active() else { return false }; + d.wb.styles.get(sh.style_id(d.selection.active)).num_fmt.as_str() == "checkbox" +} diff --git a/crates/ui-egui/src/grid/quadrant.rs b/crates/ui-egui/src/grid/quadrant.rs new file mode 100644 index 000000000..aa9e6fc07 --- /dev/null +++ b/crates/ui-egui/src/grid/quadrant.rs @@ -0,0 +1,474 @@ +use super::cell_paint::{ + Look, cell_text_y, center_across_end, col32, display_text, look, merged_edges, paint_border, paint_cf_icon, painted_halign, rotated_text_pos, + shrink_font, wrapped_cell_text_y, +}; +use super::geo::Geo; +use super::tables::{filter_button_rect, paint_filter_button}; +use super::*; + +/// Mirror what the grid painted into `layer` (the shape indices in `range`) for a right-to-left +/// sheet. Geometry is reflected; text and meshes (glyphs, pictures) keep their reading direction +/// and only move so that their box lands mirrored. Floating objects (`objects`: shape index +/// ranges with their logical rect) move as a whole, so a chart keeps its axes and a picture +/// isn't flipped. +pub(super) fn mirror_painted( + ctx: &egui::Context, + layer: egui::LayerId, + range: std::ops::Range, + geo: &Geo, + objects: &[crate::chartview::PaintedObject], +) { + let axis = geo.rect.left() + geo.rect.right(); + ctx.graphics_mut(|g| { + let list = g.entry(layer); + for i in range { + let object = objects.iter().find(|o| o.shapes.contains(&i)); + list.mutate_shape(egui::layers::ShapeIdx(i), |cs| { + cs.clip_rect = geo.mirror_rect(cs.clip_rect); + match object { + Some(o) => cs.shape.translate(vec2(axis - o.rect.right() - o.rect.left(), 0.0)), + None => mirror_shape(&mut cs.shape, axis), + } + }); + } + }); +} + +/// Reflect one shape across the vertical line `x = axis / 2`. +fn mirror_shape(shape: &mut egui::Shape, axis: f32) { + use egui::Shape; + let flip = |p: &mut Pos2| p.x = axis - p.x; + match shape { + Shape::Noop | Shape::Callback(_) => {} + Shape::Vec(v) => v.iter_mut().for_each(|s| mirror_shape(s, axis)), + Shape::Circle(c) => flip(&mut c.center), + Shape::Ellipse(e) => flip(&mut e.center), + Shape::LineSegment { points, .. } => points.iter_mut().for_each(flip), + Shape::Path(p) => { + p.points.iter_mut().for_each(flip); + // Keep the preferred (clockwise) winding for filled polygons. + p.points.reverse(); + } + Shape::Rect(r) => { + r.rect = Rect::from_min_max(pos2(axis - r.rect.right(), r.rect.top()), pos2(axis - r.rect.left(), r.rect.bottom())); + let c = r.corner_radius; + r.corner_radius = egui::CornerRadius { nw: c.ne, ne: c.nw, sw: c.se, se: c.sw }; + } + Shape::QuadraticBezier(b) => b.points.iter_mut().for_each(flip), + Shape::CubicBezier(b) => b.points.iter_mut().for_each(flip), + _ => { + let b = shape.visual_bounding_rect(); + if b.is_finite() { + shape.translate(vec2(axis - b.right() - b.left(), 0.0)); + } + } + } +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn paint_quadrant( + p: &Painter, + geo: &Geo, + wb: &Workbook, + si: usize, + sh: &Sheet, + rows: (u32, u32), + cols: (u32, u32), + cf: &mut CfCache, + t: &Tokens, +) { + let z = geo.z; + let (r0, r1) = rows; + let (c0, c1) = cols; + let range = RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)); + // Merges are looked up 20 columns beyond the quadrant too: text overflowing in from off-screen cells stops at them. + let near = RangeRef::new(CellRef::new(r0, c0.saturating_sub(20)), CellRef::new(r1, c1.saturating_add(20).min(MAX_COLS - 1))); + let merges: Vec = sh.merges.iter().filter(|m| m.intersects(&near)).copied().collect(); + let in_merge = |c: CellRef| merges.iter().find(|m| m.contains(c)).copied(); + // Gridlines. + if sh.show_gridlines { + let stroke = Stroke::new(1.0, t.gridline); + let top = geo.y(sh, r0); + let bottom = geo.y(sh, r1 + 1); + let left = geo.x(sh, c0); + let right = geo.x(sh, c1 + 1); + for c in c0..=c1 + 1 { + if c <= c1 && sh.col_width(c) == 0.0 { + continue; + } + let x = geo.x(sh, c).round() - 0.5; + p.line_segment([pos2(x, top), pos2(x, bottom)], stroke); + } + for r in r0..=r1 + 1 { + if r <= r1 && sh.row_height(r) == 0.0 { + continue; + } + let y = geo.y(sh, r).round() - 0.5; + p.line_segment([pos2(left, y), pos2(right, y)], stroke); + } + } + // Merged areas: blank them (no inner gridlines) and paint as one cell. + let mut cells: Vec = Vec::new(); + for r in r0..=r1 { + if sh.row_height(r) == 0.0 { + continue; + } + for c in c0..=c1 { + if sh.col_width(c) == 0.0 { + continue; + } + cells.push(CellRef::new(r, c)); + } + } + let empty_ok = |c: CellRef| -> bool { sh.value_ref(c).is_none_or(Value::is_empty) && in_merge(c).is_none() }; + // Backgrounds. + let mut looks: Vec<(CellRef, Rect, Look, Option)> = Vec::with_capacity(cells.len().min(20_000)); + for &c in &cells { + let (rect, anchor) = match in_merge(c) { + Some(m) if m.start != c && !(c.row == r0.max(m.start.row) && c.col == c0.max(m.start.col)) => continue, + Some(m) => (geo.range_rect(sh, m), m.start), + None => (geo.cell_rect(sh, c), c), + }; + let (lk, cfl) = look(wb, si, sh, anchor, cf); + if let Some(f) = lk.fill { + p.rect_filled(rect.expand(0.0), 0.0, f); + } else if in_merge(c).is_some() { + p.rect_filled(rect.shrink(0.5), 0.0, Color32::WHITE); + } + looks.push((anchor, rect, lk, cfl)); + } + // Text cells just outside the quadrant whose text may overflow into it: the nearest non-empty + // cell left of `c0` and right of `c1` in each row (within the same frozen / scrolled pane). + let mut outside: Vec<(CellRef, Rect, Look, Option)> = Vec::new(); + let same_pane = |col: u32| (col < geo.fc) == (c0 < geo.fc); + for r in r0..=r1 { + if sh.row_height(r) == 0.0 { + continue; + } + let left = (c0.saturating_sub(20)..c0).rev().map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n)); + let right = (c1.saturating_add(1)..=c1.saturating_add(20).min(MAX_COLS - 1)).map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n)); + for n in [left, right].into_iter().flatten() { + if same_pane(n.col) && sh.col_width(n.col) > 0.0 && in_merge(n).is_none() { + let (lk, cfl) = look(wb, si, sh, n, cf); + outside.push((n, geo.cell_rect(sh, n), lk, cfl)); + } + } + } + // Text and per-cell decorations. + for (c, rect, lk, cfl) in looks.iter().chain(&outside) { + let c = *c; + if let Some(picture) = sh.cell_pictures.get(&c) { + crate::cell_pictures::paint(p, *rect, picture); + continue; + } + let v = sh.value(c); + if let Some(l) = cfl + && let Some((frac, rgb, gradient)) = l.bar + { + let bar = Rect::from_min_size(pos2(rect.left() + 2.0, rect.top() + 2.0), vec2((rect.width() - 4.0) * frac, rect.height() - 4.0)); + let col = theme::color32(rgb); + if gradient { + let mut mesh = egui::Mesh::default(); + let light = Color32::from_rgb( + ((rgb[0] as u16 + 255 * 3) / 4) as u8, + ((rgb[1] as u16 + 255 * 3) / 4) as u8, + ((rgb[2] as u16 + 255 * 3) / 4) as u8, + ); + let i = mesh.vertices.len() as u32; + for (pt, cc) in [(bar.left_top(), col), (bar.right_top(), light), (bar.right_bottom(), light), (bar.left_bottom(), col)] { + mesh.colored_vertex(pt, cc); + } + mesh.add_triangle(i, i + 1, i + 2); + mesh.add_triangle(i, i + 2, i + 3); + p.add(mesh); + p.rect_stroke(bar, 0.0, Stroke::new(1.0, col), StrokeKind::Inside); + } else { + p.rect_filled(bar, 0.0, col); + } + } + let mut text_left = rect.left(); + if let Some(l) = cfl + && let Some((set, idx)) = &l.icon + { + let r = Rect::from_min_size(pos2(rect.left() + 2.0, rect.center().y - 6.0 * z), vec2(12.0 * z, 12.0 * z)); + paint_cf_icon(p, r, set, *idx); + text_left += 15.0 * z; + } + if lk.style.num_fmt.as_str() == "checkbox" && matches!(v, Value::Bool(_) | Value::Empty) { + let s = (11.0 * z * 1.2).clamp(8.0, 24.0); + let b = Rect::from_center_size(rect.center(), vec2(s, s)); + let on = v == Value::Bool(true); + p.rect_filled(b, 2.0, if on { Color32::from_rgb(0x10, 0x7C, 0x41) } else { Color32::WHITE }); + p.rect_stroke( + b, + 2.0, + Stroke::new(1.0, if on { Color32::from_rgb(0x10, 0x7C, 0x41) } else { Color32::from_gray(110) }), + StrokeKind::Inside, + ); + if on { + crate::icons::paint_on_sheet(p, b.shrink(1.5), crate::icons::Icon::Check, Color32::WHITE); + } + continue; + } + if cfl.as_ref().is_some_and(|l| l.hide_value) || v.is_empty() { + continue; + } + // Align and clip header text inside the content area, away from the dropdown. + let filter_space = sh.filter_button_space(c) * z; + let text_rect = Rect::from_min_max(rect.min, pos2((rect.right() - filter_space).max(rect.left()), rect.bottom())); + let rect = &text_rect; + let st = &lk.style; + let size = st.font.size * 96.0 / 72.0 * z; + let fam = theme::cell_family(&st.font.name, st.font.bold, st.font.italic); + let font = FontId::new(size, fam); + // Digits fit by the cell's own font and size, not the default 11pt (9pt marks in narrow columns). + let char_w = p.layout_no_wrap("0".to_string(), font.clone(), Color32::PLACEHOLDER).size().x.max(1.0); + let avail_w = rect.right() - text_left; + let (mut text, ncolor, numeric, fill_char) = display_text(wb, sh, c, &v, st, avail_w / z.max(0.1) * z, char_w); + if sh.show_formulas && sh.cell(c).is_some_and(|cell| cell.formula.is_some()) { + text = crate::formula_locale::display(&text, crate::i18n::current(p.ctx()).formula_locale(), wb, si); + } + if text.is_empty() { + continue; + } + let color = ncolor.unwrap_or(lk.text_color); + let halign = match st.align.h { + HAlign::General => match v { + Value::Number(_) => HAlign::Right, + Value::Bool(_) | Value::Error(_) => HAlign::Center, + _ if numeric => HAlign::Right, + _ => HAlign::Left, + }, + h => painted_halign(h, geo.rtl), + }; + let indent = st.align.indent as f32 * 9.0 * z; + let pad = 2.5 * z; + let rotated = st.align.rotation != 0 && st.align.rotation != 255; + // Rotated text wraps along its own direction (below), not across the column width. + if !rotated && (st.align.wrap || st.align.h == HAlign::Justify) { + let wrap_w = (rect.width() - 2.0 * pad - indent).max(4.0); + let mut job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, wrap_w); + job.halign = match halign { + HAlign::Center | HAlign::CenterAcross => egui::Align::Center, + HAlign::Right => egui::Align::RIGHT, + _ => egui::Align::LEFT, + }; + let galley = p.layout_job(job); + let x = match halign { + HAlign::Center | HAlign::CenterAcross => rect.center().x, + HAlign::Right => rect.right() - pad, + _ => text_left + pad + indent, + }; + let y = wrapped_cell_text_y(st.align.v, *rect, galley.size().y, z); + p.with_clip_rect(rect.intersect(p.clip_rect())).galley(pos2(x, y), galley, color); + continue; + } + // Wrap Text off: Excel ignores the line breaks and draws the whole string on one line. + let first_line: String = text.chars().filter(|&ch| ch != '\n' && ch != '\r').collect(); + // The fill position indexes `text`; move it back by the breaks removed before it. + let fill_char = fill_char.map(|(ch, pos)| (ch, pos.saturating_sub(text.get(..pos).map_or(0, |s| s.matches(['\n', '\r']).count())))); + // Shrink to fit: too-wide content is drawn in a smaller font so it fits the cell (or the + // merged area): it neither spills, nor is clipped, nor turns into ####. + let small = (st.align.shrink && !rotated && st.align.h != HAlign::Fill && fill_char.is_none()) + .then(|| shrink_font(p, &first_line, &font, avail_w - 2.0 * pad - indent)) + .flatten(); + let shrunk = small.is_some(); + let font = small.unwrap_or(font); + let mut galley = p.layout_no_wrap(first_line.clone(), font.clone(), color); + let mut w = galley.size().x; + // Numbers and dates never overflow: they show #### when too narrow. + let hashed = !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill; + if hashed { + // Measured on `#` itself: it is wider than `0` in some fonts, and #### must fit its cell. + let hash_w = p.layout_no_wrap("#".to_string(), font.clone(), color).size().x.max(1.0); + let n = ((avail_w - 2.0 * pad) / hash_w).floor().max(1.0) as usize; + galley = p.layout_no_wrap("#".repeat(n), font.clone(), color); + w = galley.size().x; + } + // Repeat fill (`*` in number formats) / Fill alignment. A number already shown as #### keeps + // it: rebuilding it from the text would overflow and clip its leading digits. + if let Some((ch, pos)) = fill_char + && !hashed + && pos <= first_line.len() + { + let base_w = w; + let cw = p.layout_no_wrap(ch.to_string(), font.clone(), color).size().x.max(1.0); + let n = (((avail_w - 2.0 * pad) - base_w) / cw).floor().max(0.0) as usize; + let mut s = first_line.clone(); + if first_line.is_char_boundary(pos) { + s.insert_str(pos, &ch.to_string().repeat(n)); + } + galley = p.layout_no_wrap(s, font.clone(), color); + w = galley.size().x; + } + // Center Across Selection: the text is centred over this cell and the empty cells to its right that also use it. + let across_end = if halign == HAlign::CenterAcross && !rotated && in_merge(c).is_none() { + center_across_end(c.col, c1 + 20, |col| { + let n = CellRef::new(c.row, col); + empty_ok(n) && wb.styles.get(sh.style_id(n)).align.h == HAlign::CenterAcross + }) + } else { + c.col + }; + // Overflow into empty neighbours for text (not upright text, whose width is its line height). + let mut clip = *rect; + let overflows = !numeric && !matches!(v, Value::Number(_)) && st.align.rotation.abs() != 90 && w > rect.width() - 2.0 * pad; + if filter_space == 0.0 && (overflows || across_end > c.col) && in_merge(c).is_none() { + let mut right = rect.right(); + let mut left = rect.left(); + if across_end > c.col { + right = geo.x(sh, across_end + 1); + } + if matches!(halign, HAlign::Left | HAlign::Center | HAlign::Justify) { + let mut cc = c.col + 1; + while right - rect.left() < w + 2.0 * pad && cc < MAX_COLS && empty_ok(CellRef::new(c.row, cc)) && cc <= c1 + 20 { + right = geo.x(sh, cc + 1); + cc += 1; + } + } + if matches!(halign, HAlign::Right | HAlign::Center) { + let mut cc = c.col; + while rect.right() - left < w + 2.0 * pad && cc > 0 && empty_ok(CellRef::new(c.row, cc - 1)) && cc + 20 > c0 { + cc -= 1; + left = geo.x(sh, cc); + } + } + clip = Rect::from_min_max(pos2(left, rect.top()), pos2(right, rect.bottom())); + // Paint over the gridlines the text passes through. + if lk.fill.is_none() { + let inner = Rect::from_min_max(pos2(clip.left() + 1.0, clip.top() + 0.5), pos2(clip.right() - 1.0, clip.bottom() - 1.0)); + let extra_l = Rect::from_min_max(inner.min, pos2(rect.left() + 1.0, inner.bottom())); + let extra_r = Rect::from_min_max(pos2(rect.right() - 1.0, inner.top()), inner.max); + if extra_l.width() > 0.0 { + p.rect_filled(extra_l, 0.0, t.grid_bg); + } + if extra_r.width() > 0.0 { + p.rect_filled(extra_r, 0.0, t.grid_bg); + } + } + } + let x = match halign { + HAlign::CenterAcross => clip.center().x - w / 2.0, + HAlign::Center | HAlign::Distributed => rect.center().x - w / 2.0, + HAlign::Right => rect.right() - pad - w - indent, + _ => text_left + pad + indent, + }; + let gh = galley.size().y; + let y = cell_text_y(st.align.v, *rect, gh, z); + let cp = p.with_clip_rect(clip.intersect(p.clip_rect())); + if rotated { + let angle = -(st.align.rotation as f32).to_radians(); + // Upright (±90°) wrapped text wraps to the row height: lines then stack across the column. + let galley = if st.align.wrap && st.align.rotation.abs() == 90 { + let job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, (rect.height() - 2.0 * pad).max(4.0)); + p.layout_job(job) + } else { + galley.clone() + }; + let pos = rotated_text_pos(*rect, galley.size(), angle, painted_halign(st.align.h, geo.rtl), st.align.v, pad, indent); + let mut shape = egui::epaint::TextShape::new(pos, galley, color); + shape.angle = angle; + cp.add(shape); + } else { + cp.galley(pos2(x, y), galley.clone(), color); + } + let baseline = y + gh * 0.82; + match st.font.underline { + Underline::None => {} + Underline::Double | Underline::DoubleAccounting => { + cp.line_segment([pos2(x, baseline + 1.0), pos2(x + w, baseline + 1.0)], Stroke::new(1.0, color)); + cp.line_segment([pos2(x, baseline + 3.0), pos2(x + w, baseline + 3.0)], Stroke::new(1.0, color)); + } + _ => { + cp.line_segment([pos2(x, baseline + 1.0), pos2(x + w, baseline + 1.0)], Stroke::new(1.0, color)); + } + } + if st.font.strike { + cp.line_segment([pos2(x, y + gh * 0.55), pos2(x + w, y + gh * 0.55)], Stroke::new(1.0, color)); + } + } + // Borders (over fills and text). + for (c, rect, lk, _) in &looks { + let b = &lk.style.borders; + let merge = merges.iter().find(|m| m.start == *c); + let edges = match merge { + // A merged area's outline lives on the cells along each of its edges (Excel writes the + // right edge on the last column's cells and the bottom edge on the last row's), so each + // edge is drawn cell by cell from the cell it belongs to. + Some(m) => merged_edges(wb, si, sh, geo, *m, *rect, (r0, r1), (c0, c1), cf), + None => vec![ + (b.top, [rect.left_top(), rect.right_top()]), + (b.bottom, [rect.left_bottom(), rect.right_bottom()]), + (b.left, [rect.left_top(), rect.left_bottom()]), + (b.right, [rect.right_top(), rect.right_bottom()]), + ], + }; + for (line, pts) in edges { + if line.is_none() { + continue; + } + let col = col32(line.color, wb, Color32::BLACK); + paint_border(p, pts, line.style, col); + } + if !b.diag_down.is_none() { + paint_border(p, [rect.left_top(), rect.right_bottom()], b.diag_down.style, col32(b.diag_down.color, wb, Color32::BLACK)); + } + if !b.diag_up.is_none() { + paint_border(p, [rect.left_bottom(), rect.right_top()], b.diag_up.style, col32(b.diag_up.color, wb, Color32::BLACK)); + } + // Comment / note indicator. + if let Some(cm) = sh.comments.get(c) { + let tri_col = if cm.threaded { Color32::from_rgb(0x88, 0x55, 0xC8) } else { Color32::from_rgb(0xD0, 0x2E, 0x2E) }; + let s = 6.0 * z.max(0.7); + p.add(egui::Shape::convex_polygon( + vec![rect.right_top(), pos2(rect.right(), rect.top() + s), pos2(rect.right() - s, rect.top())], + tri_col, + Stroke::NONE, + )); + } + // Background error checking: a number stored as text gets Excel's green corner (#1994). + if !sh.show_formulas && sh.cell(*c).and_then(gridcraft_engine::cmd::formulas::number_stored_as_text).is_some() { + let s = 6.0 * z.max(0.7); + p.add(egui::Shape::convex_polygon( + vec![rect.left_top(), pos2(rect.left() + s, rect.top()), pos2(rect.left(), rect.top() + s)], + t.error_check_green, + Stroke::NONE, + )); + } + } + // Sparklines. + for sp in &sh.sparklines { + if !range.contains(sp.cell) { + continue; + } + let rect = geo.cell_rect(sh, sp.cell).shrink(3.0); + let vals: Vec> = match gridcraft_engine::calc::evaluate(wb, si, sp.cell, &sp.source) { + Value::Array(a) => a.data.iter().map(Value::as_f64).collect(), + v => vec![v.as_f64()], + }; + let col = sp.color.resolve(&wb.theme).unwrap_or([0x2B, 0x7C, 0xD3]); + let prims = gridcraft_chart::render_sparkline(sp.kind, &vals, [col[0], col[1], col[2], 255], sp.markers, rect.width(), rect.height()); + crate::chartview::paint_prims(p, rect.min, &prims, 1.0); + } + // Filter buttons. + let mut buttons: Vec<(CellRef, bool)> = Vec::new(); + if let Some(af) = &sh.autofilter { + for col in af.range.start.col..=af.range.end.col { + let active = af.criteria.iter().any(|(o, _)| af.range.start.col + o == col); + buttons.push((CellRef::new(af.range.start.row, col), active)); + } + } + for tb in &sh.tables { + if tb.filter_button && tb.header_row { + for col in tb.range.start.col..=tb.range.end.col { + buttons.push((CellRef::new(tb.range.start.row, col), false)); + } + } + } + for (c, active) in buttons { + if !range.contains(c) { + continue; + } + paint_filter_button(p, filter_button_rect(geo, sh, c), active); + } +} diff --git a/crates/ui-egui/src/grid/selection.rs b/crates/ui-egui/src/grid/selection.rs new file mode 100644 index 000000000..3b5339f61 --- /dev/null +++ b/crates/ui-egui/src/grid/selection.rs @@ -0,0 +1,419 @@ +use super::geo::Geo; +#[cfg(test)] +use super::interact::{hidden_col_edge, hidden_row_edge}; +#[cfg(test)] +use super::show::hidden_around; +use super::*; + +/// Where the active cell is on screen: the whole merged cell when it is part of one. +fn active_rect(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection) -> Rect { + match sh.merge_at(sel.active) { + Some(m) => geo.range_rect(sh, m), + None => geo.cell_rect(sh, sel.active), + } +} + +/// The rectangles shaded for a selection: every area except the active cell, which stays clear so +/// it is plain where typing goes. A single selected cell has no fill. `outline` is where the +/// current area is drawn this frame: it glides to a new selection, and its fill with it. +pub fn selection_fill(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, outline: Rect) -> Vec { + selection_fill_where(geo, sh, sel, outline, |_| true) +} + +/// `selection_fill` for the areas `keep` accepts (a frozen pane draws only the areas it shows). +fn selection_fill_where(geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, outline: Rect, keep: impl Fn(&RangeRef) -> bool) -> Vec { + if sel.is_single_cell() { + return vec![]; + } + let active = active_rect(geo, sh, sel); + let area = |r: &RangeRef| if *r == sel.current() { outline } else { geo.range_rect(sh, *r) }; + sel.ranges.iter().filter(|r| keep(r)).flat_map(|r| rect_around(area(r), active)).collect() +} + +/// `area` without `hole`: the strips above and below the hole, then what is left beside it. The +/// selection fill is translucent, so the active cell can't be cleared by painting over it; the +/// pieces never overlap for the same reason. Pieces with no size, or whose numbers aren't finite, +/// are dropped. +fn rect_around(area: Rect, hole: Rect) -> impl Iterator { + // `intersect` takes the sane side of a NaN, which would make a broken hole cover the area. + let hole = if hole.is_finite() { area.intersect(hole) } else { Rect::NOTHING }; + let parts = if hole.is_positive() { + [ + Rect::from_min_max(area.min, pos2(area.max.x, hole.min.y)), + Rect::from_min_max(pos2(area.min.x, hole.max.y), area.max), + Rect::from_min_max(pos2(area.min.x, hole.min.y), pos2(hole.min.x, hole.max.y)), + Rect::from_min_max(pos2(hole.max.x, hole.min.y), pos2(area.max.x, hole.max.y)), + ] + } else { + [area, Rect::NOTHING, Rect::NOTHING, Rect::NOTHING] + }; + parts.into_iter().filter(|r| r.is_positive() && r.is_finite()) +} + +/// True when `r` covers at least one cell of the pane that shows `rows` x `cols` (inclusive). +/// The panes of a frozen sheet share one coordinate system, so a range that lives only in the +/// scrolling pane would otherwise be drawn over the frozen pane as well. +fn range_in_pane(r: RangeRef, rows: (u32, u32), cols: (u32, u32)) -> bool { + r.start.row <= rows.1 && r.end.row >= rows.0 && r.start.col <= cols.1 && r.end.col >= cols.0 +} + +/// The four sides of a `width` wide outline centred on `rect`, cut to `clip` (a little beyond it). +/// +/// A stroked rectangle that spans a whole column (20 million points tall) loses its precision in +/// the tessellator and its left and right sides vanish; sides filled from rectangles that stay +/// near the viewport do not. Sides wholly outside `clip` are left out. +pub(crate) fn outline_bands(rect: Rect, width: f32, clip: Rect) -> Vec { + if !rect.is_finite() || !clip.is_finite() { + return Vec::new(); + } + let h = width / 2.0; + let clip = clip.expand(width * 2.0); + let sides = [ + Rect::from_min_max(pos2(rect.left() - h, rect.top() - h), pos2(rect.left() + h, rect.bottom() + h)), + Rect::from_min_max(pos2(rect.right() - h, rect.top() - h), pos2(rect.right() + h, rect.bottom() + h)), + Rect::from_min_max(pos2(rect.left() - h, rect.top() - h), pos2(rect.right() + h, rect.top() + h)), + Rect::from_min_max(pos2(rect.left() - h, rect.bottom() - h), pos2(rect.right() + h, rect.bottom() + h)), + ]; + sides.into_iter().map(|s| s.intersect(clip)).filter(|s| s.is_finite() && s.width() > 0.0 && s.height() > 0.0).collect() +} + +/// `outline` is where the current area's border is drawn this frame (see `selection_glide`). +#[allow(clippy::too_many_arguments)] +pub(crate) fn paint_selection( + p: &Painter, + geo: &Geo, + sh: &Sheet, + sel: &gridcraft_engine::Selection, + outline: Rect, + t: &Tokens, + app: &SheetApp, + rows: (u32, u32), + cols: (u32, u32), +) { + // Fill all areas except the active cell. + for rect in selection_fill_where(geo, sh, sel, outline, |r| range_in_pane(*r, rows, cols)) { + p.rect_filled(rect, 0.0, t.sel_fill); + } + let active_range = sh.merge_at(sel.active).unwrap_or_else(|| RangeRef::cell(sel.active)); + let active = active_rect(geo, sh, sel); + let active_here = range_in_pane(active_range, rows, cols); + let cur_here = range_in_pane(sel.current(), rows, cols); + // Editing a formula: outline its references instead of the selection. + if let Some(ed) = &app.editor + && ed.is_formula() + && app.session.active().is_some_and(|d| d.uid == ed.doc) + { + let origin = app.session.active().and_then(|d| d.wb.sheet(ed.sheet)).map_or(sh.name.as_str(), |o| o.name.as_str()); + for (_, _, r, sheet, idx) in crate::editor::formula_refs(&ed.text) { + if !crate::editor::ref_is_on_sheet(sheet.as_deref(), origin, &sh.name) { + continue; + } + if !range_in_pane(r, rows, cols) { + continue; + } + let rr = geo.range_rect(sh, r); + let col = Tokens::get(p.ctx()).ref_color(idx, true); + p.rect_filled(rr, 0.0, col.gamma_multiply(0.12)); + p.rect_stroke(rr, 0.0, Stroke::new(1.5, col), StrokeKind::Inside); + for corner in [rr.left_top(), rr.right_top(), rr.left_bottom(), rr.right_bottom()] { + p.rect_filled(Rect::from_center_size(corner, vec2(4.0, 4.0)), 0.0, col); + } + } + } + if cur_here { + for side in outline_bands(outline.expand(0.5), 2.0, p.clip_rect()) { + p.rect_filled(side, 0.0, t.sel_border); + } + } + if active_here && sel.ranges.len() > 1 { + p.rect_stroke(active, 0.0, Stroke::new(1.0, t.sel_border), StrokeKind::Inside); + } + // Fill handle (bottom-right of the current area). + if app.editor.is_none() && range_in_pane(RangeRef::cell(sel.current().end), rows, cols) { + let h = Rect::from_center_size(outline.right_bottom(), vec2(6.0, 6.0)); + p.rect_filled(h.expand(1.0), 0.0, Color32::WHITE); + p.rect_filled(h, 0.0, t.sel_border); + } + // Fill-handle drag preview. + if let crate::grid::Drag::Fill { target: Some(tr), .. } = &app.grid.drag + && range_in_pane(*tr, rows, cols) + { + let rr = geo.range_rect(sh, *tr); + p.extend(egui::Shape::dashed_line( + &[rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()], + Stroke::new(1.5, Color32::from_gray(90)), + 4.0, + 3.0, + )); + } + if let crate::grid::Drag::Move { to: Some(to), .. } = &app.grid.drag { + let r = sel.current(); + let moved = RangeRef::new(*to, to.offset_clamped(r.height() as i64 - 1, r.width() as i64 - 1)); + if range_in_pane(moved, rows, cols) { + let rr = geo.range_rect(sh, moved); + p.rect_stroke(rr, 0.0, Stroke::new(2.0, Color32::from_gray(110)), StrokeKind::Middle); + } + } + if let crate::grid::Drag::MoveCols { before: Some(before), .. } = &app.grid.drag { + let x = geo.x(sh, *before); + p.line_segment([pos2(x, geo.rect.top()), pos2(x, geo.rect.bottom())], Stroke::new(3.0, t.accent)); + } + // Marching ants around the copied range. + if let Some(clip) = &app.session.clipboard + && app.session.active().is_some_and(|d| d.uid == clip.doc_uid && d.wb.active_sheet() == clip.sheet) + && range_in_pane(clip.range, rows, cols) + { + // A multi-area copy marks each area as selected. + let areas = if clip.areas.is_empty() { std::slice::from_ref(&clip.range) } else { clip.areas.as_slice() }; + for area in areas { + let rr = geo.range_rect(sh, *area).expand(0.5); + let phase = app.grid.marching_phase; + let pts = [rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()]; + p.extend(egui::Shape::dashed_line_with_offset(&pts, Stroke::new(2.0, t.sel_border), &[4.0], &[4.0], phase)); + } + } +} + +/// The areas after a click: with `add` (Ctrl) the clicked area is added, or removed when it is already +/// selected (unless it is the only one, so the selection never ends up empty). +fn toggled_ranges(mut ranges: Vec, clicked: String, add: bool) -> Vec { + if !add { + return vec![clicked]; + } + if ranges.len() > 1 && ranges.contains(&clicked) { + ranges.retain(|x| *x != clicked); + } else if !ranges.contains(&clicked) { + ranges.push(clicked); + } + ranges +} + +pub(super) fn select_ranges(app: &mut SheetApp, r: RangeRef, add: bool, active: CellRef) { + let current: Vec = + if add { app.session.active().map(|d| d.selection.ranges.iter().map(|x| x.a1()).collect()).unwrap_or_default() } else { vec![] }; + let clicked = r.a1(); + let ranges = toggled_ranges(current, clicked.clone(), add); + // A removed area takes the active cell with it; `selection.set` then puts it at the start of the last area. + let params = + if ranges.contains(&clicked) { json!({"range": ranges.join(","), "active": active.a1()}) } else { json!({"range": ranges.join(",")}) }; + let _ = app.session.run("selection.set", params); +} + +#[cfg(test)] +mod selection_fill_tests { + use super::*; + + #[test] + fn rect_around_leaves_the_hole_and_never_overlaps() { + let area = Rect::from_min_max(pos2(100.0, 50.0), pos2(400.0, 250.0)); + let check = |hole: Rect, pieces: usize| { + let parts: Vec = rect_around(area, hole).collect(); + assert_eq!(parts.len(), pieces, "{hole:?}: {parts:?}"); + let cut = area.intersect(hole); + let cut_area = if cut.is_positive() { cut.area() } else { 0.0 }; + let shaded: f32 = parts.iter().map(Rect::area).sum(); + assert!((shaded - (area.area() - cut_area)).abs() < 0.5, "everything but the hole is shaded: {hole:?}"); + for (i, a) in parts.iter().enumerate() { + assert!(area.contains_rect(*a), "nothing outside the area: {a:?}"); + assert!(!a.shrink(0.01).intersects(hole), "the hole stays clear: {a:?} vs {hole:?}"); + // The fill is translucent: a part shaded twice would show darker. + assert!(parts.iter().skip(i + 1).all(|b| !a.shrink(0.01).intersects(*b)), "{parts:?}"); + } + }; + let cell = |x: f32, y: f32| Rect::from_min_size(pos2(x, y), vec2(64.0, 16.0)); + // In the middle, in each corner (where a drag began), along an edge. + check(cell(200.0, 100.0), 4); + check(cell(100.0, 50.0), 2); + check(cell(336.0, 50.0), 2); + check(cell(100.0, 234.0), 2); + check(cell(336.0, 234.0), 2); + check(cell(100.0, 100.0), 3); + // A merged cell as wide, or as tall, as the area; one that is all of it, or more. + check(Rect::from_min_max(pos2(100.0, 100.0), pos2(400.0, 132.0)), 2); + check(Rect::from_min_max(pos2(200.0, 50.0), pos2(264.0, 250.0)), 2); + check(area, 0); + check(area.expand(30.0), 0); + // The active cell is in another area, touches this one, or is only partly inside it. + check(cell(500.0, 300.0), 1); + check(cell(400.0, 50.0), 1); + check(cell(380.0, 240.0), 2); + } + + #[test] + fn rect_around_survives_hostile_numbers() { + let sane = Rect::from_min_max(pos2(0.0, 0.0), pos2(64.0, 16.0)); + let bad = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, f32::MAX, f32::MIN, -1.0, 0.0]; + for a in bad { + for b in bad { + let weird = Rect::from_min_max(pos2(a, b), pos2(b, a)); + for (area, hole) in [(sane, weird), (weird, sane), (weird, weird)] { + for part in rect_around(area, hole) { + assert!(part.is_positive() && part.is_finite(), "{area:?} around {hole:?}: {part:?}"); + } + } + // A hole that makes no sense leaves the whole area shaded. + if !weird.intersects(sane) { + assert_eq!(rect_around(sane, weird).collect::>(), vec![sane]); + } + } + } + } +} + +#[cfg(test)] +mod pane_tests { + use super::*; + + fn rng(r0: u32, c0: u32, r1: u32, c1: u32) -> RangeRef { + RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1)) + } + + #[test] + fn selection_in_scrolling_pane_is_not_drawn_in_frozen_pane() { + // Frozen: 2 rows and 2 columns; the scrolling pane shows rows 2..=20 and columns 2..=10. + let frozen_cols = ((2, 20), (0, 1)); + let frozen_rows = ((0, 1), (2, 10)); + let corner = ((0, 1), (0, 1)); + let scrolled = ((2, 20), (2, 10)); + let sel = rng(5, 4, 7, 6); + assert!(range_in_pane(sel, scrolled.0, scrolled.1)); + assert!(!range_in_pane(sel, frozen_cols.0, frozen_cols.1)); + assert!(!range_in_pane(sel, frozen_rows.0, frozen_rows.1)); + assert!(!range_in_pane(sel, corner.0, corner.1)); + } + + #[test] + fn ctrl_click_on_a_selected_header_deselects_it() { + let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::>(); + assert_eq!(toggled_ranges(s(&["A:A", "C:C"]), "C:C".into(), true), s(&["A:A"])); + assert_eq!(toggled_ranges(s(&["A:A"]), "C:C".into(), true), s(&["A:A", "C:C"])); + assert_eq!(toggled_ranges(s(&["A:A"]), "A:A".into(), true), s(&["A:A"])); + assert_eq!(toggled_ranges(s(&["A1", "B2", "3:3"]), "B2".into(), true), s(&["A1", "3:3"])); + assert_eq!(toggled_ranges(s(&["A:A", "C:C"]), "C:C".into(), false), s(&["C:C"])); + } + + #[test] + fn selection_across_the_freeze_line_is_drawn_in_both_panes() { + let sel = rng(5, 0, 7, 6); + assert!(range_in_pane(sel, (2, 20), (0, 1))); + assert!(range_in_pane(sel, (2, 20), (2, 10))); + } + + #[test] + fn hidden_outline_symbols_take_no_room() { + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + let mut sh = Sheet::new("S"); + sh.rows.entry(3).or_default().outline = 1; + sh.cols.entry(2).or_default().outline = 2; + let shown = Geo::new(&sh, rect, vec2(0.0, 0.0)); + assert!(shown.band_w > 0.0 && shown.band_h > 0.0); + sh.show_outline_symbols = false; + let hidden = Geo::new(&sh, rect, vec2(0.0, 0.0)); + assert_eq!((hidden.band_w, hidden.band_h), (0.0, 0.0)); + assert!(hidden.cells.left() < shown.cells.left() && hidden.cells.top() < shown.cells.top()); + // Without headings too. + sh.show_headings = false; + let bare = Geo::new(&sh, rect, vec2(0.0, 0.0)); + assert_eq!((bare.band_w, bare.band_h), (0.0, 0.0)); + // No button answers a click on the empty bands' edges (the grid's left and top), where the + // level buttons would sit with no headings to hold them. + for geo in [&hidden, &bare] { + for i in 0..400 { + let t = i as f32 * 0.5; + for p in [pos2(geo.rect.left(), geo.rect.top() + t), pos2(geo.rect.left() + t, geo.rect.top())] { + assert_eq!(crate::outline::hit(geo, &sh, p), None, "{p:?}"); + } + } + } + } + + /// Two frozen rows and columns, scrolled right so that columns C:E lie under the frozen columns. + fn frozen_geo(sh: &mut Sheet) -> Geo { + sh.freeze = Some((2, 2)); + let scroll = vec2(3.0 * sh.default_col_width + 8.0, 0.0); + Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), scroll) + } + + #[test] + fn the_edge_just_past_a_hidden_line_unhides_it() { + let mut sh = Sheet::new("S"); + sh.cols.entry(11).or_default().hidden = true; // L + sh.rows.entry(26).or_default().hidden = true; // 27 + let geo = Geo::new(&sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(2000.0, 1200.0)), vec2(0.0, 0.0)); + let x = geo.x(&sh, 12); // between K and M + assert_eq!(hidden_col_edge(&geo, &sh, x + 2.0), Some(11)); + assert_eq!(hidden_col_edge(&geo, &sh, x - 2.0), None); // K's own edge + assert_eq!(hidden_col_edge(&geo, &sh, geo.x(&sh, 5)), None); + let y = geo.y(&sh, 27); // between 26 and 28 + assert_eq!(hidden_row_edge(&geo, &sh, y + 2.0), Some(26)); + assert_eq!(hidden_row_edge(&geo, &sh, y - 2.0), None); + } + + #[test] + fn headers_mark_the_edges_next_to_hidden_lines() { + let mut sh = Sheet::new("S"); + sh.cols.entry(0).or_default().hidden = true; // A + sh.cols.entry(11).or_default().hidden = true; // L + sh.rows.entry(26).or_default().hidden = true; // 27 + let col = |c| hidden_around(c, |i| sh.col_width(i)); + assert_eq!(col(1), (true, false)); // B, after hidden A + assert_eq!(col(10), (false, true)); // K + assert_eq!(col(12), (true, false)); // M + assert_eq!(col(5), (false, false)); + let row = |r| hidden_around(r, |i| sh.row_height(i)); + assert_eq!(row(25), (false, true)); + assert_eq!(row(27), (true, false)); + assert_eq!(row(0), (false, false)); + } + + #[test] + fn overlays_skip_cells_scrolled_under_the_frozen_columns() { + let mut sh = Sheet::new("S"); + let geo = frozen_geo(&mut sh); + assert!(geo.hidden_under_frozen(&sh, CellRef::new(5, 2))); + assert!(geo.hidden_under_frozen(&sh, CellRef::new(5, 4))); + assert!(!geo.hidden_under_frozen(&sh, CellRef::new(5, 5))); + assert!(!geo.hidden_under_frozen(&sh, CellRef::new(5, 0))); + let unscrolled = Geo::new(&sh, geo.rect, vec2(0.0, 0.0)); + assert!(!unscrolled.hidden_under_frozen(&sh, CellRef::new(5, 2))); + } + + #[test] + fn ranges_are_clipped_to_the_panes_that_hold_them() { + let mut sh = Sheet::new("S"); + let geo = frozen_geo(&mut sh); + let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h); + assert_eq!(geo.range_panes(rng(5, 3, 7, 6)), [Rect::from_min_max(split, geo.cells.max)]); + assert_eq!(geo.range_panes(rng(5, 0, 7, 6)).len(), 2); + assert_eq!(geo.range_panes(rng(0, 0, 7, 6)).len(), 4); + assert_eq!(geo.range_panes(rng(0, 0, 1, 1)), [Rect::from_min_max(geo.cells.min, split)]); + // The fill handle of D6:E7 sits over the frozen columns, where nothing of it is shown. + let handle = geo.range_rect(&sh, rng(5, 3, 6, 4)).right_bottom(); + assert!(handle.x < split.x); + assert!(!geo.range_panes(rng(6, 4, 6, 4)).iter().any(|r| r.contains(handle))); + } +} + +#[cfg(test)] +mod outline_band_tests { + use super::*; + + #[test] + fn whole_column_outline_keeps_left_and_right_sides_near_viewport() { + let clip = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0)); + let col = Rect::from_min_max(pos2(100.0, 40.0), pos2(180.0, 20_971_540.0)); + let sides = outline_bands(col, 2.0, clip); + // Left, right and top (in view); the bottom is 20 million points away. + assert_eq!(sides.len(), 3); + assert!(sides.iter().all(|s| s.bottom() <= clip.bottom() + 4.0 + f32::EPSILON)); + assert!(sides.iter().any(|s| s.left() < 100.0 && s.right() > 100.0 && s.height() > 500.0)); + assert!(sides.iter().any(|s| s.left() < 180.0 && s.right() > 180.0 && s.height() > 500.0)); + } + + #[test] + fn outline_outside_the_view_and_non_finite_draw_nothing() { + let clip = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0)); + assert!(outline_bands(Rect::from_min_max(pos2(900.0, 0.0), pos2(950.0, 20.0)), 2.0, clip).is_empty()); + assert!(outline_bands(Rect::from_min_max(pos2(f32::NAN, 0.0), pos2(1.0, 1.0)), 2.0, clip).is_empty()); + } +} diff --git a/crates/ui-egui/src/grid/show.rs b/crates/ui-egui/src/grid/show.rs new file mode 100644 index 000000000..442a4722d --- /dev/null +++ b/crates/ui-egui/src/grid/show.rs @@ -0,0 +1,861 @@ +use super::cursor::update_cursor; +use super::editor::{in_cell_editor, list_picker, validation_arrow}; +use super::geo::Geo; +use super::interact::{interact, paint_freeze_handles}; +use super::menus::{context_menu, error_check_tag, fill_menu, fill_options_tag, filter_menu, header_menu, object_menu}; +use super::quadrant::{mirror_painted, paint_quadrant}; +use super::selection::paint_selection; +use super::tables::{TableHeading, paint_filter_button, table_headings}; +use super::*; + +pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let available = ui.available_rect_before_wrap(); + // A right-to-left sheet has its vertical scrollbar on the left. + let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left); + let bar = 14.0_f32.min(available.width()); + let rect = if rtl { + Rect::from_min_max(pos2(available.left() + bar, available.top()), available.max) + } else { + Rect::from_min_max(available.min, pos2(available.right() - bar, available.bottom())) + }; + let resp = ui.allocate_rect(rect, Sense::click_and_drag()); + app.grid.rect = Some(rect); + let Some(d) = app.session.active() else { return }; + let wb: Arc = d.wb.clone(); + let si = wb.active_sheet(); + let Some(sh) = wb.sheet(si) else { return }; + let sel = d.selection.clone(); + let place = (d.uid, si); + let mut view = app.view(); + // Page Layout view: pages on a desk, rulers above and left of the headings. + let paper = sh.view == gridcraft_engine::model::SheetViewMode::PageLayout; + let ruler = if paper && !app.session.hide_ruler { crate::paper_view::RULER } else { 0.0 }; + // Right-to-left sheets carry the vertical ruler on the right, beside the row headings. + let grid_rect = if rtl { + Rect::from_min_max(pos2(rect.left(), (rect.top() + ruler).min(rect.bottom())), pos2((rect.right() - ruler).max(rect.left()), rect.bottom())) + } else { + Rect::from_min_max(pos2((rect.left() + ruler).min(rect.right()), (rect.top() + ruler).min(rect.bottom())), rect.max) + }; + let make_geo = |app: &mut SheetApp, scroll: egui::Vec2| -> Geo { + if paper { + let probe = Geo::new(sh, grid_rect, scroll); + if let Some(g) = crate::paper_view::grid_for(app, &wb, si, sh, probe.cells.size(), scroll) { + let g = crate::paper_view::with_dragged_margin(app, &wb, si, g); + return Geo::paged(sh, grid_rect, scroll, g); + } + } + Geo::new(sh, rect, scroll) + }; + // Scrolling. + let (scroll_delta, zoom_delta, modifiers, now) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers, i.time)); + if (resp.hovered() || resp.contains_pointer()) && !crate::text_box::contains_pointer(app, ui.ctx()) { + if zoom_delta != 1.0 { + // egui's zoom factor is e^(speed × points); one notch is `line_scroll_speed` points. + let notch = ui.ctx().options(|o| o.input_options.scroll_zoom_speed * o.input_options.line_scroll_speed); + let turned = zoom_delta.ln() / notch; + let mut zooming = WheelZoom::at(app, place, sh.zoom, now); + if now - zooming.time > WHEEL_ZOOM_IDLE { + zooming.notches = 0.0; + } + if turned.is_finite() { + zooming.notches = (zooming.notches + turned).clamp(-32.0, 32.0); + } + zooming.time = now; + // A notch steps once 60% of it has turned, and the rest of it brings `notches` back to + // about 0. (Past half, so what's left after a step can't step back.) Steps count from + // where a glide is going, not from where it has got to. + let from = zooming.glide.map_or(sh.zoom, |(to, _)| to); + let mut z = from; + while zooming.notches >= 0.6 { + z = wheel_zoom_step(z, true); + zooming.notches -= 1.0; + } + while zooming.notches <= -0.6 { + z = wheel_zoom_step(z, false); + zooming.notches += 1.0; + } + if z != from { + zooming.glide = Some((z, sh.zoom)); + } + app.grid.wheel_zoom = Some(zooming); + } else if scroll_delta != egui::Vec2::ZERO { + let z = if paper { make_geo(app, view.scroll).z } else { sh.zoom as f32 / 100.0 * DISPLAY_SCALE }; + let mut d = scroll_delta; + // A horizontal swipe moves the content the way the fingers go, which is towards + // higher columns on a mirrored sheet. + if sh.right_to_left { + d.x = -d.x; + } + if modifiers.shift && d.x == 0.0 { + d = vec2(d.y, 0.0); + } + view.scroll -= d / z; + } + } + // A one-finger touch drag pans the sheet, the content following the finger; it doesn't select. + // (Two fingers pinch-zoom through `zoom_delta` above.) + if resp.drag_started() && ui.input(|i| i.any_touches()) { + app.grid.touch_pan = true; + } + if app.grid.touch_pan && resp.dragged() && ui.input(|i| i.multi_touch().is_none()) { + let z = if paper { make_geo(app, view.scroll).z } else { sh.zoom as f32 / 100.0 * DISPLAY_SCALE }; + let mut d = resp.drag_delta(); + if sh.right_to_left { + d.x = -d.x; + } + view.scroll -= d / z; + } + // The zoom glides to its step like egui eases a wheel notch (90% of the way in 0.1 s), or goes + // there at once without animations (as for the selection outline below: View › Animations + // off, egui's off in the headless harness, or a screenshot on its way). + let mut zooming = WheelZoom::at(app, place, sh.zoom, now); + if let Some((to, shown)) = zooming.glide { + let glides = app.ui.animations && ui.ctx().global_style().animation_time > 0.0 && !app.shots.in_flight(); + let share = egui::emath::exponential_smooth_factor(0.9, 0.1, ui.input(|i| i.stable_dt).min(0.1)); + let z = if glides { zoom_glide_step(shown, to, share) } else { to }; + let _ = app.session.run("view.zoom", json!({"percent": z})); + zooming.glide = (z != to).then_some((to, z)); + ui.ctx().request_repaint(); + } + // Store a glide's progress, or that it stopped. + if app.grid.wheel_zoom.is_some_and(|w| w.glide.is_some()) { + app.grid.wheel_zoom = Some(zooming); + } + view.scroll = view.scroll.max(egui::Vec2::ZERO); + let (mut max_x, mut max_y) = (sh.col_left(MAX_COLS - 20) as f32, sh.row_top(MAX_ROWS - 40) as f32); + if paper { + // Page layout units run past sheet units (margins and gaps); the end is known once the + // pages reach the last row / column. + let g = make_geo(app, view.scroll); + if let Some(pg) = &g.paper { + let (ex, ey) = pg.extent(); + max_x = if pg.cols_complete { (ex as f32 - g.cells.width() / g.z).max(0.0) } else { 1.0e9 }; + max_y = if pg.rows_complete { (ey as f32 - g.cells.height() / g.z).max(0.0) } else { 1.0e9 }; + } + } + view.scroll.x = view.scroll.x.min(max_x); + view.scroll.y = view.scroll.y.min(max_y); + let mut geo = make_geo(app, view.scroll); + // Keep the active cell (or the moving end of an extension) visible after keyboard moves. + if app.grid.ensure_visible { + app.grid.ensure_visible = false; + let moving = std::mem::take(&mut app.grid.reveal_moving_end); + let cell = if moving { sel.moving_end() } else { sel.active }; + let from = app.grid.reveal_from.take(); + let target = app.editor.as_ref().and_then(|e| e.point_cell).unwrap_or(cell); + // A whole column's moving end sits on the sheet's last row (a whole row's on its last + // column): when an extension moves it only across, the view keeps its place the other way. + // An extension that reaches the last row itself (Ctrl+Shift+Down past the data) still + // follows it. + let extended = (moving && target == cell).then(|| sel.current()); + let before = view.scroll; + if paper { + crate::paper_view::ensure_covers(app, &wb, si, target); + geo = make_geo(app, view.scroll); + } + let cr = geo.cell_rect(sh, target); + let area = Rect::from_min_max(pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h), geo.cells.max); + if target.row >= geo.fr { + if cr.top() < area.top() { + view.scroll.y -= (area.top() - cr.top()) / geo.z; + } else if cr.bottom() > area.bottom() - 2.0 { + view.scroll.y += (cr.bottom() - area.bottom() + 2.0) / geo.z; + } + } else if from.is_some_and(|f| f.row >= geo.fr) { + // A move from the scrolled rows into the frozen ones (Page Up or Ctrl+Up onto a frozen + // row 1) brings the scrolled rows back to their top; the rows it passed over would + // otherwise stay out of sight. + view.scroll.y = 0.0; + } + if target.col >= geo.fc { + if cr.left() < area.left() { + view.scroll.x -= (area.left() - cr.left()) / geo.z; + } else if cr.right() > area.right() - 2.0 { + view.scroll.x += ((cr.right() - area.right() + 2.0) / geo.z).min(cr.left() - area.left()); + } + } else if from.is_some_and(|f| f.col >= geo.fc) { + view.scroll.x = 0.0; + } + if extended.is_some_and(|r| r.is_full_cols()) && from.is_some_and(|f| f.row == cell.row) { + view.scroll.y = before.y; + } + if extended.is_some_and(|r| r.is_full_rows()) && from.is_some_and(|f| f.col == cell.col) { + view.scroll.x = before.x; + } + view.scroll = view.scroll.max(egui::Vec2::ZERO); + geo = make_geo(app, view.scroll); + } + // Snap scrolling to whole rows/columns at rest like Excel? Keep smooth (better on trackpads). + if let Some(v) = app.view_mut() { + *v = view; + } + app.grid.cells_rect = Some(geo.mirror_rect(geo.cells)); + // The selection outline glides to a new selection. It stays on its cells when Animations is + // off, when egui's animations are (the headless harness), and while a screenshot is on its way. + // A mouse button held down (a click or a drag selecting cells) moves it at once, as in Excel: + // a gliding outline trails the pointer and reads as lag. + let pointer_selecting = ui.input(|i| i.pointer.any_down()); + let outline = app.grid.glide.frame(crate::selection_glide::Frame { + place, + range: sel.current(), + target: geo.range_rect(sh, sel.current()), + view: geo.cells, + now, + frame_nr: ui.ctx().cumulative_frame_nr(), + animate: app.ui.animations && ui.ctx().global_style().animation_time > 0.0 && !app.shots.in_flight() && !pointer_selecting, + }); + if app.grid.glide.is_gliding() { + ui.ctx().request_repaint(); + } + app.grid.geo = Some(geo.clone()); + let painter = ui.painter_at(rect); + painter.rect_filled(rect, 0.0, t.grid_bg); + // Everything up to the headers is painted in logical (left-to-right) coordinates. + let mirror_from = painter.add(egui::Shape::Noop); + let mut cf = CfCache::new(); + let quads = if geo.paper.is_some() { + crate::paper_view::paint_pages(app, &painter, &geo, &wb, si, sh, &sel, outline, &mut cf, &t) + } else { + paint_panes(app, &painter, &geo, &wb, si, sh, &sel, outline, &mut cf, &t) + }; + // Objects above cells. A hidden object can't stay selected: Delete or the Format pane would act + // on something the user can't see. + if crate::chartview::objects_hidden(&wb) { + app.selected_chart = None; + } + let objects = crate::chartview::paint_objects(app, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh); + paint_overlays(app, ui, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh, &sel); + if sh.view == gridcraft_engine::model::SheetViewMode::PageBreakPreview { + paint_pages(app, &painter.with_clip_rect(geo.cells), &geo, sh); + } + if geo.paper.is_some() { + crate::paper_view::paint_headers_footers(app, ui, &painter, &geo, &wb, sh); + } + // Headers. + if sh.show_headings { + let headings = table_headings(&geo, sh, sel.active); + paint_headers(&painter, &geo, sh, &sel, &t, &quads, &headings); + paint_freeze_handles(&painter, &geo, sh, &app.grid.drag, &t); + } + crate::outline::paint(&painter, &geo, sh, &t); + if geo.rtl { + let mirror_to = painter.add(egui::Shape::Noop); + mirror_painted(ui.ctx(), painter.layer_id(), mirror_from.0..mirror_to.0, &geo, &objects); + } + if geo.paper.is_some() { + crate::paper_view::paint_rulers(app, &painter, rect, &geo, &t); + } + // Interaction. + let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu, app.grid.fill_menu); + // A pan the grid never saw end (it was hidden mid-drag) must not swallow the next press. + if app.grid.touch_pan && !resp.dragged() && !resp.drag_stopped() { + app.grid.touch_pan = false; + } + if app.grid.touch_pan { + // Skip the frames of a touch pan, its release included. + if !resp.dragged() { + app.grid.touch_pan = false; + } + } else { + crate::paper_view::interact(app, ui, &resp, &geo, sh, &wb); + interact(app, ui, &resp, &geo, sh, &wb); + } + update_cursor(app, ui, &resp, &geo, sh); + crate::text_box::show(app, ui, &geo, sh); + if app.editor.as_ref().is_some_and(|e| !e.from_formula_bar) { + in_cell_editor(app, ui, &geo, sh, &wb); + } + filter_menu(app, ui, &geo, previous_popups.0 == app.grid.filter_menu); + list_picker(app, ui, &geo, previous_popups.1 == app.grid.list_picker); + header_menu(app, ui, previous_popups.2 == app.grid.header_menu); + context_menu(app, ui); + fill_menu(app, ui, previous_popups.3 == app.grid.fill_menu); + fill_options_tag(app, ui, &geo, sh); + object_menu(app, ui); + error_check_tag(app, ui, &geo, sh); + let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) }; + // The track starts below the column headings and frozen rows; the bar's column beside them + // takes its colour too instead of showing a light square in dark mode. + ui.painter().rect_filled(Rect::from_min_max(pos2(track_x.0, rect.top()), pos2(track_x.1, rect.bottom())), 0.0, t.tab_bar); + vertical_scrollbar(app, ui, sh, &geo, track_x, max_y, &t); + if app.session.clipboard.is_some() { + // By the clock, not per frame: a gliding outline repaints more often than the ants step. + app.grid.marching_phase = crate::selection_glide::ants_phase(now); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(60)); + } +} + +/// Normal view: up to four panes (frozen rows/columns), each painted with its own clip. +#[allow(clippy::too_many_arguments)] +fn paint_panes( + app: &SheetApp, + painter: &Painter, + geo: &Geo, + wb: &Workbook, + si: usize, + sh: &Sheet, + sel: &gridcraft_engine::Selection, + outline: Rect, + cf: &mut CfCache, + t: &Tokens, +) -> Vec<((u32, u32), (u32, u32), Rect)> { + // Quadrants: (rows, cols, clip). + let (sr0, sr1) = geo.scroll_rows(sh); + let (sc0, sc1) = geo.scroll_cols(sh); + let fr_rows = (0, geo.fr.saturating_sub(1)); + let fr_cols = (0, geo.fc.saturating_sub(1)); + let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h); + let mut quads = vec![((sr0, sr1), (sc0, sc1), Rect::from_min_max(split, geo.cells.max))]; + if geo.fr > 0 { + quads.push((fr_rows, (sc0, sc1), Rect::from_min_max(pos2(split.x, geo.cells.top()), pos2(geo.cells.right(), split.y)))); + } + if geo.fc > 0 { + quads.push(((sr0, sr1), fr_cols, Rect::from_min_max(pos2(geo.cells.left(), split.y), pos2(split.x, geo.cells.bottom())))); + } + if geo.fr > 0 && geo.fc > 0 { + quads.push((fr_rows, fr_cols, Rect::from_min_max(geo.cells.min, split))); + } + for (rows, cols, clip) in &quads { + if rows.0 > rows.1 || cols.0 > cols.1 { + continue; + } + let p = painter.with_clip_rect(*clip); + paint_quadrant(&p, geo, wb, si, sh, *rows, *cols, cf, t); + paint_selection(&p, geo, sh, sel, outline, t, app, *rows, *cols); + } + // Freeze lines. + if geo.fr > 0 { + painter.line_segment([pos2(geo.cells.left(), split.y), pos2(geo.cells.right(), split.y)], Stroke::new(1.0, Color32::from_gray(150))); + } + if geo.fc > 0 { + painter.line_segment([pos2(split.x, geo.cells.top()), pos2(split.x, geo.cells.bottom())], Stroke::new(1.0, Color32::from_gray(150))); + } + quads +} + +/// The worksheet is painted virtually, so it needs its own scrollbar rather than a +/// ScrollArea containing all million rows. Keep the scroll extent stable during a drag. +fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &Geo, track_x: (f32, f32), limit: f32, t: &Tokens) { + let track = Rect::from_min_max(pos2(track_x.0, geo.cells.top() + geo.frozen_h), pos2(track_x.1, geo.cells.bottom())); + if track.height() <= 0.0 || track.width() <= 0.0 { + return; + } + let visible = track.height() / geo.z; + let scroll = app.view().scroll.y; + let last = sh.used_range().map(|r| r.end.row.saturating_add(10)).unwrap_or(100).clamp(100, MAX_ROWS - 1); + let content = geo.scroll_top_of(sh, last); + let extent = (content - visible).max(scroll).max(0.0).min(limit); + let id = ui.scope_id().with("vscroll"); + let resp = ui.interact(track, id, Sense::click_and_drag()); + let drag_id = id.with("extent"); + if resp.drag_started() { + ui.ctx().data_mut(|d| d.insert_temp(drag_id, extent)); + } + let extent = if resp.dragged() { ui.ctx().data(|d| d.get_temp::(drag_id)).unwrap_or(extent) } else { extent }; + let thumb_h = (track.height() * visible / (extent + visible)).clamp(24.0_f32.min(track.height()), track.height()); + let travel = track.height() - thumb_h; + let thumb_top = track.top() + if extent > 0.0 { travel * (scroll / extent).clamp(0.0, 1.0) } else { 0.0 }; + let thumb = Rect::from_min_size(pos2(track.left() + 3.0, thumb_top), vec2((track.width() - 6.0).max(0.0), thumb_h)); + ui.painter().rect_filled(track, 0.0, t.tab_bar); + ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled }); + let mut next = scroll; + if resp.dragged() && travel > 0.0 { + next += resp.drag_delta().y / travel * extent; + } else if resp.clicked() + && let Some(p) = resp.interact_pointer_pos() + { + if p.y < thumb.top() { + next -= visible; + } else if p.y > thumb.bottom() { + next += visible; + } + } + if resp.hovered() { + next -= ui.input(|i| i.smooth_scroll_delta.y) / geo.z; + } + next = next.clamp(0.0, limit); + if next != scroll { + if let Some(v) = app.view_mut() { + v.scroll.y = next; + } + ui.ctx().request_repaint(); + } +} + +/// The horizontal counterpart of `vertical_scrollbar`, drawn in the sheet tab bar as in Excel. The tab +/// bar is laid out before the grid, so this reads the last frame's geometry and leaves clamping the +/// far end to the grid, which runs later in the same frame. +pub(crate) fn horizontal_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, track: Rect, t: &Tokens) { + app.grid.hscroll = Some(track); + if track.height() <= 0.0 || track.width() <= 0.0 { + return; + } + let Some(geo) = app.grid.geo.clone() else { return }; + let Some(wb) = app.session.active().map(|d| d.wb.clone()) else { return }; + let Some(sh) = wb.active() else { return }; + let visible = ((geo.cells.width() - geo.frozen_w) / geo.z).max(0.0); + let scroll = app.view().scroll.x; + let last = sh.used_range().map(|r| r.end.col.saturating_add(10)).unwrap_or(26).clamp(26, MAX_COLS - 1); + let content = geo.scroll_left_of(sh, last); + let extent = (content - visible).max(scroll).max(0.0); + let id = ui.scope_id().with("hscroll"); + let resp = ui.interact(track, id, Sense::click_and_drag()); + let drag_id = id.with("extent"); + if resp.drag_started() { + ui.ctx().data_mut(|d| d.insert_temp(drag_id, extent)); + } + let extent = if resp.dragged() { ui.ctx().data(|d| d.get_temp::(drag_id)).unwrap_or(extent) } else { extent }; + let thumb_w = (track.width() * visible / (extent + visible).max(1.0)).clamp(24.0_f32.min(track.width()), track.width()); + let travel = track.width() - thumb_w; + let offset = if extent > 0.0 { travel * (scroll / extent).clamp(0.0, 1.0) } else { 0.0 }; + // A right-to-left sheet scrolls towards the left, so its thumb starts at the right end. + let dir = if geo.rtl { -1.0 } else { 1.0 }; + let thumb_left = if geo.rtl { track.right() - thumb_w - offset } else { track.left() + offset }; + let thumb = Rect::from_min_size(pos2(thumb_left, track.top() + 3.0), vec2(thumb_w, (track.height() - 6.0).max(0.0))); + ui.painter().rect_filled(track, 0.0, t.tab_bar); + ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled }); + let mut next = scroll; + if resp.dragged() && travel > 0.0 { + next += dir * resp.drag_delta().x / travel * extent; + } else if resp.clicked() + && let Some(p) = resp.interact_pointer_pos() + { + if p.x < thumb.left() { + next -= dir * visible; + } else if p.x > thumb.right() { + next += dir * visible; + } + } + if resp.hovered() { + let d = ui.input(|i| i.smooth_scroll_delta); + next -= (if d.x != 0.0 { dir * d.x } else { d.y }) / geo.z; + } + next = next.max(0.0); + if next != scroll { + if let Some(v) = app.view_mut() { + v.scroll.x = next; + } + ui.ctx().request_repaint(); + } +} + +pub(crate) fn paint_headers( + p: &Painter, + geo: &Geo, + sh: &Sheet, + sel: &gridcraft_engine::Selection, + t: &Tokens, + quads: &[((u32, u32), (u32, u32), Rect)], + headings: &[TableHeading], +) { + let font = theme::ui_font(heading_font_size(geo.z)); + // The headers' own area: inside the outline bands. + let r = Rect::from_min_max(pos2(geo.head_left(), geo.head_top()), geo.rect.max); + let top = Rect::from_min_max(pos2(geo.cells.left(), r.top()), pos2(r.right(), geo.cells.top())); + let left = Rect::from_min_max(pos2(r.left(), geo.cells.top()), pos2(geo.cells.left(), r.bottom())); + p.rect_filled(top, 0.0, t.header_bg); + p.rect_filled(left, 0.0, t.header_bg); + let bounds = sel.bounds(); + let col_sel = |c: u32| sel.ranges.iter().any(|x| c >= x.start.col && c <= x.end.col); + let row_sel = |rr: u32| sel.ranges.iter().any(|x| rr >= x.start.row && rr <= x.end.row); + let full_col = |c: u32| sel.ranges.iter().any(|x| x.is_full_cols() && c >= x.start.col && c <= x.end.col); + let full_row = |rr: u32| sel.ranges.iter().any(|x| x.is_full_rows() && rr >= x.start.row && rr <= x.end.row); + let _ = bounds; + // Hidden columns and rows leave a double divider where they sit, as in Excel. + let hidden_mark = Stroke::new(1.0, t.header_text.gamma_multiply(0.6)); + let mut done_cols = std::collections::HashSet::new(); + let mut done_rows = std::collections::HashSet::new(); + for (rows, cols, clip) in quads { + let pc = p.with_clip_rect(Rect::from_min_max(pos2(clip.left(), top.top()), pos2(clip.right(), top.bottom()))); + if cols.0 <= cols.1 { + for c in cols.0..=cols.1 { + if !done_cols.insert((c, clip.left() as i32)) { + continue; + } + let w = sh.col_width(c) * geo.z; + if w <= 0.0 { + continue; + } + let x = geo.x(sh, c); + let cr = Rect::from_min_size(pos2(x, top.top()), vec2(w, top.height())); + if col_sel(c) { + pc.rect_filled(cr, 0.0, if full_col(c) { t.header_all_bg } else { t.header_sel_bg }); + pc.line_segment([pos2(cr.left(), cr.bottom() - 1.0), pos2(cr.right(), cr.bottom() - 1.0)], Stroke::new(2.0, t.accent)); + } + pc.line_segment([pos2(cr.right() - 0.5, cr.top() + 3.0), pos2(cr.right() - 0.5, cr.bottom())], Stroke::new(1.0, t.header_line)); + let (before, after) = hidden_around(c, |i| sh.col_width(i)); + for x in [before.then_some(cr.left()), after.then_some(cr.right())].into_iter().flatten() { + for dx in [-1.5, 1.5] { + pc.line_segment([pos2(x + dx, cr.top() + 3.0), pos2(x + dx, cr.bottom())], hidden_mark); + } + } + let color = if col_sel(c) { t.header_sel_text } else { t.header_text }; + match headings.iter().find(|h| h.col == c) { + Some(h) => { + // The name is cut off at the button, as a cell's text is at its neighbour. + let end = h.button.map_or(cr.right(), |b| b.left()) - 2.0; + let room = Rect::from_min_max(cr.min, pos2(end.max(cr.left()), cr.bottom())).intersect(pc.clip_rect()); + let pad = 4.0 * (geo.z / DISPLAY_SCALE).max(0.75); + pc.with_clip_rect(room).text(pos2(cr.left() + pad, cr.center().y), Align2::LEFT_CENTER, &h.name, font.clone(), color); + if let Some(b) = h.button { + paint_filter_button(&pc, b, h.filtered); + } + } + None => { + pc.text(cr.center(), Align2::CENTER_CENTER, col_to_letters(c), font.clone(), color); + } + } + } + } + let pr = p.with_clip_rect(Rect::from_min_max(pos2(left.left(), clip.top()), pos2(left.right(), clip.bottom()))); + if rows.0 <= rows.1 { + for rr in rows.0..=rows.1 { + if !done_rows.insert((rr, clip.top() as i32)) { + continue; + } + let h = sh.row_height(rr) * geo.z; + if h <= 0.0 { + continue; + } + let y = geo.y(sh, rr); + let cr = Rect::from_min_size(pos2(left.left(), y), vec2(left.width(), h)); + if row_sel(rr) { + pr.rect_filled(cr, 0.0, if full_row(rr) { t.header_all_bg } else { t.header_sel_bg }); + pr.line_segment([pos2(cr.right() - 1.0, cr.top()), pos2(cr.right() - 1.0, cr.bottom())], Stroke::new(2.0, t.accent)); + } + pr.line_segment([pos2(cr.left() + 3.0, cr.bottom() - 0.5), pos2(cr.right(), cr.bottom() - 0.5)], Stroke::new(1.0, t.header_line)); + let (before, after) = hidden_around(rr, |i| sh.row_height(i)); + for y in [before.then_some(cr.top()), after.then_some(cr.bottom())].into_iter().flatten() { + for dy in [-1.5, 1.5] { + pr.line_segment([pos2(cr.left() + 3.0, y + dy), pos2(cr.right(), y + dy)], hidden_mark); + } + } + let color = if row_sel(rr) { t.header_sel_text } else { t.header_text }; + pr.text(cr.center(), Align2::CENTER_CENTER, (rr as u64 + 1).to_string(), font.clone(), color); + } + } + } + // Corner (select all) and edges. + let corner = Rect::from_min_max(r.min, geo.cells.min); + p.rect_filled(corner, 0.0, t.header_bg); + let tri = vec![ + pos2(corner.right() - 3.0, corner.top() + 5.0), + pos2(corner.right() - 3.0, corner.bottom() - 3.0), + pos2(corner.left() + 7.0, corner.bottom() - 3.0), + ]; + p.add(egui::Shape::convex_polygon(tri, Color32::from_gray(190), Stroke::NONE)); + p.line_segment([pos2(r.left(), geo.cells.top() - 0.5), pos2(r.right(), geo.cells.top() - 0.5)], Stroke::new(1.0, t.header_line)); + p.line_segment([pos2(geo.cells.left() - 0.5, r.top()), pos2(geo.cells.left() - 0.5, r.bottom())], Stroke::new(1.0, t.header_line)); +} + +/// Whether the line before / after visible line `i` is hidden (zero size), so its header edges +/// get the hidden marker. Line 0 has nothing before it. +pub(super) fn hidden_around(i: u32, size: impl Fn(u32) -> f32) -> (bool, bool) { + (i > 0 && size(i - 1) <= 0.0, i < u32::MAX && size(i + 1) <= 0.0) +} + +#[allow(clippy::too_many_arguments)] +/// Paints the validation arrow and input message, notes, the hyperlink tooltip and trace precedent/dependent arrows. +pub(crate) fn paint_overlays( + app: &mut SheetApp, + ui: &egui::Ui, + p: &Painter, + geo: &Geo, + wb: &Workbook, + si: usize, + sh: &Sheet, + sel: &gridcraft_engine::Selection, +) { + // Validation dropdown arrow for the active cell. + if app.editor.is_none() + && let Some(r) = validation_arrow(sh, geo, sel.active) + { + let t = Tokens::get(p.ctx()); + p.rect_filled(r, 2.0, t.sheet_control_bg); + p.rect_stroke(r, 2.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Inside); + crate::icons::paint_on_sheet(p, r.shrink(3.0), crate::icons::Icon::Chevron, t.sheet_control_text); + } + // Input message of the active cell's validation, hidden while its list is open: it would cover the items. + if app.editor.is_none() + && app.grid.list_picker != Some(sel.active) + && let Some(dv) = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(sel.active))) + && dv.show_input + && !(dv.input_title.is_empty() && dv.input_message.is_empty()) + && !geo.hidden_under_frozen(sh, sel.active) + { + let r = geo.cell_rect(sh, sel.active); + let (at, pivot) = (geo.mirror_pos(pos2(r.left() + 8.0, r.bottom() + 6.0)), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP }); + egui::Area::new(egui::Id::unique("dv_input")).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show( + ui.ctx(), + |ui| { + egui::Frame::popup(ui.style()).fill(Color32::from_rgb(0xFF, 0xFF, 0xE1)).show(ui, |ui| { + ui.set_max_width(220.0); + if !dv.input_title.is_empty() { + ui.label(egui::RichText::new(&dv.input_title).strong().color(Color32::BLACK)); + } + ui.label(egui::RichText::new(&dv.input_message).color(Color32::BLACK)); + }); + }, + ); + } + // Notes: shown on hover (or always when marked visible). + let hover = app.grid.hover_cell; + let all_notes = app.session.show_all_notes; + for (c, cm) in &sh.comments { + let show = cm.visible || (all_notes && !cm.threaded) || hover == Some(*c); + if !show || app.grid.drag != Drag::None { + continue; + } + let r = geo.cell_rect(sh, *c); + if !p.clip_rect().intersects(r) || geo.hidden_under_frozen(sh, *c) { + continue; + } + let at = pos2(r.right() + 10.0, r.top()); + p.line_segment([r.right_top(), at + vec2(0.0, 6.0)], Stroke::new(1.0, Color32::from_gray(90))); + let (at, pivot) = (geo.mirror_pos(at), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP }); + egui::Area::new(egui::Id::unique(("note", c.row, c.col))).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show( + ui.ctx(), + |ui| { + egui::Frame::popup(ui.style()).fill(if cm.threaded { Color32::WHITE } else { Color32::from_rgb(0xFF, 0xFF, 0xE1) }).show(ui, |ui| { + ui.set_max_width(240.0); + ui.label(egui::RichText::new(format!("{}:", cm.author)).strong().color(Color32::BLACK)); + ui.label(egui::RichText::new(&cm.text).color(Color32::BLACK)); + for (a, t) in &cm.replies { + ui.separator(); + ui.label(egui::RichText::new(format!("{a}: {t}")).color(Color32::BLACK)); + } + }); + }, + ); + } + // Hyperlink tooltip. + if let Some(h) = hover.and_then(|c| sh.hyperlinks.get(&c)) + && app.editor.is_none() + { + let tip = h.tooltip.clone().unwrap_or_else(|| format!("{}\nClick once to follow. Click and hold to select this cell.", h.target)); + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique("link_tip"), egui::PopupAnchor::Pointer).show(|ui| { + ui.label(tip); + }); + } + // Trace precedents / dependents arrows. + if let Some(trace) = &app.grid.trace + && app.session.active().is_some_and(|d| d.uid == trace.uid && d.revision == trace.revision) + && trace.sheet == si + && let Some(items) = trace.items.as_array() + { + let blue = Color32::from_rgb(0x1F, 0x5F, 0xC9); + let active = geo.cell_rect(sh, trace.cell).center(); + for it in items { + if it["sheet"].as_str().is_some_and(|s| !s.eq_ignore_ascii_case(&sh.name)) { + continue; + } + let target = it["range"].as_str().or(it["cell"].as_str()).and_then(RangeRef::parse); + let Some(t) = target else { continue }; + let tr = geo.range_rect(sh, t); + if !t.is_single() { + for pane in geo.range_panes(t) { + p.with_clip_rect(pane.intersect(p.clip_rect())).rect_stroke(tr, 0.0, Stroke::new(1.5, blue), StrokeKind::Inside); + } + } + let (from, to) = if it.get("range").is_some() { (tr.center(), active) } else { (active, tr.center()) }; + p.line_segment([from, to], Stroke::new(1.5, blue)); + p.circle_filled(from, 3.0, blue); + let d = (to - from).normalized(); + let n = vec2(-d.y, d.x); + p.add(egui::Shape::convex_polygon(vec![to, to - d * 9.0 + n * 4.0, to - d * 9.0 - n * 4.0], blue, Stroke::NONE)); + } + } + let _ = (wb, si); +} + +fn paint_pages(app: &mut SheetApp, p: &Painter, geo: &Geo, sh: &Sheet) { + let Some(d) = app.session.active() else { return }; + let key = (d.uid, d.revision, d.wb.active_sheet()); + if app.grid.pages.as_ref().is_none_or(|(k, _)| *k != key) { + let pages: Vec<(RangeRef, u32)> = app + .session + .run("file.printPreview", json!({})) + .ok() + .and_then(|v| { + v["pageList"].as_array().map(|a| { + a.iter() + .filter(|pg| pg["sheet"].as_str() == Some(sh.name.as_str())) + .filter_map(|pg| Some((RangeRef::parse(pg["range"].as_str()?)?, pg["page"].as_u64()? as u32))) + .collect() + }) + }) + .unwrap_or_default(); + app.grid.pages = Some((key, pages)); + } + let Some((_, pages)) = &app.grid.pages else { return }; + if pages.is_empty() { + return; + } + // Grey everything, then clear the printed pages. + let mut printed = pages[0].0; + for (r, _) in pages { + printed = printed.union(r); + } + let pr = geo.range_rect(sh, printed); + let grey = Color32::from_black_alpha(70); + let c = p.clip_rect(); + for r in [ + Rect::from_min_max(c.min, pos2(c.right(), pr.top())), + Rect::from_min_max(pos2(c.left(), pr.bottom()), c.max), + Rect::from_min_max(pos2(c.left(), pr.top()), pos2(pr.left(), pr.bottom())), + Rect::from_min_max(pos2(pr.right(), pr.top()), pos2(c.right(), pr.bottom())), + ] { + if r.is_positive() { + p.rect_filled(r, 0.0, grey); + } + } + let blue = Color32::from_rgb(0x2E, 0x6F, 0xD8); + for (r, n) in pages { + let rr = geo.range_rect(sh, *r); + if !rr.intersects(c) { + continue; + } + p.extend(egui::Shape::dashed_line( + &[rr.left_top(), rr.right_top(), rr.right_bottom(), rr.left_bottom(), rr.left_top()], + Stroke::new(2.0, blue), + 6.0, + 3.0, + )); + p.text( + rr.center(), + Align2::CENTER_CENTER, + format!("Page {n}"), + theme::ui_bold((rr.height().min(rr.width()) / 6.0).clamp(14.0, 72.0)), + Color32::from_black_alpha(45), + ); + } + p.rect_stroke(pr, 0.0, Stroke::new(3.0, blue), StrokeKind::Outside); +} + +#[cfg(test)] +mod trace_arrow_tests { + use super::*; + + fn app() -> SheetApp { + let mut s = gridcraft_engine::Session::new(); + s.new_workbook(); + SheetApp::new(s, Default::default()) + } + + fn select(app: &mut SheetApp, cell: &str) { + app.run("selection.set", json!({"cell": cell})).unwrap(); + } + + /// What the ribbon's Trace Precedents / Trace Dependents buttons do. + fn trace(app: &mut SheetApp, id: &str) { + let r = app.run(id, json!({})).unwrap(); + app.grid.trace = Trace::new(app, r); + } + + /// The arrows `paint_overlays` draws on the active sheet, as (tail, head) points. + fn arrows(app: &mut SheetApp) -> (Geo, Vec<(Pos2, Pos2)>) { + let d = app.session.active().unwrap(); + let (wb, si, sel) = (d.wb.clone(), d.wb.active_sheet(), d.selection.clone()); + let sh = wb.sheet(si).unwrap(); + let geo = Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0)), vec2(0.0, 0.0)); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let p = ui.painter().clone(); + paint_overlays(app, ui, &p, &geo, &wb, si, sh, &sel); + }); + // No renderer consumes the font atlas upload here. + out.textures_delta.clear(); + let blue = Color32::from_rgb(0x1F, 0x5F, 0xC9); + let mut lines = Vec::new(); + for s in out.shapes { + if let egui::Shape::LineSegment { points, stroke } = s.shape + && stroke.color == blue + { + lines.push((points[0], points[1])); + } + } + (geo, lines) + } + + fn center(app: &SheetApp, geo: &Geo, cell: &str) -> Pos2 { + let d = app.session.active().unwrap(); + let sh = d.wb.sheet(d.wb.active_sheet()).unwrap(); + geo.cell_rect(sh, CellRef::parse(cell).unwrap()).center() + } + + #[test] + fn precedent_arrows_stay_on_the_traced_cell_when_the_selection_moves() { + let mut app = app(); + for (c, v) in [("B2", "1"), ("B3", "2"), ("D2", "1"), ("D3", "2"), ("F7", "=B2+B3+SUM(D2:D3)")] { + app.run("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + select(&mut app, "F7"); + trace(&mut app, "formulas.tracePrecedents"); + select(&mut app, "H7"); + let (geo, lines) = arrows(&mut app); + assert_eq!(lines.len(), 3, "B2, B3 and D2:D3: {lines:?}"); + let f7 = center(&app, &geo, "F7"); + assert!(lines.iter().all(|&(_, head)| head == f7), "every arrow points at F7, not at the active H7: {lines:?}"); + } + + #[test] + fn dependent_arrows_stay_on_the_traced_cell_when_the_selection_moves() { + let mut app = app(); + for (c, v) in [("B2", "1"), ("D4", "=B2*2"), ("E6", "=B2+1")] { + app.run("cell.set", json!({"cell": c, "input": v})).unwrap(); + } + select(&mut app, "B2"); + trace(&mut app, "formulas.traceDependents"); + select(&mut app, "C9"); + let (geo, lines) = arrows(&mut app); + assert_eq!(lines.len(), 2, "D4 and E6: {lines:?}"); + let b2 = center(&app, &geo, "B2"); + assert!(lines.iter().all(|&(tail, _)| tail == b2), "every arrow starts at B2, not at the active C9: {lines:?}"); + } + + #[test] + fn arrows_stay_on_the_sheet_of_the_traced_cell() { + let mut app = app(); + app.run("home.insertSheet", json!({"name": "Two"})).unwrap(); + app.run("cell.set", json!({"sheet": "Sheet1", "cell": "B2", "input": "1"})).unwrap(); + app.run("sheet.activate", json!({"sheet": "Two"})).unwrap(); + app.run("cell.set", json!({"cell": "F7", "input": "=Sheet1!B2"})).unwrap(); + select(&mut app, "F7"); + trace(&mut app, "formulas.tracePrecedents"); + // The precedent lives on Sheet1, but the arrow belongs to Two's F7. + app.run("sheet.activate", json!({"sheet": "Sheet1"})).unwrap(); + assert!(arrows(&mut app).1.is_empty(), "no arrow on Sheet1 from B2 to where F7 would be"); + } + + #[test] + fn inserting_a_row_removes_the_arrows() { + let mut app = app(); + app.run("cell.set", json!({"cell": "B2", "input": "1"})).unwrap(); + app.run("cell.set", json!({"cell": "F7", "input": "=B2"})).unwrap(); + select(&mut app, "F7"); + trace(&mut app, "formulas.tracePrecedents"); + assert_eq!(arrows(&mut app).1.len(), 1); + app.run("home.insertRows", json!({"rows": "4"})).unwrap(); + assert!(arrows(&mut app).1.is_empty(), "F7 is now F8: the old arrows would point at the wrong cell"); + } + + #[test] + fn arrows_stay_in_their_workbook() { + let mut app = app(); + app.session.new_workbook(); + // Two books with the same cells and the same revision: only the document tells them apart. + for book in [1, 0] { + app.session.set_active(book); + app.run("cell.set", json!({"cell": "B2", "input": "1"})).unwrap(); + app.run("cell.set", json!({"cell": "F7", "input": "=B2"})).unwrap(); + } + select(&mut app, "F7"); + trace(&mut app, "formulas.tracePrecedents"); + assert_eq!(arrows(&mut app).1.len(), 1); + app.session.set_active(1); + let revisions: Vec = app.session.documents().iter().map(|d| d.revision).collect(); + assert_eq!(revisions[0], revisions[1]); + assert!(arrows(&mut app).1.is_empty(), "Book1's arrows are not drawn on Book2"); + } +} diff --git a/crates/ui-egui/src/grid/tables.rs b/crates/ui-egui/src/grid/tables.rs new file mode 100644 index 000000000..a283b2b22 --- /dev/null +++ b/crates/ui-egui/src/grid/tables.rs @@ -0,0 +1,87 @@ +use super::geo::Geo; +use super::*; + +pub fn filter_button_rect(geo: &Geo, sh: &Sheet, c: CellRef) -> Rect { + let rect = geo.cell_rect(sh, c); + // Keep the button within the same header slot that AutoFit reserves. + let pad = 2.0 * geo.z; + let s = (rect.height() - 4.0).clamp(10.0, 18.0).min((sh.filter_button_space(c) * geo.z - pad).max(0.0)); + Rect::from_min_size(pos2(rect.right() - s - pad, rect.bottom() - s - pad), vec2(s, s)) +} + +/// A Table column heading that shows the column's header name in place of its letters. +#[derive(Clone, Debug)] +pub struct TableHeading { + pub col: u32, + /// The table's header row, which the filter menu is keyed by. + pub header_row: u32, + pub name: String, + /// The heading slot over the column (logical coordinates). + pub rect: Rect, + /// The filter button, when the table shows filter buttons. + pub button: Option, + /// The column is filtered (the button shows the funnel). + pub filtered: bool, +} + +/// The Table whose header names replace the column letters: the one holding the active cell, +/// with its header row scrolled out of view and some of its rows still in view (Excel, #1423). +pub fn heading_table<'a>(geo: &Geo, sh: &'a Sheet, active: CellRef) -> Option<&'a gridcraft_engine::model::Table> { + // Page Layout view keeps the letters. + if geo.paper.is_some() || geo.header_h <= 0.0 { + return None; + } + let tb = sh.tables.iter().find(|t| t.header_row && t.range.contains(active))?; + let header = tb.range.start.row; + // A header row in the frozen rows is always on screen. + if header < geo.fr { + return None; + } + let top = geo.cells.top() + geo.frozen_h; + let bottom = |r: u32| geo.y(sh, r) + sh.row_height(r) * geo.z; + (bottom(header) <= top + 0.5 && bottom(tb.range.end.row) > top + 0.5).then_some(tb) +} + +/// The headings of `heading_table`'s visible columns. +pub fn table_headings(geo: &Geo, sh: &Sheet, active: CellRef) -> Vec { + let Some(tb) = heading_table(geo, sh, active) else { return Vec::new() }; + let af = sh.autofilter.as_ref().filter(|af| af.range == tb.range); + let mut out = Vec::new(); + for col in tb.range.start.col..=tb.range.end.col { + let w = sh.col_width(col) * geo.z; + let off = col - tb.range.start.col; + let Some(column) = tb.columns.get(off as usize) else { continue }; + if w <= 0.0 { + continue; + } + let rect = Rect::from_min_size(pos2(geo.x(sh, col), geo.head_top()), vec2(w, geo.header_h)); + // Clear of the 4-point column resize edge, and never wider than the column. + let s = (rect.height() - 6.0).clamp(10.0, 16.0).min(w - 7.0); + let button = (tb.filter_button && s > 0.0).then(|| Rect::from_min_size(pos2(rect.right() - s - 5.0, rect.center().y - s / 2.0), vec2(s, s))); + let filtered = tb.filter.iter().any(|(o, _)| *o == off) || af.is_some_and(|af| af.criteria.iter().any(|(o, _)| *o == off)); + out.push(TableHeading { col, header_row: tb.range.start.row, name: column.name.clone(), rect, button, filtered }); + } + out +} + +pub(super) fn paint_filter_button(p: &Painter, r: Rect, filtered: bool) { + let t = Tokens::get(p.ctx()); + p.rect_filled(r, 2.0, t.sheet_control_bg); + p.rect_stroke(r, 2.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Inside); + crate::icons::paint_on_sheet( + p, + r.shrink(1.5), + if filtered { crate::icons::Icon::Filter } else { crate::icons::Icon::Chevron }, + t.sheet_control_text, + ); +} + +pub(super) fn is_filter_button(sh: &Sheet, geo: &Geo, p: Pos2) -> bool { + let c = geo.cell_at(sh, p); + sh.has_filter_button(c) && filter_button_rect(geo, sh, c).contains(p) +} + +/// The table whose header cell (`row`, `col`) was clicked, not just the first table in the column. +pub(super) fn filter_table_range(sh: &Sheet, col: u32, row: u32) -> Option { + sh.tables.iter().find(|t| t.range.start.row == row && col >= t.range.start.col && col <= t.range.end.col).map(|t| t.range) +} From 9eb2a3d68e1f1e728569af7aeda20809a3c19144 Mon Sep 17 00:00:00 2001 From: skylar Date: Mon, 26 Oct 2026 14:13:04 -0600 Subject: [PATCH 47/76] ci: require nextest 0.9.150 Use the fixed release for GHSA-vc25-qg58-w57c. --- .config/nextest.toml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .config/nextest.toml diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 000000000..2a4cd2cb9 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,3 @@ +# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI +# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET). +nextest-version = "0.9.150" From 228cf80593fd770c9703a7219a006fe9f13652a2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:43:39 +0000 Subject: [PATCH 48/76] refactor(ui-egui): move dialogs.rs items to dialogs/ --- crates/ui-egui/src/dialogs.rs | 2420 +---------------- crates/ui-egui/src/dialogs/about.rs | 26 + crates/ui-egui/src/dialogs/analysis.rs | 344 +++ crates/ui-egui/src/dialogs/charts.rs | 187 ++ crates/ui-egui/src/dialogs/command_search.rs | 88 + .../ui-egui/src/dialogs/cond_format_rules.rs | 28 + crates/ui-egui/src/dialogs/data.rs | 327 +++ crates/ui-egui/src/dialogs/find.rs | 151 + crates/ui-egui/src/dialogs/format_cells.rs | 227 ++ crates/ui-egui/src/dialogs/insert_function.rs | 72 + crates/ui-egui/src/dialogs/macros.rs | 40 + crates/ui-egui/src/dialogs/names.rs | 134 + crates/ui-egui/src/dialogs/paste.rs | 139 + crates/ui-egui/src/dialogs/show.rs | 577 ++++ crates/ui-egui/src/dialogs/spelling.rs | 54 + crates/ui-egui/src/dialogs/zoom.rs | 39 + 16 files changed, 2459 insertions(+), 2394 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/about.rs create mode 100644 crates/ui-egui/src/dialogs/analysis.rs create mode 100644 crates/ui-egui/src/dialogs/charts.rs create mode 100644 crates/ui-egui/src/dialogs/command_search.rs create mode 100644 crates/ui-egui/src/dialogs/cond_format_rules.rs create mode 100644 crates/ui-egui/src/dialogs/data.rs create mode 100644 crates/ui-egui/src/dialogs/find.rs create mode 100644 crates/ui-egui/src/dialogs/format_cells.rs create mode 100644 crates/ui-egui/src/dialogs/insert_function.rs create mode 100644 crates/ui-egui/src/dialogs/macros.rs create mode 100644 crates/ui-egui/src/dialogs/names.rs create mode 100644 crates/ui-egui/src/dialogs/paste.rs create mode 100644 crates/ui-egui/src/dialogs/show.rs create mode 100644 crates/ui-egui/src/dialogs/spelling.rs create mode 100644 crates/ui-egui/src/dialogs/zoom.rs diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8d8e71665..3a6003f0f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -13,6 +13,32 @@ use serde_json::{Map, Value as Json, json}; use crate::SheetApp; use crate::theme; +mod about; +mod analysis; +mod charts; +mod command_search; +mod cond_format_rules; +mod data; +mod find; +mod format_cells; +mod insert_function; +mod macros; +mod names; +mod paste; +mod show; +mod spelling; +mod zoom; + +use analysis::toolpak_defaults; +use charts::select_chart; +use data::{abs, data_validation_fields, remove_duplicates_range, validation_defaults}; +use find::go_to_entries; +pub(crate) use names::insert_name; +use names::pasteable_names; +pub(crate) use show::build_params; +pub use show::show; +use zoom::zoom_preset; + #[derive(Clone, Debug, PartialEq)] pub enum Field { Text { key: &'static str, label: &'static str }, @@ -999,2400 +1025,6 @@ impl Dialog { } } -/// Initial values of the Data Validation form: the rule stored on the active cell, so OK without -/// edits keeps it; a plain list rule when the cell has none. -fn validation_defaults(app: &SheetApp) -> Json { - let mut v = json!({"type": "list", "operator": "between", "ignoreBlank": true, "dropdown": true, "errorStyle": "stop"}); - let Some(doc) = app.session.active() else { return v }; - let Some(dv) = doc.wb.active().and_then(|s| s.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(doc.selection.active)))) else { - return v; - }; - // The enums serialize as their variant names; the command takes them in camelCase. - let camel = |x: Json| { - let name = x.as_str().unwrap_or_default(); - let mut chars = name.chars(); - chars.next().map(|c| c.to_lowercase().chain(chars).collect::()).unwrap_or_default() - }; - v["type"] = json!(camel(json!(dv.kind))); - v["operator"] = json!(camel(json!(dv.op))); - v["errorStyle"] = json!(camel(json!(dv.error_style))); - v["formula1"] = json!(dv.f1); - if let Some(f2) = &dv.f2 { - v["formula2"] = json!(f2); - } - v["ignoreBlank"] = json!(dv.allow_blank); - v["dropdown"] = json!(dv.in_cell_dropdown); - v["inputTitle"] = json!(dv.input_title); - v["inputMessage"] = json!(dv.input_message); - v["errorTitle"] = json!(dv.error_title); - v["errorMessage"] = json!(dv.error_message); - v -} - -/// The range Remove Duplicates starts with. A block selection is used as given; with only the -/// cursor placed, the table's data rows or the current region, as the command picks when it gets -/// no range (a single-cell range would remove nothing). -fn remove_duplicates_range(app: &SheetApp, sel: &str) -> String { - let Some(d) = app.session.active() else { return sel.to_string() }; - let Some(sh) = d.wb.active() else { return sel.to_string() }; - if !d.selection.current().is_single() { - return sel.to_string(); - } - let range = match sh.table_at(d.selection.active) { - Some(t) => t.data_range().map(|dr| gridcraft_engine::core::RangeRef::new(t.range.start, dr.end)).unwrap_or(t.range), - None => gridcraft_engine::selection::current_region(sh, d.selection.active), - }; - range.a1() -} - -fn abs(r: &gridcraft_engine::core::RangeRef) -> String { - let a = |c: gridcraft_engine::core::CellRef| format!("${}${}", gridcraft_engine::core::col_to_letters(c.col), c.row + 1); - if r.is_single() { a(r.start) } else { format!("{}:{}", a(r.start), a(r.end)) } -} - -/// The criterion controls in Excel's Settings tab depend on both Allow and Data. -/// `formula1` is the sole source for a list or custom rule; `formula2` is a -/// second bound only for Between and Not between. -fn data_validation_fields(kind: &str, operator: &str) -> Vec { - use Field::*; - let mut fields = vec![Choice { - key: "type", - label: "Allow", - options: vec![ - ("any", "Any value"), - ("whole", "Whole number"), - ("decimal", "Decimal"), - ("list", "List"), - ("date", "Date"), - ("time", "Time"), - ("textLength", "Text length"), - ("custom", "Custom"), - ], - }]; - let two_bounds = matches!(operator, "between" | "notBetween"); - match kind { - "list" => { - fields.push(Text { key: "formula1", label: "Source:" }); - fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); - fields.push(Check { key: "dropdown", label: "In-cell dropdown" }); - } - "custom" => { - fields.push(Text { key: "formula1", label: "Formula:" }); - fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); - } - "whole" | "decimal" | "date" | "time" | "textLength" => { - fields.push(Choice { - key: "operator", - label: "Data", - options: vec![ - ("between", "between"), - ("notBetween", "not between"), - ("equal", "equal to"), - ("notEqual", "not equal to"), - ("greater", "greater than"), - ("less", "less than"), - ("greaterOrEqual", "greater than or equal to"), - ("lessOrEqual", "less than or equal to"), - ], - }); - let lower = matches!(operator, "greater" | "greaterOrEqual"); - let (first, second) = match kind { - "date" => ( - if two_bounds || lower { - "Start date:" - } else if matches!(operator, "less" | "lessOrEqual") { - "End date:" - } else { - "Date:" - }, - "End date:", - ), - "time" => ( - if two_bounds || lower { - "Start time:" - } else if matches!(operator, "less" | "lessOrEqual") { - "End time:" - } else { - "Time:" - }, - "End time:", - ), - _ => ( - if two_bounds || lower { - "Minimum:" - } else if matches!(operator, "less" | "lessOrEqual") { - "Maximum:" - } else { - "Value:" - }, - "Maximum:", - ), - }; - fields.push(Text { key: "formula1", label: first }); - if two_bounds { - fields.push(Text { key: "formula2", label: second }); - } - fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); - } - _ => {} - } - fields.push(Text { key: "inputMessage", label: "Input message:" }); - fields.push(Choice { - key: "errorStyle", - label: "Error style", - options: vec![("stop", "Stop"), ("warning", "Warning"), ("information", "Information")], - }); - fields.push(Text { key: "errorMessage", label: "Error message:" }); - fields -} - -fn data_validation_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any"); - let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between"); - d.fields = data_validation_fields(kind, operator); - form_fields(ui, d); - ok_cancel(ui, confirm, open); -} - -fn advanced_filter_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - use Field::*; - let copy = d.values.get("action").and_then(Json::as_str) == Some("copy"); - d.fields = vec![ - Choice { - key: "action", - label: "Action", - options: vec![("inPlace", "Show matching rows in place"), ("copy", "Copy matching rows elsewhere")], - }, - Text { key: "range", label: "List range:" }, - Text { key: "criteria", label: "Criteria range (optional):" }, - ]; - if copy { - d.fields.push(Text { key: "copyTo", label: "Copy to:" }); - } - d.fields.push(Check { key: "unique", label: "Unique rows only" }); - form_fields(ui, d); - // Constrain the button row: the shared right-to-left layout otherwise - // consumes the window's remaining height instead of fitting the buttons. - ui.allocate_ui(vec2(ui.available_width(), ui.spacing().interact_size.y + 8.0), |ui| ok_cancel(ui, confirm, open)); -} - -/// Builds the command params from form values for dialogs that need translation. -/// The parameters OK sends to the dialog's command: its field values, reshaped per dialog. -pub(crate) fn build_params(d: &Dialog) -> Json { - let mut v = Json::Object(d.values.clone()); - match d.name.as_str() { - "advancedFilter" => { - if let Some(m) = v.as_object_mut() { - m.remove("action"); - if d.values.get("action").and_then(Json::as_str) != Some("copy") { - m.remove("copyTo"); - } - if m.get("criteria").and_then(Json::as_str).is_some_and(|s| s.trim().is_empty()) { - m.remove("criteria"); - } - } - } - "dataValidation" => { - let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any"); - let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between"); - if let Some(m) = v.as_object_mut() { - if !matches!(operator, "between" | "notBetween") || matches!(kind, "any" | "list" | "custom") { - m.remove("formula2"); - } - if matches!(kind, "any" | "list" | "custom") { - m.remove("operator"); - } - if kind == "any" { - m.remove("formula1"); - } - if kind != "list" { - m.remove("dropdown"); - } - } - } - "textToColumns" => { - let mut delims = Vec::new(); - for (k, c) in [("tab", "\t"), ("semicolon", ";"), ("comma", ","), ("space", " ")] { - if d.values.get(k).and_then(Json::as_bool) == Some(true) { - delims.push(json!(c)); - } - } - v["delimiters"] = Json::Array(delims); - } - "removeDuplicates" | "subtotal" => { - if let Some(t) = d.values.get("columnsText").and_then(Json::as_str).filter(|t| !t.trim().is_empty()) { - v["columns"] = Json::Array(t.split(',').map(|c| json!(c.trim().to_ascii_uppercase())).collect()); - } - } - "allowEditRanges" => { - if let Some(m) = v.as_object_mut() { - // Blank fields mean "unchanged"; the check box clears a password. - m.retain(|_, x| x.as_str().is_none_or(|s| !s.trim().is_empty())); - if m.remove("removePassword").and_then(|x| x.as_bool()) == Some(true) { - m.insert("password".into(), json!("")); - } - } - } - "cfQuick" => { - let mut rule = Map::new(); - for (k, val) in &d.values { - rule.insert(k.clone(), val.clone()); - } - v = json!({"rule": Json::Object(rule)}); - if let Some(pick) = d.pick.as_ref().filter(|pick| !pick.applies_to.is_empty() && !pick.sheet.is_empty()) { - v["sheet"] = json!(pick.sheet.as_str()); - v["range"] = json!(pick.applies_to); - } - } - "series" => { - if let Some(s) = d.values.get("stop").and_then(Json::as_str).and_then(|s| s.parse::().ok()) { - v["stop"] = json!(s); - } else if let Some(m) = v.as_object_mut() { - m.remove("stop"); - } - } - "defineName" => { - if let Some(m) = v.as_object_mut() { - for key in ["scope", "oldScope"] { - if m.get(key).and_then(Json::as_str) == Some("Workbook") { - m.remove(key); - } - } - } - } - _ => {} - } - v -} - -/// Takes and draws the active dialog, dispatches its body by name, consumes picture-inbox results, runs OK/confirm commands, handles Esc/close and restores the back-dialog stack. -pub fn show(app: &mut SheetApp, ctx: &egui::Context) { - let Some(mut d) = app.dialog.take() else { return }; - let home = app.ui_window(); - if d.name == "pasteSpecial" { - paste_special_shortcuts(&mut d, ctx); - } - let mut open = true; - let mut win_open = true; - let mut confirm = false; - let width = match d.name.as_str() { - "formatCells" => 560.0, - "about" => 660.0, - "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "macros" => 520.0, - "recommendedCharts" => 600.0, - "analysisToolPak" => 420.0, - "solver" => 460.0, - "zoom" => 220.0, - "pasteSpecial" => 480.0, - _ => 380.0, - }; - let window = egui::Window::new(app.ui.language.tr(&d.title)) - .id(egui::Id::unique("dialog").with(&d.name)) - .collapsible(false) - .resizable(false) - .default_width(width) - // A default position, not an anchor: egui anchors can't be dragged, and dialogs move by their title bar. - .pivot(egui::Align2::CENTER_CENTER) - .default_pos(ctx.content_rect().center() + vec2(0.0, -40.0)) - .open(&mut win_open); - let window = match d.name.as_str() { - "insertPicture" => window.default_height(240.0), - "advancedFilter" => window.default_height(0.0), - _ => window, - }; - window.show(ctx, |ui| { - ui.set_min_width(width - 20.0); - match d.name.as_str() { - "formatCells" => format_cells(app, ui, &mut d, &mut confirm), - "dataValidation" => data_validation_form(ui, &mut d, &mut confirm, &mut open), - "advancedFilter" => advanced_filter_form(ui, &mut d, &mut confirm, &mut open), - "insertFunction" => insert_function(app, ui, &mut d, &mut confirm), - "find" => find(app, ui, &mut d), - "goTo" => go_to(app, ui, &mut d, &mut confirm), - "zoom" => zoom(ui, &mut d, &mut confirm, &mut open), - "sort" => sort(app, ui, &mut d, &mut confirm), - "nameManager" => name_manager(app, ui, &mut d, &mut open), - "pasteName" => paste_name(app, ui, &mut d, &mut confirm, &mut open), - "manageRules" => manage_rules(app, ui), - "recommendedCharts" => recommended_charts(app, ui, &mut d, &mut confirm, &mut open), - "selectData" => select_data(app, ui, &mut d, &mut confirm, &mut open), - "commandSearch" => command_search(app, ui, &mut d, &mut confirm), - "goalSeek" => goal_seek(app, ui, &mut d, &mut confirm), - "pasteSpecial" => paste_special(app, ui, &mut d, &mut confirm, &mut open), - "analysisToolPak" => analysis_toolpak(app, ui, &mut d, &mut confirm, &mut open), - "solver" => solver(ui, &mut d, &mut confirm, &mut open), - "solverResults" => solver_results(app, ui, &mut d, &mut open), - "spelling" => spelling(app, ui, &mut d, &mut open), - "agents" => { - ui.label("GridCraft is fully drivable by agents. Every menu item, button and gesture is a command:"); - ui.add_space(4.0); - ui.monospace("gridcraft --control 7979 # JSON-lines control channel\ngridcraft-cli mcp --connect 7979 # MCP bridged to this window\ngridcraft-cli mcp # headless MCP server"); - ui.add_space(4.0); - ui.label(format!("{} engine commands are available. See docs/mcp.md and docs/control-protocol.md.", gridcraft_engine::command_specs().len())); - } - "about" => about(ui, &mut d), - "journal" => { - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - for (id, p) in app.session.journal.iter().rev().take(500) { - ui.monospace(format!("{id} {p}")); - } - }); - } - "macros" => macros(app, ui, &mut d), - "statistics" | "accessibility" | "errorChecking" | "evaluateFormula" | "comments" => { - let r = if d.name == "comments" { - app.session.active().and_then(|doc| doc.wb.active().map(|s| json!(s.comments.iter().map(|(c, m)| json!({"cell": c.a1(), "author": m.author, "text": m.text})).collect::>()))) - } else { - d.result.clone() - }; - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - ui.monospace(serde_json::to_string_pretty(&r.unwrap_or(Json::Null)).unwrap_or_default()); - }); - } - "insertPicture" => { - // A private inbox dies with this dialog; late browser results - // cannot affect a later picture choice or another workbook. - let arrived = d.picture_inbox.as_ref().and_then(|inbox| { - inbox.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pop() - }); - if let Some((name, bytes)) = arrived { - d.picture_inbox = None; - if bytes.len() > 16 * 1024 * 1024 { - app.message = Some((d.title.clone(), "Choose a PNG or JPEG no larger than 16 MB.".into())); - } else { - d.values.remove("path"); - d.values.insert("alt".into(), json!(name)); - d.values.insert("base64".into(), json!(gridcraft_engine::io::base64_encode(&bytes))); - } - } - if app.services.pick_picture_async.is_some() { - let name = d.values.get("alt").and_then(Json::as_str).unwrap_or("No picture selected"); - ui.label(name); - } else { - ui.label("Picture file path (PNG or JPEG):"); - let mut path = d.values.get("path").and_then(Json::as_str).unwrap_or("").to_string(); - if ui.text_edit_singleline(&mut path).changed() { - d.values.remove("base64"); - d.values.insert("path".into(), json!(path)); - } - } - if (app.services.pick_picture.is_some() || app.services.pick_picture_async.is_some()) && ui.button("Browse…").clicked() { - if let Some(start) = &app.services.pick_picture_async { - let inbox = crate::Inbox::default(); - start(inbox.clone()); - d.picture_inbox = Some(inbox); - } else if let Some(pick) = &app.services.pick_picture - && let Some(path) = pick() - { - d.values.remove("base64"); - let alt = std::path::Path::new(&path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(""); - d.values.insert("alt".into(), json!(alt)); - d.values.insert("path".into(), json!(path)); - } - } - ui.label("Description (optional):"); - let mut alt = d.values.get("alt").and_then(Json::as_str).unwrap_or("").to_string(); - if ui.text_edit_singleline(&mut alt).changed() { - d.values.insert("alt".into(), json!(alt)); - } - if d.values.get("placement").and_then(Json::as_str) == Some("cell") { - ui.label(egui::RichText::new("Fits inside the selected cell and moves with its content.").small()); - } - ok_cancel(ui, &mut confirm, &mut open); - } - "pickList" => { - let items: Vec = app - .session - .active() - .and_then(|doc| { - let sh = doc.wb.active()?; - let a = doc.selection.active; - let mut seen = std::collections::BTreeSet::new(); - let mut r = a.row; - while r > 0 && !sh.value(gridcraft_engine::core::CellRef::new(r - 1, a.col)).is_empty() { - r -= 1; - seen.insert(sh.value(gridcraft_engine::core::CellRef::new(r, a.col)).display()); - } - Some(seen.into_iter().collect()) - }) - .unwrap_or_default(); - for it in items { - if ui.button(&it).clicked() { - app.run_or_alert("cell.set", json!({"input": it})); - open = false; - } - } - } - "saveCopy" => { - let fmt = d.values.get("format").and_then(Json::as_str).unwrap_or("xlsx").to_string(); - ui.label(format!("Save a copy as .{fmt}")); - let mut path = d.values.get("path").and_then(Json::as_str).unwrap_or("").to_string(); - ui.text_edit_singleline(&mut path); - d.values.insert("path".into(), json!(path.clone())); - ok_cancel(ui, &mut confirm, &mut open); - if confirm { - // A bare name has no folder: written as typed it would land in the - // process's working directory (`/` from the Finder, the install folder on - // Windows), never where the user looks. The picker chooses the folder. - let pick = app.services.pick_save.as_ref(); - let target = if path.is_empty() { - pick.and_then(|f| f(&format!("Copy.{fmt}"))) - } else if let Some(f) = pick.filter(|_| std::path::Path::new(&path).is_relative()) { - f(&if path.ends_with(&format!(".{fmt}")) { path } else { format!("{path}.{fmt}") }) - } else { - Some(path) - }; - if let Some(t) = target { - let id = match fmt.as_str() { - "csv" => "file.exportCsv", - "html" => "file.exportHtml", - _ => "file.saveAs", - }; - app.run_or_alert(id, json!({"path": t})); - } - } - } - "saveChanges" => { - ui.label(format!("Do you want to save the changes you made to {}?", d.values.get("title").and_then(Json::as_str).unwrap_or("this workbook"))); - let quitting = d.values.get("quit").and_then(Json::as_bool) == Some(true); - ui.horizontal(|ui| { - for (label, answer) in - [("Don't Save", crate::SaveAnswer::DontSave), ("Cancel", crate::SaveAnswer::Cancel), ("Save", crate::SaveAnswer::Save)] - { - if ui.button(label).clicked() && app.answer_save_changes(answer, quitting, ui.ctx()) { - open = false; - } - } - }); - } - "start" => { - ui.heading("Start a new workbook"); - ui.horizontal_wrapped(|ui| { - if ui.button("Blank workbook").clicked() { - app.run_or_alert("file.new", json!({})); - open = false; - } - for (n, t) in gridcraft_engine::sample::SAMPLES { - if ui.button(*t).clicked() { - app.run_or_alert("file.new", json!({"sample": n})); - open = false; - } - } - }); - ui.separator(); - ui.label(egui::RichText::new("Recent").strong()); - for path in app.ui.recent.clone() { - if ui.link(&path).clicked() { - app.open_path(&path); - open = false; - } - } - if ui.button("Open…").clicked() { - app.open_dialog("open", json!({})); - open = false; - } - } - _ => form(ui, &mut d, &mut confirm, &mut open), - } - }); - // Hyperlinks inside dialogs (About ▸ Contributors) open through the host's `open_url`, since - // the native shell has no browser integration of its own. - if let Some(open_url) = &app.services.open_url { - let urls: Vec = ctx.output_mut(|o| { - let mut urls = vec![]; - o.commands.retain(|c| match c { - egui::OutputCommand::OpenUrl(u) => { - urls.push(u.url.clone()); - false - } - _ => true, - }); - urls - }); - for u in urls { - open_url(&u); - } - } - if ctx.input(|i| i.key_pressed(Key::Escape)) || !win_open { - open = false; - } - if confirm && d.name == "insertPicture" && d.picture_target != app.view_key() { - app.message = Some((d.title.clone(), "Return to the original worksheet to insert this picture, or cancel and choose another cell.".into())); - confirm = false; - } - if confirm && let Some(cmd) = d.command { - let params = build_params(&d); - match app.run(cmd, params) { - Ok(r) => { - open = false; - if d.name == "solver" { - app.open_dialog("solverResults", r); - } else if d.name == "openPassword" { - let path = d.values.get("path").and_then(Json::as_str).map(str::to_string); - app.after_open(path.as_deref(), &r); - } - } - // Data Analysis asks before replacing data in its output range. - Err(e) if d.name == "analysisToolPak" && e.contains(gridcraft_engine::cmd::analysis::OVERWRITE_ERROR) => { - d.values.insert("askOverwrite".into(), json!(true)); - } - // A wrong password: say so, clear the field and stay open for another try. - Err(e) if d.name == "openPassword" => { - d.values.insert("password".into(), json!("")); - app.message = Some((d.title.clone(), crate::clean_error(&e))); - } - Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), - } - } else if confirm { - open = false; - } - if app.ui_window() != home { - // A command run from the dialog switched windows: whatever it opened shows in the new - // window, and this dialog (or the one it returns to) stays in its own. - let keep = if open { Some(d) } else { d.back.take().map(|b| *b) }; - if let Some(keep) = keep { - app.return_dialog(home, keep); - } - return; - } - if let Some(child) = app.dialog.as_mut() { - // The body opened another dialog (Name Manager › New…). It takes over; this one waits - // behind it and comes back when it closes, as Excel returns to the Name Manager. Go To - // hands over to Go To Special for good, as in Excel. - if open && d.name != "goTo" { - child.back = Some(Box::new(d)); - } else if child.back.is_none() { - child.back = d.back.take(); - } - return; - } - if open { - app.dialog = Some(d); - } else if let Some(back) = d.back.take() { - app.dialog = Some(*back); - } -} - -/// Paste Special as Excel lays it out: Paste and Operation each in two columns of options, Skip -/// blanks and Transpose side by side, and Paste Link as a button that pastes at once. The options -/// come from the dialog's `fields`, so the letter shortcuts and the control channel see the same. -fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - let lang = app.ui.language; - let t = theme::Tokens::get(ui.ctx()); - let choices: Vec<(&'static str, &'static str, Vec<(&'static str, &'static str)>)> = d - .fields - .iter() - .filter_map(|f| match f { - Field::Choice { key, label, options } => Some((*key, *label, options.clone())), - _ => None, - }) - .collect(); - for (key, label, options) in choices { - ui.add_space(4.0); - ui.label(egui::RichText::new(lang.tr(label)).strong()); - egui::Frame::new().stroke(Stroke::new(1.0, t.separator)).corner_radius(4.0).inner_margin(egui::Margin::symmetric(10, 6)).show(ui, |ui| { - ui.set_min_width(ui.available_width()); - let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); - // Column by column, as Excel fills them. - let rows = options.len().div_ceil(2); - egui::Grid::new(("paste_special", key)).num_columns(2).min_col_width(200.0).spacing(vec2(16.0, 4.0)).show(ui, |ui| { - for r in 0..rows { - for c in 0..2 { - if let Some((value, text)) = options.get(c * rows + r) { - if ui.radio(cur == *value, lang.tr(text)).clicked() { - d.set_value(key, json!(value)); - } - } else { - ui.label(""); - } - } - ui.end_row(); - } - }); - }); - } - ui.add_space(8.0); - // In the same columns as the options above. - egui::Frame::new().inner_margin(egui::Margin::symmetric(11, 0)).show(ui, |ui| { - egui::Grid::new("paste_special_checks").num_columns(2).min_col_width(200.0).spacing(vec2(16.0, 4.0)).show(ui, |ui| { - for (key, label) in [("skipBlanks", "Skip blanks"), ("transpose", "Transpose (E)")] { - let mut b = d.values.get(key).and_then(Json::as_bool).unwrap_or(false); - if ui.checkbox(&mut b, lang.tr(label)).changed() { - d.values.insert(key.into(), json!(b)); - } - } - ui.end_row(); - }); - }); - ui.add_space(8.0); - ui.separator(); - ui.horizontal(|ui| { - // Excel's Paste Link pastes references to the copied cells straight away. - if ui.button(lang.tr("Paste Link")).on_hover_text(lang.tr("Paste formulas that refer to the copied cells")).clicked() { - d.values.insert("what".into(), json!("all")); - d.values.insert("operation".into(), json!("none")); - d.values.insert("link".into(), json!(true)); - *confirm = true; - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(t.on_accent)).fill(t.accent)); - if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { - *confirm = true; - } - if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() { - *open = false; - } - }); - }); -} - -/// Paste Special's legacy mnemonics select options; Enter still performs the paste. -fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) { - if ctx.text_edit_focused() { - return; - } - let right_alt = ctx.input(|i| { - i.key_down(Key::AltRight) - || i.events.iter().any(|event| { - matches!(event, egui::Event::Key { key: Key::AltRight, .. }) - || matches!(event, egui::Event::Key { physical_key: Some(Key::AltRight), .. }) - }) - }); - let mac = ctx.os() == egui::os::OperatingSystem::Mac; - let allowed = |m: egui::Modifiers| !m.ctrl && !m.command && !m.mac_cmd && !(m.alt && (mac || right_alt)); - let mnemonic = |key| match key { - Key::T => Some('t'), - Key::V => Some('v'), - Key::F => Some('f'), - Key::C => Some('c'), - Key::E => Some('e'), - _ => None, - }; - let mut text_to_consume = Vec::new(); - for event in ctx.input(|i| i.events.clone()) { - let egui::Event::Key { key, pressed: true, repeat, modifiers, .. } = event else { continue }; - if !allowed(modifiers) { - continue; - } - let Some(letter) = mnemonic(key) else { continue }; - text_to_consume.push(letter); - if repeat { - continue; - } - if key == Key::E { - let transpose = d.values.get("transpose").and_then(Json::as_bool).unwrap_or(false); - d.values.insert("transpose".into(), json!(!transpose)); - } else { - let what = match key { - Key::T => "formats", - Key::V => "values", - Key::F => "formulas", - _ => "comments", - }; - d.values.insert("what".into(), json!(what)); - } - } - // Browsers can deliver Text before or after Key. Act only on Key, then remove - // its matching text so one physical press cannot toggle Transpose twice. - ctx.input_mut(|i| { - i.events.retain(|event| match event { - egui::Event::Key { key, modifiers, .. } => !(allowed(*modifiers) && mnemonic(*key).is_some()), - egui::Event::Text(text) if text.len() == 1 => { - let letter = text.chars().next().unwrap_or_default().to_ascii_lowercase(); - if let Some(index) = text_to_consume.iter().position(|&c| c == letter) { - text_to_consume.remove(index); - false - } else { - true - } - } - _ => true, - }); - }); -} - -fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) { - ui.add_space(8.0); - // A row as tall as the buttons: a right-to-left layout straight in the window takes all the - // height it is offered, which stretched every form dialog to the screen's height. - ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let lang = crate::i18n::current(ui.ctx()); - let t = crate::theme::Tokens::get(ui.ctx()); - let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(t.on_accent)).fill(t.accent)); - if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { - *confirm = true; - } - if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() { - *open = false; - } - }) - }); -} - -fn form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - form_fields(ui, d); - ok_cancel(ui, confirm, open); -} - -/// Gives keyboard focus to the first text or number field the first time a form is drawn. A -/// focused number field is in typing mode with its value selected, so Alt+H, O, W then 12 and -/// Enter sets the width, as in Excel. -/// Keeps asking until the field really has focus: a new window's first frame is an invisible -/// sizing pass where a focus request is ignored. -fn take_first_focus(d: &mut Dialog, r: &egui::Response) { - if !d.focus_first_field { - return; - } - if !r.has_focus() { - r.request_focus(); - return; - } - // Focus has landed, and the field is now a text box: select its whole value so typing - // replaces it (the field's own select-on-focus fired before it was a text box). - let mut state = egui::text_edit::TextEditState::load(&r.ctx, r.id).unwrap_or_default(); - let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(usize::MAX / 2)); - state.cursor.set_char_range(Some(all)); - state.store(&r.ctx, r.id); - d.focus_first_field = false; -} - -/// Keeps a picking formula box's caret, and gives it the keyboard back after a grid click put a -/// reference in it (the click took the focus), its caret after the reference. -fn track_pick(pick: &mut RefPick, r: &egui::Response, typed: bool) { - if typed { - pick.point = None; - } - if pick.refocus { - let mut state = egui::text_edit::TextEditState::load(&r.ctx, r.id).unwrap_or_default(); - state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(pick.caret)))); - state.store(&r.ctx, r.id); - r.request_focus(); - pick.refocus = false; - } else if r.has_focus() - && let Some(range) = egui::text_edit::TextEditState::load(&r.ctx, r.id).and_then(|state| state.cursor.char_range()) - { - pick.caret = range.primary.index.into(); - } -} - -/// Draws a dialog's `fields`, editing its values. -fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { - // Field labels, choices and notes are English source strings, translated as they are drawn. - let lang = crate::i18n::current(ui.ctx()); - egui::Grid::new("form").num_columns(1).spacing(vec2(8.0, 6.0)).show(ui, |ui| { - for f in d.fields.clone() { - match f { - Field::Text { key, label } => { - ui.vertical(|ui| { - let label = ui.label(lang.tr(label)); - let mut s = d - .values - .get(key) - .map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| if v.is_null() { String::new() } else { v.to_string() })) - .unwrap_or_default(); - let multiline = key == "text"; - let r = if multiline { - ui.add(egui::TextEdit::multiline(&mut s).desired_rows(4).desired_width(320.0)).labelled_by(label.id) - } else { - ui.add(egui::TextEdit::singleline(&mut s).desired_width(320.0)).labelled_by(label.id) - }; - take_first_focus(d, &r); - if r.changed() { - d.values.insert(key.into(), json!(s)); - } - if let Some(pick) = d.pick.as_mut().filter(|pick| pick.key == key) { - track_pick(pick, &r, r.changed()); - } - }); - } - Field::Password { key, label } => { - // Masked as typed (Excel shows dots); "Show password" reveals it on purpose. - ui.vertical(|ui| { - let label = ui.label(lang.tr(label)); - let mut s = d.values.get(key).and_then(Json::as_str).unwrap_or_default().to_string(); - if ui.add(egui::TextEdit::singleline(&mut s).password(!d.show_password).desired_width(320.0)).labelled_by(label.id).changed() - { - d.values.insert(key.into(), json!(s)); - } - ui.checkbox(&mut d.show_password, lang.tr("Show password")); - }); - } - Field::Number { key, label } => { - ui.horizontal(|ui| { - let label = ui.label(lang.tr(label)); - let mut n = d.values.get(key).and_then(Json::as_f64).unwrap_or(0.0); - // Typed values count straight away: otherwise Enter or OK in the same frame - // confirms the old value, because a number field only commits when it loses focus. - let r = ui.add(egui::DragValue::new(&mut n).speed(0.5).update_while_editing(true)).labelled_by(label.id); - take_first_focus(d, &r); - if r.changed() { - d.values.insert(key.into(), json!(n)); - } - }); - } - Field::Choice { key, label, options } => { - ui.vertical(|ui| { - let field_label = ui.label(egui::RichText::new(lang.tr(label)).strong()); - let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); - if d.name == "dataValidation" { - let selected = options.iter().find(|(val, _)| *val == cur).map(|(_, text)| lang.tr(text)).unwrap_or(""); - egui::ComboBox::from_id_salt(("dataValidation", key)) - .selected_text(selected) - .width(300.0) - .show_ui(ui, |ui| { - for (val, text) in options { - if ui.selectable_label(cur == val, lang.tr(text)).clicked() { - d.set_value(key, json!(val)); - } - } - }) - .response - .labelled_by(field_label.id); - } else { - for (val, text) in options { - if ui.radio(cur == val, lang.tr(text)).clicked() { - d.set_value(key, json!(val)); - } - } - } - }); - } - Field::Check { key, label } => { - let mut b = d.values.get(key).and_then(Json::as_bool).unwrap_or(false); - if ui.checkbox(&mut b, lang.tr(label)).changed() { - d.values.insert(key.into(), json!(b)); - } - } - Field::Note(t) => { - // A note can carry user data (a URL, a file or sheet name); `tr` reads a `|` as a - // context separator, so such a note is shown as it is. - let text = if t.contains('|') { t.as_str() } else { lang.tr(&t) }; - ui.label(egui::RichText::new(text).small()); - } - } - ui.end_row(); - } - }); -} - -/// Help ▸ About GridCraft: the app, its contributors and the AI models that helped. The credits -/// are compiled in (`crate::credits`, docs/contributors.md). -fn about(ui: &mut egui::Ui, d: &mut Dialog) { - ui.horizontal(|ui| { - for tab in ["About", "Contributors", "Models"] { - if ui.selectable_label(d.tab == tab, tab).clicked() { - d.tab = tab.to_string(); - } - } - }); - ui.separator(); - match d.tab.as_str() { - "Contributors" => crate::credits::contributors_ui(ui), - "Models" => crate::credits::models_ui(ui), - _ => { - ui.heading("GridCraft"); - ui.label(format!("Version {}", env!("CARGO_PKG_VERSION"))); - ui.add_space(4.0); - ui.label("A clean-room, open-source, Rust-native spreadsheet. Part of ArtCraft."); - ui.hyperlink_to("getartcraft.com/apps/gridcraft", "https://getartcraft.com/apps/gridcraft"); - ui.hyperlink_to("Join the ArtCraft Discord", "https://discord.gg/artcraft"); - } - } -} - -/// The Macros dialog: lists GridCraft's own recorded scripts and (if the file carries one) the -/// preserved VBA project. Mirrors Excel's Developer ▸ Macros list, but is honest that VBA is -/// preserved and not executed. -fn macros(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { - ui.label("Automation in this workbook. GridCraft runs its own scripts; a file's VBA project is preserved but not executed."); - ui.add_space(6.0); - let items: Vec = d.result.as_ref().and_then(Json::as_array).cloned().unwrap_or_default(); - if items.is_empty() { - ui.weak("This workbook has no scripts or macros."); - return; - } - // Scripts that came with a file stay disabled until the user trusts the workbook (#2178). - if items.iter().any(|it| it.get("trusted").and_then(Json::as_bool) == Some(false)) { - ui.horizontal(|ui| { - ui.weak("Scripts in this workbook came from a file and are disabled."); - if ui.button("Trust Scripts").on_hover_text("Let this workbook's scripts run for the rest of this session").clicked() { - let _ = app.run("automate.trustScripts", json!({"trusted": true})); - d.result = app.session.run("view.macros", json!({})).ok(); - } - }); - ui.add_space(6.0); - } - egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { - for it in &items { - let name = it.get("name").and_then(Json::as_str).unwrap_or("(unnamed)"); - let is_vba = it.get("kind").and_then(Json::as_str) == Some("vba"); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(name).strong()); - if is_vba { - ui.weak("— VBA project (preserved, not run)"); - } else { - let steps = it.get("steps").and_then(Json::as_u64).unwrap_or(0); - ui.weak(format!("— {steps} step{}", if steps == 1 { "" } else { "s" })); - } - }); - } - }); -} - -/// Draws the six-tab Format Cells editor (number, alignment, font, border, fill, protection); the Number tab shows a sample preview, and the style draft updates. -fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { - // `d.tab` stays the English name (it is what `open_dialog` and tests pass); only the drawn - // labels are translated. The Fill tab is keyed by context: the Alignment tab's "Fill" is a - // different word in some languages. - let lang = app.ui.language; - ui.horizontal(|ui| { - for (tab, key) in [ - ("Number", "Number"), - ("Alignment", "Alignment"), - ("Font", "Font"), - ("Border", "Border"), - ("Fill", "Format Cells|Fill"), - ("Protection", "Protection"), - ] { - if ui.selectable_label(d.tab == tab, lang.tr(key)).clicked() { - d.tab = tab.to_string(); - } - } - }); - ui.separator(); - let mut st: gridcraft_engine::model::Style = d.values.get("style").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); - let sample = app.session.active().and_then(|doc| doc.wb.active().map(|s| s.value(doc.selection.active))).unwrap_or_default(); - match d.tab.as_str() { - "Number" => { - let mut code = st.num_fmt.as_str().to_string(); - ui.columns(2, |cols| { - cols[0].label(egui::RichText::new(lang.tr("Category:")).strong()); - for name in - ["General", "Number", "Currency", "Accounting", "Short Date", "Long Date", "Time", "Percentage", "Fraction", "Scientific", "Text"] - { - let c = gridcraft_engine::cmd::format::format_code_for(name).to_string(); - if cols[0].selectable_label(code == c, lang.tr(name)).clicked() { - code = c; - } - } - cols[1].label(egui::RichText::new(lang.tr("Sample")).strong()); - let preview = app.session.active().map(|doc| gridcraft_engine::display::format(&sample, &code, &doc.wb).text).unwrap_or_default(); - cols[1].label(egui::RichText::new(preview).font(theme::ui_font(15.0))); - cols[1].add_space(8.0); - let label = cols[1].label(lang.tr("Type (custom format code):")); - cols[1].text_edit_singleline(&mut code).labelled_by(label.id); - for c in [ - "0", - "0.00", - "#,##0", - "#,##0.00", - "#,##0;[Red]-#,##0", - "0%", - "0.00%", - "0.00E+00", - "# ?/?", - "m/d/yyyy", - "d-mmm-yy", - "mmmm d, yyyy", - "h:mm AM/PM", - "h:mm:ss", - "[h]:mm:ss", - "@", - ] { - if cols[1].small_button(c).clicked() { - code = c.to_string(); - } - } - }); - st.num_fmt = gridcraft_engine::model::NumFmt::new(&code); - } - "Alignment" => { - use gridcraft_engine::model::{HAlign, VAlign}; - ui.label(egui::RichText::new(lang.tr("Horizontal")).strong()); - ui.horizontal_wrapped(|ui| { - for (h, n) in [ - (HAlign::General, "General"), - (HAlign::Left, "Left"), - (HAlign::Center, "Center"), - (HAlign::Right, "Right"), - (HAlign::Fill, "Fill"), - (HAlign::Justify, "Justify"), - (HAlign::CenterAcross, "Center Across Selection"), - (HAlign::Distributed, "Distributed"), - ] { - ui.radio_value(&mut st.align.h, h, lang.tr(n)); - } - }); - ui.label(egui::RichText::new(lang.tr("Vertical")).strong()); - ui.horizontal_wrapped(|ui| { - for (v, n) in [ - (VAlign::Top, "Top"), - (VAlign::Center, "Center"), - (VAlign::Bottom, "Bottom"), - (VAlign::Justify, "Justify"), - (VAlign::Distributed, "Distributed"), - ] { - ui.radio_value(&mut st.align.v, v, lang.tr(n)); - } - }); - let mut indent = st.align.indent as f32; - ui.add(egui::Slider::new(&mut indent, 0.0..=15.0).text(lang.tr("Indent"))); - st.align.indent = indent as u8; - let mut rot = if st.align.rotation == 255 { 0.0 } else { st.align.rotation as f32 }; - ui.add(egui::Slider::new(&mut rot, -90.0..=90.0).text(lang.tr("Orientation (degrees)"))); - st.align.rotation = rot as i16; - ui.checkbox(&mut st.align.wrap, lang.tr("Wrap text")); - ui.checkbox(&mut st.align.shrink, lang.tr("Shrink to fit")); - } - "Font" => { - egui::ComboBox::from_label(lang.tr("Font")).selected_text(st.font.name.clone()).show_ui(ui, |ui| { - for f in crate::fonts::families() { - ui.selectable_value(&mut st.font.name, f.clone(), f.clone()); - } - }); - ui.add(egui::Slider::new(&mut st.font.size, 6.0..=72.0).text(lang.tr("Size"))); - ui.horizontal(|ui| { - ui.checkbox(&mut st.font.bold, lang.tr("Bold")); - ui.checkbox(&mut st.font.italic, lang.tr("Italic")); - ui.checkbox(&mut st.font.strike, lang.tr("Strikethrough")); - }); - use gridcraft_engine::model::{Underline, VertAlign}; - ui.horizontal(|ui| { - for (u, n) in [ - (Underline::None, "None"), - (Underline::Single, "Single"), - (Underline::Double, "Double"), - (Underline::SingleAccounting, "Single Accounting"), - (Underline::DoubleAccounting, "Double Accounting"), - ] { - ui.radio_value(&mut st.font.underline, u, lang.tr(n)); - } - }); - ui.horizontal(|ui| { - ui.radio_value(&mut st.font.vert, VertAlign::Baseline, lang.tr("Normal")); - ui.radio_value(&mut st.font.vert, VertAlign::Superscript, lang.tr("Superscript")); - ui.radio_value(&mut st.font.vert, VertAlign::Subscript, lang.tr("Subscript")); - }); - ui.label(lang.tr("Color:")); - let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default(); - if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("Automatic")) { - st.font.color = - if c == "none" { gridcraft_engine::model::Color::Auto } else { gridcraft_engine::model::Color::from_hex(&c).unwrap_or_default() }; - } - } - "Border" => { - use gridcraft_engine::model::{BorderLine, BorderStyle}; - let line = BorderLine { style: BorderStyle::Thin, color: gridcraft_engine::model::Color::Auto }; - ui.horizontal(|ui| { - if ui.button(lang.tr("None")).clicked() { - st.borders = Default::default(); - } - if ui.button(lang.tr("Outline")).clicked() { - st.borders.top = line; - st.borders.bottom = line; - st.borders.left = line; - st.borders.right = line; - } - }); - for (n, slot) in [ - ("Top", &mut st.borders.top), - ("Bottom", &mut st.borders.bottom), - ("Left", &mut st.borders.left), - ("Right", &mut st.borders.right), - ("Diagonal ↘", &mut st.borders.diag_down), - ("Diagonal ↗", &mut st.borders.diag_up), - ] { - let n = lang.tr(n); - ui.horizontal(|ui| { - ui.label(format!("{n}:")); - for (s, label) in [ - (BorderStyle::None, "none"), - (BorderStyle::Thin, "thin"), - (BorderStyle::Medium, "medium"), - (BorderStyle::Thick, "thick"), - (BorderStyle::Dashed, "dashed"), - (BorderStyle::Dotted, "dotted"), - (BorderStyle::Double, "double"), - ] { - let label = lang.tr(label); - let response = ui.selectable_label(slot.style == s, label); - let selected = slot.style == s; - response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected, format!("{n} {label}"))); - if response.clicked() { - slot.style = s; - } - } - }); - } - let theme = app.session.active().map(|doc| &doc.wb.theme).cloned().unwrap_or_default(); - crate::border_preview::sample(ui, &st.borders, &theme); - } - "Fill" => { - let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default(); - ui.label(lang.tr("Background Color:")); - if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("No Color")) { - st.fill = if c == "none" { - Default::default() - } else { - gridcraft_engine::model::Fill::solid(gridcraft_engine::model::Color::from_hex(&c).unwrap_or_default()) - }; - } - } - "Protection" => { - ui.checkbox(&mut st.protection.locked, lang.tr("Locked")); - ui.checkbox(&mut st.protection.hidden, lang.tr("Hidden")); - ui.label( - egui::RichText::new( - lang.tr("Locking cells or hiding formulas has no effect until you protect the worksheet (Review › Protect Sheet)."), - ) - .small(), - ); - } - _ => {} - } - if let Ok(v) = serde_json::to_value(&st) { - d.values.insert("style".into(), v); - } - let mut open = true; - ok_cancel(ui, confirm, &mut open); - if *confirm && let Some(style) = d.values.get("style").cloned() { - app.run_or_alert("home.formatCells", json!({"style": style})); - } - if !open { - app.dialog = None; - *confirm = true; // closes - } -} - -/// Insert Function: searches names (as the formula language spells them) and descriptions, shows -/// each hit's category and description, and starts editing `=NAME(` (or appends to the formula). -fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { - let language = app.ui.language; - ui.horizontal(|ui| { - ui.label(language.tr("Search:")); - let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(300.0).hint_text(language.tr("e.g. lookup, average, date"))); - r.request_focus(); - }); - let locale = language.formula_locale(); - let list = gridcraft_engine::cmd::formulas::function_list(); - // The names and descriptions searched are the ones shown, so in the interface language. - let q = d.search.to_lowercase(); - let hits: Vec<&Json> = list - .iter() - .filter(|f| { - q.is_empty() - || f["name"].as_str().is_some_and(|n| locale.function_name(n).to_lowercase().contains(&q)) - || f["description"].as_str().is_some_and(|n| language.function_description(n).unwrap_or(n).to_lowercase().contains(&q)) - }) - .take(300) - .collect(); - egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { - for (i, f) in hits.iter().enumerate() { - let name = locale.function_name(f["name"].as_str().unwrap_or("")); - let category = language.tr(function_category_label(f["category"].as_str().unwrap_or(""))); - let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{name:<18} {category}")).min_size(vec2(480.0, 20.0))); - if r.clicked() { - d.list_index = i; - } - if r.double_clicked() { - d.list_index = i; - *confirm = true; - } - } - }); - if let Some(f) = hits.get(d.list_index) { - let name = f["name"].as_str().unwrap_or(""); - ui.separator(); - ui.label(egui::RichText::new(crate::formula_locale::signature(name, locale, language).unwrap_or_default()).strong()); - ui.label(crate::formula_locale::description(name, locale, language).unwrap_or_default()); - } - let mut open = true; - ok_cancel(ui, confirm, &mut open); - if *confirm && let Some(n) = hits.get(d.list_index).and_then(|f| f["name"].as_str()) { - let n = app.session.active().map(|doc| crate::formula_locale::completion_name(n, locale, &doc.wb, doc.wb.active_sheet())).unwrap_or(n); - let cur = app.editor.as_ref().map(|e| e.text.clone()); - let text = match cur { - Some(t) if t.starts_with('=') => format!("{t}{n}("), - _ => format!("={n}("), - }; - app.begin_edit(Some(text), false); - } - if !open { - *confirm = true; - } -} - -/// Excel's name for a function category, given the catalog's id (`MathTrig`). -fn function_category_label(id: &str) -> &str { - match id { - "DateTime" => "Date & Time", - "Lookup" => "Lookup & Reference", - "MathTrig" => "Math & Trig", - "AddIn" => "Add-in and Automation", - // Financial, Logical, Text, Statistical, Engineering, Database, Information, … - other => other, - } -} - -/// Draws the Find/Replace form with case, whole-cell and workbook options, requests field focus once, and runs find/replace with the current parameters. -fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { - ui.horizontal(|ui| { - for tab in ["Find", "Replace"] { - if ui.selectable_label(d.tab == tab, tab).clicked() { - d.tab = tab.into(); - } - } - }); - let mut what = d.values.get("what").and_then(Json::as_str).unwrap_or("").to_string(); - let mut with = d.values.get("with").and_then(Json::as_str).unwrap_or("").to_string(); - ui.horizontal(|ui| { - let label = ui.label("Find what:"); - let r = ui.add(egui::TextEdit::singleline(&mut what).desired_width(260.0)).labelled_by(label.id); - // Like Excel, the cursor starts in "Find what" - asked for once, so clicking another field still works. - if !d.values.contains_key("focused") { - r.request_focus(); - d.values.insert("focused".into(), json!(true)); - } - }); - if d.tab == "Replace" { - ui.horizontal(|ui| { - let label = ui.label("Replace with:"); - ui.add(egui::TextEdit::singleline(&mut with).desired_width(244.0)).labelled_by(label.id); - }); - } - let mut case = d.values.get("matchCase").and_then(Json::as_bool).unwrap_or(false); - let mut whole = d.values.get("wholeCell").and_then(Json::as_bool).unwrap_or(false); - let mut wb = d.values.get("within").and_then(Json::as_str) == Some("workbook"); - ui.horizontal(|ui| { - ui.checkbox(&mut case, "Match case"); - ui.checkbox(&mut whole, "Match entire cell contents"); - ui.checkbox(&mut wb, "Within workbook"); - }); - d.values.insert("what".into(), json!(what)); - d.values.insert("with".into(), json!(with)); - d.values.insert("matchCase".into(), json!(case)); - d.values.insert("wholeCell".into(), json!(whole)); - d.values.insert("within".into(), json!(if wb { "workbook" } else { "sheet" })); - let base = json!({"what": what, "matchCase": case, "wholeCell": whole, "within": if wb { "workbook" } else { "sheet" }}); - ui.horizontal(|ui| { - if d.tab == "Replace" { - if ui.button("Replace All").clicked() { - let mut p = base.clone(); - p["with"] = json!(with); - p["all"] = json!(true); - match app.run("edit.replace", p) { - Ok(r) => app.toast = Some((format!("All done. We made {} replacements.", r["replaced"]), crate::now_ms())), - Err(e) => app.message = Some(("Find and Replace".into(), crate::clean_error(&e))), - } - } - if ui.button("Replace").clicked() { - let mut p = base.clone(); - p["with"] = json!(with); - p["all"] = json!(false); - let _ = app.run("edit.replace", p); - let _ = app.run("edit.find", base.clone()); - } - } - if ui.button("Find All").clicked() { - let mut p = base.clone(); - p["all"] = json!(true); - d.result = app.run("edit.find", p).ok(); - } - if ui.button("Find Next").clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { - if let Err(e) = app.run("edit.find", base.clone()) { - app.message = Some(("Find and Replace".into(), crate::clean_error(&e))); - } - app.grid.ensure_visible = true; - } - }); - if let Some(r) = &d.result - && let Some(list) = r["results"].as_array() - { - ui.separator(); - ui.label(format!("{} cell(s) found", list.len())); - egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { - for item in list { - let label = format!( - "{} {} {}", - item["sheet"].as_str().unwrap_or(""), - item["cell"].as_str().unwrap_or(""), - item["value"].as_str().unwrap_or("") - ); - if ui.selectable_label(false, label).clicked() { - if let Some(s) = item["sheet"].as_str() { - let _ = app.run("sheet.activate", json!({"sheet": s})); - } - let _ = app.run("selection.set", json!({"cell": item["cell"]})); - app.grid.ensure_visible = true; - } - } - }); - } -} - -/// Zoom's Magnification presets, as `preset` values and labels, in Excel's order. -const ZOOM_PRESETS: [(&str, &str); 5] = [("200", "200%"), ("100", "100%"), ("75", "75%"), ("50", "50%"), ("25", "25%")]; - -/// The `preset` that shows a zoom percentage: its Magnification entry, or Custom. -fn zoom_preset(percent: f64) -> &'static str { - ZOOM_PRESETS.iter().map(|(val, _)| *val).find(|val| val.parse::().ok() == Some(percent)).unwrap_or("custom") -} - -/// View › Zoom, laid out as Excel's: one Magnification column of 200% to 25%, Fit selection and -/// Custom with its percentage box. The current zoom is pre-selected; typing a percentage -/// selects Custom; Fit selection zooms so the selected cells fill the grid. -fn zoom(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - let lang = crate::i18n::current(ui.ctx()); - let cur = d.values.get("preset").and_then(Json::as_str).unwrap_or("custom").to_string(); - ui.label(egui::RichText::new(lang.tr("Magnification")).strong()); - for (val, text) in ZOOM_PRESETS { - if ui.radio(cur == val, text).clicked() { - d.set_value("preset", json!(val)); - } - } - if ui.radio(cur == "fit", lang.tr("Fit selection")).clicked() { - d.set_value("preset", json!("fit")); - } - ui.horizontal(|ui| { - if ui.radio(cur == "custom", format!("{}:", lang.tr("Custom"))).clicked() { - d.set_value("preset", json!("custom")); - } - // Whole percentages, as Excel shows them; the engine clamps to 10..400 as well. - let mut percent = d.values.get("percent").and_then(Json::as_f64).unwrap_or(100.0).round() as i64; - if ui.add(egui::DragValue::new(&mut percent).range(10..=400).speed(1.0)).changed() { - d.set_value("percent", json!(percent)); - } - ui.label("%"); - }); - ok_cancel(ui, confirm, open); -} - -/// What the Go To dialog lists: the named ranges of every sheet, then the references gone to -/// before, newest first. -fn go_to_entries(app: &SheetApp) -> Vec { - app.session - .active() - .map(|doc| { - let mut v = gridcraft_engine::cmd::edit::go_to_names(&doc.wb, doc.wb.active_sheet()); - v.extend(doc.go_to_recent.iter().cloned()); - v - }) - .unwrap_or_default() -} - -/// Draws a virtualized name/reference list and editable reference field, links to Go To Special, and confirms with `edit.goTo`. -fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { - let lang = app.ui.language; - let entries: Vec = - d.result.as_ref().and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_str).map(str::to_owned).collect()).unwrap_or_default(); - let current = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string(); - // A workbook can hold thousands of names: only the rows in view are laid out. - let row_height = ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y; - egui::ScrollArea::vertical().max_height(160.0).auto_shrink([false, true]).show_rows(ui, row_height, entries.len(), |ui, rows| { - for n in entries.get(rows).unwrap_or_default() { - let resp = ui.selectable_label(current == *n, n); - if resp.clicked() || resp.double_clicked() { - d.values.insert("reference".into(), json!(n)); - } - if resp.double_clicked() { - *confirm = true; - } - } - }); - let mut r = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string(); - ui.horizontal(|ui| { - let label = ui.label(lang.tr("Reference:")); - ui.text_edit_singleline(&mut r).labelled_by(label.id).request_focus(); - }); - d.values.insert("reference".into(), json!(r.clone())); - if ui.button("Special…").clicked() { - app.open_dialog("goToSpecial", json!({})); - return; - } - let mut open = true; - ok_cancel(ui, confirm, &mut open); - if *confirm { - app.run_or_alert("edit.goTo", json!({"reference": r, "record": true})); - app.grid.ensure_visible = true; - } - if !open { - *confirm = true; - } -} - -/// Draws the sort-level editor: header toggle, column/by/order controls, add/remove level, and `data.sort` on confirmation. -fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { - let lang = app.ui.language; - let mut header = d.values.get("header").and_then(Json::as_bool).unwrap_or(true); - ui.checkbox(&mut header, "My list has headers"); - d.values.insert("header".into(), json!(header)); - let mut levels: Vec = d.values.get("levels").and_then(Json::as_array).cloned().unwrap_or_default(); - // Columns of the region around the active cell, labelled with their header (or first value). - let columns: Vec<(String, String)> = app - .session - .active() - .and_then(|d| { - let sh = d.wb.active()?; - let region = gridcraft_engine::selection::current_region(sh, d.selection.active); - Some( - (region.start.col..=region.end.col) - .map(|c| { - let letter = gridcraft_engine::core::col_to_letters(c); - // The region starts at the header row when one is present, so the - // label always comes from start.row: header text with headers on, - // first data value with them off. - let label_row = region.start.row; - let text = gridcraft_engine::display::cell_text(&d.wb, sh, gridcraft_engine::core::CellRef::new(label_row, c)); - let text: String = text.chars().take(24).collect(); - (letter.clone(), if text.is_empty() { letter } else { format!("{letter}: {text}") }) - }) - .collect(), - ) - }) - .unwrap_or_default(); - let mut remove = None; - for (i, lv) in levels.iter_mut().enumerate() { - ui.horizontal(|ui| { - let label = ui.label(lang.tr(if i == 0 { "Sort by" } else { "Then by" })); - let mut col = lv["column"].as_str().unwrap_or("A").to_string(); - if columns.is_empty() { - ui.add(egui::TextEdit::singleline(&mut col).desired_width(50.0)).labelled_by(label.id); - } else { - egui::ComboBox::from_id_salt(("sort_col", i)) - .selected_text(col.clone()) - .show_ui(ui, |ui| { - for (letter, label) in &columns { - ui.selectable_value(&mut col, letter.clone(), label.as_str()); - } - }) - .response - .labelled_by(label.id); - } - lv["column"] = json!(col.to_ascii_uppercase()); - let mut by = lv["by"].as_str().unwrap_or("values").to_string(); - egui::ComboBox::from_id_salt(("by", i)) - .selected_text(match by.as_str() { - "cellColor" => "Cell Color", - "fontColor" => "Font Color", - _ => "Cell Values", - }) - .show_ui(ui, |ui| { - ui.selectable_value(&mut by, "values".into(), "Cell Values"); - ui.selectable_value(&mut by, "cellColor".into(), "Cell Color"); - ui.selectable_value(&mut by, "fontColor".into(), "Font Color"); - }) - .response - .accessible_name(lang.tr("Sort On")); - lv["by"] = json!(by); - let mut desc = lv["order"].as_str() == Some("desc"); - ui.radio_value(&mut desc, false, "A to Z"); - ui.radio_value(&mut desc, true, "Z to A"); - lv["order"] = json!(if desc { "desc" } else { "asc" }); - if crate::widgets::icon_button(ui, Icon::Close, crate::theme::Tokens::get(ui.ctx()).text_dim, "Remove level", vec2(18.0, 18.0)).clicked() - { - remove = Some(i); - } - }); - } - if let Some(i) = remove - && levels.len() > 1 - { - levels.remove(i); - } - if ui.button("+ Add Level").clicked() { - let used: Vec = levels.iter().filter_map(|l| l.get("column").and_then(Json::as_str).map(str::to_string)).collect(); - let next = columns - .iter() - .map(|(l, _)| l) - .find(|l| !used.contains(l)) - .cloned() - .or_else(|| columns.last().map(|(l, _)| l.clone())) - .unwrap_or_else(|| "B".into()); - levels.push(json!({"column": next, "order": "asc"})); - } - d.values.insert("levels".into(), Json::Array(levels.clone())); - let mut open = true; - ok_cancel(ui, confirm, &mut open); - if *confirm { - app.run_or_alert("data.sort", json!({"header": header, "keys": levels})); - } - if !open { - *confirm = true; - } -} - -fn name_manager(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) { - let list = app.session.run("formulas.nameManager", json!({})).unwrap_or(Json::Null); - let names = list.as_array().cloned().unwrap_or_default(); - let selected = names.get(d.list_index).cloned(); - let field = |n: &Json, k: &str| n.get(k).and_then(Json::as_str).unwrap_or("").to_string(); - ui.horizontal(|ui| { - if ui.button("New…").clicked() { - app.open_dialog("defineName", json!({})); - } - if ui.add_enabled(selected.is_some(), egui::Button::new("Edit…")).clicked() - && let Some(n) = &selected - { - app.open_dialog( - "defineName", - json!({ - "name": field(n, "name"), "scope": field(n, "scope"), "comment": field(n, "comment"), - "refersTo": field(n, "refersTo"), "oldName": field(n, "name"), "oldScope": field(n, "scope"), - }), - ); - } - if ui.add_enabled(selected.is_some(), egui::Button::new("Delete")).clicked() - && let Some(n) = &selected - { - app.run_or_alert("formulas.deleteName", json!({"name": field(n, "name"), "scope": field(n, "scope")})); - } - }); - ui.add_space(4.0); - egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { - egui::Grid::new("names").striped(true).num_columns(4).show(ui, |ui| { - ui.strong("Name"); - ui.strong("Value"); - ui.strong("Refers To"); - ui.strong("Scope"); - ui.end_row(); - if names.is_empty() { - ui.label(egui::RichText::new("No names defined").italics()); - ui.end_row(); - } - for (i, n) in names.iter().enumerate() { - if ui.selectable_label(i == d.list_index, field(n, "name")).clicked() { - d.list_index = i; - } - ui.label(n.get("value").map(|v| v.to_string()).unwrap_or_default().chars().take(30).collect::()); - ui.label(field(n, "refersTo")); - ui.label(field(n, "scope")); - ui.end_row(); - } - }); - }); - ui.add_space(8.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button(" Close ").clicked() { - *open = false; - } - }); -} - -/// The names Paste Names offers in the active workbook. -fn pasteable_names(app: &SheetApp) -> Vec { - app.session - .active() - .map(|doc| gridcraft_engine::cmd::formulas::pasteable_names(&doc.wb).into_iter().map(|(n, _)| n).collect()) - .unwrap_or_default() -} - -/// Puts a defined name into the formula being edited, at the caret. Without an edit under way it -/// starts one in the active cell with `=` and the name. -pub(crate) fn insert_name(app: &mut SheetApp, name: &str) { - match app.editor.as_mut() { - Some(ed) => ed.insert_at_caret(name), - None => app.begin_edit(Some(format!("={name}")), false), - } -} - -/// Paste Name (F3, Formulas › Use in Formula › Paste Names…): OK, or a double-click, inserts the -/// selected name (`formulas.pasteName` with `name`); Paste List writes every name and what it -/// refers to from the active cell. -fn paste_name(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - let names: Vec = - d.result.as_ref().and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_str).map(str::to_owned).collect()).unwrap_or_default(); - // The `name` value is the selection, so `ui.dialog.set` can choose one too. - if let Some(i) = d.values.get("name").and_then(Json::as_str).and_then(|n| names.iter().position(|x| x == n)) { - d.list_index = i; - } - let (down, up) = ui.input_mut(|i| (i.consume_key(egui::Modifiers::NONE, Key::ArrowDown), i.consume_key(egui::Modifiers::NONE, Key::ArrowUp))); - let last = names.len().saturating_sub(1); - if down { - d.list_index = (d.list_index + 1).min(last); - } - if up { - d.list_index = d.list_index.saturating_sub(1); - } - d.list_index = d.list_index.min(last); - ui.label(app.ui.language.tr("Paste name")); - if names.is_empty() { - ui.label(egui::RichText::new(app.ui.language.tr("No names defined")).italics()); - } - // A workbook can hold thousands of names: only the rows in view are laid out. - let row_height = ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y; - let step = row_height + ui.spacing().item_spacing.y; - egui::ScrollArea::vertical().max_height(220.0).auto_shrink([false, true]).show_rows(ui, row_height, names.len(), |ui, rows| { - let top = ui.cursor().top() - rows.start as f32 * step; - for (i, n) in names.iter().enumerate().skip(rows.start).take(rows.len()) { - let r = ui.selectable_label(i == d.list_index, n); - if r.clicked() { - d.list_index = i; - } - if r.double_clicked() { - *confirm = true; - } - } - // The arrow keys can move the selection onto a row that isn't laid out. - if down || up { - let rect = egui::Rect::from_min_size(egui::pos2(ui.min_rect().left(), top + d.list_index as f32 * step), egui::vec2(1.0, row_height)); - if !ui.clip_rect().contains_rect(rect) { - ui.scroll_to_rect(rect, Some(if down { egui::Align::Max } else { egui::Align::Min })); - } - } - }); - ui.add_space(4.0); - // Paste List is disabled while a cell is being edited. - if ui.add_enabled(!names.is_empty() && app.editor.is_none(), egui::Button::new(app.ui.language.tr("Paste List"))).clicked() { - app.run_or_alert("formulas.pasteName", json!({"list": true})); - *open = false; - return; - } - if let Some(n) = names.get(d.list_index) { - d.values.insert("name".into(), json!(n)); - } - ok_cancel(ui, confirm, open); -} - -/// Every chart type the All Charts tab offers: `(type, subtype, label)` for `insert.chart`. -const ALL_CHARTS: [(&str, &str, &str); 21] = [ - ("column", "", "Clustered Column"), - ("column", "stacked", "Stacked Column"), - ("column", "stacked100", "100% Stacked Column"), - ("bar", "", "Clustered Bar"), - ("bar", "stacked", "Stacked Bar"), - ("bar", "stacked100", "100% Stacked Bar"), - ("line", "", "Line"), - ("line", "markers", "Line with Markers"), - ("line", "stacked", "Stacked Line"), - ("pie", "", "Pie"), - ("doughnut", "", "Doughnut"), - ("area", "", "Area"), - ("area", "stacked", "Stacked Area"), - ("area", "stacked100", "100% Stacked Area"), - ("scatter", "", "Scatter"), - ("scatter", "lines", "Scatter with Lines"), - ("radar", "", "Radar"), - ("histogram", "", "Histogram"), - ("waterfall", "", "Waterfall"), - ("funnel", "", "Funnel"), - ("combo", "", "Combo"), -]; - -/// The chart types listed on the dialog's current tab. -fn chart_choices(d: &Dialog) -> Vec<(String, String, String)> { - if d.tab == "All Charts" { - return ALL_CHARTS.iter().map(|(t, s, l)| (t.to_string(), s.to_string(), l.to_string())).collect(); - } - let field = |x: &Json, k: &str| x.get(k).and_then(Json::as_str).unwrap_or("").to_string(); - d.result - .as_ref() - .and_then(Json::as_array) - .map(|a| a.iter().map(|x| (field(x, "type"), field(x, "subtype"), field(x, "label"))).collect()) - .unwrap_or_default() -} - -/// Selects entry `i` of the current tab: OK inserts it. -fn select_chart(d: &mut Dialog, i: usize) { - let Some((t, sub, _)) = chart_choices(d).into_iter().nth(i) else { return }; - d.list_index = i; - d.values.insert("type".into(), json!(t)); - d.values.insert("subtype".into(), json!(sub)); -} - -fn chart_icon(t: &str) -> Icon { - match t { - "column" | "bar" | "histogram" | "waterfall" | "funnel" | "combo" => Icon::ChartBar, - "line" | "area" | "radar" => Icon::ChartLine, - "pie" | "doughnut" => Icon::ChartPie, - "scatter" | "bubble" => Icon::ChartScatter, - _ => Icon::Chart, - } -} - -fn recommended_charts(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - ui.horizontal(|ui| { - for tab in ["Recommended Charts", "All Charts"] { - if ui.selectable_label(d.tab == tab, tab).clicked() && d.tab != tab { - d.tab = tab.to_string(); - select_chart(d, 0); - } - } - }); - ui.separator(); - let choices = chart_choices(d); - let t = crate::theme::Tokens::get(ui.ctx()); - ui.horizontal_top(|ui| { - ui.vertical(|ui| { - ui.set_width(190.0); - egui::ScrollArea::vertical().id_salt("chart_choices").max_height(300.0).show(ui, |ui| { - for (i, (kind, _, label)) in choices.iter().enumerate() { - let resp = ui - .horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); - icons::paint(ui.painter(), r.shrink(2.0), chart_icon(kind), t.text); - ui.selectable_label(i == d.list_index, label.as_str()) - }) - .inner; - if resp.clicked() { - select_chart(d, i); - } - if resp.double_clicked() { - select_chart(d, i); - *confirm = true; - } - } - }); - }); - ui.add_space(12.0); - ui.vertical(|ui| { - let label = choices.get(d.list_index).map(|c| c.2.clone()).unwrap_or_default(); - ui.strong(label); - let (rect, _) = ui.allocate_exact_size(vec2(360.0, 216.0), Sense::hover()); - ui.painter().rect_filled(rect, 2.0, Color32::WHITE); - ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); - let params = json!({"type": d.values.get("type").cloned().unwrap_or(Json::Null), "subtype": d.values.get("subtype").cloned().unwrap_or(Json::Null)}); - if let Some(doc) = app.session.active() - && let Ok((sheet, chart)) = gridcraft_engine::cmd::insert::build_chart(&app.session, ¶ms) - { - crate::chartview::paint_chart(ui.painter(), rect, &doc.wb, sheet, &chart); - } - }); - }); - ok_cancel(ui, confirm, open); - if *confirm { - *confirm = false; - let params = - json!({"type": d.values.get("type").cloned().unwrap_or(Json::Null), "subtype": d.values.get("subtype").cloned().unwrap_or(Json::Null)}); - match app.run("insert.chart", params) { - Ok(r) => { - // The new chart is selected, as after inserting any object. - app.selected_chart = r.get("chart").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()); - *open = false; - } - Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), - } - } -} - -/// Draws chart range, series keep/remove and category-range controls and runs `chart.selectData` when something changed. -fn select_data(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - let text = |d: &Dialog, k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); - let edit = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str| { - let label = ui.label(label); - let mut v = text(d, key); - if ui.add(egui::TextEdit::singleline(&mut v).desired_width(420.0)).labelled_by(label.id).changed() { - d.values.insert(key.into(), json!(v)); - } - }; - edit(ui, d, "range", "Chart data range:"); - ui.add_space(6.0); - ui.strong("Legend Entries (Series)"); - let names: Vec = - d.values.get("names").and_then(Json::as_array).map(|a| a.iter().map(|n| n.as_str().unwrap_or("").to_string()).collect()).unwrap_or_default(); - let mut remove: Vec = - d.values.get("remove").and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_u64).collect()).unwrap_or_default(); - let kept = names.len().saturating_sub(remove.len()); - egui::ScrollArea::vertical().id_salt("series").max_height(160.0).show(ui, |ui| { - for (i, name) in names.iter().enumerate() { - let i = i as u64; - let removed = remove.contains(&i); - ui.horizontal(|ui| { - let label = if name.is_empty() { format!("Series{}", i + 1) } else { name.clone() }; - ui.label(if removed { egui::RichText::new(label).strikethrough().weak() } else { egui::RichText::new(label) }); - if removed { - if ui.small_button("Keep").clicked() { - remove.retain(|x| *x != i); - } - } else if ui.add_enabled(kept > 1, egui::Button::new("Remove").small()).clicked() { - remove.push(i); - } - }); - } - }); - d.values.insert("remove".into(), json!(remove)); - ui.add_space(6.0); - edit(ui, d, "categories", "Horizontal (Category) Axis Labels:"); - ok_cancel(ui, confirm, open); - if *confirm { - *confirm = false; - let mut params = serde_json::Map::new(); - params.insert("chart".into(), d.values.get("chart").cloned().unwrap_or(Json::Null)); - let range_changed = text(d, "range") != text(d, "range0"); - if range_changed { - params.insert("range".into(), json!(text(d, "range"))); - } else if !remove.is_empty() { - // A new range rebuilds the series; removals only apply to the current ones. - params.insert("removeSeries".into(), json!(remove)); - } - if text(d, "categories") != text(d, "categories0") || (range_changed && !text(d, "categories").is_empty()) { - params.insert("categories".into(), json!(text(d, "categories"))); - } - if params.len() == 1 { - *open = false; - return; - } - match app.run("chart.selectData", Json::Object(params)) { - Ok(_) => *open = false, - Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), - } - } -} - -fn manage_rules(app: &mut SheetApp, ui: &mut egui::Ui) { - let list = app.session.run("home.manageRules", json!({})).unwrap_or(Json::Null); - let rules = list["rules"].as_array().cloned().unwrap_or_default(); - if rules.is_empty() { - ui.label("There are no conditional formatting rules on this sheet."); - } - for (i, r) in rules.iter().enumerate() { - ui.horizontal(|ui| { - let kind = r["rule"].as_object().and_then(|o| o.keys().next().cloned()).unwrap_or_default(); - ui.label(format!( - "{}. {kind} applies to {}", - i + 1, - r["ranges"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::>().join(",")).unwrap_or_default() - )); - if ui.small_button("▲").clicked() { - app.run_or_alert("home.manageRules", json!({"moveUp": i})); - } - if ui.small_button("▼").clicked() { - app.run_or_alert("home.manageRules", json!({"moveDown": i})); - } - if ui.small_button("Delete").clicked() { - app.run_or_alert("home.manageRules", json!({"delete": i})); - } - }); - } -} - -fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { - let (down, up, home, end, pgup, pgdn) = ui.input_mut(|i| { - ( - i.consume_key(egui::Modifiers::NONE, Key::ArrowDown), - i.consume_key(egui::Modifiers::NONE, Key::ArrowUp), - i.consume_key(egui::Modifiers::CTRL, Key::Home), - i.consume_key(egui::Modifiers::CTRL, Key::End), - i.consume_key(egui::Modifiers::NONE, Key::PageUp), - i.consume_key(egui::Modifiers::NONE, Key::PageDown), - ) - }); - let width = ui.available_width(); - let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(width).hint_text("Type a command, e.g. freeze, chart, bold…")); - - if !d.focus_initialized { - r.request_focus(); - d.focus_initialized = true; - } - - let q = d.search.to_ascii_lowercase(); - let cmds: Vec<(String, String, String)> = app - .session - .commands() - .into_iter() - .filter(|c| c.enabled) - .filter(|c| q.is_empty() || c.label.to_ascii_lowercase().contains(&q) || c.id.to_ascii_lowercase().contains(&q)) - .map(|c| (c.id.to_string(), c.label.to_string(), c.menu.join(" › "))) - .take(40) - .collect(); - - let last = cmds.len().saturating_sub(1); - - let row_h = ui.spacing().interact_size.y.max(20.0); - let row_step = row_h + ui.spacing().item_spacing.y; - - const LIST_HEIGHT: f32 = 320.0; - - let page_size = ((LIST_HEIGHT / row_step).floor() as usize).saturating_sub(1).max(1); - - if down { - d.list_index = (d.list_index + 1).min(last); - } - if up { - d.list_index = d.list_index.saturating_sub(1); - } - if pgdn { - d.list_index = (d.list_index + page_size).min(last); - } - if pgup { - d.list_index = d.list_index.saturating_sub(page_size); - } - if home { - d.list_index = 0; - } - if end { - d.list_index = last; - } - - d.list_index = d.list_index.min(last); - - egui::ScrollArea::vertical().max_height(LIST_HEIGHT).auto_shrink([true, false]).show(ui, |ui| { - let scroll_area_width = ui.available_width(); - for (i, (id, label, menu)) in cmds.iter().enumerate() { - let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{label} {menu}")).min_size(vec2(scroll_area_width, 20.0))); - - if (down || up || home || end || pgup || pgdn) && i == d.list_index && !ui.clip_rect().contains_rect(r.rect) { - r.scroll_to_me(Some(if down || end || pgdn { egui::Align::Max } else { egui::Align::Min })); - } - - if r.clicked() { - d.list_index = i; - *confirm = true; - } - - r.on_hover_text(id); - } - }); - if ui.input(|i| i.key_pressed(Key::Enter)) { - *confirm = true; - } - if *confirm && let Some((id, ..)) = cmds.get(d.list_index) { - let id = id.clone(); - app.dialog = None; - app.run_or_alert(&id, json!({})); - } -} - -fn goal_seek(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { - let get = |k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); - let (mut set, mut to, mut changing) = (get("set"), get("to"), get("changing")); - egui::Grid::new("gs").show(ui, |ui| { - ui.label("Set cell:"); - ui.text_edit_singleline(&mut set); - ui.end_row(); - ui.label("To value:"); - ui.text_edit_singleline(&mut to); - ui.end_row(); - ui.label("By changing cell:"); - ui.text_edit_singleline(&mut changing); - ui.end_row(); - }); - d.values.insert("set".into(), json!(set.clone())); - d.values.insert("to".into(), json!(to.clone())); - d.values.insert("changing".into(), json!(changing.clone())); - let mut open = true; - ok_cancel(ui, confirm, &mut open); - if *confirm { - match app.run("data.goalSeek", json!({"set": set, "to": to.parse::().unwrap_or(0.0), "changing": changing})) { - Ok(r) => app.toast = Some((format!("Goal Seeking found a solution: {}", r["value"]), crate::now_ms())), - Err(e) => app.message = Some(("Goal Seek".into(), crate::clean_error(&e))), - } - } - if !open { - *confirm = true; - } -} - -/// The fields of one Analysis ToolPak tool's form (input and options; output is drawn apart). -fn toolpak_fields(tool: &str) -> Vec { - use Field::*; - let grouped = || Choice { key: "groupedBy", label: "Grouped By:", options: vec![("columns", "Columns"), ("rows", "Rows")] }; - let labels = || Check { key: "labels", label: "Labels in first row" }; - let range = || Text { key: "range", label: "Input Range:" }; - let two = || vec![Text { key: "range1", label: "Variable 1 Range:" }, Text { key: "range2", label: "Variable 2 Range:" }]; - match tool { - "descriptive" => vec![ - range(), - grouped(), - labels(), - Check { key: "summary", label: "Summary statistics" }, - Text { key: "confidence", label: "Confidence Level for Mean (%), blank for none:" }, - Text { key: "kthLargest", label: "Kth Largest (blank for none):" }, - Text { key: "kthSmallest", label: "Kth Smallest (blank for none):" }, - ], - "histogram" => vec![ - range(), - Text { key: "bins", label: "Bin Range (blank for automatic bins):" }, - labels(), - Check { key: "pareto", label: "Pareto (sorted histogram)" }, - Check { key: "cumulative", label: "Cumulative Percentage" }, - ], - "correlation" | "covariance" | "rankPercentile" => vec![range(), grouped(), labels()], - "anovaSingle" => vec![range(), grouped(), labels(), Text { key: "alpha", label: "Alpha:" }], - "regression" => vec![ - Text { key: "inputY", label: "Input Y Range:" }, - Text { key: "inputX", label: "Input X Range:" }, - labels(), - Check { key: "constantZero", label: "Constant is Zero" }, - Text { key: "confidence", label: "Confidence Level (%):" }, - Check { key: "residuals", label: "Residuals" }, - Check { key: "standardizedResiduals", label: "Standardized Residuals" }, - Check { key: "normalProbability", label: "Normal Probability" }, - ], - "movingAverage" => { - vec![range(), labels(), Text { key: "interval", label: "Interval:" }, Check { key: "standardErrors", label: "Standard Errors" }] - } - "exponentialSmoothing" => { - vec![range(), labels(), Text { key: "damping", label: "Damping factor:" }, Check { key: "standardErrors", label: "Standard Errors" }] - } - "fTest" => { - let mut v = two(); - v.extend([labels(), Text { key: "alpha", label: "Alpha:" }]); - v - } - _ => { - let mut v = two(); - v.extend([ - Text { key: "hypothesizedDifference", label: "Hypothesized Mean Difference:" }, - labels(), - Text { key: "alpha", label: "Alpha:" }, - ]); - v - } - } -} - -/// The starting values of a tool's form: the selection as its input, a new sheet as output. -fn toolpak_defaults(sel: &str, tool: &str) -> Map { - let mut v = json!({ - "tool": tool, "range": sel, "inputY": "", "inputX": "", "range1": sel, "range2": "", "bins": "", - "groupedBy": "columns", "labels": false, "summary": true, "confidence": "", "kthLargest": "", "kthSmallest": "", - "interval": "3", "damping": "0.3", "alpha": "0.05", "hypothesizedDifference": "0", - "outputTo": "newSheet", "output": "", "newSheet": "", - }); - if tool == "regression" { - v["confidence"] = json!("95"); - v["inputY"] = json!(sel); - } - v.as_object().cloned().unwrap_or_default() -} - -/// Data › Data Analysis: pick a tool, then fill in its form. OK runs `data.analysisToolPak` -/// (through the dialog's command); a report that would replace data asks first. -fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - use gridcraft_engine::cmd::analysis::TOOLS; - if d.values.get("askOverwrite").and_then(Json::as_bool) == Some(true) { - ui.label("The output range already holds data. Replace it with the report?"); - ui.horizontal(|ui| { - if ui.button("Replace").clicked() { - d.values.remove("askOverwrite"); - d.values.insert("overwrite".into(), json!(true)); - *confirm = true; - } - if ui.button("Cancel").clicked() { - d.values.remove("askOverwrite"); - } - }); - return; - } - let tool = d.values.get("tool").and_then(Json::as_str).unwrap_or("").to_string(); - let Some((_, label)) = TOOLS.iter().find(|(id, _)| *id == tool) else { - ui.label(egui::RichText::new("Analysis Tools").strong()); - let pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string(); - let mut chosen = false; - egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { - for (id, label) in TOOLS { - let r = ui.selectable_label(pick == *id, *label); - if r.clicked() { - d.values.insert("pick".into(), json!(id)); - } - if r.double_clicked() { - chosen = true; - } - } - }); - let mut ok = false; - ok_cancel(ui, &mut ok, open); - let pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string(); - if (ok || chosen) && TOOLS.iter().any(|(id, _)| *id == pick) { - let sel = app.session.active().map(|doc| doc.selection.current().a1()).unwrap_or_default(); - d.values = toolpak_defaults(&sel, &pick); - } - return; - }; - d.title = label.to_string(); - d.fields = toolpak_fields(&tool); - form_fields(ui, d); - ui.separator(); - ui.label(egui::RichText::new("Output options").strong()); - let to = d.values.get("outputTo").and_then(Json::as_str).unwrap_or("newSheet").to_string(); - for (key, choice, text_label) in [("output", "range", "Output Range:"), ("newSheet", "newSheet", "New Worksheet Ply:")] { - ui.horizontal(|ui| { - if ui.radio(to == choice, text_label).clicked() { - d.values.insert("outputTo".into(), json!(choice)); - } - let mut t = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); - if ui.add(egui::TextEdit::singleline(&mut t).desired_width(160.0)).changed() { - d.values.insert(key.into(), json!(t)); - d.values.insert("outputTo".into(), json!(choice)); - } - }); - } - ok_cancel(ui, confirm, open); -} - -/// Data › Solver: the Solver Parameters form. Solve runs `data.solver` through the dialog's -/// command; the result opens Solver Results. -fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { - let lang = crate::i18n::current(ui.ctx()); - let text = |d: &Dialog, k: &str| d.values.get(k).map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())).unwrap_or_default(); - let edit = |ui: &mut egui::Ui, d: &mut Dialog, k: &str, width: f32, label: &str| { - let mut t = text(d, k); - if ui.add(egui::TextEdit::singleline(&mut t).desired_width(width)).accessible_name(lang.tr(label)).changed() { - d.values.insert(k.into(), json!(t)); - } - }; - ui.horizontal(|ui| { - ui.label("Set Objective:"); - edit(ui, d, "objective", 200.0, "Set Objective:"); - }); - let goal = text(d, "goal"); - ui.horizontal(|ui| { - ui.label("To:"); - for (g, label) in [("max", "Max"), ("min", "Min"), ("value", "Value Of:")] { - if ui.radio(goal == g, label).clicked() { - d.values.insert("goal".into(), json!(g)); - } - } - edit(ui, d, "value", 80.0, "Value Of:"); - }); - ui.label("By Changing Variable Cells:"); - edit(ui, d, "variables", 420.0, "By Changing Variable Cells:"); - ui.label("Subject to the Constraints:"); - let list: Vec = d - .values - .get("constraints") - .and_then(Json::as_array) - .map(|a| a.iter().map(|c| c.as_str().map(str::to_string).unwrap_or_else(|| c.to_string())).collect()) - .unwrap_or_default(); - egui::Frame::group(ui.style()).show(ui, |ui| { - ui.set_min_size(vec2(420.0, 90.0)); - egui::ScrollArea::vertical().max_height(110.0).show(ui, |ui| { - for (i, c) in list.iter().enumerate() { - if ui.selectable_label(d.list_index == i, c).clicked() { - d.list_index = i; - } - } - }); - }); - ui.horizontal(|ui| { - edit(ui, d, "newLhs", 110.0, "Constraint Cell Reference"); - let op = text(d, "newOp"); - egui::ComboBox::from_id_salt("solverOp") - .selected_text(op.clone()) - .width(60.0) - .show_ui(ui, |ui| { - for o in ["<=", "=", ">=", "int", "bin"] { - if ui.selectable_label(op == o, o).clicked() { - d.values.insert("newOp".into(), json!(o)); - } - } - }) - .response - .accessible_name(lang.tr("Constraint Operator")); - if !matches!(op.as_str(), "int" | "bin") { - edit(ui, d, "newRhs", 110.0, "Constraint Value"); - } - if ui.button("Add").clicked() { - let lhs = text(d, "newLhs"); - if !lhs.trim().is_empty() { - let rhs = if matches!(op.as_str(), "int" | "bin") { String::new() } else { format!(" {}", text(d, "newRhs").trim()) }; - let mut list = list.clone(); - list.push(format!("{} {op}{rhs}", lhs.trim())); - d.values.insert("constraints".into(), json!(list)); - d.values.insert("newLhs".into(), json!("")); - d.values.insert("newRhs".into(), json!("")); - } - } - if ui.add_enabled(d.list_index < list.len(), egui::Button::new("Delete")).clicked() { - let mut list = list.clone(); - if d.list_index < list.len() { - list.remove(d.list_index); - } - d.values.insert("constraints".into(), json!(list)); - } - }); - let mut nonneg = d.values.get("nonNegative").and_then(Json::as_bool).unwrap_or(true); - if ui.checkbox(&mut nonneg, "Make Unconstrained Variables Non-Negative").changed() { - d.values.insert("nonNegative".into(), json!(nonneg)); - } - let method = text(d, "method"); - ui.horizontal(|ui| { - ui.label("Select a Solving Method:"); - let label = |m: &str| match m { - "simplex" => "Simplex LP", - "evolutionary" => "Evolutionary", - _ => "GRG Nonlinear", - }; - egui::ComboBox::from_id_salt("solverMethod") - .selected_text(label(&method)) - .show_ui(ui, |ui| { - for m in ["grg", "simplex", "evolutionary"] { - if ui.selectable_label(method == m, label(m)).clicked() { - d.values.insert("method".into(), json!(m)); - } - } - }) - .response - .accessible_name(lang.tr("Select a Solving Method:")); - }); - ui.label( - egui::RichText::new(match method.as_str() { - "simplex" => "Simplex LP: for linear models; integer and binary constraints by branch and bound.", - "evolutionary" => "Evolutionary: a genetic search for models that aren't smooth; every variable cell needs bounds.", - _ => "GRG Nonlinear: a local optimum of a smooth model, from the current values.", - }) - .small(), - ); - ui.add_space(8.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let t = crate::theme::Tokens::get(ui.ctx()); - let solve = ui.add(egui::Button::new(egui::RichText::new(" Solve ").color(t.on_accent)).fill(t.accent)); - if solve.clicked() && !text(d, "objective").trim().is_empty() { - *confirm = true; - } - if ui.button(" Close ").clicked() { - *open = false; - } - }); -} - -/// Solver Results: the outcome, and keeping or restoring the variable cells. -fn solver_results(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) { - let message = d.values.get("message").and_then(Json::as_str).unwrap_or("").to_string(); - ui.label(message); - let changed = d.values.get("changed").and_then(Json::as_bool).unwrap_or(false); - let restore = d.values.get("restore").and_then(Json::as_bool).unwrap_or(false); - if changed { - ui.add_space(4.0); - if ui.radio(!restore, "Keep Solver Solution").clicked() { - d.values.insert("restore".into(), json!(false)); - } - if ui.radio(restore, "Restore Original Values").clicked() { - d.values.insert("restore".into(), json!(true)); - } - } - // Reports describe the solution whether it is kept or not. - let solved = d.values.get("values").and_then(Json::as_object).is_some_and(|v| !v.is_empty()); - if solved { - ui.add_space(4.0); - ui.label("Reports:"); - for (key, label) in [("answerReport", "Answer"), ("limitsReport", "Limits")] { - let mut on = d.values.get(key).and_then(Json::as_bool).unwrap_or(false); - if ui.checkbox(&mut on, label).changed() { - d.values.insert(key.into(), json!(on)); - } - } - } - let mut confirm = false; - ok_cancel(ui, &mut confirm, open); - if confirm { - // The solution (and the saved model) is one undo step. - if changed && restore { - app.run_or_alert("edit.undo", json!({})); - } - let reports: Vec<&str> = [("answerReport", "answer"), ("limitsReport", "limits")] - .into_iter() - .filter(|(key, _)| d.values.get(*key).and_then(Json::as_bool) == Some(true)) - .map(|(_, kind)| kind) - .collect(); - if solved && !reports.is_empty() { - let field = |k: &str| d.values.get(k).cloned().unwrap_or(Json::Null); - let params = json!({"reports": reports, "model": field("model"), "values": field("values"), "original": field("original"), "message": field("message")}); - app.run_or_alert("data.solverReport", params); - } - *open = false; - } -} - -fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) { - let r = d.result.clone().unwrap_or(Json::Null); - if let Some(e) = r.get("error").and_then(Json::as_str) { - ui.label(app.ui.language.tr_error(e)); - return; - } - let issues = r["issues"].as_array().cloned().unwrap_or_default(); - let Some(it) = issues.get(d.list_index) else { - ui.label("The spelling check is complete for the entire sheet."); - if ui.button("OK").clicked() { - *open = false; - } - return; - }; - let word = it["word"].as_str().unwrap_or("").to_string(); - let cell = it["cell"].as_str().unwrap_or("").to_string(); - ui.label(format!("Not in Dictionary ({cell}):")); - ui.label(egui::RichText::new(&word).strong().color(crate::theme::Tokens::get(ui.ctx()).danger)); - let sugg: Vec = - it["suggestions"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default(); - ui.label("Suggestions:"); - let mut pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string(); - for s in &sugg { - if ui.selectable_label(pick == *s, s).clicked() { - pick = s.clone(); - } - } - if sugg.is_empty() { - ui.label(egui::RichText::new("(No suggestions)").italics()); - } - d.values.insert("pick".into(), json!(pick.clone())); - let _ = app.session.run("selection.set", json!({"cell": cell})); - ui.horizontal(|ui| { - if ui.button("Ignore Once").clicked() { - d.list_index += 1; - } - if ui.button("Add to Dictionary").clicked() { - let _ = app.session.run("review.addToDictionary", json!({"word": word})); - d.list_index += 1; - } - if !pick.is_empty() { - if ui.button("Change").clicked() { - app.run_or_alert("review.changeSpelling", json!({"cell": cell, "word": word, "to": pick})); - d.list_index += 1; - } - if ui.button("Change All").clicked() { - app.run_or_alert("review.changeSpelling", json!({"word": word, "to": pick, "all": true})); - d.list_index += 1; - } - } - }); -} - -#[cfg(test)] -mod data_validation_tests { - use super::*; - - #[test] - fn list_uses_one_source_and_does_not_submit_a_second_list() { - let fields = data_validation_fields("list", "between"); - assert!(fields.iter().any(|f| matches!(f, Field::Text { key: "formula1", label: "Source:" }))); - assert!(!fields.iter().any(|f| matches!(f, Field::Text { key: "formula2", .. } | Field::Choice { key: "operator", .. }))); - - let dialog = Dialog::form( - "dataValidation", - "Data Validation", - "data.validation", - fields, - json!({"type": "list", "operator": "between", "formula1": "ABC,DEF", "formula2": "GHI,JKL", "dropdown": true}), - ); - let params = build_params(&dialog); - assert_eq!(params["formula1"], "ABC,DEF"); - assert!(params.get("formula2").is_none()); - assert!(params.get("operator").is_none()); - } - - #[test] - fn only_between_operators_show_and_submit_two_bounds() { - let two = data_validation_fields("whole", "between"); - assert!(two.iter().any(|f| matches!(f, Field::Text { key: "formula2", label: "Maximum:" }))); - let one = data_validation_fields("date", "lessOrEqual"); - assert!(one.iter().any(|f| matches!(f, Field::Text { key: "formula1", label: "End date:" }))); - assert!(!one.iter().any(|f| matches!(f, Field::Text { key: "formula2", .. }))); - - let dialog = Dialog::form( - "dataValidation", - "Data Validation", - "data.validation", - one, - json!({"type": "date", "operator": "lessOrEqual", "formula1": "2026-10-12", "formula2": "2030-01-01"}), - ); - let params = build_params(&dialog); - assert_eq!(params["operator"], "lessOrEqual"); - assert_eq!(params["formula1"], "2026-10-12"); - assert!(params.get("formula2").is_none()); - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/dialogs/about.rs b/crates/ui-egui/src/dialogs/about.rs new file mode 100644 index 000000000..a337627d9 --- /dev/null +++ b/crates/ui-egui/src/dialogs/about.rs @@ -0,0 +1,26 @@ +use super::*; + +/// Help ▸ About GridCraft: the app, its contributors and the AI models that helped. The credits +/// are compiled in (`crate::credits`, docs/contributors.md). +pub(super) fn about(ui: &mut egui::Ui, d: &mut Dialog) { + ui.horizontal(|ui| { + for tab in ["About", "Contributors", "Models"] { + if ui.selectable_label(d.tab == tab, tab).clicked() { + d.tab = tab.to_string(); + } + } + }); + ui.separator(); + match d.tab.as_str() { + "Contributors" => crate::credits::contributors_ui(ui), + "Models" => crate::credits::models_ui(ui), + _ => { + ui.heading("GridCraft"); + ui.label(format!("Version {}", env!("CARGO_PKG_VERSION"))); + ui.add_space(4.0); + ui.label("A clean-room, open-source, Rust-native spreadsheet. Part of ArtCraft."); + ui.hyperlink_to("getartcraft.com/apps/gridcraft", "https://getartcraft.com/apps/gridcraft"); + ui.hyperlink_to("Join the ArtCraft Discord", "https://discord.gg/artcraft"); + } + } +} diff --git a/crates/ui-egui/src/dialogs/analysis.rs b/crates/ui-egui/src/dialogs/analysis.rs new file mode 100644 index 000000000..595be229c --- /dev/null +++ b/crates/ui-egui/src/dialogs/analysis.rs @@ -0,0 +1,344 @@ +use super::show::{form_fields, ok_cancel}; +use super::*; + +pub(super) fn goal_seek(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + let get = |k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); + let (mut set, mut to, mut changing) = (get("set"), get("to"), get("changing")); + egui::Grid::new("gs").show(ui, |ui| { + ui.label("Set cell:"); + ui.text_edit_singleline(&mut set); + ui.end_row(); + ui.label("To value:"); + ui.text_edit_singleline(&mut to); + ui.end_row(); + ui.label("By changing cell:"); + ui.text_edit_singleline(&mut changing); + ui.end_row(); + }); + d.values.insert("set".into(), json!(set.clone())); + d.values.insert("to".into(), json!(to.clone())); + d.values.insert("changing".into(), json!(changing.clone())); + let mut open = true; + ok_cancel(ui, confirm, &mut open); + if *confirm { + match app.run("data.goalSeek", json!({"set": set, "to": to.parse::().unwrap_or(0.0), "changing": changing})) { + Ok(r) => app.toast = Some((format!("Goal Seeking found a solution: {}", r["value"]), crate::now_ms())), + Err(e) => app.message = Some(("Goal Seek".into(), crate::clean_error(&e))), + } + } + if !open { + *confirm = true; + } +} + +/// The fields of one Analysis ToolPak tool's form (input and options; output is drawn apart). +fn toolpak_fields(tool: &str) -> Vec { + use Field::*; + let grouped = || Choice { key: "groupedBy", label: "Grouped By:", options: vec![("columns", "Columns"), ("rows", "Rows")] }; + let labels = || Check { key: "labels", label: "Labels in first row" }; + let range = || Text { key: "range", label: "Input Range:" }; + let two = || vec![Text { key: "range1", label: "Variable 1 Range:" }, Text { key: "range2", label: "Variable 2 Range:" }]; + match tool { + "descriptive" => vec![ + range(), + grouped(), + labels(), + Check { key: "summary", label: "Summary statistics" }, + Text { key: "confidence", label: "Confidence Level for Mean (%), blank for none:" }, + Text { key: "kthLargest", label: "Kth Largest (blank for none):" }, + Text { key: "kthSmallest", label: "Kth Smallest (blank for none):" }, + ], + "histogram" => vec![ + range(), + Text { key: "bins", label: "Bin Range (blank for automatic bins):" }, + labels(), + Check { key: "pareto", label: "Pareto (sorted histogram)" }, + Check { key: "cumulative", label: "Cumulative Percentage" }, + ], + "correlation" | "covariance" | "rankPercentile" => vec![range(), grouped(), labels()], + "anovaSingle" => vec![range(), grouped(), labels(), Text { key: "alpha", label: "Alpha:" }], + "regression" => vec![ + Text { key: "inputY", label: "Input Y Range:" }, + Text { key: "inputX", label: "Input X Range:" }, + labels(), + Check { key: "constantZero", label: "Constant is Zero" }, + Text { key: "confidence", label: "Confidence Level (%):" }, + Check { key: "residuals", label: "Residuals" }, + Check { key: "standardizedResiduals", label: "Standardized Residuals" }, + Check { key: "normalProbability", label: "Normal Probability" }, + ], + "movingAverage" => { + vec![range(), labels(), Text { key: "interval", label: "Interval:" }, Check { key: "standardErrors", label: "Standard Errors" }] + } + "exponentialSmoothing" => { + vec![range(), labels(), Text { key: "damping", label: "Damping factor:" }, Check { key: "standardErrors", label: "Standard Errors" }] + } + "fTest" => { + let mut v = two(); + v.extend([labels(), Text { key: "alpha", label: "Alpha:" }]); + v + } + _ => { + let mut v = two(); + v.extend([ + Text { key: "hypothesizedDifference", label: "Hypothesized Mean Difference:" }, + labels(), + Text { key: "alpha", label: "Alpha:" }, + ]); + v + } + } +} + +/// The starting values of a tool's form: the selection as its input, a new sheet as output. +pub(super) fn toolpak_defaults(sel: &str, tool: &str) -> Map { + let mut v = json!({ + "tool": tool, "range": sel, "inputY": "", "inputX": "", "range1": sel, "range2": "", "bins": "", + "groupedBy": "columns", "labels": false, "summary": true, "confidence": "", "kthLargest": "", "kthSmallest": "", + "interval": "3", "damping": "0.3", "alpha": "0.05", "hypothesizedDifference": "0", + "outputTo": "newSheet", "output": "", "newSheet": "", + }); + if tool == "regression" { + v["confidence"] = json!("95"); + v["inputY"] = json!(sel); + } + v.as_object().cloned().unwrap_or_default() +} + +/// Data › Data Analysis: pick a tool, then fill in its form. OK runs `data.analysisToolPak` +/// (through the dialog's command); a report that would replace data asks first. +pub(super) fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + use gridcraft_engine::cmd::analysis::TOOLS; + if d.values.get("askOverwrite").and_then(Json::as_bool) == Some(true) { + ui.label("The output range already holds data. Replace it with the report?"); + ui.horizontal(|ui| { + if ui.button("Replace").clicked() { + d.values.remove("askOverwrite"); + d.values.insert("overwrite".into(), json!(true)); + *confirm = true; + } + if ui.button("Cancel").clicked() { + d.values.remove("askOverwrite"); + } + }); + return; + } + let tool = d.values.get("tool").and_then(Json::as_str).unwrap_or("").to_string(); + let Some((_, label)) = TOOLS.iter().find(|(id, _)| *id == tool) else { + ui.label(egui::RichText::new("Analysis Tools").strong()); + let pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string(); + let mut chosen = false; + egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { + for (id, label) in TOOLS { + let r = ui.selectable_label(pick == *id, *label); + if r.clicked() { + d.values.insert("pick".into(), json!(id)); + } + if r.double_clicked() { + chosen = true; + } + } + }); + let mut ok = false; + ok_cancel(ui, &mut ok, open); + let pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string(); + if (ok || chosen) && TOOLS.iter().any(|(id, _)| *id == pick) { + let sel = app.session.active().map(|doc| doc.selection.current().a1()).unwrap_or_default(); + d.values = toolpak_defaults(&sel, &pick); + } + return; + }; + d.title = label.to_string(); + d.fields = toolpak_fields(&tool); + form_fields(ui, d); + ui.separator(); + ui.label(egui::RichText::new("Output options").strong()); + let to = d.values.get("outputTo").and_then(Json::as_str).unwrap_or("newSheet").to_string(); + for (key, choice, text_label) in [("output", "range", "Output Range:"), ("newSheet", "newSheet", "New Worksheet Ply:")] { + ui.horizontal(|ui| { + if ui.radio(to == choice, text_label).clicked() { + d.values.insert("outputTo".into(), json!(choice)); + } + let mut t = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); + if ui.add(egui::TextEdit::singleline(&mut t).desired_width(160.0)).changed() { + d.values.insert(key.into(), json!(t)); + d.values.insert("outputTo".into(), json!(choice)); + } + }); + } + ok_cancel(ui, confirm, open); +} + +/// Data › Solver: the Solver Parameters form. Solve runs `data.solver` through the dialog's +/// command; the result opens Solver Results. +pub(super) fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let lang = crate::i18n::current(ui.ctx()); + let text = |d: &Dialog, k: &str| d.values.get(k).map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())).unwrap_or_default(); + let edit = |ui: &mut egui::Ui, d: &mut Dialog, k: &str, width: f32, label: &str| { + let mut t = text(d, k); + if ui.add(egui::TextEdit::singleline(&mut t).desired_width(width)).accessible_name(lang.tr(label)).changed() { + d.values.insert(k.into(), json!(t)); + } + }; + ui.horizontal(|ui| { + ui.label("Set Objective:"); + edit(ui, d, "objective", 200.0, "Set Objective:"); + }); + let goal = text(d, "goal"); + ui.horizontal(|ui| { + ui.label("To:"); + for (g, label) in [("max", "Max"), ("min", "Min"), ("value", "Value Of:")] { + if ui.radio(goal == g, label).clicked() { + d.values.insert("goal".into(), json!(g)); + } + } + edit(ui, d, "value", 80.0, "Value Of:"); + }); + ui.label("By Changing Variable Cells:"); + edit(ui, d, "variables", 420.0, "By Changing Variable Cells:"); + ui.label("Subject to the Constraints:"); + let list: Vec = d + .values + .get("constraints") + .and_then(Json::as_array) + .map(|a| a.iter().map(|c| c.as_str().map(str::to_string).unwrap_or_else(|| c.to_string())).collect()) + .unwrap_or_default(); + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.set_min_size(vec2(420.0, 90.0)); + egui::ScrollArea::vertical().max_height(110.0).show(ui, |ui| { + for (i, c) in list.iter().enumerate() { + if ui.selectable_label(d.list_index == i, c).clicked() { + d.list_index = i; + } + } + }); + }); + ui.horizontal(|ui| { + edit(ui, d, "newLhs", 110.0, "Constraint Cell Reference"); + let op = text(d, "newOp"); + egui::ComboBox::from_id_salt("solverOp") + .selected_text(op.clone()) + .width(60.0) + .show_ui(ui, |ui| { + for o in ["<=", "=", ">=", "int", "bin"] { + if ui.selectable_label(op == o, o).clicked() { + d.values.insert("newOp".into(), json!(o)); + } + } + }) + .response + .accessible_name(lang.tr("Constraint Operator")); + if !matches!(op.as_str(), "int" | "bin") { + edit(ui, d, "newRhs", 110.0, "Constraint Value"); + } + if ui.button("Add").clicked() { + let lhs = text(d, "newLhs"); + if !lhs.trim().is_empty() { + let rhs = if matches!(op.as_str(), "int" | "bin") { String::new() } else { format!(" {}", text(d, "newRhs").trim()) }; + let mut list = list.clone(); + list.push(format!("{} {op}{rhs}", lhs.trim())); + d.values.insert("constraints".into(), json!(list)); + d.values.insert("newLhs".into(), json!("")); + d.values.insert("newRhs".into(), json!("")); + } + } + if ui.add_enabled(d.list_index < list.len(), egui::Button::new("Delete")).clicked() { + let mut list = list.clone(); + if d.list_index < list.len() { + list.remove(d.list_index); + } + d.values.insert("constraints".into(), json!(list)); + } + }); + let mut nonneg = d.values.get("nonNegative").and_then(Json::as_bool).unwrap_or(true); + if ui.checkbox(&mut nonneg, "Make Unconstrained Variables Non-Negative").changed() { + d.values.insert("nonNegative".into(), json!(nonneg)); + } + let method = text(d, "method"); + ui.horizontal(|ui| { + ui.label("Select a Solving Method:"); + let label = |m: &str| match m { + "simplex" => "Simplex LP", + "evolutionary" => "Evolutionary", + _ => "GRG Nonlinear", + }; + egui::ComboBox::from_id_salt("solverMethod") + .selected_text(label(&method)) + .show_ui(ui, |ui| { + for m in ["grg", "simplex", "evolutionary"] { + if ui.selectable_label(method == m, label(m)).clicked() { + d.values.insert("method".into(), json!(m)); + } + } + }) + .response + .accessible_name(lang.tr("Select a Solving Method:")); + }); + ui.label( + egui::RichText::new(match method.as_str() { + "simplex" => "Simplex LP: for linear models; integer and binary constraints by branch and bound.", + "evolutionary" => "Evolutionary: a genetic search for models that aren't smooth; every variable cell needs bounds.", + _ => "GRG Nonlinear: a local optimum of a smooth model, from the current values.", + }) + .small(), + ); + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let t = crate::theme::Tokens::get(ui.ctx()); + let solve = ui.add(egui::Button::new(egui::RichText::new(" Solve ").color(t.on_accent)).fill(t.accent)); + if solve.clicked() && !text(d, "objective").trim().is_empty() { + *confirm = true; + } + if ui.button(" Close ").clicked() { + *open = false; + } + }); +} + +/// Solver Results: the outcome, and keeping or restoring the variable cells. +pub(super) fn solver_results(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) { + let message = d.values.get("message").and_then(Json::as_str).unwrap_or("").to_string(); + ui.label(message); + let changed = d.values.get("changed").and_then(Json::as_bool).unwrap_or(false); + let restore = d.values.get("restore").and_then(Json::as_bool).unwrap_or(false); + if changed { + ui.add_space(4.0); + if ui.radio(!restore, "Keep Solver Solution").clicked() { + d.values.insert("restore".into(), json!(false)); + } + if ui.radio(restore, "Restore Original Values").clicked() { + d.values.insert("restore".into(), json!(true)); + } + } + // Reports describe the solution whether it is kept or not. + let solved = d.values.get("values").and_then(Json::as_object).is_some_and(|v| !v.is_empty()); + if solved { + ui.add_space(4.0); + ui.label("Reports:"); + for (key, label) in [("answerReport", "Answer"), ("limitsReport", "Limits")] { + let mut on = d.values.get(key).and_then(Json::as_bool).unwrap_or(false); + if ui.checkbox(&mut on, label).changed() { + d.values.insert(key.into(), json!(on)); + } + } + } + let mut confirm = false; + ok_cancel(ui, &mut confirm, open); + if confirm { + // The solution (and the saved model) is one undo step. + if changed && restore { + app.run_or_alert("edit.undo", json!({})); + } + let reports: Vec<&str> = [("answerReport", "answer"), ("limitsReport", "limits")] + .into_iter() + .filter(|(key, _)| d.values.get(*key).and_then(Json::as_bool) == Some(true)) + .map(|(_, kind)| kind) + .collect(); + if solved && !reports.is_empty() { + let field = |k: &str| d.values.get(k).cloned().unwrap_or(Json::Null); + let params = json!({"reports": reports, "model": field("model"), "values": field("values"), "original": field("original"), "message": field("message")}); + app.run_or_alert("data.solverReport", params); + } + *open = false; + } +} diff --git a/crates/ui-egui/src/dialogs/charts.rs b/crates/ui-egui/src/dialogs/charts.rs new file mode 100644 index 000000000..bd7da3d66 --- /dev/null +++ b/crates/ui-egui/src/dialogs/charts.rs @@ -0,0 +1,187 @@ +use super::show::ok_cancel; +use super::*; + +/// Every chart type the All Charts tab offers: `(type, subtype, label)` for `insert.chart`. +const ALL_CHARTS: [(&str, &str, &str); 21] = [ + ("column", "", "Clustered Column"), + ("column", "stacked", "Stacked Column"), + ("column", "stacked100", "100% Stacked Column"), + ("bar", "", "Clustered Bar"), + ("bar", "stacked", "Stacked Bar"), + ("bar", "stacked100", "100% Stacked Bar"), + ("line", "", "Line"), + ("line", "markers", "Line with Markers"), + ("line", "stacked", "Stacked Line"), + ("pie", "", "Pie"), + ("doughnut", "", "Doughnut"), + ("area", "", "Area"), + ("area", "stacked", "Stacked Area"), + ("area", "stacked100", "100% Stacked Area"), + ("scatter", "", "Scatter"), + ("scatter", "lines", "Scatter with Lines"), + ("radar", "", "Radar"), + ("histogram", "", "Histogram"), + ("waterfall", "", "Waterfall"), + ("funnel", "", "Funnel"), + ("combo", "", "Combo"), +]; + +/// The chart types listed on the dialog's current tab. +fn chart_choices(d: &Dialog) -> Vec<(String, String, String)> { + if d.tab == "All Charts" { + return ALL_CHARTS.iter().map(|(t, s, l)| (t.to_string(), s.to_string(), l.to_string())).collect(); + } + let field = |x: &Json, k: &str| x.get(k).and_then(Json::as_str).unwrap_or("").to_string(); + d.result + .as_ref() + .and_then(Json::as_array) + .map(|a| a.iter().map(|x| (field(x, "type"), field(x, "subtype"), field(x, "label"))).collect()) + .unwrap_or_default() +} + +/// Selects entry `i` of the current tab: OK inserts it. +pub(super) fn select_chart(d: &mut Dialog, i: usize) { + let Some((t, sub, _)) = chart_choices(d).into_iter().nth(i) else { return }; + d.list_index = i; + d.values.insert("type".into(), json!(t)); + d.values.insert("subtype".into(), json!(sub)); +} + +fn chart_icon(t: &str) -> Icon { + match t { + "column" | "bar" | "histogram" | "waterfall" | "funnel" | "combo" => Icon::ChartBar, + "line" | "area" | "radar" => Icon::ChartLine, + "pie" | "doughnut" => Icon::ChartPie, + "scatter" | "bubble" => Icon::ChartScatter, + _ => Icon::Chart, + } +} + +pub(super) fn recommended_charts(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + ui.horizontal(|ui| { + for tab in ["Recommended Charts", "All Charts"] { + if ui.selectable_label(d.tab == tab, tab).clicked() && d.tab != tab { + d.tab = tab.to_string(); + select_chart(d, 0); + } + } + }); + ui.separator(); + let choices = chart_choices(d); + let t = crate::theme::Tokens::get(ui.ctx()); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(190.0); + egui::ScrollArea::vertical().id_salt("chart_choices").max_height(300.0).show(ui, |ui| { + for (i, (kind, _, label)) in choices.iter().enumerate() { + let resp = ui + .horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); + icons::paint(ui.painter(), r.shrink(2.0), chart_icon(kind), t.text); + ui.selectable_label(i == d.list_index, label.as_str()) + }) + .inner; + if resp.clicked() { + select_chart(d, i); + } + if resp.double_clicked() { + select_chart(d, i); + *confirm = true; + } + } + }); + }); + ui.add_space(12.0); + ui.vertical(|ui| { + let label = choices.get(d.list_index).map(|c| c.2.clone()).unwrap_or_default(); + ui.strong(label); + let (rect, _) = ui.allocate_exact_size(vec2(360.0, 216.0), Sense::hover()); + ui.painter().rect_filled(rect, 2.0, Color32::WHITE); + ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); + let params = json!({"type": d.values.get("type").cloned().unwrap_or(Json::Null), "subtype": d.values.get("subtype").cloned().unwrap_or(Json::Null)}); + if let Some(doc) = app.session.active() + && let Ok((sheet, chart)) = gridcraft_engine::cmd::insert::build_chart(&app.session, ¶ms) + { + crate::chartview::paint_chart(ui.painter(), rect, &doc.wb, sheet, &chart); + } + }); + }); + ok_cancel(ui, confirm, open); + if *confirm { + *confirm = false; + let params = + json!({"type": d.values.get("type").cloned().unwrap_or(Json::Null), "subtype": d.values.get("subtype").cloned().unwrap_or(Json::Null)}); + match app.run("insert.chart", params) { + Ok(r) => { + // The new chart is selected, as after inserting any object. + app.selected_chart = r.get("chart").and_then(Json::as_u64).and_then(|id| u32::try_from(id).ok()); + *open = false; + } + Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), + } + } +} + +/// Draws chart range, series keep/remove and category-range controls and runs `chart.selectData` when something changed. +pub(super) fn select_data(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let text = |d: &Dialog, k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); + let edit = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str| { + let label = ui.label(label); + let mut v = text(d, key); + if ui.add(egui::TextEdit::singleline(&mut v).desired_width(420.0)).labelled_by(label.id).changed() { + d.values.insert(key.into(), json!(v)); + } + }; + edit(ui, d, "range", "Chart data range:"); + ui.add_space(6.0); + ui.strong("Legend Entries (Series)"); + let names: Vec = + d.values.get("names").and_then(Json::as_array).map(|a| a.iter().map(|n| n.as_str().unwrap_or("").to_string()).collect()).unwrap_or_default(); + let mut remove: Vec = + d.values.get("remove").and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_u64).collect()).unwrap_or_default(); + let kept = names.len().saturating_sub(remove.len()); + egui::ScrollArea::vertical().id_salt("series").max_height(160.0).show(ui, |ui| { + for (i, name) in names.iter().enumerate() { + let i = i as u64; + let removed = remove.contains(&i); + ui.horizontal(|ui| { + let label = if name.is_empty() { format!("Series{}", i + 1) } else { name.clone() }; + ui.label(if removed { egui::RichText::new(label).strikethrough().weak() } else { egui::RichText::new(label) }); + if removed { + if ui.small_button("Keep").clicked() { + remove.retain(|x| *x != i); + } + } else if ui.add_enabled(kept > 1, egui::Button::new("Remove").small()).clicked() { + remove.push(i); + } + }); + } + }); + d.values.insert("remove".into(), json!(remove)); + ui.add_space(6.0); + edit(ui, d, "categories", "Horizontal (Category) Axis Labels:"); + ok_cancel(ui, confirm, open); + if *confirm { + *confirm = false; + let mut params = serde_json::Map::new(); + params.insert("chart".into(), d.values.get("chart").cloned().unwrap_or(Json::Null)); + let range_changed = text(d, "range") != text(d, "range0"); + if range_changed { + params.insert("range".into(), json!(text(d, "range"))); + } else if !remove.is_empty() { + // A new range rebuilds the series; removals only apply to the current ones. + params.insert("removeSeries".into(), json!(remove)); + } + if text(d, "categories") != text(d, "categories0") || (range_changed && !text(d, "categories").is_empty()) { + params.insert("categories".into(), json!(text(d, "categories"))); + } + if params.len() == 1 { + *open = false; + return; + } + match app.run("chart.selectData", Json::Object(params)) { + Ok(_) => *open = false, + Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), + } + } +} diff --git a/crates/ui-egui/src/dialogs/command_search.rs b/crates/ui-egui/src/dialogs/command_search.rs new file mode 100644 index 000000000..509ca4700 --- /dev/null +++ b/crates/ui-egui/src/dialogs/command_search.rs @@ -0,0 +1,88 @@ +use super::*; + +pub(super) fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + let (down, up, home, end, pgup, pgdn) = ui.input_mut(|i| { + ( + i.consume_key(egui::Modifiers::NONE, Key::ArrowDown), + i.consume_key(egui::Modifiers::NONE, Key::ArrowUp), + i.consume_key(egui::Modifiers::CTRL, Key::Home), + i.consume_key(egui::Modifiers::CTRL, Key::End), + i.consume_key(egui::Modifiers::NONE, Key::PageUp), + i.consume_key(egui::Modifiers::NONE, Key::PageDown), + ) + }); + let width = ui.available_width(); + let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(width).hint_text("Type a command, e.g. freeze, chart, bold…")); + + if !d.focus_initialized { + r.request_focus(); + d.focus_initialized = true; + } + + let q = d.search.to_ascii_lowercase(); + let cmds: Vec<(String, String, String)> = app + .session + .commands() + .into_iter() + .filter(|c| c.enabled) + .filter(|c| q.is_empty() || c.label.to_ascii_lowercase().contains(&q) || c.id.to_ascii_lowercase().contains(&q)) + .map(|c| (c.id.to_string(), c.label.to_string(), c.menu.join(" › "))) + .take(40) + .collect(); + + let last = cmds.len().saturating_sub(1); + + let row_h = ui.spacing().interact_size.y.max(20.0); + let row_step = row_h + ui.spacing().item_spacing.y; + + const LIST_HEIGHT: f32 = 320.0; + + let page_size = ((LIST_HEIGHT / row_step).floor() as usize).saturating_sub(1).max(1); + + if down { + d.list_index = (d.list_index + 1).min(last); + } + if up { + d.list_index = d.list_index.saturating_sub(1); + } + if pgdn { + d.list_index = (d.list_index + page_size).min(last); + } + if pgup { + d.list_index = d.list_index.saturating_sub(page_size); + } + if home { + d.list_index = 0; + } + if end { + d.list_index = last; + } + + d.list_index = d.list_index.min(last); + + egui::ScrollArea::vertical().max_height(LIST_HEIGHT).auto_shrink([true, false]).show(ui, |ui| { + let scroll_area_width = ui.available_width(); + for (i, (id, label, menu)) in cmds.iter().enumerate() { + let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{label} {menu}")).min_size(vec2(scroll_area_width, 20.0))); + + if (down || up || home || end || pgup || pgdn) && i == d.list_index && !ui.clip_rect().contains_rect(r.rect) { + r.scroll_to_me(Some(if down || end || pgdn { egui::Align::Max } else { egui::Align::Min })); + } + + if r.clicked() { + d.list_index = i; + *confirm = true; + } + + r.on_hover_text(id); + } + }); + if ui.input(|i| i.key_pressed(Key::Enter)) { + *confirm = true; + } + if *confirm && let Some((id, ..)) = cmds.get(d.list_index) { + let id = id.clone(); + app.dialog = None; + app.run_or_alert(&id, json!({})); + } +} diff --git a/crates/ui-egui/src/dialogs/cond_format_rules.rs b/crates/ui-egui/src/dialogs/cond_format_rules.rs new file mode 100644 index 000000000..216f79e31 --- /dev/null +++ b/crates/ui-egui/src/dialogs/cond_format_rules.rs @@ -0,0 +1,28 @@ +use super::*; + +pub(super) fn manage_rules(app: &mut SheetApp, ui: &mut egui::Ui) { + let list = app.session.run("home.manageRules", json!({})).unwrap_or(Json::Null); + let rules = list["rules"].as_array().cloned().unwrap_or_default(); + if rules.is_empty() { + ui.label("There are no conditional formatting rules on this sheet."); + } + for (i, r) in rules.iter().enumerate() { + ui.horizontal(|ui| { + let kind = r["rule"].as_object().and_then(|o| o.keys().next().cloned()).unwrap_or_default(); + ui.label(format!( + "{}. {kind} applies to {}", + i + 1, + r["ranges"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::>().join(",")).unwrap_or_default() + )); + if ui.small_button("▲").clicked() { + app.run_or_alert("home.manageRules", json!({"moveUp": i})); + } + if ui.small_button("▼").clicked() { + app.run_or_alert("home.manageRules", json!({"moveDown": i})); + } + if ui.small_button("Delete").clicked() { + app.run_or_alert("home.manageRules", json!({"delete": i})); + } + }); + } +} diff --git a/crates/ui-egui/src/dialogs/data.rs b/crates/ui-egui/src/dialogs/data.rs new file mode 100644 index 000000000..615c64ab9 --- /dev/null +++ b/crates/ui-egui/src/dialogs/data.rs @@ -0,0 +1,327 @@ +#[cfg(test)] +use super::show::build_params; +use super::show::{form_fields, ok_cancel}; +use super::*; + +/// Initial values of the Data Validation form: the rule stored on the active cell, so OK without +/// edits keeps it; a plain list rule when the cell has none. +pub(super) fn validation_defaults(app: &SheetApp) -> Json { + let mut v = json!({"type": "list", "operator": "between", "ignoreBlank": true, "dropdown": true, "errorStyle": "stop"}); + let Some(doc) = app.session.active() else { return v }; + let Some(dv) = doc.wb.active().and_then(|s| s.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(doc.selection.active)))) else { + return v; + }; + // The enums serialize as their variant names; the command takes them in camelCase. + let camel = |x: Json| { + let name = x.as_str().unwrap_or_default(); + let mut chars = name.chars(); + chars.next().map(|c| c.to_lowercase().chain(chars).collect::()).unwrap_or_default() + }; + v["type"] = json!(camel(json!(dv.kind))); + v["operator"] = json!(camel(json!(dv.op))); + v["errorStyle"] = json!(camel(json!(dv.error_style))); + v["formula1"] = json!(dv.f1); + if let Some(f2) = &dv.f2 { + v["formula2"] = json!(f2); + } + v["ignoreBlank"] = json!(dv.allow_blank); + v["dropdown"] = json!(dv.in_cell_dropdown); + v["inputTitle"] = json!(dv.input_title); + v["inputMessage"] = json!(dv.input_message); + v["errorTitle"] = json!(dv.error_title); + v["errorMessage"] = json!(dv.error_message); + v +} + +/// The range Remove Duplicates starts with. A block selection is used as given; with only the +/// cursor placed, the table's data rows or the current region, as the command picks when it gets +/// no range (a single-cell range would remove nothing). +pub(super) fn remove_duplicates_range(app: &SheetApp, sel: &str) -> String { + let Some(d) = app.session.active() else { return sel.to_string() }; + let Some(sh) = d.wb.active() else { return sel.to_string() }; + if !d.selection.current().is_single() { + return sel.to_string(); + } + let range = match sh.table_at(d.selection.active) { + Some(t) => t.data_range().map(|dr| gridcraft_engine::core::RangeRef::new(t.range.start, dr.end)).unwrap_or(t.range), + None => gridcraft_engine::selection::current_region(sh, d.selection.active), + }; + range.a1() +} + +pub(super) fn abs(r: &gridcraft_engine::core::RangeRef) -> String { + let a = |c: gridcraft_engine::core::CellRef| format!("${}${}", gridcraft_engine::core::col_to_letters(c.col), c.row + 1); + if r.is_single() { a(r.start) } else { format!("{}:{}", a(r.start), a(r.end)) } +} + +/// The criterion controls in Excel's Settings tab depend on both Allow and Data. +/// `formula1` is the sole source for a list or custom rule; `formula2` is a +/// second bound only for Between and Not between. +pub(super) fn data_validation_fields(kind: &str, operator: &str) -> Vec { + use Field::*; + let mut fields = vec![Choice { + key: "type", + label: "Allow", + options: vec![ + ("any", "Any value"), + ("whole", "Whole number"), + ("decimal", "Decimal"), + ("list", "List"), + ("date", "Date"), + ("time", "Time"), + ("textLength", "Text length"), + ("custom", "Custom"), + ], + }]; + let two_bounds = matches!(operator, "between" | "notBetween"); + match kind { + "list" => { + fields.push(Text { key: "formula1", label: "Source:" }); + fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); + fields.push(Check { key: "dropdown", label: "In-cell dropdown" }); + } + "custom" => { + fields.push(Text { key: "formula1", label: "Formula:" }); + fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); + } + "whole" | "decimal" | "date" | "time" | "textLength" => { + fields.push(Choice { + key: "operator", + label: "Data", + options: vec![ + ("between", "between"), + ("notBetween", "not between"), + ("equal", "equal to"), + ("notEqual", "not equal to"), + ("greater", "greater than"), + ("less", "less than"), + ("greaterOrEqual", "greater than or equal to"), + ("lessOrEqual", "less than or equal to"), + ], + }); + let lower = matches!(operator, "greater" | "greaterOrEqual"); + let (first, second) = match kind { + "date" => ( + if two_bounds || lower { + "Start date:" + } else if matches!(operator, "less" | "lessOrEqual") { + "End date:" + } else { + "Date:" + }, + "End date:", + ), + "time" => ( + if two_bounds || lower { + "Start time:" + } else if matches!(operator, "less" | "lessOrEqual") { + "End time:" + } else { + "Time:" + }, + "End time:", + ), + _ => ( + if two_bounds || lower { + "Minimum:" + } else if matches!(operator, "less" | "lessOrEqual") { + "Maximum:" + } else { + "Value:" + }, + "Maximum:", + ), + }; + fields.push(Text { key: "formula1", label: first }); + if two_bounds { + fields.push(Text { key: "formula2", label: second }); + } + fields.push(Check { key: "ignoreBlank", label: "Ignore blank" }); + } + _ => {} + } + fields.push(Text { key: "inputMessage", label: "Input message:" }); + fields.push(Choice { + key: "errorStyle", + label: "Error style", + options: vec![("stop", "Stop"), ("warning", "Warning"), ("information", "Information")], + }); + fields.push(Text { key: "errorMessage", label: "Error message:" }); + fields +} + +pub(super) fn data_validation_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any"); + let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between"); + d.fields = data_validation_fields(kind, operator); + form_fields(ui, d); + ok_cancel(ui, confirm, open); +} + +pub(super) fn advanced_filter_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + use Field::*; + let copy = d.values.get("action").and_then(Json::as_str) == Some("copy"); + d.fields = vec![ + Choice { + key: "action", + label: "Action", + options: vec![("inPlace", "Show matching rows in place"), ("copy", "Copy matching rows elsewhere")], + }, + Text { key: "range", label: "List range:" }, + Text { key: "criteria", label: "Criteria range (optional):" }, + ]; + if copy { + d.fields.push(Text { key: "copyTo", label: "Copy to:" }); + } + d.fields.push(Check { key: "unique", label: "Unique rows only" }); + form_fields(ui, d); + // Constrain the button row: the shared right-to-left layout otherwise + // consumes the window's remaining height instead of fitting the buttons. + ui.allocate_ui(vec2(ui.available_width(), ui.spacing().interact_size.y + 8.0), |ui| ok_cancel(ui, confirm, open)); +} + +/// Draws the sort-level editor: header toggle, column/by/order controls, add/remove level, and `data.sort` on confirmation. +pub(super) fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + let lang = app.ui.language; + let mut header = d.values.get("header").and_then(Json::as_bool).unwrap_or(true); + ui.checkbox(&mut header, "My list has headers"); + d.values.insert("header".into(), json!(header)); + let mut levels: Vec = d.values.get("levels").and_then(Json::as_array).cloned().unwrap_or_default(); + // Columns of the region around the active cell, labelled with their header (or first value). + let columns: Vec<(String, String)> = app + .session + .active() + .and_then(|d| { + let sh = d.wb.active()?; + let region = gridcraft_engine::selection::current_region(sh, d.selection.active); + Some( + (region.start.col..=region.end.col) + .map(|c| { + let letter = gridcraft_engine::core::col_to_letters(c); + // The region starts at the header row when one is present, so the + // label always comes from start.row: header text with headers on, + // first data value with them off. + let label_row = region.start.row; + let text = gridcraft_engine::display::cell_text(&d.wb, sh, gridcraft_engine::core::CellRef::new(label_row, c)); + let text: String = text.chars().take(24).collect(); + (letter.clone(), if text.is_empty() { letter } else { format!("{letter}: {text}") }) + }) + .collect(), + ) + }) + .unwrap_or_default(); + let mut remove = None; + for (i, lv) in levels.iter_mut().enumerate() { + ui.horizontal(|ui| { + let label = ui.label(lang.tr(if i == 0 { "Sort by" } else { "Then by" })); + let mut col = lv["column"].as_str().unwrap_or("A").to_string(); + if columns.is_empty() { + ui.add(egui::TextEdit::singleline(&mut col).desired_width(50.0)).labelled_by(label.id); + } else { + egui::ComboBox::from_id_salt(("sort_col", i)) + .selected_text(col.clone()) + .show_ui(ui, |ui| { + for (letter, label) in &columns { + ui.selectable_value(&mut col, letter.clone(), label.as_str()); + } + }) + .response + .labelled_by(label.id); + } + lv["column"] = json!(col.to_ascii_uppercase()); + let mut by = lv["by"].as_str().unwrap_or("values").to_string(); + egui::ComboBox::from_id_salt(("by", i)) + .selected_text(match by.as_str() { + "cellColor" => "Cell Color", + "fontColor" => "Font Color", + _ => "Cell Values", + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut by, "values".into(), "Cell Values"); + ui.selectable_value(&mut by, "cellColor".into(), "Cell Color"); + ui.selectable_value(&mut by, "fontColor".into(), "Font Color"); + }) + .response + .accessible_name(lang.tr("Sort On")); + lv["by"] = json!(by); + let mut desc = lv["order"].as_str() == Some("desc"); + ui.radio_value(&mut desc, false, "A to Z"); + ui.radio_value(&mut desc, true, "Z to A"); + lv["order"] = json!(if desc { "desc" } else { "asc" }); + if crate::widgets::icon_button(ui, Icon::Close, crate::theme::Tokens::get(ui.ctx()).text_dim, "Remove level", vec2(18.0, 18.0)).clicked() + { + remove = Some(i); + } + }); + } + if let Some(i) = remove + && levels.len() > 1 + { + levels.remove(i); + } + if ui.button("+ Add Level").clicked() { + let used: Vec = levels.iter().filter_map(|l| l.get("column").and_then(Json::as_str).map(str::to_string)).collect(); + let next = columns + .iter() + .map(|(l, _)| l) + .find(|l| !used.contains(l)) + .cloned() + .or_else(|| columns.last().map(|(l, _)| l.clone())) + .unwrap_or_else(|| "B".into()); + levels.push(json!({"column": next, "order": "asc"})); + } + d.values.insert("levels".into(), Json::Array(levels.clone())); + let mut open = true; + ok_cancel(ui, confirm, &mut open); + if *confirm { + app.run_or_alert("data.sort", json!({"header": header, "keys": levels})); + } + if !open { + *confirm = true; + } +} + +#[cfg(test)] +mod data_validation_tests { + use super::*; + + #[test] + fn list_uses_one_source_and_does_not_submit_a_second_list() { + let fields = data_validation_fields("list", "between"); + assert!(fields.iter().any(|f| matches!(f, Field::Text { key: "formula1", label: "Source:" }))); + assert!(!fields.iter().any(|f| matches!(f, Field::Text { key: "formula2", .. } | Field::Choice { key: "operator", .. }))); + + let dialog = Dialog::form( + "dataValidation", + "Data Validation", + "data.validation", + fields, + json!({"type": "list", "operator": "between", "formula1": "ABC,DEF", "formula2": "GHI,JKL", "dropdown": true}), + ); + let params = build_params(&dialog); + assert_eq!(params["formula1"], "ABC,DEF"); + assert!(params.get("formula2").is_none()); + assert!(params.get("operator").is_none()); + } + + #[test] + fn only_between_operators_show_and_submit_two_bounds() { + let two = data_validation_fields("whole", "between"); + assert!(two.iter().any(|f| matches!(f, Field::Text { key: "formula2", label: "Maximum:" }))); + let one = data_validation_fields("date", "lessOrEqual"); + assert!(one.iter().any(|f| matches!(f, Field::Text { key: "formula1", label: "End date:" }))); + assert!(!one.iter().any(|f| matches!(f, Field::Text { key: "formula2", .. }))); + + let dialog = Dialog::form( + "dataValidation", + "Data Validation", + "data.validation", + one, + json!({"type": "date", "operator": "lessOrEqual", "formula1": "2026-10-12", "formula2": "2030-01-01"}), + ); + let params = build_params(&dialog); + assert_eq!(params["operator"], "lessOrEqual"); + assert_eq!(params["formula1"], "2026-10-12"); + assert!(params.get("formula2").is_none()); + } +} diff --git a/crates/ui-egui/src/dialogs/find.rs b/crates/ui-egui/src/dialogs/find.rs new file mode 100644 index 000000000..27bd3687e --- /dev/null +++ b/crates/ui-egui/src/dialogs/find.rs @@ -0,0 +1,151 @@ +use super::show::ok_cancel; +use super::*; + +/// Draws the Find/Replace form with case, whole-cell and workbook options, requests field focus once, and runs find/replace with the current parameters. +pub(super) fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { + ui.horizontal(|ui| { + for tab in ["Find", "Replace"] { + if ui.selectable_label(d.tab == tab, tab).clicked() { + d.tab = tab.into(); + } + } + }); + let mut what = d.values.get("what").and_then(Json::as_str).unwrap_or("").to_string(); + let mut with = d.values.get("with").and_then(Json::as_str).unwrap_or("").to_string(); + ui.horizontal(|ui| { + let label = ui.label("Find what:"); + let r = ui.add(egui::TextEdit::singleline(&mut what).desired_width(260.0)).labelled_by(label.id); + // Like Excel, the cursor starts in "Find what" - asked for once, so clicking another field still works. + if !d.values.contains_key("focused") { + r.request_focus(); + d.values.insert("focused".into(), json!(true)); + } + }); + if d.tab == "Replace" { + ui.horizontal(|ui| { + let label = ui.label("Replace with:"); + ui.add(egui::TextEdit::singleline(&mut with).desired_width(244.0)).labelled_by(label.id); + }); + } + let mut case = d.values.get("matchCase").and_then(Json::as_bool).unwrap_or(false); + let mut whole = d.values.get("wholeCell").and_then(Json::as_bool).unwrap_or(false); + let mut wb = d.values.get("within").and_then(Json::as_str) == Some("workbook"); + ui.horizontal(|ui| { + ui.checkbox(&mut case, "Match case"); + ui.checkbox(&mut whole, "Match entire cell contents"); + ui.checkbox(&mut wb, "Within workbook"); + }); + d.values.insert("what".into(), json!(what)); + d.values.insert("with".into(), json!(with)); + d.values.insert("matchCase".into(), json!(case)); + d.values.insert("wholeCell".into(), json!(whole)); + d.values.insert("within".into(), json!(if wb { "workbook" } else { "sheet" })); + let base = json!({"what": what, "matchCase": case, "wholeCell": whole, "within": if wb { "workbook" } else { "sheet" }}); + ui.horizontal(|ui| { + if d.tab == "Replace" { + if ui.button("Replace All").clicked() { + let mut p = base.clone(); + p["with"] = json!(with); + p["all"] = json!(true); + match app.run("edit.replace", p) { + Ok(r) => app.toast = Some((format!("All done. We made {} replacements.", r["replaced"]), crate::now_ms())), + Err(e) => app.message = Some(("Find and Replace".into(), crate::clean_error(&e))), + } + } + if ui.button("Replace").clicked() { + let mut p = base.clone(); + p["with"] = json!(with); + p["all"] = json!(false); + let _ = app.run("edit.replace", p); + let _ = app.run("edit.find", base.clone()); + } + } + if ui.button("Find All").clicked() { + let mut p = base.clone(); + p["all"] = json!(true); + d.result = app.run("edit.find", p).ok(); + } + if ui.button("Find Next").clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { + if let Err(e) = app.run("edit.find", base.clone()) { + app.message = Some(("Find and Replace".into(), crate::clean_error(&e))); + } + app.grid.ensure_visible = true; + } + }); + if let Some(r) = &d.result + && let Some(list) = r["results"].as_array() + { + ui.separator(); + ui.label(format!("{} cell(s) found", list.len())); + egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { + for item in list { + let label = format!( + "{} {} {}", + item["sheet"].as_str().unwrap_or(""), + item["cell"].as_str().unwrap_or(""), + item["value"].as_str().unwrap_or("") + ); + if ui.selectable_label(false, label).clicked() { + if let Some(s) = item["sheet"].as_str() { + let _ = app.run("sheet.activate", json!({"sheet": s})); + } + let _ = app.run("selection.set", json!({"cell": item["cell"]})); + app.grid.ensure_visible = true; + } + } + }); + } +} + +/// What the Go To dialog lists: the named ranges of every sheet, then the references gone to +/// before, newest first. +pub(super) fn go_to_entries(app: &SheetApp) -> Vec { + app.session + .active() + .map(|doc| { + let mut v = gridcraft_engine::cmd::edit::go_to_names(&doc.wb, doc.wb.active_sheet()); + v.extend(doc.go_to_recent.iter().cloned()); + v + }) + .unwrap_or_default() +} + +/// Draws a virtualized name/reference list and editable reference field, links to Go To Special, and confirms with `edit.goTo`. +pub(super) fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + let lang = app.ui.language; + let entries: Vec = + d.result.as_ref().and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_str).map(str::to_owned).collect()).unwrap_or_default(); + let current = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string(); + // A workbook can hold thousands of names: only the rows in view are laid out. + let row_height = ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y; + egui::ScrollArea::vertical().max_height(160.0).auto_shrink([false, true]).show_rows(ui, row_height, entries.len(), |ui, rows| { + for n in entries.get(rows).unwrap_or_default() { + let resp = ui.selectable_label(current == *n, n); + if resp.clicked() || resp.double_clicked() { + d.values.insert("reference".into(), json!(n)); + } + if resp.double_clicked() { + *confirm = true; + } + } + }); + let mut r = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string(); + ui.horizontal(|ui| { + let label = ui.label(lang.tr("Reference:")); + ui.text_edit_singleline(&mut r).labelled_by(label.id).request_focus(); + }); + d.values.insert("reference".into(), json!(r.clone())); + if ui.button("Special…").clicked() { + app.open_dialog("goToSpecial", json!({})); + return; + } + let mut open = true; + ok_cancel(ui, confirm, &mut open); + if *confirm { + app.run_or_alert("edit.goTo", json!({"reference": r, "record": true})); + app.grid.ensure_visible = true; + } + if !open { + *confirm = true; + } +} diff --git a/crates/ui-egui/src/dialogs/format_cells.rs b/crates/ui-egui/src/dialogs/format_cells.rs new file mode 100644 index 000000000..f28c1de1b --- /dev/null +++ b/crates/ui-egui/src/dialogs/format_cells.rs @@ -0,0 +1,227 @@ +use super::show::ok_cancel; +use super::*; + +/// Draws the six-tab Format Cells editor (number, alignment, font, border, fill, protection); the Number tab shows a sample preview, and the style draft updates. +pub(super) fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + // `d.tab` stays the English name (it is what `open_dialog` and tests pass); only the drawn + // labels are translated. The Fill tab is keyed by context: the Alignment tab's "Fill" is a + // different word in some languages. + let lang = app.ui.language; + ui.horizontal(|ui| { + for (tab, key) in [ + ("Number", "Number"), + ("Alignment", "Alignment"), + ("Font", "Font"), + ("Border", "Border"), + ("Fill", "Format Cells|Fill"), + ("Protection", "Protection"), + ] { + if ui.selectable_label(d.tab == tab, lang.tr(key)).clicked() { + d.tab = tab.to_string(); + } + } + }); + ui.separator(); + let mut st: gridcraft_engine::model::Style = d.values.get("style").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + let sample = app.session.active().and_then(|doc| doc.wb.active().map(|s| s.value(doc.selection.active))).unwrap_or_default(); + match d.tab.as_str() { + "Number" => { + let mut code = st.num_fmt.as_str().to_string(); + ui.columns(2, |cols| { + cols[0].label(egui::RichText::new(lang.tr("Category:")).strong()); + for name in + ["General", "Number", "Currency", "Accounting", "Short Date", "Long Date", "Time", "Percentage", "Fraction", "Scientific", "Text"] + { + let c = gridcraft_engine::cmd::format::format_code_for(name).to_string(); + if cols[0].selectable_label(code == c, lang.tr(name)).clicked() { + code = c; + } + } + cols[1].label(egui::RichText::new(lang.tr("Sample")).strong()); + let preview = app.session.active().map(|doc| gridcraft_engine::display::format(&sample, &code, &doc.wb).text).unwrap_or_default(); + cols[1].label(egui::RichText::new(preview).font(theme::ui_font(15.0))); + cols[1].add_space(8.0); + let label = cols[1].label(lang.tr("Type (custom format code):")); + cols[1].text_edit_singleline(&mut code).labelled_by(label.id); + for c in [ + "0", + "0.00", + "#,##0", + "#,##0.00", + "#,##0;[Red]-#,##0", + "0%", + "0.00%", + "0.00E+00", + "# ?/?", + "m/d/yyyy", + "d-mmm-yy", + "mmmm d, yyyy", + "h:mm AM/PM", + "h:mm:ss", + "[h]:mm:ss", + "@", + ] { + if cols[1].small_button(c).clicked() { + code = c.to_string(); + } + } + }); + st.num_fmt = gridcraft_engine::model::NumFmt::new(&code); + } + "Alignment" => { + use gridcraft_engine::model::{HAlign, VAlign}; + ui.label(egui::RichText::new(lang.tr("Horizontal")).strong()); + ui.horizontal_wrapped(|ui| { + for (h, n) in [ + (HAlign::General, "General"), + (HAlign::Left, "Left"), + (HAlign::Center, "Center"), + (HAlign::Right, "Right"), + (HAlign::Fill, "Fill"), + (HAlign::Justify, "Justify"), + (HAlign::CenterAcross, "Center Across Selection"), + (HAlign::Distributed, "Distributed"), + ] { + ui.radio_value(&mut st.align.h, h, lang.tr(n)); + } + }); + ui.label(egui::RichText::new(lang.tr("Vertical")).strong()); + ui.horizontal_wrapped(|ui| { + for (v, n) in [ + (VAlign::Top, "Top"), + (VAlign::Center, "Center"), + (VAlign::Bottom, "Bottom"), + (VAlign::Justify, "Justify"), + (VAlign::Distributed, "Distributed"), + ] { + ui.radio_value(&mut st.align.v, v, lang.tr(n)); + } + }); + let mut indent = st.align.indent as f32; + ui.add(egui::Slider::new(&mut indent, 0.0..=15.0).text(lang.tr("Indent"))); + st.align.indent = indent as u8; + let mut rot = if st.align.rotation == 255 { 0.0 } else { st.align.rotation as f32 }; + ui.add(egui::Slider::new(&mut rot, -90.0..=90.0).text(lang.tr("Orientation (degrees)"))); + st.align.rotation = rot as i16; + ui.checkbox(&mut st.align.wrap, lang.tr("Wrap text")); + ui.checkbox(&mut st.align.shrink, lang.tr("Shrink to fit")); + } + "Font" => { + egui::ComboBox::from_label(lang.tr("Font")).selected_text(st.font.name.clone()).show_ui(ui, |ui| { + for f in crate::fonts::families() { + ui.selectable_value(&mut st.font.name, f.clone(), f.clone()); + } + }); + ui.add(egui::Slider::new(&mut st.font.size, 6.0..=72.0).text(lang.tr("Size"))); + ui.horizontal(|ui| { + ui.checkbox(&mut st.font.bold, lang.tr("Bold")); + ui.checkbox(&mut st.font.italic, lang.tr("Italic")); + ui.checkbox(&mut st.font.strike, lang.tr("Strikethrough")); + }); + use gridcraft_engine::model::{Underline, VertAlign}; + ui.horizontal(|ui| { + for (u, n) in [ + (Underline::None, "None"), + (Underline::Single, "Single"), + (Underline::Double, "Double"), + (Underline::SingleAccounting, "Single Accounting"), + (Underline::DoubleAccounting, "Double Accounting"), + ] { + ui.radio_value(&mut st.font.underline, u, lang.tr(n)); + } + }); + ui.horizontal(|ui| { + ui.radio_value(&mut st.font.vert, VertAlign::Baseline, lang.tr("Normal")); + ui.radio_value(&mut st.font.vert, VertAlign::Superscript, lang.tr("Superscript")); + ui.radio_value(&mut st.font.vert, VertAlign::Subscript, lang.tr("Subscript")); + }); + ui.label(lang.tr("Color:")); + let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default(); + if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("Automatic")) { + st.font.color = + if c == "none" { gridcraft_engine::model::Color::Auto } else { gridcraft_engine::model::Color::from_hex(&c).unwrap_or_default() }; + } + } + "Border" => { + use gridcraft_engine::model::{BorderLine, BorderStyle}; + let line = BorderLine { style: BorderStyle::Thin, color: gridcraft_engine::model::Color::Auto }; + ui.horizontal(|ui| { + if ui.button(lang.tr("None")).clicked() { + st.borders = Default::default(); + } + if ui.button(lang.tr("Outline")).clicked() { + st.borders.top = line; + st.borders.bottom = line; + st.borders.left = line; + st.borders.right = line; + } + }); + for (n, slot) in [ + ("Top", &mut st.borders.top), + ("Bottom", &mut st.borders.bottom), + ("Left", &mut st.borders.left), + ("Right", &mut st.borders.right), + ("Diagonal ↘", &mut st.borders.diag_down), + ("Diagonal ↗", &mut st.borders.diag_up), + ] { + let n = lang.tr(n); + ui.horizontal(|ui| { + ui.label(format!("{n}:")); + for (s, label) in [ + (BorderStyle::None, "none"), + (BorderStyle::Thin, "thin"), + (BorderStyle::Medium, "medium"), + (BorderStyle::Thick, "thick"), + (BorderStyle::Dashed, "dashed"), + (BorderStyle::Dotted, "dotted"), + (BorderStyle::Double, "double"), + ] { + let label = lang.tr(label); + let response = ui.selectable_label(slot.style == s, label); + let selected = slot.style == s; + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected, format!("{n} {label}"))); + if response.clicked() { + slot.style = s; + } + } + }); + } + let theme = app.session.active().map(|doc| &doc.wb.theme).cloned().unwrap_or_default(); + crate::border_preview::sample(ui, &st.borders, &theme); + } + "Fill" => { + let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default(); + ui.label(lang.tr("Background Color:")); + if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("No Color")) { + st.fill = if c == "none" { + Default::default() + } else { + gridcraft_engine::model::Fill::solid(gridcraft_engine::model::Color::from_hex(&c).unwrap_or_default()) + }; + } + } + "Protection" => { + ui.checkbox(&mut st.protection.locked, lang.tr("Locked")); + ui.checkbox(&mut st.protection.hidden, lang.tr("Hidden")); + ui.label( + egui::RichText::new( + lang.tr("Locking cells or hiding formulas has no effect until you protect the worksheet (Review › Protect Sheet)."), + ) + .small(), + ); + } + _ => {} + } + if let Ok(v) = serde_json::to_value(&st) { + d.values.insert("style".into(), v); + } + let mut open = true; + ok_cancel(ui, confirm, &mut open); + if *confirm && let Some(style) = d.values.get("style").cloned() { + app.run_or_alert("home.formatCells", json!({"style": style})); + } + if !open { + app.dialog = None; + *confirm = true; // closes + } +} diff --git a/crates/ui-egui/src/dialogs/insert_function.rs b/crates/ui-egui/src/dialogs/insert_function.rs new file mode 100644 index 000000000..2a240b1af --- /dev/null +++ b/crates/ui-egui/src/dialogs/insert_function.rs @@ -0,0 +1,72 @@ +use super::show::ok_cancel; +use super::*; + +/// Insert Function: searches names (as the formula language spells them) and descriptions, shows +/// each hit's category and description, and starts editing `=NAME(` (or appends to the formula). +pub(super) fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + let language = app.ui.language; + ui.horizontal(|ui| { + ui.label(language.tr("Search:")); + let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(300.0).hint_text(language.tr("e.g. lookup, average, date"))); + r.request_focus(); + }); + let locale = language.formula_locale(); + let list = gridcraft_engine::cmd::formulas::function_list(); + // The names and descriptions searched are the ones shown, so in the interface language. + let q = d.search.to_lowercase(); + let hits: Vec<&Json> = list + .iter() + .filter(|f| { + q.is_empty() + || f["name"].as_str().is_some_and(|n| locale.function_name(n).to_lowercase().contains(&q)) + || f["description"].as_str().is_some_and(|n| language.function_description(n).unwrap_or(n).to_lowercase().contains(&q)) + }) + .take(300) + .collect(); + egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { + for (i, f) in hits.iter().enumerate() { + let name = locale.function_name(f["name"].as_str().unwrap_or("")); + let category = language.tr(function_category_label(f["category"].as_str().unwrap_or(""))); + let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{name:<18} {category}")).min_size(vec2(480.0, 20.0))); + if r.clicked() { + d.list_index = i; + } + if r.double_clicked() { + d.list_index = i; + *confirm = true; + } + } + }); + if let Some(f) = hits.get(d.list_index) { + let name = f["name"].as_str().unwrap_or(""); + ui.separator(); + ui.label(egui::RichText::new(crate::formula_locale::signature(name, locale, language).unwrap_or_default()).strong()); + ui.label(crate::formula_locale::description(name, locale, language).unwrap_or_default()); + } + let mut open = true; + ok_cancel(ui, confirm, &mut open); + if *confirm && let Some(n) = hits.get(d.list_index).and_then(|f| f["name"].as_str()) { + let n = app.session.active().map(|doc| crate::formula_locale::completion_name(n, locale, &doc.wb, doc.wb.active_sheet())).unwrap_or(n); + let cur = app.editor.as_ref().map(|e| e.text.clone()); + let text = match cur { + Some(t) if t.starts_with('=') => format!("{t}{n}("), + _ => format!("={n}("), + }; + app.begin_edit(Some(text), false); + } + if !open { + *confirm = true; + } +} + +/// Excel's name for a function category, given the catalog's id (`MathTrig`). +fn function_category_label(id: &str) -> &str { + match id { + "DateTime" => "Date & Time", + "Lookup" => "Lookup & Reference", + "MathTrig" => "Math & Trig", + "AddIn" => "Add-in and Automation", + // Financial, Logical, Text, Statistical, Engineering, Database, Information, … + other => other, + } +} diff --git a/crates/ui-egui/src/dialogs/macros.rs b/crates/ui-egui/src/dialogs/macros.rs new file mode 100644 index 000000000..269643f8d --- /dev/null +++ b/crates/ui-egui/src/dialogs/macros.rs @@ -0,0 +1,40 @@ +use super::*; + +/// The Macros dialog: lists GridCraft's own recorded scripts and (if the file carries one) the +/// preserved VBA project. Mirrors Excel's Developer ▸ Macros list, but is honest that VBA is +/// preserved and not executed. +pub(super) fn macros(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { + ui.label("Automation in this workbook. GridCraft runs its own scripts; a file's VBA project is preserved but not executed."); + ui.add_space(6.0); + let items: Vec = d.result.as_ref().and_then(Json::as_array).cloned().unwrap_or_default(); + if items.is_empty() { + ui.weak("This workbook has no scripts or macros."); + return; + } + // Scripts that came with a file stay disabled until the user trusts the workbook (#2178). + if items.iter().any(|it| it.get("trusted").and_then(Json::as_bool) == Some(false)) { + ui.horizontal(|ui| { + ui.weak("Scripts in this workbook came from a file and are disabled."); + if ui.button("Trust Scripts").on_hover_text("Let this workbook's scripts run for the rest of this session").clicked() { + let _ = app.run("automate.trustScripts", json!({"trusted": true})); + d.result = app.session.run("view.macros", json!({})).ok(); + } + }); + ui.add_space(6.0); + } + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + for it in &items { + let name = it.get("name").and_then(Json::as_str).unwrap_or("(unnamed)"); + let is_vba = it.get("kind").and_then(Json::as_str) == Some("vba"); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(name).strong()); + if is_vba { + ui.weak("— VBA project (preserved, not run)"); + } else { + let steps = it.get("steps").and_then(Json::as_u64).unwrap_or(0); + ui.weak(format!("— {steps} step{}", if steps == 1 { "" } else { "s" })); + } + }); + } + }); +} diff --git a/crates/ui-egui/src/dialogs/names.rs b/crates/ui-egui/src/dialogs/names.rs new file mode 100644 index 000000000..f12374dae --- /dev/null +++ b/crates/ui-egui/src/dialogs/names.rs @@ -0,0 +1,134 @@ +use super::show::ok_cancel; +use super::*; + +pub(super) fn name_manager(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) { + let list = app.session.run("formulas.nameManager", json!({})).unwrap_or(Json::Null); + let names = list.as_array().cloned().unwrap_or_default(); + let selected = names.get(d.list_index).cloned(); + let field = |n: &Json, k: &str| n.get(k).and_then(Json::as_str).unwrap_or("").to_string(); + ui.horizontal(|ui| { + if ui.button("New…").clicked() { + app.open_dialog("defineName", json!({})); + } + if ui.add_enabled(selected.is_some(), egui::Button::new("Edit…")).clicked() + && let Some(n) = &selected + { + app.open_dialog( + "defineName", + json!({ + "name": field(n, "name"), "scope": field(n, "scope"), "comment": field(n, "comment"), + "refersTo": field(n, "refersTo"), "oldName": field(n, "name"), "oldScope": field(n, "scope"), + }), + ); + } + if ui.add_enabled(selected.is_some(), egui::Button::new("Delete")).clicked() + && let Some(n) = &selected + { + app.run_or_alert("formulas.deleteName", json!({"name": field(n, "name"), "scope": field(n, "scope")})); + } + }); + ui.add_space(4.0); + egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { + egui::Grid::new("names").striped(true).num_columns(4).show(ui, |ui| { + ui.strong("Name"); + ui.strong("Value"); + ui.strong("Refers To"); + ui.strong("Scope"); + ui.end_row(); + if names.is_empty() { + ui.label(egui::RichText::new("No names defined").italics()); + ui.end_row(); + } + for (i, n) in names.iter().enumerate() { + if ui.selectable_label(i == d.list_index, field(n, "name")).clicked() { + d.list_index = i; + } + ui.label(n.get("value").map(|v| v.to_string()).unwrap_or_default().chars().take(30).collect::()); + ui.label(field(n, "refersTo")); + ui.label(field(n, "scope")); + ui.end_row(); + } + }); + }); + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(" Close ").clicked() { + *open = false; + } + }); +} + +/// The names Paste Names offers in the active workbook. +pub(super) fn pasteable_names(app: &SheetApp) -> Vec { + app.session + .active() + .map(|doc| gridcraft_engine::cmd::formulas::pasteable_names(&doc.wb).into_iter().map(|(n, _)| n).collect()) + .unwrap_or_default() +} + +/// Puts a defined name into the formula being edited, at the caret. Without an edit under way it +/// starts one in the active cell with `=` and the name. +pub(crate) fn insert_name(app: &mut SheetApp, name: &str) { + match app.editor.as_mut() { + Some(ed) => ed.insert_at_caret(name), + None => app.begin_edit(Some(format!("={name}")), false), + } +} + +/// Paste Name (F3, Formulas › Use in Formula › Paste Names…): OK, or a double-click, inserts the +/// selected name (`formulas.pasteName` with `name`); Paste List writes every name and what it +/// refers to from the active cell. +pub(super) fn paste_name(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let names: Vec = + d.result.as_ref().and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_str).map(str::to_owned).collect()).unwrap_or_default(); + // The `name` value is the selection, so `ui.dialog.set` can choose one too. + if let Some(i) = d.values.get("name").and_then(Json::as_str).and_then(|n| names.iter().position(|x| x == n)) { + d.list_index = i; + } + let (down, up) = ui.input_mut(|i| (i.consume_key(egui::Modifiers::NONE, Key::ArrowDown), i.consume_key(egui::Modifiers::NONE, Key::ArrowUp))); + let last = names.len().saturating_sub(1); + if down { + d.list_index = (d.list_index + 1).min(last); + } + if up { + d.list_index = d.list_index.saturating_sub(1); + } + d.list_index = d.list_index.min(last); + ui.label(app.ui.language.tr("Paste name")); + if names.is_empty() { + ui.label(egui::RichText::new(app.ui.language.tr("No names defined")).italics()); + } + // A workbook can hold thousands of names: only the rows in view are laid out. + let row_height = ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y; + let step = row_height + ui.spacing().item_spacing.y; + egui::ScrollArea::vertical().max_height(220.0).auto_shrink([false, true]).show_rows(ui, row_height, names.len(), |ui, rows| { + let top = ui.cursor().top() - rows.start as f32 * step; + for (i, n) in names.iter().enumerate().skip(rows.start).take(rows.len()) { + let r = ui.selectable_label(i == d.list_index, n); + if r.clicked() { + d.list_index = i; + } + if r.double_clicked() { + *confirm = true; + } + } + // The arrow keys can move the selection onto a row that isn't laid out. + if down || up { + let rect = egui::Rect::from_min_size(egui::pos2(ui.min_rect().left(), top + d.list_index as f32 * step), egui::vec2(1.0, row_height)); + if !ui.clip_rect().contains_rect(rect) { + ui.scroll_to_rect(rect, Some(if down { egui::Align::Max } else { egui::Align::Min })); + } + } + }); + ui.add_space(4.0); + // Paste List is disabled while a cell is being edited. + if ui.add_enabled(!names.is_empty() && app.editor.is_none(), egui::Button::new(app.ui.language.tr("Paste List"))).clicked() { + app.run_or_alert("formulas.pasteName", json!({"list": true})); + *open = false; + return; + } + if let Some(n) = names.get(d.list_index) { + d.values.insert("name".into(), json!(n)); + } + ok_cancel(ui, confirm, open); +} diff --git a/crates/ui-egui/src/dialogs/paste.rs b/crates/ui-egui/src/dialogs/paste.rs new file mode 100644 index 000000000..bc01a7da2 --- /dev/null +++ b/crates/ui-egui/src/dialogs/paste.rs @@ -0,0 +1,139 @@ +use super::*; + +/// Paste Special as Excel lays it out: Paste and Operation each in two columns of options, Skip +/// blanks and Transpose side by side, and Paste Link as a button that pastes at once. The options +/// come from the dialog's `fields`, so the letter shortcuts and the control channel see the same. +pub(super) fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let lang = app.ui.language; + let t = theme::Tokens::get(ui.ctx()); + let choices: Vec<(&'static str, &'static str, Vec<(&'static str, &'static str)>)> = d + .fields + .iter() + .filter_map(|f| match f { + Field::Choice { key, label, options } => Some((*key, *label, options.clone())), + _ => None, + }) + .collect(); + for (key, label, options) in choices { + ui.add_space(4.0); + ui.label(egui::RichText::new(lang.tr(label)).strong()); + egui::Frame::new().stroke(Stroke::new(1.0, t.separator)).corner_radius(4.0).inner_margin(egui::Margin::symmetric(10, 6)).show(ui, |ui| { + ui.set_min_width(ui.available_width()); + let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); + // Column by column, as Excel fills them. + let rows = options.len().div_ceil(2); + egui::Grid::new(("paste_special", key)).num_columns(2).min_col_width(200.0).spacing(vec2(16.0, 4.0)).show(ui, |ui| { + for r in 0..rows { + for c in 0..2 { + if let Some((value, text)) = options.get(c * rows + r) { + if ui.radio(cur == *value, lang.tr(text)).clicked() { + d.set_value(key, json!(value)); + } + } else { + ui.label(""); + } + } + ui.end_row(); + } + }); + }); + } + ui.add_space(8.0); + // In the same columns as the options above. + egui::Frame::new().inner_margin(egui::Margin::symmetric(11, 0)).show(ui, |ui| { + egui::Grid::new("paste_special_checks").num_columns(2).min_col_width(200.0).spacing(vec2(16.0, 4.0)).show(ui, |ui| { + for (key, label) in [("skipBlanks", "Skip blanks"), ("transpose", "Transpose (E)")] { + let mut b = d.values.get(key).and_then(Json::as_bool).unwrap_or(false); + if ui.checkbox(&mut b, lang.tr(label)).changed() { + d.values.insert(key.into(), json!(b)); + } + } + ui.end_row(); + }); + }); + ui.add_space(8.0); + ui.separator(); + ui.horizontal(|ui| { + // Excel's Paste Link pastes references to the copied cells straight away. + if ui.button(lang.tr("Paste Link")).on_hover_text(lang.tr("Paste formulas that refer to the copied cells")).clicked() { + d.values.insert("what".into(), json!("all")); + d.values.insert("operation".into(), json!("none")); + d.values.insert("link".into(), json!(true)); + *confirm = true; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(t.on_accent)).fill(t.accent)); + if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { + *confirm = true; + } + if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() { + *open = false; + } + }); + }); +} + +/// Paste Special's legacy mnemonics select options; Enter still performs the paste. +pub(super) fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) { + if ctx.text_edit_focused() { + return; + } + let right_alt = ctx.input(|i| { + i.key_down(Key::AltRight) + || i.events.iter().any(|event| { + matches!(event, egui::Event::Key { key: Key::AltRight, .. }) + || matches!(event, egui::Event::Key { physical_key: Some(Key::AltRight), .. }) + }) + }); + let mac = ctx.os() == egui::os::OperatingSystem::Mac; + let allowed = |m: egui::Modifiers| !m.ctrl && !m.command && !m.mac_cmd && !(m.alt && (mac || right_alt)); + let mnemonic = |key| match key { + Key::T => Some('t'), + Key::V => Some('v'), + Key::F => Some('f'), + Key::C => Some('c'), + Key::E => Some('e'), + _ => None, + }; + let mut text_to_consume = Vec::new(); + for event in ctx.input(|i| i.events.clone()) { + let egui::Event::Key { key, pressed: true, repeat, modifiers, .. } = event else { continue }; + if !allowed(modifiers) { + continue; + } + let Some(letter) = mnemonic(key) else { continue }; + text_to_consume.push(letter); + if repeat { + continue; + } + if key == Key::E { + let transpose = d.values.get("transpose").and_then(Json::as_bool).unwrap_or(false); + d.values.insert("transpose".into(), json!(!transpose)); + } else { + let what = match key { + Key::T => "formats", + Key::V => "values", + Key::F => "formulas", + _ => "comments", + }; + d.values.insert("what".into(), json!(what)); + } + } + // Browsers can deliver Text before or after Key. Act only on Key, then remove + // its matching text so one physical press cannot toggle Transpose twice. + ctx.input_mut(|i| { + i.events.retain(|event| match event { + egui::Event::Key { key, modifiers, .. } => !(allowed(*modifiers) && mnemonic(*key).is_some()), + egui::Event::Text(text) if text.len() == 1 => { + let letter = text.chars().next().unwrap_or_default().to_ascii_lowercase(); + if let Some(index) = text_to_consume.iter().position(|&c| c == letter) { + text_to_consume.remove(index); + false + } else { + true + } + } + _ => true, + }); + }); +} diff --git a/crates/ui-egui/src/dialogs/show.rs b/crates/ui-egui/src/dialogs/show.rs new file mode 100644 index 000000000..d6bc92daa --- /dev/null +++ b/crates/ui-egui/src/dialogs/show.rs @@ -0,0 +1,577 @@ +use super::about::about; +use super::analysis::{analysis_toolpak, goal_seek, solver, solver_results}; +use super::charts::{recommended_charts, select_data}; +use super::command_search::command_search; +use super::cond_format_rules::manage_rules; +use super::data::{advanced_filter_form, data_validation_form, sort}; +use super::find::{find, go_to}; +use super::format_cells::format_cells; +use super::insert_function::insert_function; +use super::macros::macros; +use super::names::{name_manager, paste_name}; +use super::paste::{paste_special, paste_special_shortcuts}; +use super::spelling::spelling; +use super::zoom::zoom; +use super::*; + +/// Builds the command params from form values for dialogs that need translation. +/// The parameters OK sends to the dialog's command: its field values, reshaped per dialog. +pub(crate) fn build_params(d: &Dialog) -> Json { + let mut v = Json::Object(d.values.clone()); + match d.name.as_str() { + "advancedFilter" => { + if let Some(m) = v.as_object_mut() { + m.remove("action"); + if d.values.get("action").and_then(Json::as_str) != Some("copy") { + m.remove("copyTo"); + } + if m.get("criteria").and_then(Json::as_str).is_some_and(|s| s.trim().is_empty()) { + m.remove("criteria"); + } + } + } + "dataValidation" => { + let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any"); + let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between"); + if let Some(m) = v.as_object_mut() { + if !matches!(operator, "between" | "notBetween") || matches!(kind, "any" | "list" | "custom") { + m.remove("formula2"); + } + if matches!(kind, "any" | "list" | "custom") { + m.remove("operator"); + } + if kind == "any" { + m.remove("formula1"); + } + if kind != "list" { + m.remove("dropdown"); + } + } + } + "textToColumns" => { + let mut delims = Vec::new(); + for (k, c) in [("tab", "\t"), ("semicolon", ";"), ("comma", ","), ("space", " ")] { + if d.values.get(k).and_then(Json::as_bool) == Some(true) { + delims.push(json!(c)); + } + } + v["delimiters"] = Json::Array(delims); + } + "removeDuplicates" | "subtotal" => { + if let Some(t) = d.values.get("columnsText").and_then(Json::as_str).filter(|t| !t.trim().is_empty()) { + v["columns"] = Json::Array(t.split(',').map(|c| json!(c.trim().to_ascii_uppercase())).collect()); + } + } + "allowEditRanges" => { + if let Some(m) = v.as_object_mut() { + // Blank fields mean "unchanged"; the check box clears a password. + m.retain(|_, x| x.as_str().is_none_or(|s| !s.trim().is_empty())); + if m.remove("removePassword").and_then(|x| x.as_bool()) == Some(true) { + m.insert("password".into(), json!("")); + } + } + } + "cfQuick" => { + let mut rule = Map::new(); + for (k, val) in &d.values { + rule.insert(k.clone(), val.clone()); + } + v = json!({"rule": Json::Object(rule)}); + if let Some(pick) = d.pick.as_ref().filter(|pick| !pick.applies_to.is_empty() && !pick.sheet.is_empty()) { + v["sheet"] = json!(pick.sheet.as_str()); + v["range"] = json!(pick.applies_to); + } + } + "series" => { + if let Some(s) = d.values.get("stop").and_then(Json::as_str).and_then(|s| s.parse::().ok()) { + v["stop"] = json!(s); + } else if let Some(m) = v.as_object_mut() { + m.remove("stop"); + } + } + "defineName" => { + if let Some(m) = v.as_object_mut() { + for key in ["scope", "oldScope"] { + if m.get(key).and_then(Json::as_str) == Some("Workbook") { + m.remove(key); + } + } + } + } + _ => {} + } + v +} + +/// Takes and draws the active dialog, dispatches its body by name, consumes picture-inbox results, runs OK/confirm commands, handles Esc/close and restores the back-dialog stack. +pub fn show(app: &mut SheetApp, ctx: &egui::Context) { + let Some(mut d) = app.dialog.take() else { return }; + let home = app.ui_window(); + if d.name == "pasteSpecial" { + paste_special_shortcuts(&mut d, ctx); + } + let mut open = true; + let mut win_open = true; + let mut confirm = false; + let width = match d.name.as_str() { + "formatCells" => 560.0, + "about" => 660.0, + "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "macros" => 520.0, + "recommendedCharts" => 600.0, + "analysisToolPak" => 420.0, + "solver" => 460.0, + "zoom" => 220.0, + "pasteSpecial" => 480.0, + _ => 380.0, + }; + let window = egui::Window::new(app.ui.language.tr(&d.title)) + .id(egui::Id::unique("dialog").with(&d.name)) + .collapsible(false) + .resizable(false) + .default_width(width) + // A default position, not an anchor: egui anchors can't be dragged, and dialogs move by their title bar. + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center() + vec2(0.0, -40.0)) + .open(&mut win_open); + let window = match d.name.as_str() { + "insertPicture" => window.default_height(240.0), + "advancedFilter" => window.default_height(0.0), + _ => window, + }; + window.show(ctx, |ui| { + ui.set_min_width(width - 20.0); + match d.name.as_str() { + "formatCells" => format_cells(app, ui, &mut d, &mut confirm), + "dataValidation" => data_validation_form(ui, &mut d, &mut confirm, &mut open), + "advancedFilter" => advanced_filter_form(ui, &mut d, &mut confirm, &mut open), + "insertFunction" => insert_function(app, ui, &mut d, &mut confirm), + "find" => find(app, ui, &mut d), + "goTo" => go_to(app, ui, &mut d, &mut confirm), + "zoom" => zoom(ui, &mut d, &mut confirm, &mut open), + "sort" => sort(app, ui, &mut d, &mut confirm), + "nameManager" => name_manager(app, ui, &mut d, &mut open), + "pasteName" => paste_name(app, ui, &mut d, &mut confirm, &mut open), + "manageRules" => manage_rules(app, ui), + "recommendedCharts" => recommended_charts(app, ui, &mut d, &mut confirm, &mut open), + "selectData" => select_data(app, ui, &mut d, &mut confirm, &mut open), + "commandSearch" => command_search(app, ui, &mut d, &mut confirm), + "goalSeek" => goal_seek(app, ui, &mut d, &mut confirm), + "pasteSpecial" => paste_special(app, ui, &mut d, &mut confirm, &mut open), + "analysisToolPak" => analysis_toolpak(app, ui, &mut d, &mut confirm, &mut open), + "solver" => solver(ui, &mut d, &mut confirm, &mut open), + "solverResults" => solver_results(app, ui, &mut d, &mut open), + "spelling" => spelling(app, ui, &mut d, &mut open), + "agents" => { + ui.label("GridCraft is fully drivable by agents. Every menu item, button and gesture is a command:"); + ui.add_space(4.0); + ui.monospace("gridcraft --control 7979 # JSON-lines control channel\ngridcraft-cli mcp --connect 7979 # MCP bridged to this window\ngridcraft-cli mcp # headless MCP server"); + ui.add_space(4.0); + ui.label(format!("{} engine commands are available. See docs/mcp.md and docs/control-protocol.md.", gridcraft_engine::command_specs().len())); + } + "about" => about(ui, &mut d), + "journal" => { + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for (id, p) in app.session.journal.iter().rev().take(500) { + ui.monospace(format!("{id} {p}")); + } + }); + } + "macros" => macros(app, ui, &mut d), + "statistics" | "accessibility" | "errorChecking" | "evaluateFormula" | "comments" => { + let r = if d.name == "comments" { + app.session.active().and_then(|doc| doc.wb.active().map(|s| json!(s.comments.iter().map(|(c, m)| json!({"cell": c.a1(), "author": m.author, "text": m.text})).collect::>()))) + } else { + d.result.clone() + }; + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + ui.monospace(serde_json::to_string_pretty(&r.unwrap_or(Json::Null)).unwrap_or_default()); + }); + } + "insertPicture" => { + // A private inbox dies with this dialog; late browser results + // cannot affect a later picture choice or another workbook. + let arrived = d.picture_inbox.as_ref().and_then(|inbox| { + inbox.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pop() + }); + if let Some((name, bytes)) = arrived { + d.picture_inbox = None; + if bytes.len() > 16 * 1024 * 1024 { + app.message = Some((d.title.clone(), "Choose a PNG or JPEG no larger than 16 MB.".into())); + } else { + d.values.remove("path"); + d.values.insert("alt".into(), json!(name)); + d.values.insert("base64".into(), json!(gridcraft_engine::io::base64_encode(&bytes))); + } + } + if app.services.pick_picture_async.is_some() { + let name = d.values.get("alt").and_then(Json::as_str).unwrap_or("No picture selected"); + ui.label(name); + } else { + ui.label("Picture file path (PNG or JPEG):"); + let mut path = d.values.get("path").and_then(Json::as_str).unwrap_or("").to_string(); + if ui.text_edit_singleline(&mut path).changed() { + d.values.remove("base64"); + d.values.insert("path".into(), json!(path)); + } + } + if (app.services.pick_picture.is_some() || app.services.pick_picture_async.is_some()) && ui.button("Browse…").clicked() { + if let Some(start) = &app.services.pick_picture_async { + let inbox = crate::Inbox::default(); + start(inbox.clone()); + d.picture_inbox = Some(inbox); + } else if let Some(pick) = &app.services.pick_picture + && let Some(path) = pick() + { + d.values.remove("base64"); + let alt = std::path::Path::new(&path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(""); + d.values.insert("alt".into(), json!(alt)); + d.values.insert("path".into(), json!(path)); + } + } + ui.label("Description (optional):"); + let mut alt = d.values.get("alt").and_then(Json::as_str).unwrap_or("").to_string(); + if ui.text_edit_singleline(&mut alt).changed() { + d.values.insert("alt".into(), json!(alt)); + } + if d.values.get("placement").and_then(Json::as_str) == Some("cell") { + ui.label(egui::RichText::new("Fits inside the selected cell and moves with its content.").small()); + } + ok_cancel(ui, &mut confirm, &mut open); + } + "pickList" => { + let items: Vec = app + .session + .active() + .and_then(|doc| { + let sh = doc.wb.active()?; + let a = doc.selection.active; + let mut seen = std::collections::BTreeSet::new(); + let mut r = a.row; + while r > 0 && !sh.value(gridcraft_engine::core::CellRef::new(r - 1, a.col)).is_empty() { + r -= 1; + seen.insert(sh.value(gridcraft_engine::core::CellRef::new(r, a.col)).display()); + } + Some(seen.into_iter().collect()) + }) + .unwrap_or_default(); + for it in items { + if ui.button(&it).clicked() { + app.run_or_alert("cell.set", json!({"input": it})); + open = false; + } + } + } + "saveCopy" => { + let fmt = d.values.get("format").and_then(Json::as_str).unwrap_or("xlsx").to_string(); + ui.label(format!("Save a copy as .{fmt}")); + let mut path = d.values.get("path").and_then(Json::as_str).unwrap_or("").to_string(); + ui.text_edit_singleline(&mut path); + d.values.insert("path".into(), json!(path.clone())); + ok_cancel(ui, &mut confirm, &mut open); + if confirm { + // A bare name has no folder: written as typed it would land in the + // process's working directory (`/` from the Finder, the install folder on + // Windows), never where the user looks. The picker chooses the folder. + let pick = app.services.pick_save.as_ref(); + let target = if path.is_empty() { + pick.and_then(|f| f(&format!("Copy.{fmt}"))) + } else if let Some(f) = pick.filter(|_| std::path::Path::new(&path).is_relative()) { + f(&if path.ends_with(&format!(".{fmt}")) { path } else { format!("{path}.{fmt}") }) + } else { + Some(path) + }; + if let Some(t) = target { + let id = match fmt.as_str() { + "csv" => "file.exportCsv", + "html" => "file.exportHtml", + _ => "file.saveAs", + }; + app.run_or_alert(id, json!({"path": t})); + } + } + } + "saveChanges" => { + ui.label(format!("Do you want to save the changes you made to {}?", d.values.get("title").and_then(Json::as_str).unwrap_or("this workbook"))); + let quitting = d.values.get("quit").and_then(Json::as_bool) == Some(true); + ui.horizontal(|ui| { + for (label, answer) in + [("Don't Save", crate::SaveAnswer::DontSave), ("Cancel", crate::SaveAnswer::Cancel), ("Save", crate::SaveAnswer::Save)] + { + if ui.button(label).clicked() && app.answer_save_changes(answer, quitting, ui.ctx()) { + open = false; + } + } + }); + } + "start" => { + ui.heading("Start a new workbook"); + ui.horizontal_wrapped(|ui| { + if ui.button("Blank workbook").clicked() { + app.run_or_alert("file.new", json!({})); + open = false; + } + for (n, t) in gridcraft_engine::sample::SAMPLES { + if ui.button(*t).clicked() { + app.run_or_alert("file.new", json!({"sample": n})); + open = false; + } + } + }); + ui.separator(); + ui.label(egui::RichText::new("Recent").strong()); + for path in app.ui.recent.clone() { + if ui.link(&path).clicked() { + app.open_path(&path); + open = false; + } + } + if ui.button("Open…").clicked() { + app.open_dialog("open", json!({})); + open = false; + } + } + _ => form(ui, &mut d, &mut confirm, &mut open), + } + }); + // Hyperlinks inside dialogs (About ▸ Contributors) open through the host's `open_url`, since + // the native shell has no browser integration of its own. + if let Some(open_url) = &app.services.open_url { + let urls: Vec = ctx.output_mut(|o| { + let mut urls = vec![]; + o.commands.retain(|c| match c { + egui::OutputCommand::OpenUrl(u) => { + urls.push(u.url.clone()); + false + } + _ => true, + }); + urls + }); + for u in urls { + open_url(&u); + } + } + if ctx.input(|i| i.key_pressed(Key::Escape)) || !win_open { + open = false; + } + if confirm && d.name == "insertPicture" && d.picture_target != app.view_key() { + app.message = Some((d.title.clone(), "Return to the original worksheet to insert this picture, or cancel and choose another cell.".into())); + confirm = false; + } + if confirm && let Some(cmd) = d.command { + let params = build_params(&d); + match app.run(cmd, params) { + Ok(r) => { + open = false; + if d.name == "solver" { + app.open_dialog("solverResults", r); + } else if d.name == "openPassword" { + let path = d.values.get("path").and_then(Json::as_str).map(str::to_string); + app.after_open(path.as_deref(), &r); + } + } + // Data Analysis asks before replacing data in its output range. + Err(e) if d.name == "analysisToolPak" && e.contains(gridcraft_engine::cmd::analysis::OVERWRITE_ERROR) => { + d.values.insert("askOverwrite".into(), json!(true)); + } + // A wrong password: say so, clear the field and stay open for another try. + Err(e) if d.name == "openPassword" => { + d.values.insert("password".into(), json!("")); + app.message = Some((d.title.clone(), crate::clean_error(&e))); + } + Err(e) => app.message = Some((d.title.clone(), crate::clean_error(&e))), + } + } else if confirm { + open = false; + } + if app.ui_window() != home { + // A command run from the dialog switched windows: whatever it opened shows in the new + // window, and this dialog (or the one it returns to) stays in its own. + let keep = if open { Some(d) } else { d.back.take().map(|b| *b) }; + if let Some(keep) = keep { + app.return_dialog(home, keep); + } + return; + } + if let Some(child) = app.dialog.as_mut() { + // The body opened another dialog (Name Manager › New…). It takes over; this one waits + // behind it and comes back when it closes, as Excel returns to the Name Manager. Go To + // hands over to Go To Special for good, as in Excel. + if open && d.name != "goTo" { + child.back = Some(Box::new(d)); + } else if child.back.is_none() { + child.back = d.back.take(); + } + return; + } + if open { + app.dialog = Some(d); + } else if let Some(back) = d.back.take() { + app.dialog = Some(*back); + } +} + +pub(super) fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) { + ui.add_space(8.0); + // A row as tall as the buttons: a right-to-left layout straight in the window takes all the + // height it is offered, which stretched every form dialog to the screen's height. + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let lang = crate::i18n::current(ui.ctx()); + let t = crate::theme::Tokens::get(ui.ctx()); + let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(t.on_accent)).fill(t.accent)); + if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { + *confirm = true; + } + if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() { + *open = false; + } + }) + }); +} + +fn form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + form_fields(ui, d); + ok_cancel(ui, confirm, open); +} + +/// Gives keyboard focus to the first text or number field the first time a form is drawn. A +/// focused number field is in typing mode with its value selected, so Alt+H, O, W then 12 and +/// Enter sets the width, as in Excel. +/// Keeps asking until the field really has focus: a new window's first frame is an invisible +/// sizing pass where a focus request is ignored. +fn take_first_focus(d: &mut Dialog, r: &egui::Response) { + if !d.focus_first_field { + return; + } + if !r.has_focus() { + r.request_focus(); + return; + } + // Focus has landed, and the field is now a text box: select its whole value so typing + // replaces it (the field's own select-on-focus fired before it was a text box). + let mut state = egui::text_edit::TextEditState::load(&r.ctx, r.id).unwrap_or_default(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(usize::MAX / 2)); + state.cursor.set_char_range(Some(all)); + state.store(&r.ctx, r.id); + d.focus_first_field = false; +} + +/// Keeps a picking formula box's caret, and gives it the keyboard back after a grid click put a +/// reference in it (the click took the focus), its caret after the reference. +fn track_pick(pick: &mut RefPick, r: &egui::Response, typed: bool) { + if typed { + pick.point = None; + } + if pick.refocus { + let mut state = egui::text_edit::TextEditState::load(&r.ctx, r.id).unwrap_or_default(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(pick.caret)))); + state.store(&r.ctx, r.id); + r.request_focus(); + pick.refocus = false; + } else if r.has_focus() + && let Some(range) = egui::text_edit::TextEditState::load(&r.ctx, r.id).and_then(|state| state.cursor.char_range()) + { + pick.caret = range.primary.index.into(); + } +} + +/// Draws a dialog's `fields`, editing its values. +pub(super) fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { + // Field labels, choices and notes are English source strings, translated as they are drawn. + let lang = crate::i18n::current(ui.ctx()); + egui::Grid::new("form").num_columns(1).spacing(vec2(8.0, 6.0)).show(ui, |ui| { + for f in d.fields.clone() { + match f { + Field::Text { key, label } => { + ui.vertical(|ui| { + let label = ui.label(lang.tr(label)); + let mut s = d + .values + .get(key) + .map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| if v.is_null() { String::new() } else { v.to_string() })) + .unwrap_or_default(); + let multiline = key == "text"; + let r = if multiline { + ui.add(egui::TextEdit::multiline(&mut s).desired_rows(4).desired_width(320.0)).labelled_by(label.id) + } else { + ui.add(egui::TextEdit::singleline(&mut s).desired_width(320.0)).labelled_by(label.id) + }; + take_first_focus(d, &r); + if r.changed() { + d.values.insert(key.into(), json!(s)); + } + if let Some(pick) = d.pick.as_mut().filter(|pick| pick.key == key) { + track_pick(pick, &r, r.changed()); + } + }); + } + Field::Password { key, label } => { + // Masked as typed (Excel shows dots); "Show password" reveals it on purpose. + ui.vertical(|ui| { + let label = ui.label(lang.tr(label)); + let mut s = d.values.get(key).and_then(Json::as_str).unwrap_or_default().to_string(); + if ui.add(egui::TextEdit::singleline(&mut s).password(!d.show_password).desired_width(320.0)).labelled_by(label.id).changed() + { + d.values.insert(key.into(), json!(s)); + } + ui.checkbox(&mut d.show_password, lang.tr("Show password")); + }); + } + Field::Number { key, label } => { + ui.horizontal(|ui| { + let label = ui.label(lang.tr(label)); + let mut n = d.values.get(key).and_then(Json::as_f64).unwrap_or(0.0); + // Typed values count straight away: otherwise Enter or OK in the same frame + // confirms the old value, because a number field only commits when it loses focus. + let r = ui.add(egui::DragValue::new(&mut n).speed(0.5).update_while_editing(true)).labelled_by(label.id); + take_first_focus(d, &r); + if r.changed() { + d.values.insert(key.into(), json!(n)); + } + }); + } + Field::Choice { key, label, options } => { + ui.vertical(|ui| { + let field_label = ui.label(egui::RichText::new(lang.tr(label)).strong()); + let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); + if d.name == "dataValidation" { + let selected = options.iter().find(|(val, _)| *val == cur).map(|(_, text)| lang.tr(text)).unwrap_or(""); + egui::ComboBox::from_id_salt(("dataValidation", key)) + .selected_text(selected) + .width(300.0) + .show_ui(ui, |ui| { + for (val, text) in options { + if ui.selectable_label(cur == val, lang.tr(text)).clicked() { + d.set_value(key, json!(val)); + } + } + }) + .response + .labelled_by(field_label.id); + } else { + for (val, text) in options { + if ui.radio(cur == val, lang.tr(text)).clicked() { + d.set_value(key, json!(val)); + } + } + } + }); + } + Field::Check { key, label } => { + let mut b = d.values.get(key).and_then(Json::as_bool).unwrap_or(false); + if ui.checkbox(&mut b, lang.tr(label)).changed() { + d.values.insert(key.into(), json!(b)); + } + } + Field::Note(t) => { + // A note can carry user data (a URL, a file or sheet name); `tr` reads a `|` as a + // context separator, so such a note is shown as it is. + let text = if t.contains('|') { t.as_str() } else { lang.tr(&t) }; + ui.label(egui::RichText::new(text).small()); + } + } + ui.end_row(); + } + }); +} diff --git a/crates/ui-egui/src/dialogs/spelling.rs b/crates/ui-egui/src/dialogs/spelling.rs new file mode 100644 index 000000000..2a53b6d8a --- /dev/null +++ b/crates/ui-egui/src/dialogs/spelling.rs @@ -0,0 +1,54 @@ +use super::*; + +pub(super) fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) { + let r = d.result.clone().unwrap_or(Json::Null); + if let Some(e) = r.get("error").and_then(Json::as_str) { + ui.label(app.ui.language.tr_error(e)); + return; + } + let issues = r["issues"].as_array().cloned().unwrap_or_default(); + let Some(it) = issues.get(d.list_index) else { + ui.label("The spelling check is complete for the entire sheet."); + if ui.button("OK").clicked() { + *open = false; + } + return; + }; + let word = it["word"].as_str().unwrap_or("").to_string(); + let cell = it["cell"].as_str().unwrap_or("").to_string(); + ui.label(format!("Not in Dictionary ({cell}):")); + ui.label(egui::RichText::new(&word).strong().color(crate::theme::Tokens::get(ui.ctx()).danger)); + let sugg: Vec = + it["suggestions"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default(); + ui.label("Suggestions:"); + let mut pick = d.values.get("pick").and_then(Json::as_str).unwrap_or("").to_string(); + for s in &sugg { + if ui.selectable_label(pick == *s, s).clicked() { + pick = s.clone(); + } + } + if sugg.is_empty() { + ui.label(egui::RichText::new("(No suggestions)").italics()); + } + d.values.insert("pick".into(), json!(pick.clone())); + let _ = app.session.run("selection.set", json!({"cell": cell})); + ui.horizontal(|ui| { + if ui.button("Ignore Once").clicked() { + d.list_index += 1; + } + if ui.button("Add to Dictionary").clicked() { + let _ = app.session.run("review.addToDictionary", json!({"word": word})); + d.list_index += 1; + } + if !pick.is_empty() { + if ui.button("Change").clicked() { + app.run_or_alert("review.changeSpelling", json!({"cell": cell, "word": word, "to": pick})); + d.list_index += 1; + } + if ui.button("Change All").clicked() { + app.run_or_alert("review.changeSpelling", json!({"word": word, "to": pick, "all": true})); + d.list_index += 1; + } + } + }); +} diff --git a/crates/ui-egui/src/dialogs/zoom.rs b/crates/ui-egui/src/dialogs/zoom.rs new file mode 100644 index 000000000..a81b03b35 --- /dev/null +++ b/crates/ui-egui/src/dialogs/zoom.rs @@ -0,0 +1,39 @@ +use super::show::ok_cancel; +use super::*; + +/// Zoom's Magnification presets, as `preset` values and labels, in Excel's order. +const ZOOM_PRESETS: [(&str, &str); 5] = [("200", "200%"), ("100", "100%"), ("75", "75%"), ("50", "50%"), ("25", "25%")]; + +/// The `preset` that shows a zoom percentage: its Magnification entry, or Custom. +pub(super) fn zoom_preset(percent: f64) -> &'static str { + ZOOM_PRESETS.iter().map(|(val, _)| *val).find(|val| val.parse::().ok() == Some(percent)).unwrap_or("custom") +} + +/// View › Zoom, laid out as Excel's: one Magnification column of 200% to 25%, Fit selection and +/// Custom with its percentage box. The current zoom is pre-selected; typing a percentage +/// selects Custom; Fit selection zooms so the selected cells fill the grid. +pub(super) fn zoom(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let lang = crate::i18n::current(ui.ctx()); + let cur = d.values.get("preset").and_then(Json::as_str).unwrap_or("custom").to_string(); + ui.label(egui::RichText::new(lang.tr("Magnification")).strong()); + for (val, text) in ZOOM_PRESETS { + if ui.radio(cur == val, text).clicked() { + d.set_value("preset", json!(val)); + } + } + if ui.radio(cur == "fit", lang.tr("Fit selection")).clicked() { + d.set_value("preset", json!("fit")); + } + ui.horizontal(|ui| { + if ui.radio(cur == "custom", format!("{}:", lang.tr("Custom"))).clicked() { + d.set_value("preset", json!("custom")); + } + // Whole percentages, as Excel shows them; the engine clamps to 10..400 as well. + let mut percent = d.values.get("percent").and_then(Json::as_f64).unwrap_or(100.0).round() as i64; + if ui.add(egui::DragValue::new(&mut percent).range(10..=400).speed(1.0)).changed() { + d.set_value("percent", json!(percent)); + } + ui.label("%"); + }); + ok_cancel(ui, confirm, open); +} From 0069d91e90cc9f437147bbf13ec340d7c159a640 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:18:14 +0000 Subject: [PATCH 49/76] refactor(engine): move cmd/edit.rs items to cmd/edit/ --- crates/engine/src/cmd/edit.rs | 1568 +------------------ crates/engine/src/cmd/edit/cell.rs | 287 ++++ crates/engine/src/cmd/edit/clipboard.rs | 269 ++++ crates/engine/src/cmd/edit/fill.rs | 96 ++ crates/engine/src/cmd/edit/find.rs | 184 +++ crates/engine/src/cmd/edit/go_to.rs | 199 +++ crates/engine/src/cmd/edit/paste_special.rs | 351 +++++ crates/engine/src/cmd/edit/selection.rs | 171 ++ 8 files changed, 1584 insertions(+), 1541 deletions(-) create mode 100644 crates/engine/src/cmd/edit/cell.rs create mode 100644 crates/engine/src/cmd/edit/clipboard.rs create mode 100644 crates/engine/src/cmd/edit/fill.rs create mode 100644 crates/engine/src/cmd/edit/find.rs create mode 100644 crates/engine/src/cmd/edit/go_to.rs create mode 100644 crates/engine/src/cmd/edit/paste_special.rs create mode 100644 crates/engine/src/cmd/edit/selection.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 32164fb24..e48fe8838 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -8,6 +8,33 @@ use super::*; use crate::selection::{current_region, jump}; use crate::{Clipboard, Session}; +mod cell; +mod clipboard; +mod fill; +mod find; +mod go_to; +mod paste_special; +mod selection; + +use cell::{cell_set, range_fill, range_set_values}; +pub(crate) use cell::{check_arg_counts, input_to_cell, json_to_input, parse_typed_formula}; +#[cfg(test)] +pub(crate) use clipboard::parse_tsv; +use clipboard::{clear_clipboard, copy, cut, paste}; +use fill::{auto_fill, fill_dir, fill_series, flash_fill}; +#[cfg(test)] +use find::replace_ci; +pub(crate) use find::wildcard; +use find::{find, replace}; +pub(super) use go_to::go_to; +pub use go_to::go_to_names; +use go_to::go_to_special; +#[cfg(test)] +use go_to::{GO_TO_MAX_AREAS, GO_TO_RECENT, reference_parts}; +use paste_special::paste_special; +pub(crate) use paste_special::rewrite_all_formulas; +use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_set}; + pub fn specs() -> Vec { vec![ cmd!("cell.set", "Enter Cell", [], None, "{cell?: \"B2\", input: \"text, number or =formula\", sheet?, array?: bool}", has_doc, cell_set), @@ -116,657 +143,6 @@ pub fn specs() -> Vec { ] } -// ---------------------------------------------------------------- entry - -/// What typing `input` into a cell produces (constant or formula, plus an automatic number -/// format). Inherits the destination's cell, row or column style, including its number -/// format, before parsing. Errors when the formula can't be parsed. -pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gridcraft_model::Workbook) -> Result> { - let style = wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); - if input.is_empty() { - let c = Cell { value: Value::Empty, formula: None, style }; - return Ok(if c.is_blank() { None } else { Some(c) }); - } - let fmt_is_text = wb.styles.get(style).num_fmt.as_str() == "@"; - if fmt_is_text { - return Ok(Some(Cell { value: Value::text(input), formula: None, style })); - } - let is_formula = input.starts_with('=') - || (input.len() > 1 - && (input.starts_with('+') || input.starts_with('-')) - && gridcraft_core::parse::parse_number_text(input).is_none() - && input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '(')); - if is_formula { - let body = input.strip_prefix('=').unwrap_or(input); - let expr = wb.name_call_case(parse_typed_formula(body)?); - check_arg_counts(&expr)?; - let mut cell = Cell::formula(Formula::from_expr(expr)); - cell.style = style; - // Formulas whose result is a date/time get a format from functions like TODAY(). - if wb.styles.get(style).num_fmt.as_str() == "General" { - let f = cell.formula.as_ref().map(|f| f.text.to_ascii_uppercase()).unwrap_or_default(); - let auto = if f.starts_with("TODAY(") - || f.starts_with("DATE(") - || f.starts_with("EDATE(") - || f.starts_with("EOMONTH(") - || f.starts_with("WORKDAY(") - { - Some("m/d/yyyy") - } else if f.starts_with("NOW(") { - Some("m/d/yyyy h:mm") - } else if f.starts_with("TIME(") { - Some("h:mm AM/PM") - } else { - None - }; - if let Some(code) = auto { - cell.style = wb.styles.derive(style, |s| s.num_fmt = gridcraft_model::NumFmt::new(code)); - } - } - return Ok(Some(cell)); - } - let mut parsed = gridcraft_core::parse::parse_input(input, wb.date_system); - // Like Excel's automatic percent entry: a bare number of 1 or more typed into a cell already - // formatted as a percentage is a percent (3 -> 3%); smaller numbers are taken as fractions. - if parsed.format.is_none() - && !input.contains('%') - && wb.styles.get(style).num_fmt.as_str().ends_with('%') - && let Value::Number(n) = parsed.value - && n.abs() >= 1.0 - { - parsed.value = Value::Number(n / 100.0); - } - let mut style = style; - if let Some(code) = parsed.format { - let cur = wb.styles.get(style).num_fmt.as_str().to_string(); - if cur == "General" { - style = wb.styles.derive(style, |s| s.num_fmt = gridcraft_model::NumFmt::new(code)); - } - } - // Wrap text automatically when the input has line breaks (Alt+Enter). - if input.contains('\n') && !wb.styles.get(style).align.wrap { - style = wb.styles.derive(style, |s| s.align.wrap = true); - } - Ok(Some(Cell { value: parsed.value, formula: None, style })) -} - -/// Parses a formula as it is typed (without the `=`). Excel closes missing parentheses for you, -/// and refuses a formula that still doesn't parse. -pub(crate) fn parse_typed_formula(body: &str) -> Result { - // Excel closes missing parentheses for you. - let parsed = gridcraft_formula::parse_closing_parens(body, gridcraft_formula::parse); - parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}"))) -} - -/// Like Excel, a formula that calls a built-in function with too few or too many arguments is -/// refused when it's entered, rather than stored to show `#VALUE!`. Functions the registry doesn't -/// hold (special forms like LET, LAMBDA, OFFSET, INDIRECT, SUBTOTAL), defined LAMBDA names and -/// unknown functions are skipped. -pub(crate) fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> { - let mut bad = None; - expr.walk(&mut |e| { - if bad.is_none() - && let gridcraft_formula::Expr::Call(name, args) = e - && let Some(spec) = gridcraft_functions::lookup(name) - && !spec.accepts(args.len()) - { - let few = args.len() < spec.min_args; - bad = Some(format!("{} has too {} arguments. Use {}", spec.name, if few { "few" } else { "many" }, spec.signature)); - } - }); - match bad { - Some(msg) => Err(EngineError::Other(msg)), - None => Ok(()), - } -} - -fn cell_set(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; - let at = match cell_param_on(s, p, "cell", sheet)? { - Some(c) => c, - None => s.doc()?.selection.active, - }; - // Both spellings take a string, number or boolean, as typed (#697). - let input = p.get("input").or_else(|| p.get("value")).map(json_to_input).ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?; - let array = bool_param(p, "array").unwrap_or(false); - let d = s.doc()?; - if let Some(sh) = d.wb.sheet(sheet) - && sh.is_protected() - && d.wb.styles.get(sh.style_id(at)).protection.locked - && let Some(m) = locked_cell_error(sh, at, &d.unlocked_ranges) - { - return Err(EngineError::Other(m)); - } - let active_sheet = s.doc()?.wb.active_sheet(); - edit(s, |cx| { - if array { - // Legacy Ctrl+Shift+Enter array formula over the selection, when the selection holds - // the cell; a cell elsewhere (or on another sheet) gets a one-cell array. - let selected = cx.sel.current(); - let range = if sheet == active_sheet && selected.contains(at) { selected } else { RangeRef::cell(at) }; - // A table header can't hold an array's value, and a table resizes under it. - if !range.is_single() && cx.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.range.intersects(&range))) { - return Err(EngineError::Other("Multi-cell array formulas aren't allowed in tables.".into())); - } - let mut cell = input_to_cell(&input, sheet, range.start, &mut cx.wb)?.unwrap_or_default(); - if let Some(f) = cell.formula.as_mut() { - std::sync::Arc::make_mut(f).array = Some(range); - } - let sh = cx.sheet_mut(sheet)?; - // The array takes the whole range: other cells in it lose their contents. - for c in range.iter().skip(1).take(1_000_000) { - if let Some(old) = sh.cells.get(c).filter(|x| !x.value.is_empty() || x.formula.is_some()) { - let style = old.style; - sh.set_cell(c, Cell { style, ..Cell::default() }); - } - } - sh.set_cell(range.start, cell); - cx.touch(sheet, range.start); - return Ok(Json::Null); - } - let cell = input_to_cell(&input, sheet, at, &mut cx.wb)?; - let sh = cx.sheet_mut(sheet)?; - match cell { - Some(c) => sh.set_cell(at, c), - None => { - sh.remove_cell(at); - } - } - super::data::grow_autofilter(sh, at); - cx.touch(sheet, at); - Ok(Json::Null) - })?; - let v = s.doc()?.wb.sheet(sheet).map(|sh| sh.value(at)).unwrap_or_default(); - Ok(json!({"cell": at.a1(), "value": v.display()})) -} - -pub(crate) fn json_to_input(v: &Json) -> String { - match v { - Json::Null => String::new(), - Json::Bool(b) => { - if *b { - "TRUE".into() - } else { - "FALSE".into() - } - } - Json::Number(n) => n.to_string(), - Json::String(s) => s.clone(), - other => other.to_string(), - } -} - -fn range_set_values(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; - let start = target_range(s, p)?.start; - let Some(rows) = p.get("values").and_then(Json::as_array) else { return Err(bad("range.setValues", "missing `values` (array of rows)")) }; - // A string is run through typed input by default, which reads it with en-US rules: a caller - // sending "05.04.2026" from a dd.mm.yyyy export got 4 May, and "2.500" became 2.5, in silence - // (#2255). `parse: false` stores a string as the text it is; numbers, booleans and formulas - // are unaffected either way. - let parse = match p.get("parse") { - None | Some(Json::Null) => true, - Some(Json::Bool(b)) => *b, - Some(other) => return Err(bad("range.setValues", format!("parse {other}: true or false"))), - }; - if rows.len() > MAX_ROWS as usize { - return Err(bad("range.setValues", "too many rows")); - } - let mut count = 0; - edit(s, |cx| { - for (ri, row) in rows.iter().enumerate() { - let cols: Vec<&Json> = match row.as_array() { - Some(a) => a.iter().collect(), - None => vec![row], - }; - for (ci, v) in cols.iter().enumerate() { - let Some(at) = start.offset(ri as i64, ci as i64) else { continue }; - let input = json_to_input(v); - let cell = match v { - Json::Number(n) => Some(Cell { - value: Value::number(n.as_f64().unwrap_or(0.0)), - formula: None, - style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), - }), - Json::Bool(b) => { - Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() }) - } - // Text as given: no date or number reading, and a leading `'` is part of the - // text rather than the marker that makes it text. A formula is still a - // formula, which is what the ticket asks for and what `=` means in a cell. - Json::String(text) if !parse && !text.starts_with('=') => { - let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); - // Line breaks still turn on Wrap Text, as typed input does. - if text.contains('\n') && !cx.wb.styles.get(style).align.wrap { - style = cx.wb.styles.derive(style, |s| s.align.wrap = true); - } - Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style }) - } - _ => input_to_cell(&input, sheet, at, &mut cx.wb)?, - }; - let sh = cx.sheet_mut(sheet)?; - match cell { - Some(c) => sh.set_cell(at, c), - None => { - sh.remove_cell(at); - } - } - super::data::grow_autofilter(sh, at); - cx.touch(sheet, at); - count += 1; - } - } - Ok(()) - })?; - Ok(json!({"cells": count})) -} - -fn range_fill(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; - let ranges = target_ranges(s, p)?; - let input = str_param(p, "input").unwrap_or("").to_string(); - let origin = s.doc()?.selection.active; - edit(s, |cx| { - // Each cell gets what typing the input there gives: the automatic number format where - // it's General, its own format otherwise, text where it's Text. That depends only on - // the cell's style, so the input is parsed once per style. - let mut by_style: std::collections::HashMap> = std::collections::HashMap::new(); - for r in &ranges { - if r.count() > 2_000_000 { - return Err(bad("range.fill", "range too large")); - } - for at in r.iter() { - let style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); - let base = match by_style.get(&style) { - Some(c) => c.clone(), - None => { - let c = input_to_cell(&input, sheet, at, &mut cx.wb)?; - by_style.insert(style, c.clone()); - c - } - }; - let mut cell = base.unwrap_or_default(); - if let Some(f) = &cell.formula - && let Some(e) = f.expr() - { - let shifted = gridcraft_formula::adjust::shift_relative(e, at.row as i64 - origin.row as i64, at.col as i64 - origin.col as i64); - cell.formula = Some(std::sync::Arc::new(Formula::from_expr(shifted))); - } - let sh = cx.sheet_mut(sheet)?; - sh.set_cell(at, cell); - super::data::grow_autofilter(sh, at); - cx.touch(sheet, at); - } - } - Ok(Json::Null) - }) -} - -// ---------------------------------------------------------------- selection - -/// Whether the cell may be selected: a protected sheet can forbid selecting locked and/or unlocked cells. -fn cell_selectable(wb: &gridcraft_model::Workbook, sheet: &gridcraft_model::Sheet, c: CellRef) -> bool { - let Some(p) = sheet.protection.as_ref() else { return true }; - if wb.styles.get(sheet.style_id(c)).protection.locked { p.select_locked } else { p.select_unlocked } -} - -fn selection_set(s: &mut Session, p: &Json) -> Result { - if p.get("sheet").is_some() { - let i = target_sheet(s, p)?; - s.doc_mut()?.wb_switch_sheet(i); - } else if let Some(r) = str_param(p, "range").or_else(|| str_param(p, "cell")) - && let (Some(_), _) = split_sheet(r) - { - let i = target_sheet(s, p)?; - s.doc_mut()?.wb_switch_sheet(i); - } - let ranges = target_ranges(s, p)?; - let active = cell_param(s, p, "active")?; - let d = s.doc_mut()?; - let active = active.or_else(|| ranges.last().map(|r| r.start)).unwrap_or_default(); - let mut sel = crate::Selection { active, anchor: ranges.last().map(|r| r.start).unwrap_or(active), ranges }; - if let Some(sh) = d.wb.active() { - sel.expand_merges(sh); - if !cell_selectable(&d.wb, sh, sel.active) { - return Err(EngineError::Other("The cell you are trying to change is on a protected sheet that does not allow selecting it.".into())); - } - } - d.selection = sel; - Ok(json!({"selection": d.selection.a1(), "active": d.selection.active.a1()})) -} - -impl crate::DocState { - /// Switches the active sheet, remembering each sheet's selection. - pub fn wb_switch_sheet(&mut self, i: usize) { - if i >= self.wb.sheets.len() || i == self.wb.active_sheet() { - return; - } - let cur = self.wb.active_sheet(); - if self.sheet_selections.len() < self.wb.sheets.len() { - self.sheet_selections.resize(self.wb.sheets.len(), crate::Selection::default()); - } - if let Some(slot) = self.sheet_selections.get_mut(cur) { - *slot = self.selection.clone(); - } - // View state: set in place, so the workbook is neither copied nor made dirty. - self.wb.set_active_sheet(i); - self.selection = self.sheet_selections.get(i).cloned().unwrap_or_default(); - } -} - -fn selection_move(s: &mut Session, p: &Json) -> Result { - let dr = p.get("dr").and_then(Json::as_i64).unwrap_or(0); - let dc = p.get("dc").and_then(Json::as_i64).unwrap_or(0); - let extend = bool_param(p, "extend").unwrap_or(false); - let jmp = bool_param(p, "jump").unwrap_or(false); - let d = s.doc_mut()?; - let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; - let sel = &mut d.selection; - // Extension moves the far corner, not the active cell. - let from = if extend { sel.moving_end() } else { sel.active }; - // Skip hidden rows/columns and step over merged areas. - let mut to = if jmp { - jump(sheet, from, dr.signum(), dc.signum()) - } else { - let mut t = from.offset_clamped(dr, dc); - let mut guard = 0; - while (sheet.is_row_hidden(t.row) || sheet.is_col_hidden(t.col)) && guard < 100_000 { - let n = t.offset_clamped(dr.signum(), dc.signum()); - if n == t { - break; - } - t = n; - guard += 1; - } - if !extend && let Some(m) = sheet.merge_at(from) { - // Leaving a merged cell moves from its edge. - if dr > 0 { - t = CellRef::new(m.end.offset_clamped(dr, 0).row, from.col); - } else if dc > 0 { - t = CellRef::new(from.row, m.end.offset_clamped(0, dc).col); - } - } - t - }; - if let Some(m) = sheet.merge_at(to) - && !extend - { - to = m.start; - } - if !extend && !cell_selectable(&d.wb, sheet, to) { - // Step on to the next selectable cell in the same direction; stay put if there is none. - let (sr, sc) = (dr.signum(), dc.signum()); - let mut t = to; - let mut found = false; - if !jmp && (sr, sc) != (0, 0) { - for _ in 0..10_000 { - let n = t.offset_clamped(sr, sc); - if n == t { - break; - } - t = n; - if !sheet.is_row_hidden(t.row) && !sheet.is_col_hidden(t.col) && cell_selectable(&d.wb, sheet, t) { - found = true; - break; - } - } - } - if !found { - return Ok(json!({"selection": sel.a1(), "active": sel.active.a1()})); - } - to = t; - } - if extend { - sel.extend_to(to); - sel.expand_merges(sheet); - } else { - *sel = crate::Selection::at(to); - if let Some(m) = sheet.merge_at(to) { - sel.ranges = vec![m]; - } - } - Ok(json!({"selection": sel.a1(), "active": sel.active.a1()})) -} - -fn selection_next(s: &mut Session, p: &Json) -> Result { - let fwd = bool_param(p, "forward").unwrap_or(true); - let by_row = bool_param(p, "byRow").unwrap_or(false); - let d = s.doc_mut()?; - d.selection.cycle(fwd, by_row); - Ok(json!({"active": d.selection.active.a1()})) -} - -fn select_all(s: &mut Session, _: &Json) -> Result { - let d = s.doc_mut()?; - let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; - let region = current_region(sheet, d.selection.active); - let all = RangeRef::all(); - let r = if region.is_single() || d.selection.current() == region { all } else { region }; - let active = d.selection.active; - d.selection = crate::Selection { active, anchor: r.start, ranges: vec![r] }; - Ok(json!({"selection": d.selection.a1()})) -} - -fn select_region(s: &mut Session, _: &Json) -> Result { - let d = s.doc_mut()?; - let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; - let r = current_region(sheet, d.selection.active); - let active = d.selection.active; - d.selection = crate::Selection { active, anchor: r.start, ranges: vec![r] }; - Ok(json!({"selection": d.selection.a1()})) -} - -fn select_row(s: &mut Session, _: &Json) -> Result { - let d = s.doc_mut()?; - let r = d.selection.current(); - let active = d.selection.active; - d.selection = crate::Selection { active, anchor: CellRef::new(r.start.row, 0), ranges: vec![RangeRef::rows(r.start.row, r.end.row)] }; - Ok(json!({"selection": d.selection.a1()})) -} - -fn select_col(s: &mut Session, _: &Json) -> Result { - let d = s.doc_mut()?; - let r = d.selection.current(); - let active = d.selection.active; - d.selection = crate::Selection { active, anchor: CellRef::new(0, r.start.col), ranges: vec![RangeRef::cols(r.start.col, r.end.col)] }; - Ok(json!({"selection": d.selection.a1()})) -} - -/// How many references the Go To dialog keeps for going back to. -const GO_TO_RECENT: usize = 10; -/// The most areas one Go To selects. -const GO_TO_MAX_AREAS: usize = 2000; -/// How deep a name that refers to other names is followed. -const GO_TO_NAME_DEPTH: usize = 8; - -pub(super) fn go_to(s: &mut Session, p: &Json) -> Result { - if bool_param(p, "list") == Some(true) { - let d = s.doc()?; - return Ok(json!({"names": go_to_names(&d.wb, d.wb.active_sheet()), "recent": d.go_to_recent})); - } - let Some(reference) = str_param(p, "reference").or_else(|| str_param(p, "range")) else { return Err(bad("edit.goTo", "missing `reference`")) }; - let d = s.doc()?; - let mut target: Option = None; - let mut areas: Vec = Vec::new(); - let mut named = false; - for part in reference_parts(reference) { - let Some((sheet, found, by_name)) = go_to_target(&d.wb, d.wb.active_sheet(), part, 0) else { - return Err(EngineError::Other("Reference isn't valid.".into())); - }; - // One selection holds the areas of one sheet. - if target.is_some_and(|t| t != sheet) { - return Err(EngineError::Other("Reference isn't valid.".into())); - } - target = Some(sheet); - named |= by_name; - areas.extend(found); - if areas.len() > GO_TO_MAX_AREAS { - return Err(EngineError::Other(format!("Go To selects at most {GO_TO_MAX_AREAS} areas."))); - } - } - let Some(sheet) = target else { return Err(EngineError::Other("Reference isn't valid.".into())) }; - let range = areas.iter().map(RangeRef::a1).collect::>().join(","); - let out = selection_set(s, &json!({"sheet": sheet, "range": range}))?; - if bool_param(p, "record") == Some(true) && !named { - let d = s.doc_mut()?; - let prefix = gridcraft_core::quote_sheet(&d.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default()); - let entry = areas.iter().map(|r| format!("{prefix}!{}", r.a1())).collect::>().join(","); - d.go_to_recent.retain(|r| !r.eq_ignore_ascii_case(&entry)); - d.go_to_recent.insert(0, entry); - d.go_to_recent.truncate(GO_TO_RECENT); - } - Ok(out) -} - -/// The parts of a Go To reference, split at the commas outside quoted sheet names and -/// parentheses: `Price, Type` or `B14:C22,F19:G30`. The parts are read one at a time, so a caller -/// that stops at an invalid part or at `GO_TO_MAX_AREAS` does not split the rest of the text. -fn reference_parts(text: &str) -> impl Iterator { - let (mut depth, mut quoted, mut start, mut done) = (0usize, false, 0, false); - let mut chars = text.char_indices(); - std::iter::from_fn(move || { - if done { - return None; - } - for (i, c) in chars.by_ref() { - match c { - '\'' => quoted = !quoted, - '(' if !quoted => depth += 1, - ')' if !quoted => depth = depth.saturating_sub(1), - ',' if !quoted && depth == 0 => { - let part = text.get(start..i).unwrap_or("").trim(); - start = i + 1; - return Some(part); - } - _ => {} - } - } - done = true; - Some(text.get(start..).unwrap_or("").trim()) - }) -} - -/// Where one part of a Go To reference points, seen from sheet `sheet`: the sheet, its areas, -/// and whether a defined name or a table gave them. `Sheet2!Name` is the name scoped to Sheet2. -/// A name counts when what it refers to is a reference; a constant or a formula is not one. -fn go_to_target(wb: &gridcraft_model::Workbook, sheet: usize, part: &str, depth: usize) -> Option<(usize, Vec, bool)> { - if part.is_empty() || depth > GO_TO_NAME_DEPTH { - return None; - } - let (prefix, body) = split_sheet(part); - let on = match &prefix { - Some(n) => wb.sheet_index(n)?, - None => sheet, - }; - if let Some(r) = RangeRef::parse(&body.replace('$', "")) { - return Some((on, vec![r], false)); - } - let name = match prefix { - Some(_) => wb.names.iter().find(|n| n.scope == Some(on) && gridcraft_formula::same_name(&n.name, body)), - None => wb.name(body, sheet), - }; - if let Some(n) = name { - return name_areas(wb, n, sheet, depth).map(|(t, areas)| (t, areas, true)); - } - if prefix.is_none() - && let Some((si, ti)) = wb.table(body) - { - // As in Excel, a table's name stands for its data, without the header and totals rows. - let t = wb.sheet(si)?.tables.get(ti)?; - return Some((si, vec![t.data_range().unwrap_or(t.range)], true)); - } - None -} - -/// The sheet and areas defined name `n` refers to, seen from sheet `sheet`, when that is a -/// reference. -fn name_areas(wb: &gridcraft_model::Workbook, n: &gridcraft_model::DefinedName, sheet: usize, depth: usize) -> Option<(usize, Vec)> { - let from = n.scope.unwrap_or(sheet); - let formula = n.formula.trim().trim_start_matches('='); - let mut target: Option = None; - let mut areas = Vec::new(); - for p in reference_parts(formula) { - let (s, found, _) = go_to_target(wb, from, p, depth + 1)?; - if target.is_some_and(|t| t != s) { - return None; - } - target = Some(s); - areas.extend(found); - if areas.len() > GO_TO_MAX_AREAS { - return None; - } - } - target.map(|t| (t, areas)) -} - -/// The names the Go To dialog lists, seen from sheet `sheet`: the named ranges of the workbook and -/// of every sheet, in alphabetical order. A name scoped to another sheet is written `Sheet2!Name`, -/// and the active sheet's own name hides the workbook name it shares. Hidden names and names that -/// do not refer to a range are left out. -pub fn go_to_names(wb: &gridcraft_model::Workbook, sheet: usize) -> Vec { - let own: std::collections::HashSet = - wb.names.iter().filter(|n| n.scope == Some(sheet)).map(|n| gridcraft_formula::fold_name(&n.name)).collect(); - let mut out: Vec = wb - .names - .iter() - .filter(|n| !n.hidden && name_areas(wb, n, sheet, 0).is_some()) - .filter_map(|n| match n.scope { - None if own.contains(&gridcraft_formula::fold_name(&n.name)) => None, - None => Some(n.name.clone()), - Some(s) if s == sheet => Some(n.name.clone()), - Some(s) => Some(format!("{}!{}", gridcraft_core::quote_sheet(&wb.sheet(s)?.name), n.name)), - }) - .collect(); - out.sort_by_cached_key(|n| gridcraft_formula::fold_name(n)); - out.dedup_by(|a, b| gridcraft_formula::same_name(a, b)); - out -} - -fn go_to_special(s: &mut Session, p: &Json) -> Result { - let kind = str_param(p, "kind").unwrap_or("blanks"); - let d = s.doc()?; - let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; - let scope = if d.selection.is_single_cell() { sheet.used_range().unwrap_or(RangeRef::cell(d.selection.active)) } else { d.selection.bounds() }; - if kind == "lastCell" { - let c = sheet.used_range().map(|r| r.end).unwrap_or_default(); - return selection_set(s, &json!({"cell": c.a1()})); - } - if kind == "currentRegion" { - return select_region(s, &json!({})); - } - let mut hits: Vec = Vec::new(); - if scope.count() > 4_000_000 { - return Err(bad("edit.goToSpecial", "range too large")); - } - for c in scope.iter() { - let cell = sheet.cell(c); - let v = sheet.value(c); - let ok = match kind { - "blanks" => v.is_empty(), - "constants" => cell.is_some_and(|x| x.formula.is_none() && !x.value.is_empty()), - "formulas" => cell.is_some_and(|x| x.formula.is_some()), - "comments" | "notes" => sheet.comments.contains_key(&c), - "errors" => v.is_error(), - "numbers" => v.is_number(), - "text" => v.is_text(), - "visible" => !sheet.is_row_hidden(c.row) && !sheet.is_col_hidden(c.col), - "conditionalFormats" => sheet.cond_formats.iter().any(|cf| cf.ranges.iter().any(|r| r.contains(c))), - "dataValidation" => sheet.validations.iter().any(|dv| dv.ranges.iter().any(|r| r.contains(c))), - _ => return Err(bad("edit.goToSpecial", format!("unknown kind `{kind}`"))), - }; - if ok { - hits.push(c); - } - } - if hits.is_empty() { - return Err(EngineError::Other("No cells were found.".into())); - } - let ranges = coalesce(&hits); - let d = s.doc_mut()?; - d.selection = crate::Selection { active: hits[0], anchor: hits[0], ranges }; - Ok(json!({"selection": d.selection.a1(), "count": hits.len()})) -} - /// Merges cells into vertical runs per column (a compact multi-area selection). fn coalesce(cells: &[CellRef]) -> Vec { let mut sorted = cells.to_vec(); @@ -832,619 +208,6 @@ fn redo(s: &mut Session, p: &Json) -> Result { Ok(json!({"redone": label})) } -// ---------------------------------------------------------------- clipboard - -fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { - let multiple = || EngineError::Other("This action won't work on multiple selections.".into()); - let sheet = target_sheet(s, p)?; - let ranges = target_ranges(s, p)?; - if ranges.len() > 1 && cut { - return Err(multiple()); - } - // The selection as made (untrimmed), for Insert Copied Cells. - let selected = ranges.first().copied().unwrap_or_default(); - let d = s.doc()?; - let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; - let trim = |range: RangeRef| { - if range.is_full_cols() || range.is_full_rows() { - let used = sh.used_range().unwrap_or(RangeRef::cell(range.start)); - range.intersection(&used.union(&RangeRef::cell(range.start))).unwrap_or(range) - } else { - range - } - }; - let (wb, range, areas) = if ranges.len() > 1 { - let areas: Vec = ranges.iter().map(|r| trim(*r)).collect(); - let (wb, block) = slide_together(&d.wb, sheet, &areas).ok_or_else(multiple)?; - (std::sync::Arc::new(wb), block, areas) - } else { - (d.wb.clone(), trim(ranges.first().copied().unwrap_or_default()), Vec::new()) - }; - let text = crate::display::range_text(&wb, sheet, range); - // The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and - // scripts get it only when they ask for it. - let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&wb, sheet, range, &text) } else { None }; - let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected, trust: d.trust }; - s.clipboard = Some(clip); - let mut result = json!({"range": range.a1(), "text": text}); - if let Some(html) = html { - result["html"] = Json::String(html); - } - Ok(result) -} - -/// Excel copies a multi-area selection only when the areas line up: all cover the same rows (they -/// slide together side by side, left to right) or all cover the same columns (stacked, top to -/// bottom), without overlapping. Returns a copy of the workbook whose sheet holds the areas packed -/// into one block at the first area's place, and that block. Formulas travel as their values, as -/// in Excel. `None` when the areas don't line up. -fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRef]) -> Option<(gridcraft_model::Workbook, RangeRef)> { - let first = *areas.first()?; - let across = areas.iter().all(|a| a.start.row == first.start.row && a.end.row == first.end.row); - let down = areas.iter().all(|a| a.start.col == first.start.col && a.end.col == first.end.col); - let mut sorted = areas.to_vec(); - if across { - sorted.sort_by_key(|a| a.start.col); - } else if down { - sorted.sort_by_key(|a| a.start.row); - } else { - return None; - } - if sorted.windows(2).any(|w| matches!(w, [a, b] if a.intersects(b))) { - return None; - } - let anchor = sorted.first()?.start; - let (h, w) = if across { - (first.height(), sorted.iter().map(RangeRef::width).fold(0u32, u32::saturating_add)) - } else { - (sorted.iter().map(RangeRef::height).fold(0u32, u32::saturating_add), first.width()) - }; - let block = RangeRef::new(anchor, anchor.offset(i64::from(h) - 1, i64::from(w) - 1)?); - let src = wb.sheet(sheet)?; - let mut out = wb.clone(); - let dst = out.sheet_mut(sheet)?; - dst.take_cells(block); - dst.spill.retain(|c, _| !block.contains(*c)); - dst.spill_ranges.retain(|c, _| !block.contains(*c)); - dst.comments.retain(|c, _| !block.contains(*c)); - dst.merges.retain(|m| !m.intersects(&block)); - if across { - dst.cols.retain(|c, _| !(block.start.col..=block.end.col).contains(c)); - } else { - dst.rows.retain(|r, _| !(block.start.row..=block.end.row).contains(r)); - } - let mut next = if across { anchor.col } else { anchor.row }; - for a in &sorted { - let (dr, dc) = if across { (0, i64::from(next) - i64::from(a.start.col)) } else { (i64::from(next) - i64::from(a.start.row), 0) }; - let cells = src.cells.iter_range(*a).map(|(c, _)| c).chain(src.spill.keys().copied().filter(|c| a.contains(*c))); - for c in cells { - let Some(to) = c.offset(dr, dc) else { continue }; - let style = src.cell(c).map(|x| x.style).unwrap_or_default(); - dst.set_cell(to, Cell { value: src.value(c), formula: None, style }); - if let Some(picture) = src.cell_pictures.get(&c) { - dst.cell_pictures.insert(to, picture.clone()); - } - } - for (c, comment) in src.comments.iter().filter(|(c, _)| a.contains(**c)) { - if let Some(to) = c.offset(dr, dc) { - dst.comments.insert(to, comment.clone()); - } - } - for m in src.merges.iter().filter(|m| a.contains_range(m)) { - if let (Some(s), Some(e)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) { - dst.merges.push(RangeRef::new(s, e)); - } - } - // Column widths (side by side) or row heights and hidden rows (stacked) follow their cells. - if across { - for (c, info) in src.cols.range(a.start.col..=a.end.col) { - dst.cols.insert(next.saturating_add(c.saturating_sub(a.start.col)), *info); - } - } else { - for (r, info) in src.rows.range(a.start.row..=a.end.row) { - dst.rows.insert(next.saturating_add(r.saturating_sub(a.start.row)), *info); - } - } - next = next.saturating_add(if across { a.width() } else { a.height() }); - } - Some((out, block)) -} - -fn copy(s: &mut Session, p: &Json) -> Result { - copy_impl(s, p, false) -} -fn cut(s: &mut Session, p: &Json) -> Result { - copy_impl(s, p, true) -} -fn clear_clipboard(s: &mut Session, _: &Json) -> Result { - s.clipboard = None; - s.format_painter = None; - ok() -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum What { - All, - Formulas, - Values, - Formats, - Comments, - Validation, - AllExceptBorders, - ColumnWidths, - FormulasAndNumberFormats, - ValuesAndNumberFormats, -} - -fn paste(s: &mut Session, p: &Json) -> Result { - // External text (from the system clipboard) when it differs from ours. - if let Some(text) = str_param(p, "text") - && s.clipboard.as_ref().is_none_or(|c| c.text.trim_end_matches(['\r', '\n']) != text.trim_end_matches(['\r', '\n'])) - { - return paste_text(s, p, text); - } - if s.clipboard.is_none() { - return Err(EngineError::Other("Nothing to paste.".into())); - } - paste_special(s, &json!({"what": "all", "at": p.get("at").cloned().unwrap_or(Json::Null)})) -} - -fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result { - let sheet = target_sheet(s, p)?; - let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start); - let rows: Vec> = parse_tsv(text); - let h = rows.len(); - let w = rows.iter().map(Vec::len).max().unwrap_or(0); - // The HTML flavour, when the host passed it: the number behind each cell's shown text. Read - // only as far as the text's own shape, so spans in the markup can't expand without bound. - let html = str_param(p, "html").map(|html| crate::clip_html::table_cells(html, h, w)).unwrap_or_default(); - edit(s, |cx| { - for (ri, row) in rows.iter().enumerate() { - for (ci, v) in row.iter().enumerate() { - let Some(c) = at.offset(ri as i64, ci as i64) else { continue }; - let mut cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str())))); - if let Some(cl) = cell.as_mut() { - numeric_from_html(cl, v, html.get(ri).and_then(|r| r.get(ci)), &mut cx.wb); - } - let sh = cx.sheet_mut(sheet)?; - match cell { - Some(cell) => sh.set_cell(c, cell), - None => { - sh.remove_cell(c); - } - } - cx.touch(sheet, c); - } - } - if h > 0 && w > 0 { - let end = at.offset_clamped(h as i64 - 1, w as i64 - 1); - *cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] }; - } - Ok(()) - })?; - Ok(json!({"rows": h, "cols": w})) -} - -/// A pasted cell that came out as text ("Rp10,000.00" isn't en-US input) becomes the number the -/// source recorded for it in its HTML, with a format that shows the same text when one can be -/// inferred and the cell has none. The HTML cell must show the same text as the tab-separated -/// field, so HTML that belongs to another copy (or a table laid out differently) changes nothing. -fn numeric_from_html(cell: &mut Cell, field: &str, html: Option<&crate::clip_html::HtmlCell>, wb: &mut gridcraft_model::Workbook) { - let Some(hc) = html else { return }; - let Some(n) = hc.num else { return }; - if cell.formula.is_some() || !matches!(cell.value, Value::Text(_)) || wb.styles.get(cell.style).num_fmt.as_str() == "@" { - return; - } - if crate::clip_html::normalized(&hc.text) != crate::clip_html::normalized(field) { - return; - } - cell.value = Value::number(n); - if wb.styles.get(cell.style).num_fmt.as_str() == "General" - && let Some(code) = crate::clip_html::format_for(field, n) - { - cell.style = wb.styles.derive(cell.style, |s| s.num_fmt = gridcraft_model::NumFmt::new(&code)); - } -} - -/// Tab-separated text with Excel-style quoting. -pub(crate) fn parse_tsv(text: &str) -> Vec> { - let mut rows = Vec::new(); - let mut row = Vec::new(); - let mut field = String::new(); - let mut chars = text.chars().peekable(); - let mut quoted = false; - let mut at_start = true; - while let Some(c) = chars.next() { - if quoted { - if c == '"' { - if chars.peek() == Some(&'"') { - field.push('"'); - chars.next(); - } else { - quoted = false; - } - } else { - field.push(c); - } - continue; - } - match c { - '"' if at_start => { - quoted = true; - at_start = false; - } - '\t' => { - row.push(std::mem::take(&mut field)); - at_start = true; - } - '\r' => {} - '\n' => { - row.push(std::mem::take(&mut field)); - rows.push(std::mem::take(&mut row)); - at_start = true; - } - _ => { - field.push(c); - at_start = false; - } - } - } - if !field.is_empty() || !row.is_empty() { - row.push(field); - rows.push(row); - } - rows -} - -fn paste_special(s: &mut Session, p: &Json) -> Result { - let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) }; - let what = match str_param(p, "what").unwrap_or("all") { - "all" => What::All, - "formulas" => What::Formulas, - "values" => What::Values, - "formats" => What::Formats, - "comments" | "notes" => What::Comments, - "validation" => What::Validation, - "allExceptBorders" => What::AllExceptBorders, - "columnWidths" => What::ColumnWidths, - "formulasAndNumberFormats" => What::FormulasAndNumberFormats, - "valuesAndNumberFormats" => What::ValuesAndNumberFormats, - other => return Err(bad("edit.pasteSpecial", format!("unknown `what`: {other}"))), - }; - let op = str_param(p, "operation").unwrap_or("none").to_string(); - let skip_blanks = bool_param(p, "skipBlanks").unwrap_or(false); - let transpose = bool_param(p, "transpose").unwrap_or(false); - let link = bool_param(p, "link").unwrap_or(false); - if link && !clip.areas.is_empty() { - return Err(EngineError::Other("This action won't work on multiple selections.".into())); - } - let dest_sheet = target_sheet(s, p)?; - let sel = s.doc()?.selection.current(); - let at = cell_param_on(s, p, "at", dest_sheet)?.unwrap_or(sel.start); - let src = clip.range; - let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) }; - // Tile the copy over a larger destination that is a multiple of its size. - let tile = sel.height() % h == 0 - && sel.width() % w == 0 - && !sel.is_single() - && cell_param_on(s, p, "at", dest_sheet)?.is_none() - && !sel.is_full_cols() - && !sel.is_full_rows() - && sel.count() <= 4_000_000; - let (tiles_r, tiles_c) = if tile { (sel.height() / h, sel.width() / w) } else { (1, 1) }; - let same_doc = s.doc()?.uid == clip.doc_uid; - let cut_move = clip.cut && same_doc && what == What::All; - let src_wb = clip.wb.clone(); - let Some(src_sheet) = src_wb.sheet(clip.sheet) else { return Err(EngineError::Other("The copied sheet no longer exists.".into())) }; - let src_sheet_name = src_sheet.name.clone(); - if cut_move && at.offset((tiles_r * h).saturating_sub(1) as i64, (tiles_c * w).saturating_sub(1) as i64).is_none() { - // Check the whole footprint before the source is emptied, so a move that overflows the grid loses nothing. - return Err(EngineError::Other("The cut area doesn't fit at this position; nothing was moved.".into())); - } - edit(s, |cx| { - // Styles from another workbook must be re-interned. - let mut style_map = std::collections::HashMap::new(); - let mut map_style = |id: StyleId, wb: &mut gridcraft_model::Workbook| -> StyleId { - if same_doc { - return id; - } - *style_map.entry(id).or_insert_with(|| wb.styles.intern(src_wb.styles.get(id).clone())) - }; - let mut moved_formulas = Vec::new(); - let mut moved_pictures = std::collections::BTreeMap::new(); - if cut_move { - // Remove the source first (contents and formats), and fix references to it. - let source = cx.sheet_mut(clip.sheet)?; - moved_pictures = source.cell_pictures.iter().filter(|(c, _)| src.contains(**c)).map(|(c, p)| (*c, p.clone())).collect(); - let taken = source.take_cells(src); - // Notes, hyperlinks and merged areas sit beside the cells, and a cut takes them - // with the block: the destination is written from the clipboard snapshot taken - // before the cut, so removing them here loses nothing and leaves the old place - // clean instead of leaving one merged block and one note behind. - source.comments.retain(|c, _| !src.contains(*c)); - source.hyperlinks.retain(|c, _| !src.contains(*c)); - source.merges.retain(|m| !src.contains_range(m)); - for (c, _) in &taken { - cx.touch(clip.sheet, *c); - } - moved_formulas = taken; - } - for tr in 0..tiles_r { - for tc in 0..tiles_c { - for r in 0..src.height() { - for c in 0..src.width() { - let sc = CellRef::new(src.start.row + r, src.start.col + c); - let (dr, dc) = if transpose { (c, r) } else { (r, c) }; - let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue }; - let src_cell = if cut_move { - moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone()) - } else { - src_sheet.cell(sc).cloned() - }; - let src_val = src_sheet.value(sc); - let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned(); - if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) { - continue; - } - let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default(); - let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned(); - let mut new_picture = old_picture.clone(); - let mut new = old.clone(); - let shift = |cell: &Cell| -> Option> { - let f = cell.formula.as_ref()?; - let e = f.expr()?; - if cut_move { - // A moved formula still reads cells on its original sheet. Qualify - // those references before the workbook-wide move adjustment below. - let e = if dest_sheet != clip.sheet { - e.map(&mut |x| match x { - gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => { - r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone()); - gridcraft_formula::Expr::Ref(r) - } - other => other, - }) - } else { - e - }; - let mut moved = Formula::from_expr(e); - // The array range moves with its formula. - let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); - moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?))); - moved.dynamic = f.dynamic; - return Some(std::sync::Arc::new(moved)); - } - let moved = - gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); - Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) })) - }; - if link { - let sheet_prefix = if dest_sheet == clip.sheet && same_doc { - String::new() - } else { - format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name)) - }; - new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1())))); - new.value = Value::Empty; - new_picture = None; - } else { - let sc_cell = src_cell.clone().unwrap_or_default(); - match what { - What::All | What::AllExceptBorders => { - new_picture = src_picture.clone(); - new.formula = shift(&sc_cell); - new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; - let st = map_style(sc_cell.style, &mut cx.wb); - new.style = if what == What::AllExceptBorders { - let borders = cx.wb.styles.get(old.style).borders; - cx.wb.styles.derive(st, |s| s.borders = borders) - } else { - st - }; - } - What::Formulas | What::FormulasAndNumberFormats => { - new_picture = src_picture.clone(); - new.formula = shift(&sc_cell); - new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; - if what == What::FormulasAndNumberFormats { - let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); - new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); - } - } - What::Values | What::ValuesAndNumberFormats => { - new_picture = src_picture.clone(); - new.formula = None; - new.value = src_val.clone(); - if what == What::ValuesAndNumberFormats { - let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); - new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); - } - } - What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb), - What::Comments | What::Validation | What::ColumnWidths => {} - } - // Operations combine numbers with what is already there. - if op != "none" - && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) - && new.formula.is_none() - { - if old_picture.is_some() || new_picture.is_some() { - new_picture = None; - new.value = Value::Error(gridcraft_core::CellError::Value); - } - let a = old.value.clone(); - let b = new.value.clone(); - // Numbers stored as text take part (multiplying by 1 turns them into - // numbers); other text and errors stay as they are. - if let (Ok(x), Ok(y)) = (a.to_number(), b.to_number()) - && (a.is_number() || a.is_empty() || a.is_text()) - && (b.is_number() || b.is_empty()) - { - new.value = match op.as_str() { - "add" => Value::number(x + y), - "subtract" => Value::number(x - y), - "multiply" => Value::number(x * y), - "divide" => { - if y == 0.0 { - Value::Error(gridcraft_core::CellError::Div0) - } else { - Value::number(x / y) - } - } - _ => b.clone(), - }; - } else if (a.is_text() || a.is_error()) && (b.is_number() || b.is_empty()) { - new.value = a; - } - // A formula already there is kept and combined with the pasted number. - if let Some(f) = &old.formula - && let Ok(y) = b.to_number() - && (b.is_number() || b.is_empty()) - && let Some(sym) = match op.as_str() { - "add" => Some('+'), - "subtract" => Some('-'), - "multiply" => Some('*'), - "divide" => Some('/'), - _ => None, - } - { - let text = format!("=({}){sym}{}", f.text, gridcraft_core::number_to_text(y)); - new.formula = Some(std::sync::Arc::new(Formula::new(&text))); - new.value = Value::Empty; - } - } - } - // The copied cell's validation rule replaces whatever rule the destination had. - if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) { - let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned(); - let sh = cx.sheet_mut(dest_sheet)?; - super::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]); - if let Some(mut dv) = rule { - // Relative references move with the rule, as pasted formulas do. - let first = dv.ranges.first().map_or(sc, |r| r.start); - dv.f1 = super::data::move_rule_formula(&dv.f1, first, dest); - dv.f2 = dv.f2.map(|f| super::data::move_rule_formula(&f, first, dest)); - dv.ranges = vec![RangeRef::new(dest, dest)]; - sh.validations.push(dv); - } - } - match what { - What::Comments => { - if let Some(cm) = src_sheet.comments.get(&sc).cloned() { - cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); - } - } - What::ColumnWidths => { - if let Some(info) = src_sheet.cols.get(&sc.col).copied() { - cx.sheet_mut(dest_sheet)?.cols.insert(dest.col, info); - } - } - What::All => { - if let Some(cm) = src_sheet.comments.get(&sc).cloned() { - cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); - } - if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() { - cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h); - } - cx.sheet_mut(dest_sheet)?.set_cell(dest, new); - } - _ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new), - } - if let Some(picture) = new_picture { - cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture); - } - cx.touch(dest_sheet, dest); - } - } - } - } - // Merged areas inside the copied block travel with it. - if matches!(what, What::All | What::Formats) && !transpose { - let merges: Vec = src_sheet.merges.iter().filter(|m| src.contains_range(m)).copied().collect(); - for m in merges { - let dr = at.row as i64 - src.start.row as i64; - let dc = at.col as i64 - src.start.col as i64; - if let (Some(a), Some(b)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) { - let nm = RangeRef::new(a, b); - let sh = cx.sheet_mut(dest_sheet)?; - sh.merges.retain(|x| !x.intersects(&nm)); - sh.merges.push(nm); - } - } - } - if cut_move { - // References elsewhere to the moved block now point to its new place. - let dest_name = cx.wb.sheet(dest_sheet).map(|s| s.name.clone()).unwrap_or_default(); - let e = if dest_sheet == clip.sheet { - gridcraft_formula::adjust::Edit::Move { from: src, to_row: at.row, to_col: at.col } - } else { - gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name, to_row: at.row, to_col: at.col } - }; - rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e); - cx.structural = true; - } - let end = at.offset_clamped((tiles_r * h) as i64 - 1, (tiles_c * w) as i64 - 1); - *cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] }; - Ok(()) - })?; - // Formulas and validation from an untrusted file leave the destination untrusted; plain values, - // formats (cell styles and merged areas, no conditional formats), column widths and notes - // carry nothing active. A linked paste writes a formula into every cell whatever `what` says, - // except for column widths and notes, which write no cells at all. - let passive = - matches!(what, What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Formats | What::Values | What::ValuesAndNumberFormats)); - if !same_doc && !passive { - let d = s.doc_mut()?; - d.trust = d.trust.receive(clip.trust); - } - if cut_move { - s.clipboard = None; - } - Ok(json!({"pasted": s.doc()?.selection.a1()})) -} - -/// Applies a reference adjustment to every cell formula and defined name in the workbook. -pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) { - use gridcraft_formula::adjust::Edit; - // Rows and columns inserted or deleted on the target sheet move its legacy array ranges too - // (a cut moves its arrays itself). - let moves_arrays = matches!(e, Edit::Insert { .. } | Edit::Delete { .. } | Edit::InsertCells { .. } | Edit::DeleteCells { .. }); - for si in 0..wb.sheets.len() { - let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default(); - let keys: Vec<(CellRef, std::sync::Arc)> = - wb.sheets.get(si).map(|s| s.cells.iter().filter_map(|(c, cell)| cell.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default(); - if keys.is_empty() { - continue; - } - let on_target = moves_arrays && host.eq_ignore_ascii_case(target); - let Some(sheet) = wb.sheet_mut(si) else { continue }; - for (c, f) in keys { - let Some(expr) = f.expr() else { continue }; - let new = gridcraft_formula::adjust::adjust(expr, &host, target, e); - let text = gridcraft_formula::print(&new); - let array = match f.array { - Some(a) if on_target => super::sheet::map_range(a, e), - a => a, - }; - if (text != f.text || array != f.array) - && let Some(cell) = sheet.cells.get_mut(c) - { - let mut nf = Formula::from_expr(new); - nf.array = array; - nf.dynamic = f.dynamic; - cell.formula = Some(std::sync::Arc::new(nf)); - } - } - } - for n in wb.names.iter_mut() { - if let Ok(expr) = gridcraft_formula::parse_name(&n.formula) { - // Names are workbook-level: unqualified refs don't occur; qualified ones adjust. - let new = gridcraft_formula::adjust::adjust(expr, "\u{0}", target, e); - n.formula = gridcraft_formula::print(&new); - } - } -} - // ---------------------------------------------------------------- clear fn clear(s: &mut Session, p: &Json, what: &str) -> Result { @@ -1538,283 +301,6 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result { }) } -// ---------------------------------------------------------------- fill - -fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result { - let sheet = target_sheet(s, p)?; - let mut r = target_range(s, p)?; - // Whole rows or columns fill only where the sheet has content (nothing on an empty sheet). - if r.is_full_cols() || r.is_full_rows() { - match s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| r.intersection(&u)) { - Some(u) => r = u, - None => return Ok(Json::Null), - } - } - if r.count() > crate::fill::MAX_FILL_CELLS { - return Err(bad("edit.fill", "range too large")); - } - // With one row selected, Fill Down copies from the row above (Excel behaviour). - if dr == 1 && r.height() == 1 { - r.start.row = r.start.row.saturating_sub(1); - } - if dc == 1 && r.width() == 1 { - r.start.col = r.start.col.saturating_sub(1); - } - let src = if dr == 1 { - RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) - } else if dr == -1 { - RangeRef::new(CellRef::new(r.end.row, r.start.col), r.end) - } else if dc == 1 { - RangeRef::new(r.start, CellRef::new(r.end.row, r.start.col)) - } else { - RangeRef::new(CellRef::new(r.start.row, r.end.col), r.end) - }; - edit(s, |cx| { - crate::fill::fill(cx, sheet, src, r, crate::fill::FillMode::Copy)?; - Ok(Json::Null) - }) -} - -fn auto_fill(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; - let src = str_param(p, "source").and_then(RangeRef::parse).map_or_else(|| target_range(s, p), Ok)?; - let Some(target) = str_param(p, "target").and_then(RangeRef::parse) else { return Err(bad("edit.autoFill", "missing `target`")) }; - let mode = match str_param(p, "mode").unwrap_or("series") { - "copy" => crate::fill::FillMode::Copy, - "alternate" => crate::fill::FillMode::Alternate, - "formats" => crate::fill::FillMode::Formats, - "values" => crate::fill::FillMode::ValuesOnly, - "extend" => crate::fill::FillMode::Extend, - "days" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Day), - "weekdays" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Weekday), - "months" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Month), - "years" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Year), - _ => crate::fill::FillMode::Series, - }; - if target.count() > crate::fill::MAX_FILL_CELLS || src.count() > crate::fill::MAX_FILL_CELLS { - return Err(bad("edit.autoFill", "range too large")); - } - // Excel refuses a target that doesn't hold the source in a corner. Such targets used to fill - // every source lane across the whole target extent: billions of cells for whole rows (`2:3`) - // into whole columns (`A:A`). - if !crate::fill::holds_source_in_corner(src, target) { - return Err(bad("edit.autoFill", "the target must contain the source in one of its corners")); - } - edit(s, |cx| { - crate::fill::fill(cx, sheet, src, target, mode)?; - *cx.sel = crate::Selection { active: src.start, anchor: src.start, ranges: vec![target.union(&src)] }; - Ok(Json::Null) - }) -} - -fn fill_series(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; - let r = target_range(s, p)?; - let rows = str_param(p, "direction").map(|d| d == "rows").unwrap_or(r.width() > r.height()); - let kind = str_param(p, "type").unwrap_or("linear").to_string(); - let step = f64_param(p, "step").unwrap_or(1.0); - let stop = f64_param(p, "stop"); - let unit = str_param(p, "dateUnit").unwrap_or("day").to_string(); - edit(s, |cx| { - crate::fill::series(cx, sheet, r, rows, &kind, step, stop, &unit)?; - Ok(Json::Null) - }) -} - -fn flash_fill(s: &mut Session, p: &Json) -> Result { - let sheet = target_sheet(s, p)?; - let r = target_range(s, p)?; - let n = edit(s, |cx| crate::fill::flash_fill(cx, sheet, r.start))?; - if n == 0 { - return Err(EngineError::Other( - "We looked at all the data next to your selection and didn't see a pattern for filling in values for you.".into(), - )); - } - Ok(json!({"filled": n})) -} - -// ---------------------------------------------------------------- find & replace - -fn matches(hay: &str, what: &str, case: bool, whole: bool) -> bool { - let (h, w) = if case { (hay.to_string(), what.to_string()) } else { (hay.to_lowercase(), what.to_lowercase()) }; - let has_wild = w.contains(['*', '?']); - if has_wild { - let pat = if whole { w } else { format!("*{w}*") }; - return wildcard(&h, &pat); - } - if whole { h == w } else { h.contains(&w) } -} - -/// `*`/`?` wildcard match with `~` escapes. -pub(crate) fn wildcard(text: &str, pat: &str) -> bool { - let t: Vec = text.chars().collect(); - let mut p: Vec<(char, bool)> = Vec::new(); - let mut it = pat.chars(); - while let Some(c) = it.next() { - if c == '~' { - if let Some(n) = it.next() { - p.push((n, true)); - } - } else { - p.push((c, false)); - } - } - let (mut ti, mut pi) = (0usize, 0usize); - let (mut star, mut mark) = (None, 0usize); - while ti < t.len() { - if pi < p.len() && (p[pi] == ('?', false) || (p[pi].0 == t[ti] && p[pi] != ('*', false))) { - ti += 1; - pi += 1; - } else if pi < p.len() && p[pi] == ('*', false) { - star = Some(pi); - mark = ti; - pi += 1; - } else if let Some(sp) = star { - pi = sp + 1; - mark += 1; - ti = mark; - } else { - return false; - } - } - while pi < p.len() && p[pi] == ('*', false) { - pi += 1; - } - pi == p.len() -} - -fn find(s: &mut Session, p: &Json) -> Result { - let what = str_param(p, "what").unwrap_or("").to_string(); - if what.is_empty() { - return Err(bad("edit.find", "missing `what`")); - } - let case = bool_param(p, "matchCase").unwrap_or(false); - let whole = bool_param(p, "wholeCell").unwrap_or(false); - let values = str_param(p, "lookIn") == Some("values"); - let by_cols = bool_param(p, "byColumns").unwrap_or(false); - let all = bool_param(p, "all").unwrap_or(false); - let workbook = str_param(p, "within") == Some("workbook"); - s.last_find = Some(p.clone()); - let d = s.doc()?; - let sheets: Vec = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet()] }; - let mut hits: Vec<(usize, CellRef, String)> = Vec::new(); - for si in sheets { - let Some(sh) = d.wb.sheet(si) else { continue }; - let mut cells: Vec<(CellRef, String)> = sh - .cells - .iter() - .filter_map(|(c, cell)| { - let text = if values || cell.formula.is_none() || crate::display::formula_hidden(&d.wb, sh, c) { - crate::display::cell_text(&d.wb, sh, c) - } else { - cell.input_text() - }; - matches(&text, &what, case, whole).then_some((c, text)) - }) - .collect(); - if by_cols { - cells.sort_by_key(|(c, _)| (c.col, c.row)); - } - hits.extend(cells.into_iter().map(|(c, t)| (si, c, t))); - } - if hits.is_empty() { - return Err(EngineError::Other(format!("We couldn't find what you were looking for: {what}"))); - } - if all { - let list: Vec = - hits.iter().map(|(si, c, t)| json!({"sheet": d.wb.sheet(*si).map(|s| s.name.clone()), "cell": c.a1(), "value": t})).collect(); - return Ok(json!({"count": list.len(), "results": list})); - } - // Next hit after the active cell. - let cur = (d.wb.active_sheet(), d.selection.active); - let key = |si: usize, c: CellRef| if by_cols { (si, c.col, c.row) } else { (si, c.row, c.col) }; - let next = hits.iter().find(|(si, c, _)| key(*si, *c) > key(cur.0, cur.1)).or_else(|| hits.first()).cloned(); - let Some((si, c, t)) = next else { return Err(EngineError::Other("not found".into())) }; - let dd = s.doc_mut()?; - dd.wb_switch_sheet(si); - dd.selection = crate::Selection::at(c); - Ok(json!({"cell": c.a1(), "sheet": si, "value": t, "count": hits.len()})) -} - -fn replace(s: &mut Session, p: &Json) -> Result { - let what = str_param(p, "what").unwrap_or("").to_string(); - let with = str_param(p, "with").unwrap_or("").to_string(); - if what.is_empty() { - return Err(bad("edit.replace", "missing `what`")); - } - let case = bool_param(p, "matchCase").unwrap_or(false); - let whole = bool_param(p, "wholeCell").unwrap_or(false); - let all = bool_param(p, "all").unwrap_or(true); - let workbook = str_param(p, "within") == Some("workbook"); - let active = s.doc()?.selection.active; - let n = edit(s, |cx| { - let sheets: Vec = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet()] }; - let mut n = 0; - for si in sheets { - let cells: Vec<(CellRef, String)> = - cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, _)| (c, sh.input_text(c))).collect()).unwrap_or_default(); - for (c, text) in cells { - if !all && c != active { - continue; - } - if !matches(&text, &what, case, whole) { - continue; - } - let new = if whole { - with.clone() - } else if case { - text.replace(&what, &with) - } else { - replace_ci(&text, &what, &with) - }; - let cell = input_to_cell(&new, si, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str())))); - let sh = cx.sheet_mut(si)?; - match cell { - Some(cell) => sh.set_cell(c, cell), - None => { - sh.remove_cell(c); - } - } - cx.touch(si, c); - n += 1; - } - } - Ok(n) - })?; - Ok(json!({"replaced": n})) -} - -/// Whether case-folding `s` maps every char to exactly one char of the same UTF-8 length, so -/// byte positions in the folded text still line up with the original's character boundaries. -fn folds_in_place(s: &str) -> bool { - s.chars().all(|c| { - let mut folded = c.to_lowercase(); - let first = folded.next(); - folded.next().is_none() && first.is_some_and(|l| l.len_utf8() == c.len_utf8()) - }) -} - -fn replace_ci(text: &str, what: &str, with: &str) -> String { - let lower = text.to_lowercase(); - let w = what.to_lowercase(); - // Slicing `text` at positions found in `lower` is only sound when folding kept every byte - // boundary: a length check alone misses foldings that keep the total length while shifting - // boundaries (U+212A KELVIN SIGN shrinks 3→1 bytes, U+0130 grows 2→3), where the tail used - // to be dropped. Those fall back to the case-sensitive replace, as length changes already do. - if lower.len() != text.len() || w.is_empty() || !folds_in_place(text) || !folds_in_place(what) { - return text.replace(what, with); - } - let mut out = String::new(); - let mut i = 0; - while let Some(pos) = lower.get(i..).and_then(|s| s.find(&w)) { - out.push_str(text.get(i..i + pos).unwrap_or("")); - out.push_str(with); - i += pos + w.len(); - } - out.push_str(text.get(i..).unwrap_or("")); - out -} - // ---------------------------------------------------------------- format painter fn format_painter(s: &mut Session, p: &Json) -> Result { diff --git a/crates/engine/src/cmd/edit/cell.rs b/crates/engine/src/cmd/edit/cell.rs new file mode 100644 index 000000000..62a6b38bd --- /dev/null +++ b/crates/engine/src/cmd/edit/cell.rs @@ -0,0 +1,287 @@ +use super::*; + +// ---------------------------------------------------------------- entry + +/// What typing `input` into a cell produces (constant or formula, plus an automatic number +/// format). Inherits the destination's cell, row or column style, including its number +/// format, before parsing. Errors when the formula can't be parsed. +pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gridcraft_model::Workbook) -> Result> { + let style = wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); + if input.is_empty() { + let c = Cell { value: Value::Empty, formula: None, style }; + return Ok(if c.is_blank() { None } else { Some(c) }); + } + let fmt_is_text = wb.styles.get(style).num_fmt.as_str() == "@"; + if fmt_is_text { + return Ok(Some(Cell { value: Value::text(input), formula: None, style })); + } + let is_formula = input.starts_with('=') + || (input.len() > 1 + && (input.starts_with('+') || input.starts_with('-')) + && gridcraft_core::parse::parse_number_text(input).is_none() + && input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '(')); + if is_formula { + let body = input.strip_prefix('=').unwrap_or(input); + let expr = wb.name_call_case(parse_typed_formula(body)?); + check_arg_counts(&expr)?; + let mut cell = Cell::formula(Formula::from_expr(expr)); + cell.style = style; + // Formulas whose result is a date/time get a format from functions like TODAY(). + if wb.styles.get(style).num_fmt.as_str() == "General" { + let f = cell.formula.as_ref().map(|f| f.text.to_ascii_uppercase()).unwrap_or_default(); + let auto = if f.starts_with("TODAY(") + || f.starts_with("DATE(") + || f.starts_with("EDATE(") + || f.starts_with("EOMONTH(") + || f.starts_with("WORKDAY(") + { + Some("m/d/yyyy") + } else if f.starts_with("NOW(") { + Some("m/d/yyyy h:mm") + } else if f.starts_with("TIME(") { + Some("h:mm AM/PM") + } else { + None + }; + if let Some(code) = auto { + cell.style = wb.styles.derive(style, |s| s.num_fmt = gridcraft_model::NumFmt::new(code)); + } + } + return Ok(Some(cell)); + } + let mut parsed = gridcraft_core::parse::parse_input(input, wb.date_system); + // Like Excel's automatic percent entry: a bare number of 1 or more typed into a cell already + // formatted as a percentage is a percent (3 -> 3%); smaller numbers are taken as fractions. + if parsed.format.is_none() + && !input.contains('%') + && wb.styles.get(style).num_fmt.as_str().ends_with('%') + && let Value::Number(n) = parsed.value + && n.abs() >= 1.0 + { + parsed.value = Value::Number(n / 100.0); + } + let mut style = style; + if let Some(code) = parsed.format { + let cur = wb.styles.get(style).num_fmt.as_str().to_string(); + if cur == "General" { + style = wb.styles.derive(style, |s| s.num_fmt = gridcraft_model::NumFmt::new(code)); + } + } + // Wrap text automatically when the input has line breaks (Alt+Enter). + if input.contains('\n') && !wb.styles.get(style).align.wrap { + style = wb.styles.derive(style, |s| s.align.wrap = true); + } + Ok(Some(Cell { value: parsed.value, formula: None, style })) +} + +/// Parses a formula as it is typed (without the `=`). Excel closes missing parentheses for you, +/// and refuses a formula that still doesn't parse. +pub(crate) fn parse_typed_formula(body: &str) -> Result { + // Excel closes missing parentheses for you. + let parsed = gridcraft_formula::parse_closing_parens(body, gridcraft_formula::parse); + parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}"))) +} + +/// Like Excel, a formula that calls a built-in function with too few or too many arguments is +/// refused when it's entered, rather than stored to show `#VALUE!`. Functions the registry doesn't +/// hold (special forms like LET, LAMBDA, OFFSET, INDIRECT, SUBTOTAL), defined LAMBDA names and +/// unknown functions are skipped. +pub(crate) fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> { + let mut bad = None; + expr.walk(&mut |e| { + if bad.is_none() + && let gridcraft_formula::Expr::Call(name, args) = e + && let Some(spec) = gridcraft_functions::lookup(name) + && !spec.accepts(args.len()) + { + let few = args.len() < spec.min_args; + bad = Some(format!("{} has too {} arguments. Use {}", spec.name, if few { "few" } else { "many" }, spec.signature)); + } + }); + match bad { + Some(msg) => Err(EngineError::Other(msg)), + None => Ok(()), + } +} + +pub(super) fn cell_set(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let at = match cell_param_on(s, p, "cell", sheet)? { + Some(c) => c, + None => s.doc()?.selection.active, + }; + // Both spellings take a string, number or boolean, as typed (#697). + let input = p.get("input").or_else(|| p.get("value")).map(json_to_input).ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?; + let array = bool_param(p, "array").unwrap_or(false); + let d = s.doc()?; + if let Some(sh) = d.wb.sheet(sheet) + && sh.is_protected() + && d.wb.styles.get(sh.style_id(at)).protection.locked + && let Some(m) = locked_cell_error(sh, at, &d.unlocked_ranges) + { + return Err(EngineError::Other(m)); + } + let active_sheet = s.doc()?.wb.active_sheet(); + edit(s, |cx| { + if array { + // Legacy Ctrl+Shift+Enter array formula over the selection, when the selection holds + // the cell; a cell elsewhere (or on another sheet) gets a one-cell array. + let selected = cx.sel.current(); + let range = if sheet == active_sheet && selected.contains(at) { selected } else { RangeRef::cell(at) }; + // A table header can't hold an array's value, and a table resizes under it. + if !range.is_single() && cx.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.range.intersects(&range))) { + return Err(EngineError::Other("Multi-cell array formulas aren't allowed in tables.".into())); + } + let mut cell = input_to_cell(&input, sheet, range.start, &mut cx.wb)?.unwrap_or_default(); + if let Some(f) = cell.formula.as_mut() { + std::sync::Arc::make_mut(f).array = Some(range); + } + let sh = cx.sheet_mut(sheet)?; + // The array takes the whole range: other cells in it lose their contents. + for c in range.iter().skip(1).take(1_000_000) { + if let Some(old) = sh.cells.get(c).filter(|x| !x.value.is_empty() || x.formula.is_some()) { + let style = old.style; + sh.set_cell(c, Cell { style, ..Cell::default() }); + } + } + sh.set_cell(range.start, cell); + cx.touch(sheet, range.start); + return Ok(Json::Null); + } + let cell = input_to_cell(&input, sheet, at, &mut cx.wb)?; + let sh = cx.sheet_mut(sheet)?; + match cell { + Some(c) => sh.set_cell(at, c), + None => { + sh.remove_cell(at); + } + } + crate::cmd::data::grow_autofilter(sh, at); + cx.touch(sheet, at); + Ok(Json::Null) + })?; + let v = s.doc()?.wb.sheet(sheet).map(|sh| sh.value(at)).unwrap_or_default(); + Ok(json!({"cell": at.a1(), "value": v.display()})) +} + +pub(crate) fn json_to_input(v: &Json) -> String { + match v { + Json::Null => String::new(), + Json::Bool(b) => { + if *b { + "TRUE".into() + } else { + "FALSE".into() + } + } + Json::Number(n) => n.to_string(), + Json::String(s) => s.clone(), + other => other.to_string(), + } +} + +pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let start = target_range(s, p)?.start; + let Some(rows) = p.get("values").and_then(Json::as_array) else { return Err(bad("range.setValues", "missing `values` (array of rows)")) }; + // A string is run through typed input by default, which reads it with en-US rules: a caller + // sending "05.04.2026" from a dd.mm.yyyy export got 4 May, and "2.500" became 2.5, in silence + // (#2255). `parse: false` stores a string as the text it is; numbers, booleans and formulas + // are unaffected either way. + let parse = match p.get("parse") { + None | Some(Json::Null) => true, + Some(Json::Bool(b)) => *b, + Some(other) => return Err(bad("range.setValues", format!("parse {other}: true or false"))), + }; + if rows.len() > MAX_ROWS as usize { + return Err(bad("range.setValues", "too many rows")); + } + let mut count = 0; + edit(s, |cx| { + for (ri, row) in rows.iter().enumerate() { + let cols: Vec<&Json> = match row.as_array() { + Some(a) => a.iter().collect(), + None => vec![row], + }; + for (ci, v) in cols.iter().enumerate() { + let Some(at) = start.offset(ri as i64, ci as i64) else { continue }; + let input = json_to_input(v); + let cell = match v { + Json::Number(n) => Some(Cell { + value: Value::number(n.as_f64().unwrap_or(0.0)), + formula: None, + style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), + }), + Json::Bool(b) => { + Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() }) + } + // Text as given: no date or number reading, and a leading `'` is part of the + // text rather than the marker that makes it text. A formula is still a + // formula, which is what the ticket asks for and what `=` means in a cell. + Json::String(text) if !parse && !text.starts_with('=') => { + let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); + // Line breaks still turn on Wrap Text, as typed input does. + if text.contains('\n') && !cx.wb.styles.get(style).align.wrap { + style = cx.wb.styles.derive(style, |s| s.align.wrap = true); + } + Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style }) + } + _ => input_to_cell(&input, sheet, at, &mut cx.wb)?, + }; + let sh = cx.sheet_mut(sheet)?; + match cell { + Some(c) => sh.set_cell(at, c), + None => { + sh.remove_cell(at); + } + } + crate::cmd::data::grow_autofilter(sh, at); + cx.touch(sheet, at); + count += 1; + } + } + Ok(()) + })?; + Ok(json!({"cells": count})) +} + +pub(super) fn range_fill(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let ranges = target_ranges(s, p)?; + let input = str_param(p, "input").unwrap_or("").to_string(); + let origin = s.doc()?.selection.active; + edit(s, |cx| { + // Each cell gets what typing the input there gives: the automatic number format where + // it's General, its own format otherwise, text where it's Text. That depends only on + // the cell's style, so the input is parsed once per style. + let mut by_style: std::collections::HashMap> = std::collections::HashMap::new(); + for r in &ranges { + if r.count() > 2_000_000 { + return Err(bad("range.fill", "range too large")); + } + for at in r.iter() { + let style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); + let base = match by_style.get(&style) { + Some(c) => c.clone(), + None => { + let c = input_to_cell(&input, sheet, at, &mut cx.wb)?; + by_style.insert(style, c.clone()); + c + } + }; + let mut cell = base.unwrap_or_default(); + if let Some(f) = &cell.formula + && let Some(e) = f.expr() + { + let shifted = gridcraft_formula::adjust::shift_relative(e, at.row as i64 - origin.row as i64, at.col as i64 - origin.col as i64); + cell.formula = Some(std::sync::Arc::new(Formula::from_expr(shifted))); + } + let sh = cx.sheet_mut(sheet)?; + sh.set_cell(at, cell); + crate::cmd::data::grow_autofilter(sh, at); + cx.touch(sheet, at); + } + } + Ok(Json::Null) + }) +} diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs new file mode 100644 index 000000000..60c05c687 --- /dev/null +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -0,0 +1,269 @@ +use super::cell::input_to_cell; +use super::paste_special::paste_special; +use super::*; + +// ---------------------------------------------------------------- clipboard + +fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { + let multiple = || EngineError::Other("This action won't work on multiple selections.".into()); + let sheet = target_sheet(s, p)?; + let ranges = target_ranges(s, p)?; + if ranges.len() > 1 && cut { + return Err(multiple()); + } + // The selection as made (untrimmed), for Insert Copied Cells. + let selected = ranges.first().copied().unwrap_or_default(); + let d = s.doc()?; + let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; + let trim = |range: RangeRef| { + if range.is_full_cols() || range.is_full_rows() { + let used = sh.used_range().unwrap_or(RangeRef::cell(range.start)); + range.intersection(&used.union(&RangeRef::cell(range.start))).unwrap_or(range) + } else { + range + } + }; + let (wb, range, areas) = if ranges.len() > 1 { + let areas: Vec = ranges.iter().map(|r| trim(*r)).collect(); + let (wb, block) = slide_together(&d.wb, sheet, &areas).ok_or_else(multiple)?; + (std::sync::Arc::new(wb), block, areas) + } else { + (d.wb.clone(), trim(ranges.first().copied().unwrap_or_default()), Vec::new()) + }; + let text = crate::display::range_text(&wb, sheet, range); + // The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and + // scripts get it only when they ask for it. + let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&wb, sheet, range, &text) } else { None }; + let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected, trust: d.trust }; + s.clipboard = Some(clip); + let mut result = json!({"range": range.a1(), "text": text}); + if let Some(html) = html { + result["html"] = Json::String(html); + } + Ok(result) +} + +/// Excel copies a multi-area selection only when the areas line up: all cover the same rows (they +/// slide together side by side, left to right) or all cover the same columns (stacked, top to +/// bottom), without overlapping. Returns a copy of the workbook whose sheet holds the areas packed +/// into one block at the first area's place, and that block. Formulas travel as their values, as +/// in Excel. `None` when the areas don't line up. +fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRef]) -> Option<(gridcraft_model::Workbook, RangeRef)> { + let first = *areas.first()?; + let across = areas.iter().all(|a| a.start.row == first.start.row && a.end.row == first.end.row); + let down = areas.iter().all(|a| a.start.col == first.start.col && a.end.col == first.end.col); + let mut sorted = areas.to_vec(); + if across { + sorted.sort_by_key(|a| a.start.col); + } else if down { + sorted.sort_by_key(|a| a.start.row); + } else { + return None; + } + if sorted.windows(2).any(|w| matches!(w, [a, b] if a.intersects(b))) { + return None; + } + let anchor = sorted.first()?.start; + let (h, w) = if across { + (first.height(), sorted.iter().map(RangeRef::width).fold(0u32, u32::saturating_add)) + } else { + (sorted.iter().map(RangeRef::height).fold(0u32, u32::saturating_add), first.width()) + }; + let block = RangeRef::new(anchor, anchor.offset(i64::from(h) - 1, i64::from(w) - 1)?); + let src = wb.sheet(sheet)?; + let mut out = wb.clone(); + let dst = out.sheet_mut(sheet)?; + dst.take_cells(block); + dst.spill.retain(|c, _| !block.contains(*c)); + dst.spill_ranges.retain(|c, _| !block.contains(*c)); + dst.comments.retain(|c, _| !block.contains(*c)); + dst.merges.retain(|m| !m.intersects(&block)); + if across { + dst.cols.retain(|c, _| !(block.start.col..=block.end.col).contains(c)); + } else { + dst.rows.retain(|r, _| !(block.start.row..=block.end.row).contains(r)); + } + let mut next = if across { anchor.col } else { anchor.row }; + for a in &sorted { + let (dr, dc) = if across { (0, i64::from(next) - i64::from(a.start.col)) } else { (i64::from(next) - i64::from(a.start.row), 0) }; + let cells = src.cells.iter_range(*a).map(|(c, _)| c).chain(src.spill.keys().copied().filter(|c| a.contains(*c))); + for c in cells { + let Some(to) = c.offset(dr, dc) else { continue }; + let style = src.cell(c).map(|x| x.style).unwrap_or_default(); + dst.set_cell(to, Cell { value: src.value(c), formula: None, style }); + if let Some(picture) = src.cell_pictures.get(&c) { + dst.cell_pictures.insert(to, picture.clone()); + } + } + for (c, comment) in src.comments.iter().filter(|(c, _)| a.contains(**c)) { + if let Some(to) = c.offset(dr, dc) { + dst.comments.insert(to, comment.clone()); + } + } + for m in src.merges.iter().filter(|m| a.contains_range(m)) { + if let (Some(s), Some(e)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) { + dst.merges.push(RangeRef::new(s, e)); + } + } + // Column widths (side by side) or row heights and hidden rows (stacked) follow their cells. + if across { + for (c, info) in src.cols.range(a.start.col..=a.end.col) { + dst.cols.insert(next.saturating_add(c.saturating_sub(a.start.col)), *info); + } + } else { + for (r, info) in src.rows.range(a.start.row..=a.end.row) { + dst.rows.insert(next.saturating_add(r.saturating_sub(a.start.row)), *info); + } + } + next = next.saturating_add(if across { a.width() } else { a.height() }); + } + Some((out, block)) +} + +pub(super) fn copy(s: &mut Session, p: &Json) -> Result { + copy_impl(s, p, false) +} + +pub(super) fn cut(s: &mut Session, p: &Json) -> Result { + copy_impl(s, p, true) +} + +pub(super) fn clear_clipboard(s: &mut Session, _: &Json) -> Result { + s.clipboard = None; + s.format_painter = None; + ok() +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum What { + All, + Formulas, + Values, + Formats, + Comments, + Validation, + AllExceptBorders, + ColumnWidths, + FormulasAndNumberFormats, + ValuesAndNumberFormats, +} + +pub(super) fn paste(s: &mut Session, p: &Json) -> Result { + // External text (from the system clipboard) when it differs from ours. + if let Some(text) = str_param(p, "text") + && s.clipboard.as_ref().is_none_or(|c| c.text.trim_end_matches(['\r', '\n']) != text.trim_end_matches(['\r', '\n'])) + { + return paste_text(s, p, text); + } + if s.clipboard.is_none() { + return Err(EngineError::Other("Nothing to paste.".into())); + } + paste_special(s, &json!({"what": "all", "at": p.get("at").cloned().unwrap_or(Json::Null)})) +} + +fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result { + let sheet = target_sheet(s, p)?; + let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start); + let rows: Vec> = parse_tsv(text); + let h = rows.len(); + let w = rows.iter().map(Vec::len).max().unwrap_or(0); + // The HTML flavour, when the host passed it: the number behind each cell's shown text. Read + // only as far as the text's own shape, so spans in the markup can't expand without bound. + let html = str_param(p, "html").map(|html| crate::clip_html::table_cells(html, h, w)).unwrap_or_default(); + edit(s, |cx| { + for (ri, row) in rows.iter().enumerate() { + for (ci, v) in row.iter().enumerate() { + let Some(c) = at.offset(ri as i64, ci as i64) else { continue }; + let mut cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str())))); + if let Some(cl) = cell.as_mut() { + numeric_from_html(cl, v, html.get(ri).and_then(|r| r.get(ci)), &mut cx.wb); + } + let sh = cx.sheet_mut(sheet)?; + match cell { + Some(cell) => sh.set_cell(c, cell), + None => { + sh.remove_cell(c); + } + } + cx.touch(sheet, c); + } + } + if h > 0 && w > 0 { + let end = at.offset_clamped(h as i64 - 1, w as i64 - 1); + *cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] }; + } + Ok(()) + })?; + Ok(json!({"rows": h, "cols": w})) +} + +/// A pasted cell that came out as text ("Rp10,000.00" isn't en-US input) becomes the number the +/// source recorded for it in its HTML, with a format that shows the same text when one can be +/// inferred and the cell has none. The HTML cell must show the same text as the tab-separated +/// field, so HTML that belongs to another copy (or a table laid out differently) changes nothing. +fn numeric_from_html(cell: &mut Cell, field: &str, html: Option<&crate::clip_html::HtmlCell>, wb: &mut gridcraft_model::Workbook) { + let Some(hc) = html else { return }; + let Some(n) = hc.num else { return }; + if cell.formula.is_some() || !matches!(cell.value, Value::Text(_)) || wb.styles.get(cell.style).num_fmt.as_str() == "@" { + return; + } + if crate::clip_html::normalized(&hc.text) != crate::clip_html::normalized(field) { + return; + } + cell.value = Value::number(n); + if wb.styles.get(cell.style).num_fmt.as_str() == "General" + && let Some(code) = crate::clip_html::format_for(field, n) + { + cell.style = wb.styles.derive(cell.style, |s| s.num_fmt = gridcraft_model::NumFmt::new(&code)); + } +} + +/// Tab-separated text with Excel-style quoting. +pub(crate) fn parse_tsv(text: &str) -> Vec> { + let mut rows = Vec::new(); + let mut row = Vec::new(); + let mut field = String::new(); + let mut chars = text.chars().peekable(); + let mut quoted = false; + let mut at_start = true; + while let Some(c) = chars.next() { + if quoted { + if c == '"' { + if chars.peek() == Some(&'"') { + field.push('"'); + chars.next(); + } else { + quoted = false; + } + } else { + field.push(c); + } + continue; + } + match c { + '"' if at_start => { + quoted = true; + at_start = false; + } + '\t' => { + row.push(std::mem::take(&mut field)); + at_start = true; + } + '\r' => {} + '\n' => { + row.push(std::mem::take(&mut field)); + rows.push(std::mem::take(&mut row)); + at_start = true; + } + _ => { + field.push(c); + at_start = false; + } + } + } + if !field.is_empty() || !row.is_empty() { + row.push(field); + rows.push(row); + } + rows +} diff --git a/crates/engine/src/cmd/edit/fill.rs b/crates/engine/src/cmd/edit/fill.rs new file mode 100644 index 000000000..84f4cd6d3 --- /dev/null +++ b/crates/engine/src/cmd/edit/fill.rs @@ -0,0 +1,96 @@ +use super::*; + +// ---------------------------------------------------------------- fill + +pub(super) fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result { + let sheet = target_sheet(s, p)?; + let mut r = target_range(s, p)?; + // Whole rows or columns fill only where the sheet has content (nothing on an empty sheet). + if r.is_full_cols() || r.is_full_rows() { + match s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| r.intersection(&u)) { + Some(u) => r = u, + None => return Ok(Json::Null), + } + } + if r.count() > crate::fill::MAX_FILL_CELLS { + return Err(bad("edit.fill", "range too large")); + } + // With one row selected, Fill Down copies from the row above (Excel behaviour). + if dr == 1 && r.height() == 1 { + r.start.row = r.start.row.saturating_sub(1); + } + if dc == 1 && r.width() == 1 { + r.start.col = r.start.col.saturating_sub(1); + } + let src = if dr == 1 { + RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) + } else if dr == -1 { + RangeRef::new(CellRef::new(r.end.row, r.start.col), r.end) + } else if dc == 1 { + RangeRef::new(r.start, CellRef::new(r.end.row, r.start.col)) + } else { + RangeRef::new(CellRef::new(r.start.row, r.end.col), r.end) + }; + edit(s, |cx| { + crate::fill::fill(cx, sheet, src, r, crate::fill::FillMode::Copy)?; + Ok(Json::Null) + }) +} + +pub(super) fn auto_fill(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let src = str_param(p, "source").and_then(RangeRef::parse).map_or_else(|| target_range(s, p), Ok)?; + let Some(target) = str_param(p, "target").and_then(RangeRef::parse) else { return Err(bad("edit.autoFill", "missing `target`")) }; + let mode = match str_param(p, "mode").unwrap_or("series") { + "copy" => crate::fill::FillMode::Copy, + "alternate" => crate::fill::FillMode::Alternate, + "formats" => crate::fill::FillMode::Formats, + "values" => crate::fill::FillMode::ValuesOnly, + "extend" => crate::fill::FillMode::Extend, + "days" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Day), + "weekdays" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Weekday), + "months" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Month), + "years" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Year), + _ => crate::fill::FillMode::Series, + }; + if target.count() > crate::fill::MAX_FILL_CELLS || src.count() > crate::fill::MAX_FILL_CELLS { + return Err(bad("edit.autoFill", "range too large")); + } + // Excel refuses a target that doesn't hold the source in a corner. Such targets used to fill + // every source lane across the whole target extent: billions of cells for whole rows (`2:3`) + // into whole columns (`A:A`). + if !crate::fill::holds_source_in_corner(src, target) { + return Err(bad("edit.autoFill", "the target must contain the source in one of its corners")); + } + edit(s, |cx| { + crate::fill::fill(cx, sheet, src, target, mode)?; + *cx.sel = crate::Selection { active: src.start, anchor: src.start, ranges: vec![target.union(&src)] }; + Ok(Json::Null) + }) +} + +pub(super) fn fill_series(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let r = target_range(s, p)?; + let rows = str_param(p, "direction").map(|d| d == "rows").unwrap_or(r.width() > r.height()); + let kind = str_param(p, "type").unwrap_or("linear").to_string(); + let step = f64_param(p, "step").unwrap_or(1.0); + let stop = f64_param(p, "stop"); + let unit = str_param(p, "dateUnit").unwrap_or("day").to_string(); + edit(s, |cx| { + crate::fill::series(cx, sheet, r, rows, &kind, step, stop, &unit)?; + Ok(Json::Null) + }) +} + +pub(super) fn flash_fill(s: &mut Session, p: &Json) -> Result { + let sheet = target_sheet(s, p)?; + let r = target_range(s, p)?; + let n = edit(s, |cx| crate::fill::flash_fill(cx, sheet, r.start))?; + if n == 0 { + return Err(EngineError::Other( + "We looked at all the data next to your selection and didn't see a pattern for filling in values for you.".into(), + )); + } + Ok(json!({"filled": n})) +} diff --git a/crates/engine/src/cmd/edit/find.rs b/crates/engine/src/cmd/edit/find.rs new file mode 100644 index 000000000..2dcbfa886 --- /dev/null +++ b/crates/engine/src/cmd/edit/find.rs @@ -0,0 +1,184 @@ +use super::cell::input_to_cell; +use super::*; + +// ---------------------------------------------------------------- find & replace + +fn matches(hay: &str, what: &str, case: bool, whole: bool) -> bool { + let (h, w) = if case { (hay.to_string(), what.to_string()) } else { (hay.to_lowercase(), what.to_lowercase()) }; + let has_wild = w.contains(['*', '?']); + if has_wild { + let pat = if whole { w } else { format!("*{w}*") }; + return wildcard(&h, &pat); + } + if whole { h == w } else { h.contains(&w) } +} + +/// `*`/`?` wildcard match with `~` escapes. +pub(crate) fn wildcard(text: &str, pat: &str) -> bool { + let t: Vec = text.chars().collect(); + let mut p: Vec<(char, bool)> = Vec::new(); + let mut it = pat.chars(); + while let Some(c) = it.next() { + if c == '~' { + if let Some(n) = it.next() { + p.push((n, true)); + } + } else { + p.push((c, false)); + } + } + let (mut ti, mut pi) = (0usize, 0usize); + let (mut star, mut mark) = (None, 0usize); + while ti < t.len() { + if pi < p.len() && (p[pi] == ('?', false) || (p[pi].0 == t[ti] && p[pi] != ('*', false))) { + ti += 1; + pi += 1; + } else if pi < p.len() && p[pi] == ('*', false) { + star = Some(pi); + mark = ti; + pi += 1; + } else if let Some(sp) = star { + pi = sp + 1; + mark += 1; + ti = mark; + } else { + return false; + } + } + while pi < p.len() && p[pi] == ('*', false) { + pi += 1; + } + pi == p.len() +} + +pub(super) fn find(s: &mut Session, p: &Json) -> Result { + let what = str_param(p, "what").unwrap_or("").to_string(); + if what.is_empty() { + return Err(bad("edit.find", "missing `what`")); + } + let case = bool_param(p, "matchCase").unwrap_or(false); + let whole = bool_param(p, "wholeCell").unwrap_or(false); + let values = str_param(p, "lookIn") == Some("values"); + let by_cols = bool_param(p, "byColumns").unwrap_or(false); + let all = bool_param(p, "all").unwrap_or(false); + let workbook = str_param(p, "within") == Some("workbook"); + s.last_find = Some(p.clone()); + let d = s.doc()?; + let sheets: Vec = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet()] }; + let mut hits: Vec<(usize, CellRef, String)> = Vec::new(); + for si in sheets { + let Some(sh) = d.wb.sheet(si) else { continue }; + let mut cells: Vec<(CellRef, String)> = sh + .cells + .iter() + .filter_map(|(c, cell)| { + let text = if values || cell.formula.is_none() || crate::display::formula_hidden(&d.wb, sh, c) { + crate::display::cell_text(&d.wb, sh, c) + } else { + cell.input_text() + }; + matches(&text, &what, case, whole).then_some((c, text)) + }) + .collect(); + if by_cols { + cells.sort_by_key(|(c, _)| (c.col, c.row)); + } + hits.extend(cells.into_iter().map(|(c, t)| (si, c, t))); + } + if hits.is_empty() { + return Err(EngineError::Other(format!("We couldn't find what you were looking for: {what}"))); + } + if all { + let list: Vec = + hits.iter().map(|(si, c, t)| json!({"sheet": d.wb.sheet(*si).map(|s| s.name.clone()), "cell": c.a1(), "value": t})).collect(); + return Ok(json!({"count": list.len(), "results": list})); + } + // Next hit after the active cell. + let cur = (d.wb.active_sheet(), d.selection.active); + let key = |si: usize, c: CellRef| if by_cols { (si, c.col, c.row) } else { (si, c.row, c.col) }; + let next = hits.iter().find(|(si, c, _)| key(*si, *c) > key(cur.0, cur.1)).or_else(|| hits.first()).cloned(); + let Some((si, c, t)) = next else { return Err(EngineError::Other("not found".into())) }; + let dd = s.doc_mut()?; + dd.wb_switch_sheet(si); + dd.selection = crate::Selection::at(c); + Ok(json!({"cell": c.a1(), "sheet": si, "value": t, "count": hits.len()})) +} + +pub(super) fn replace(s: &mut Session, p: &Json) -> Result { + let what = str_param(p, "what").unwrap_or("").to_string(); + let with = str_param(p, "with").unwrap_or("").to_string(); + if what.is_empty() { + return Err(bad("edit.replace", "missing `what`")); + } + let case = bool_param(p, "matchCase").unwrap_or(false); + let whole = bool_param(p, "wholeCell").unwrap_or(false); + let all = bool_param(p, "all").unwrap_or(true); + let workbook = str_param(p, "within") == Some("workbook"); + let active = s.doc()?.selection.active; + let n = edit(s, |cx| { + let sheets: Vec = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet()] }; + let mut n = 0; + for si in sheets { + let cells: Vec<(CellRef, String)> = + cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, _)| (c, sh.input_text(c))).collect()).unwrap_or_default(); + for (c, text) in cells { + if !all && c != active { + continue; + } + if !matches(&text, &what, case, whole) { + continue; + } + let new = if whole { + with.clone() + } else if case { + text.replace(&what, &with) + } else { + replace_ci(&text, &what, &with) + }; + let cell = input_to_cell(&new, si, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str())))); + let sh = cx.sheet_mut(si)?; + match cell { + Some(cell) => sh.set_cell(c, cell), + None => { + sh.remove_cell(c); + } + } + cx.touch(si, c); + n += 1; + } + } + Ok(n) + })?; + Ok(json!({"replaced": n})) +} + +/// Whether case-folding `s` maps every char to exactly one char of the same UTF-8 length, so +/// byte positions in the folded text still line up with the original's character boundaries. +fn folds_in_place(s: &str) -> bool { + s.chars().all(|c| { + let mut folded = c.to_lowercase(); + let first = folded.next(); + folded.next().is_none() && first.is_some_and(|l| l.len_utf8() == c.len_utf8()) + }) +} + +pub(super) fn replace_ci(text: &str, what: &str, with: &str) -> String { + let lower = text.to_lowercase(); + let w = what.to_lowercase(); + // Slicing `text` at positions found in `lower` is only sound when folding kept every byte + // boundary: a length check alone misses foldings that keep the total length while shifting + // boundaries (U+212A KELVIN SIGN shrinks 3→1 bytes, U+0130 grows 2→3), where the tail used + // to be dropped. Those fall back to the case-sensitive replace, as length changes already do. + if lower.len() != text.len() || w.is_empty() || !folds_in_place(text) || !folds_in_place(what) { + return text.replace(what, with); + } + let mut out = String::new(); + let mut i = 0; + while let Some(pos) = lower.get(i..).and_then(|s| s.find(&w)) { + out.push_str(text.get(i..i + pos).unwrap_or("")); + out.push_str(with); + i += pos + w.len(); + } + out.push_str(text.get(i..).unwrap_or("")); + out +} diff --git a/crates/engine/src/cmd/edit/go_to.rs b/crates/engine/src/cmd/edit/go_to.rs new file mode 100644 index 000000000..b94bcd221 --- /dev/null +++ b/crates/engine/src/cmd/edit/go_to.rs @@ -0,0 +1,199 @@ +use super::selection::{select_region, selection_set}; +use super::*; + +/// How many references the Go To dialog keeps for going back to. +pub(super) const GO_TO_RECENT: usize = 10; + +/// The most areas one Go To selects. +pub(super) const GO_TO_MAX_AREAS: usize = 2000; + +/// How deep a name that refers to other names is followed. +const GO_TO_NAME_DEPTH: usize = 8; + +pub(in super::super) fn go_to(s: &mut Session, p: &Json) -> Result { + if bool_param(p, "list") == Some(true) { + let d = s.doc()?; + return Ok(json!({"names": go_to_names(&d.wb, d.wb.active_sheet()), "recent": d.go_to_recent})); + } + let Some(reference) = str_param(p, "reference").or_else(|| str_param(p, "range")) else { return Err(bad("edit.goTo", "missing `reference`")) }; + let d = s.doc()?; + let mut target: Option = None; + let mut areas: Vec = Vec::new(); + let mut named = false; + for part in reference_parts(reference) { + let Some((sheet, found, by_name)) = go_to_target(&d.wb, d.wb.active_sheet(), part, 0) else { + return Err(EngineError::Other("Reference isn't valid.".into())); + }; + // One selection holds the areas of one sheet. + if target.is_some_and(|t| t != sheet) { + return Err(EngineError::Other("Reference isn't valid.".into())); + } + target = Some(sheet); + named |= by_name; + areas.extend(found); + if areas.len() > GO_TO_MAX_AREAS { + return Err(EngineError::Other(format!("Go To selects at most {GO_TO_MAX_AREAS} areas."))); + } + } + let Some(sheet) = target else { return Err(EngineError::Other("Reference isn't valid.".into())) }; + let range = areas.iter().map(RangeRef::a1).collect::>().join(","); + let out = selection_set(s, &json!({"sheet": sheet, "range": range}))?; + if bool_param(p, "record") == Some(true) && !named { + let d = s.doc_mut()?; + let prefix = gridcraft_core::quote_sheet(&d.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default()); + let entry = areas.iter().map(|r| format!("{prefix}!{}", r.a1())).collect::>().join(","); + d.go_to_recent.retain(|r| !r.eq_ignore_ascii_case(&entry)); + d.go_to_recent.insert(0, entry); + d.go_to_recent.truncate(GO_TO_RECENT); + } + Ok(out) +} + +/// The parts of a Go To reference, split at the commas outside quoted sheet names and +/// parentheses: `Price, Type` or `B14:C22,F19:G30`. The parts are read one at a time, so a caller +/// that stops at an invalid part or at `GO_TO_MAX_AREAS` does not split the rest of the text. +pub(super) fn reference_parts(text: &str) -> impl Iterator { + let (mut depth, mut quoted, mut start, mut done) = (0usize, false, 0, false); + let mut chars = text.char_indices(); + std::iter::from_fn(move || { + if done { + return None; + } + for (i, c) in chars.by_ref() { + match c { + '\'' => quoted = !quoted, + '(' if !quoted => depth += 1, + ')' if !quoted => depth = depth.saturating_sub(1), + ',' if !quoted && depth == 0 => { + let part = text.get(start..i).unwrap_or("").trim(); + start = i + 1; + return Some(part); + } + _ => {} + } + } + done = true; + Some(text.get(start..).unwrap_or("").trim()) + }) +} + +/// Where one part of a Go To reference points, seen from sheet `sheet`: the sheet, its areas, +/// and whether a defined name or a table gave them. `Sheet2!Name` is the name scoped to Sheet2. +/// A name counts when what it refers to is a reference; a constant or a formula is not one. +fn go_to_target(wb: &gridcraft_model::Workbook, sheet: usize, part: &str, depth: usize) -> Option<(usize, Vec, bool)> { + if part.is_empty() || depth > GO_TO_NAME_DEPTH { + return None; + } + let (prefix, body) = split_sheet(part); + let on = match &prefix { + Some(n) => wb.sheet_index(n)?, + None => sheet, + }; + if let Some(r) = RangeRef::parse(&body.replace('$', "")) { + return Some((on, vec![r], false)); + } + let name = match prefix { + Some(_) => wb.names.iter().find(|n| n.scope == Some(on) && gridcraft_formula::same_name(&n.name, body)), + None => wb.name(body, sheet), + }; + if let Some(n) = name { + return name_areas(wb, n, sheet, depth).map(|(t, areas)| (t, areas, true)); + } + if prefix.is_none() + && let Some((si, ti)) = wb.table(body) + { + // As in Excel, a table's name stands for its data, without the header and totals rows. + let t = wb.sheet(si)?.tables.get(ti)?; + return Some((si, vec![t.data_range().unwrap_or(t.range)], true)); + } + None +} + +/// The sheet and areas defined name `n` refers to, seen from sheet `sheet`, when that is a +/// reference. +fn name_areas(wb: &gridcraft_model::Workbook, n: &gridcraft_model::DefinedName, sheet: usize, depth: usize) -> Option<(usize, Vec)> { + let from = n.scope.unwrap_or(sheet); + let formula = n.formula.trim().trim_start_matches('='); + let mut target: Option = None; + let mut areas = Vec::new(); + for p in reference_parts(formula) { + let (s, found, _) = go_to_target(wb, from, p, depth + 1)?; + if target.is_some_and(|t| t != s) { + return None; + } + target = Some(s); + areas.extend(found); + if areas.len() > GO_TO_MAX_AREAS { + return None; + } + } + target.map(|t| (t, areas)) +} + +/// The names the Go To dialog lists, seen from sheet `sheet`: the named ranges of the workbook and +/// of every sheet, in alphabetical order. A name scoped to another sheet is written `Sheet2!Name`, +/// and the active sheet's own name hides the workbook name it shares. Hidden names and names that +/// do not refer to a range are left out. +pub fn go_to_names(wb: &gridcraft_model::Workbook, sheet: usize) -> Vec { + let own: std::collections::HashSet = + wb.names.iter().filter(|n| n.scope == Some(sheet)).map(|n| gridcraft_formula::fold_name(&n.name)).collect(); + let mut out: Vec = wb + .names + .iter() + .filter(|n| !n.hidden && name_areas(wb, n, sheet, 0).is_some()) + .filter_map(|n| match n.scope { + None if own.contains(&gridcraft_formula::fold_name(&n.name)) => None, + None => Some(n.name.clone()), + Some(s) if s == sheet => Some(n.name.clone()), + Some(s) => Some(format!("{}!{}", gridcraft_core::quote_sheet(&wb.sheet(s)?.name), n.name)), + }) + .collect(); + out.sort_by_cached_key(|n| gridcraft_formula::fold_name(n)); + out.dedup_by(|a, b| gridcraft_formula::same_name(a, b)); + out +} + +pub(super) fn go_to_special(s: &mut Session, p: &Json) -> Result { + let kind = str_param(p, "kind").unwrap_or("blanks"); + let d = s.doc()?; + let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; + let scope = if d.selection.is_single_cell() { sheet.used_range().unwrap_or(RangeRef::cell(d.selection.active)) } else { d.selection.bounds() }; + if kind == "lastCell" { + let c = sheet.used_range().map(|r| r.end).unwrap_or_default(); + return selection_set(s, &json!({"cell": c.a1()})); + } + if kind == "currentRegion" { + return select_region(s, &json!({})); + } + let mut hits: Vec = Vec::new(); + if scope.count() > 4_000_000 { + return Err(bad("edit.goToSpecial", "range too large")); + } + for c in scope.iter() { + let cell = sheet.cell(c); + let v = sheet.value(c); + let ok = match kind { + "blanks" => v.is_empty(), + "constants" => cell.is_some_and(|x| x.formula.is_none() && !x.value.is_empty()), + "formulas" => cell.is_some_and(|x| x.formula.is_some()), + "comments" | "notes" => sheet.comments.contains_key(&c), + "errors" => v.is_error(), + "numbers" => v.is_number(), + "text" => v.is_text(), + "visible" => !sheet.is_row_hidden(c.row) && !sheet.is_col_hidden(c.col), + "conditionalFormats" => sheet.cond_formats.iter().any(|cf| cf.ranges.iter().any(|r| r.contains(c))), + "dataValidation" => sheet.validations.iter().any(|dv| dv.ranges.iter().any(|r| r.contains(c))), + _ => return Err(bad("edit.goToSpecial", format!("unknown kind `{kind}`"))), + }; + if ok { + hits.push(c); + } + } + if hits.is_empty() { + return Err(EngineError::Other("No cells were found.".into())); + } + let ranges = coalesce(&hits); + let d = s.doc_mut()?; + d.selection = crate::Selection { active: hits[0], anchor: hits[0], ranges }; + Ok(json!({"selection": d.selection.a1(), "count": hits.len()})) +} diff --git a/crates/engine/src/cmd/edit/paste_special.rs b/crates/engine/src/cmd/edit/paste_special.rs new file mode 100644 index 000000000..eba1b429e --- /dev/null +++ b/crates/engine/src/cmd/edit/paste_special.rs @@ -0,0 +1,351 @@ +use super::clipboard::What; +use super::*; + +pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result { + let Some(clip) = s.clipboard.clone() else { return Err(EngineError::Other("Nothing to paste.".into())) }; + let what = match str_param(p, "what").unwrap_or("all") { + "all" => What::All, + "formulas" => What::Formulas, + "values" => What::Values, + "formats" => What::Formats, + "comments" | "notes" => What::Comments, + "validation" => What::Validation, + "allExceptBorders" => What::AllExceptBorders, + "columnWidths" => What::ColumnWidths, + "formulasAndNumberFormats" => What::FormulasAndNumberFormats, + "valuesAndNumberFormats" => What::ValuesAndNumberFormats, + other => return Err(bad("edit.pasteSpecial", format!("unknown `what`: {other}"))), + }; + let op = str_param(p, "operation").unwrap_or("none").to_string(); + let skip_blanks = bool_param(p, "skipBlanks").unwrap_or(false); + let transpose = bool_param(p, "transpose").unwrap_or(false); + let link = bool_param(p, "link").unwrap_or(false); + if link && !clip.areas.is_empty() { + return Err(EngineError::Other("This action won't work on multiple selections.".into())); + } + let dest_sheet = target_sheet(s, p)?; + let sel = s.doc()?.selection.current(); + let at = cell_param_on(s, p, "at", dest_sheet)?.unwrap_or(sel.start); + let src = clip.range; + let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) }; + // Tile the copy over a larger destination that is a multiple of its size. + let tile = sel.height() % h == 0 + && sel.width() % w == 0 + && !sel.is_single() + && cell_param_on(s, p, "at", dest_sheet)?.is_none() + && !sel.is_full_cols() + && !sel.is_full_rows() + && sel.count() <= 4_000_000; + let (tiles_r, tiles_c) = if tile { (sel.height() / h, sel.width() / w) } else { (1, 1) }; + let same_doc = s.doc()?.uid == clip.doc_uid; + let cut_move = clip.cut && same_doc && what == What::All; + let src_wb = clip.wb.clone(); + let Some(src_sheet) = src_wb.sheet(clip.sheet) else { return Err(EngineError::Other("The copied sheet no longer exists.".into())) }; + let src_sheet_name = src_sheet.name.clone(); + if cut_move && at.offset((tiles_r * h).saturating_sub(1) as i64, (tiles_c * w).saturating_sub(1) as i64).is_none() { + // Check the whole footprint before the source is emptied, so a move that overflows the grid loses nothing. + return Err(EngineError::Other("The cut area doesn't fit at this position; nothing was moved.".into())); + } + edit(s, |cx| { + // Styles from another workbook must be re-interned. + let mut style_map = std::collections::HashMap::new(); + let mut map_style = |id: StyleId, wb: &mut gridcraft_model::Workbook| -> StyleId { + if same_doc { + return id; + } + *style_map.entry(id).or_insert_with(|| wb.styles.intern(src_wb.styles.get(id).clone())) + }; + let mut moved_formulas = Vec::new(); + let mut moved_pictures = std::collections::BTreeMap::new(); + if cut_move { + // Remove the source first (contents and formats), and fix references to it. + let source = cx.sheet_mut(clip.sheet)?; + moved_pictures = source.cell_pictures.iter().filter(|(c, _)| src.contains(**c)).map(|(c, p)| (*c, p.clone())).collect(); + let taken = source.take_cells(src); + // Notes, hyperlinks and merged areas sit beside the cells, and a cut takes them + // with the block: the destination is written from the clipboard snapshot taken + // before the cut, so removing them here loses nothing and leaves the old place + // clean instead of leaving one merged block and one note behind. + source.comments.retain(|c, _| !src.contains(*c)); + source.hyperlinks.retain(|c, _| !src.contains(*c)); + source.merges.retain(|m| !src.contains_range(m)); + for (c, _) in &taken { + cx.touch(clip.sheet, *c); + } + moved_formulas = taken; + } + for tr in 0..tiles_r { + for tc in 0..tiles_c { + for r in 0..src.height() { + for c in 0..src.width() { + let sc = CellRef::new(src.start.row + r, src.start.col + c); + let (dr, dc) = if transpose { (c, r) } else { (r, c) }; + let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue }; + let src_cell = if cut_move { + moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone()) + } else { + src_sheet.cell(sc).cloned() + }; + let src_val = src_sheet.value(sc); + let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned(); + if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) { + continue; + } + let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default(); + let old_picture = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell_pictures.get(&dest)).cloned(); + let mut new_picture = old_picture.clone(); + let mut new = old.clone(); + let shift = |cell: &Cell| -> Option> { + let f = cell.formula.as_ref()?; + let e = f.expr()?; + if cut_move { + // A moved formula still reads cells on its original sheet. Qualify + // those references before the workbook-wide move adjustment below. + let e = if dest_sheet != clip.sheet { + e.map(&mut |x| match x { + gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => { + r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone()); + gridcraft_formula::Expr::Ref(r) + } + other => other, + }) + } else { + e + }; + let mut moved = Formula::from_expr(e); + // The array range moves with its formula. + let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); + moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?))); + moved.dynamic = f.dynamic; + return Some(std::sync::Arc::new(moved)); + } + let moved = + gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64); + Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) })) + }; + if link { + let sheet_prefix = if dest_sheet == clip.sheet && same_doc { + String::new() + } else { + format!("{}!", gridcraft_formula::quote_sheet(&src_sheet_name)) + }; + new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1())))); + new.value = Value::Empty; + new_picture = None; + } else { + let sc_cell = src_cell.clone().unwrap_or_default(); + match what { + What::All | What::AllExceptBorders => { + new_picture = src_picture.clone(); + new.formula = shift(&sc_cell); + new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; + let st = map_style(sc_cell.style, &mut cx.wb); + new.style = if what == What::AllExceptBorders { + let borders = cx.wb.styles.get(old.style).borders; + cx.wb.styles.derive(st, |s| s.borders = borders) + } else { + st + }; + } + What::Formulas | What::FormulasAndNumberFormats => { + new_picture = src_picture.clone(); + new.formula = shift(&sc_cell); + new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() }; + if what == What::FormulasAndNumberFormats { + let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); + new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); + } + } + What::Values | What::ValuesAndNumberFormats => { + new_picture = src_picture.clone(); + new.formula = None; + new.value = src_val.clone(); + if what == What::ValuesAndNumberFormats { + let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone(); + new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf); + } + } + What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb), + What::Comments | What::Validation | What::ColumnWidths => {} + } + // Operations combine numbers with what is already there. + if op != "none" + && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) + && new.formula.is_none() + { + if old_picture.is_some() || new_picture.is_some() { + new_picture = None; + new.value = Value::Error(gridcraft_core::CellError::Value); + } + let a = old.value.clone(); + let b = new.value.clone(); + // Numbers stored as text take part (multiplying by 1 turns them into + // numbers); other text and errors stay as they are. + if let (Ok(x), Ok(y)) = (a.to_number(), b.to_number()) + && (a.is_number() || a.is_empty() || a.is_text()) + && (b.is_number() || b.is_empty()) + { + new.value = match op.as_str() { + "add" => Value::number(x + y), + "subtract" => Value::number(x - y), + "multiply" => Value::number(x * y), + "divide" => { + if y == 0.0 { + Value::Error(gridcraft_core::CellError::Div0) + } else { + Value::number(x / y) + } + } + _ => b.clone(), + }; + } else if (a.is_text() || a.is_error()) && (b.is_number() || b.is_empty()) { + new.value = a; + } + // A formula already there is kept and combined with the pasted number. + if let Some(f) = &old.formula + && let Ok(y) = b.to_number() + && (b.is_number() || b.is_empty()) + && let Some(sym) = match op.as_str() { + "add" => Some('+'), + "subtract" => Some('-'), + "multiply" => Some('*'), + "divide" => Some('/'), + _ => None, + } + { + let text = format!("=({}){sym}{}", f.text, gridcraft_core::number_to_text(y)); + new.formula = Some(std::sync::Arc::new(Formula::new(&text))); + new.value = Value::Empty; + } + } + } + // The copied cell's validation rule replaces whatever rule the destination had. + if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) { + let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned(); + let sh = cx.sheet_mut(dest_sheet)?; + crate::cmd::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]); + if let Some(mut dv) = rule { + // Relative references move with the rule, as pasted formulas do. + let first = dv.ranges.first().map_or(sc, |r| r.start); + dv.f1 = crate::cmd::data::move_rule_formula(&dv.f1, first, dest); + dv.f2 = dv.f2.map(|f| crate::cmd::data::move_rule_formula(&f, first, dest)); + dv.ranges = vec![RangeRef::new(dest, dest)]; + sh.validations.push(dv); + } + } + match what { + What::Comments => { + if let Some(cm) = src_sheet.comments.get(&sc).cloned() { + cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); + } + } + What::ColumnWidths => { + if let Some(info) = src_sheet.cols.get(&sc.col).copied() { + cx.sheet_mut(dest_sheet)?.cols.insert(dest.col, info); + } + } + What::All => { + if let Some(cm) = src_sheet.comments.get(&sc).cloned() { + cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); + } + if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() { + cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h); + } + cx.sheet_mut(dest_sheet)?.set_cell(dest, new); + } + _ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new), + } + if let Some(picture) = new_picture { + cx.sheet_mut(dest_sheet)?.cell_pictures.insert(dest, picture); + } + cx.touch(dest_sheet, dest); + } + } + } + } + // Merged areas inside the copied block travel with it. + if matches!(what, What::All | What::Formats) && !transpose { + let merges: Vec = src_sheet.merges.iter().filter(|m| src.contains_range(m)).copied().collect(); + for m in merges { + let dr = at.row as i64 - src.start.row as i64; + let dc = at.col as i64 - src.start.col as i64; + if let (Some(a), Some(b)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) { + let nm = RangeRef::new(a, b); + let sh = cx.sheet_mut(dest_sheet)?; + sh.merges.retain(|x| !x.intersects(&nm)); + sh.merges.push(nm); + } + } + } + if cut_move { + // References elsewhere to the moved block now point to its new place. + let dest_name = cx.wb.sheet(dest_sheet).map(|s| s.name.clone()).unwrap_or_default(); + let e = if dest_sheet == clip.sheet { + gridcraft_formula::adjust::Edit::Move { from: src, to_row: at.row, to_col: at.col } + } else { + gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name, to_row: at.row, to_col: at.col } + }; + rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e); + cx.structural = true; + } + let end = at.offset_clamped((tiles_r * h) as i64 - 1, (tiles_c * w) as i64 - 1); + *cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] }; + Ok(()) + })?; + // Formulas and validation from an untrusted file leave the destination untrusted; plain values, + // formats (cell styles and merged areas, no conditional formats), column widths and notes + // carry nothing active. A linked paste writes a formula into every cell whatever `what` says, + // except for column widths and notes, which write no cells at all. + let passive = + matches!(what, What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Formats | What::Values | What::ValuesAndNumberFormats)); + if !same_doc && !passive { + let d = s.doc_mut()?; + d.trust = d.trust.receive(clip.trust); + } + if cut_move { + s.clipboard = None; + } + Ok(json!({"pasted": s.doc()?.selection.a1()})) +} + +/// Applies a reference adjustment to every cell formula and defined name in the workbook. +pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) { + use gridcraft_formula::adjust::Edit; + // Rows and columns inserted or deleted on the target sheet move its legacy array ranges too + // (a cut moves its arrays itself). + let moves_arrays = matches!(e, Edit::Insert { .. } | Edit::Delete { .. } | Edit::InsertCells { .. } | Edit::DeleteCells { .. }); + for si in 0..wb.sheets.len() { + let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default(); + let keys: Vec<(CellRef, std::sync::Arc)> = + wb.sheets.get(si).map(|s| s.cells.iter().filter_map(|(c, cell)| cell.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default(); + if keys.is_empty() { + continue; + } + let on_target = moves_arrays && host.eq_ignore_ascii_case(target); + let Some(sheet) = wb.sheet_mut(si) else { continue }; + for (c, f) in keys { + let Some(expr) = f.expr() else { continue }; + let new = gridcraft_formula::adjust::adjust(expr, &host, target, e); + let text = gridcraft_formula::print(&new); + let array = match f.array { + Some(a) if on_target => crate::cmd::sheet::map_range(a, e), + a => a, + }; + if (text != f.text || array != f.array) + && let Some(cell) = sheet.cells.get_mut(c) + { + let mut nf = Formula::from_expr(new); + nf.array = array; + nf.dynamic = f.dynamic; + cell.formula = Some(std::sync::Arc::new(nf)); + } + } + } + for n in wb.names.iter_mut() { + if let Ok(expr) = gridcraft_formula::parse_name(&n.formula) { + // Names are workbook-level: unqualified refs don't occur; qualified ones adjust. + let new = gridcraft_formula::adjust::adjust(expr, "\u{0}", target, e); + n.formula = gridcraft_formula::print(&new); + } + } +} diff --git a/crates/engine/src/cmd/edit/selection.rs b/crates/engine/src/cmd/edit/selection.rs new file mode 100644 index 000000000..95793f338 --- /dev/null +++ b/crates/engine/src/cmd/edit/selection.rs @@ -0,0 +1,171 @@ +use super::*; + +// ---------------------------------------------------------------- selection + +/// Whether the cell may be selected: a protected sheet can forbid selecting locked and/or unlocked cells. +fn cell_selectable(wb: &gridcraft_model::Workbook, sheet: &gridcraft_model::Sheet, c: CellRef) -> bool { + let Some(p) = sheet.protection.as_ref() else { return true }; + if wb.styles.get(sheet.style_id(c)).protection.locked { p.select_locked } else { p.select_unlocked } +} + +pub(super) fn selection_set(s: &mut Session, p: &Json) -> Result { + if p.get("sheet").is_some() { + let i = target_sheet(s, p)?; + s.doc_mut()?.wb_switch_sheet(i); + } else if let Some(r) = str_param(p, "range").or_else(|| str_param(p, "cell")) + && let (Some(_), _) = split_sheet(r) + { + let i = target_sheet(s, p)?; + s.doc_mut()?.wb_switch_sheet(i); + } + let ranges = target_ranges(s, p)?; + let active = cell_param(s, p, "active")?; + let d = s.doc_mut()?; + let active = active.or_else(|| ranges.last().map(|r| r.start)).unwrap_or_default(); + let mut sel = crate::Selection { active, anchor: ranges.last().map(|r| r.start).unwrap_or(active), ranges }; + if let Some(sh) = d.wb.active() { + sel.expand_merges(sh); + if !cell_selectable(&d.wb, sh, sel.active) { + return Err(EngineError::Other("The cell you are trying to change is on a protected sheet that does not allow selecting it.".into())); + } + } + d.selection = sel; + Ok(json!({"selection": d.selection.a1(), "active": d.selection.active.a1()})) +} + +impl crate::DocState { + /// Switches the active sheet, remembering each sheet's selection. + pub fn wb_switch_sheet(&mut self, i: usize) { + if i >= self.wb.sheets.len() || i == self.wb.active_sheet() { + return; + } + let cur = self.wb.active_sheet(); + if self.sheet_selections.len() < self.wb.sheets.len() { + self.sheet_selections.resize(self.wb.sheets.len(), crate::Selection::default()); + } + if let Some(slot) = self.sheet_selections.get_mut(cur) { + *slot = self.selection.clone(); + } + // View state: set in place, so the workbook is neither copied nor made dirty. + self.wb.set_active_sheet(i); + self.selection = self.sheet_selections.get(i).cloned().unwrap_or_default(); + } +} + +pub(super) fn selection_move(s: &mut Session, p: &Json) -> Result { + let dr = p.get("dr").and_then(Json::as_i64).unwrap_or(0); + let dc = p.get("dc").and_then(Json::as_i64).unwrap_or(0); + let extend = bool_param(p, "extend").unwrap_or(false); + let jmp = bool_param(p, "jump").unwrap_or(false); + let d = s.doc_mut()?; + let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; + let sel = &mut d.selection; + // Extension moves the far corner, not the active cell. + let from = if extend { sel.moving_end() } else { sel.active }; + // Skip hidden rows/columns and step over merged areas. + let mut to = if jmp { + jump(sheet, from, dr.signum(), dc.signum()) + } else { + let mut t = from.offset_clamped(dr, dc); + let mut guard = 0; + while (sheet.is_row_hidden(t.row) || sheet.is_col_hidden(t.col)) && guard < 100_000 { + let n = t.offset_clamped(dr.signum(), dc.signum()); + if n == t { + break; + } + t = n; + guard += 1; + } + if !extend && let Some(m) = sheet.merge_at(from) { + // Leaving a merged cell moves from its edge. + if dr > 0 { + t = CellRef::new(m.end.offset_clamped(dr, 0).row, from.col); + } else if dc > 0 { + t = CellRef::new(from.row, m.end.offset_clamped(0, dc).col); + } + } + t + }; + if let Some(m) = sheet.merge_at(to) + && !extend + { + to = m.start; + } + if !extend && !cell_selectable(&d.wb, sheet, to) { + // Step on to the next selectable cell in the same direction; stay put if there is none. + let (sr, sc) = (dr.signum(), dc.signum()); + let mut t = to; + let mut found = false; + if !jmp && (sr, sc) != (0, 0) { + for _ in 0..10_000 { + let n = t.offset_clamped(sr, sc); + if n == t { + break; + } + t = n; + if !sheet.is_row_hidden(t.row) && !sheet.is_col_hidden(t.col) && cell_selectable(&d.wb, sheet, t) { + found = true; + break; + } + } + } + if !found { + return Ok(json!({"selection": sel.a1(), "active": sel.active.a1()})); + } + to = t; + } + if extend { + sel.extend_to(to); + sel.expand_merges(sheet); + } else { + *sel = crate::Selection::at(to); + if let Some(m) = sheet.merge_at(to) { + sel.ranges = vec![m]; + } + } + Ok(json!({"selection": sel.a1(), "active": sel.active.a1()})) +} + +pub(super) fn selection_next(s: &mut Session, p: &Json) -> Result { + let fwd = bool_param(p, "forward").unwrap_or(true); + let by_row = bool_param(p, "byRow").unwrap_or(false); + let d = s.doc_mut()?; + d.selection.cycle(fwd, by_row); + Ok(json!({"active": d.selection.active.a1()})) +} + +pub(super) fn select_all(s: &mut Session, _: &Json) -> Result { + let d = s.doc_mut()?; + let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; + let region = current_region(sheet, d.selection.active); + let all = RangeRef::all(); + let r = if region.is_single() || d.selection.current() == region { all } else { region }; + let active = d.selection.active; + d.selection = crate::Selection { active, anchor: r.start, ranges: vec![r] }; + Ok(json!({"selection": d.selection.a1()})) +} + +pub(super) fn select_region(s: &mut Session, _: &Json) -> Result { + let d = s.doc_mut()?; + let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; + let r = current_region(sheet, d.selection.active); + let active = d.selection.active; + d.selection = crate::Selection { active, anchor: r.start, ranges: vec![r] }; + Ok(json!({"selection": d.selection.a1()})) +} + +pub(super) fn select_row(s: &mut Session, _: &Json) -> Result { + let d = s.doc_mut()?; + let r = d.selection.current(); + let active = d.selection.active; + d.selection = crate::Selection { active, anchor: CellRef::new(r.start.row, 0), ranges: vec![RangeRef::rows(r.start.row, r.end.row)] }; + Ok(json!({"selection": d.selection.a1()})) +} + +pub(super) fn select_col(s: &mut Session, _: &Json) -> Result { + let d = s.doc_mut()?; + let r = d.selection.current(); + let active = d.selection.active; + d.selection = crate::Selection { active, anchor: CellRef::new(0, r.start.col), ranges: vec![RangeRef::cols(r.start.col, r.end.col)] }; + Ok(json!({"selection": d.selection.a1()})) +} From 73c52d49b7659c2ceaa9991a26f4595e59571fd2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:24:30 +0000 Subject: [PATCH 50/76] refactor(engine): move cmd/print.rs items to cmd/print/ --- crates/engine/src/cmd/print.rs | 1520 +----------------- crates/engine/src/cmd/print/draw.rs | 467 ++++++ crates/engine/src/cmd/print/header_footer.rs | 261 +++ crates/engine/src/cmd/print/layout.rs | 364 +++++ crates/engine/src/cmd/print/objects.rs | 177 ++ crates/engine/src/cmd/print/text.rs | 263 +++ 6 files changed, 1552 insertions(+), 1500 deletions(-) create mode 100644 crates/engine/src/cmd/print/draw.rs create mode 100644 crates/engine/src/cmd/print/header_footer.rs create mode 100644 crates/engine/src/cmd/print/layout.rs create mode 100644 crates/engine/src/cmd/print/objects.rs create mode 100644 crates/engine/src/cmd/print/text.rs diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index d55841f7b..3b6dcb0ec 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -16,6 +16,26 @@ use serde_json::{Value as Json, json}; use super::*; +mod draw; +mod header_footer; +mod layout; +mod objects; +mod text; + +use draw::{Block, PrintedBlock, draw_block, draw_block_links, draw_headings, make_block, printed_target}; +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, +}; +#[cfg(test)] +use layout::bands; +pub(crate) use layout::split_bands; +pub use layout::{PrintOptions, SheetLayout, layout_sheet, layouts, paper_size, print_range}; +use layout::{known_paper, seg_size, segments}; +use objects::draw_objects; +#[cfg(test)] +use text::{center_across_width, wrap_lines}; + pub fn specs() -> Vec { vec![ cmd!( @@ -61,1506 +81,6 @@ pub fn specs() -> Vec { pub(crate) const PX_TO_PT: f32 = 0.75; /// Most pages one export produces. pub const MAX_PAGES: usize = 2000; -/// Cells in a page block above which only stored cells are visited (not every grid position). -const DENSE_BLOCK: u64 = 60_000; - -// ---------------------------------------------------------------- paper - -/// Paper size in points (portrait) for a paper name; unknown names fall back to Letter. -pub fn paper_size(name: &str) -> (f32, f32) { - known_paper(name).unwrap_or((612.0, 792.0)) -} - -/// Paper sizes in points, portrait. `B4`/`B5`/`B6` are the **JIS** sizes Japanese Excel and its -/// e-mail attachments use (257×364, 182×257 and 128×182 mm); the ISO B series stays reachable as -/// `B4 (ISO)`/`B5 (ISO)`/`B6 (ISO)`. The Japanese postcards and envelopes are the JIS ones too. -fn known_paper(name: &str) -> Option<(f32, f32)> { - // Spaces, hyphens, underscores and parentheses don't matter: `B5 (JIS)`, `b5-jis` and `b5` - // are one name. - let n = name.trim().to_ascii_lowercase().replace([' ', '-', '_', '(', ')'], ""); - Some(match n.as_str() { - "letter" | "usletter" => (612.0, 792.0), - "legal" | "uslegal" => (612.0, 1008.0), - "a4" => (595.28, 841.89), - "a3" => (841.89, 1190.55), - "a5" => (419.53, 595.28), - "a6" => (297.64, 419.53), - "tabloid" | "11x17" => (792.0, 1224.0), - "ledger" => (1224.0, 792.0), - "executive" => (522.0, 756.0), - "b4" | "b4jis" => (728.50, 1031.81), - "b5" | "b5jis" => (515.91, 728.50), - "b6" | "b6jis" => (362.83, 515.91), - "b4iso" => (708.66, 1000.63), - "b5iso" => (498.90, 708.66), - "b6iso" => (354.33, 498.90), - "japanesepostcard" | "postcard" => (283.46, 419.53), - "japanesedoublepostcard" | "doublepostcard" => (419.53, 566.93), - "japaneseenvelope#3" | "chou#3" => (340.16, 666.14), - // 長形4号 is 90×205 mm; 90×225 mm is 長形40号, a different envelope, so it stays out. - "japaneseenvelope#4" | "chou#4" => (255.12, 581.10), - "japaneseenvelopekaku#2" | "kaku#2" => (680.31, 941.10), - "japaneseenvelopekaku#3" | "kaku#3" => (612.28, 785.20), - _ => return None, - }) -} - -// ---------------------------------------------------------------- layout - -/// The pagination of one sheet. -#[derive(Clone, Debug)] -pub struct SheetLayout { - pub sheet: usize, - pub settings: PrintSettings, - pub page_w: f32, - pub page_h: f32, - /// Left, right, top, bottom, header, footer margins in points. - pub margins: [f32; 6], - pub scale: f32, - pub area: RangeRef, - pub row_bands: Vec<(u32, u32)>, - pub col_bands: Vec<(u32, u32)>, - /// Pages in print order: (row band, column band). - pub pages: Vec<(usize, usize)>, - pub title_rows: Option<(u32, u32)>, - pub title_cols: Option<(u32, u32)>, - /// Heading sizes in sheet pixels (0 when headings are off). - pub heading_w: f32, - pub heading_h: f32, - /// Manual page breaks apply (they don't when the sheet is fitted to pages). - pub manual_breaks: bool, -} - -impl SheetLayout { - fn k(&self) -> f32 { - PX_TO_PT * self.scale - } - pub(crate) fn avail(&self) -> (f32, f32) { - let [l, r, t, b, ..] = self.margins; - ((self.page_w - l - r).max(36.0), (self.page_h - t - b).max(36.0)) - } - /// Left and right margins, in points, that header and footer sections line up with: the - /// page's side margins with Align with Page Margins on, otherwise 0.7 inch on each side, at - /// most a quarter of the page width. - pub(crate) fn hf_sides(&self) -> (f32, f32) { - if self.settings.hf_align_with_margins { - (self.margins[0], self.margins[1]) - } else { - let side = HF_FIXED_SIDE_MARGIN.min(self.page_w / 4.0); - (side, side) - } - } - /// Scale of header and footer text: the print scale with Scale with Document on, otherwise 1. - pub(crate) fn hf_text_scale(&self) -> f32 { - if self.settings.hf_scale_with_doc { self.scale } else { 1.0 } - } -} - -/// Side margin of headers and footers, in points, when Align with Page Margins is off. -const HF_FIXED_SIDE_MARGIN: f32 = 0.7 * 72.0; - -/// Options shared by the print commands. -#[derive(Clone, Debug, Default)] -pub struct PrintOptions { - pub all_sheets: bool, - pub range: Option, - pub fit_to_page: Option, -} - -impl PrintOptions { - fn from_params(p: &Json, cmd: &str) -> Result { - let all_sheets = match str_param(p, "sheets") { - None | Some("active") => false, - Some("all") | Some("workbook") => true, - Some(other) => return Err(bad(cmd, format!("`sheets` must be active or all, not `{other}`"))), - }; - let range = match str_param(p, "range").map(str::trim).filter(|r| !r.is_empty()) { - Some(r) => Some(RangeRef::parse(split_sheet(r).1).ok_or_else(|| bad(cmd, format!("not a range: `{r}`")))?), - None => None, - }; - Ok(PrintOptions { all_sheets, range, fit_to_page: bool_param(p, "fitToPage") }) - } -} - -/// The range a sheet prints: its print area, else the used range grown to cover drawing -/// objects. `None` when there is nothing to print. -pub fn print_range(sh: &Sheet) -> Option { - if let Some(a) = sh.print.print_area { - return Some(trim_full(sh, a)); - } - let mut r = sh.used_range(); - let mut grow = |a: &Anchor| { - let x = sh.col_left(a.cell.col) + a.dx as f64; - let y = sh.row_top(a.cell.row) + a.dy as f64; - let end = CellRef::new(sh.row_at(y + a.height.max(0.0) as f64), sh.col_at(x + a.width.max(0.0) as f64)); - let ar = RangeRef::new(a.cell, end); - r = Some(r.map_or(ar, |u| u.union(&ar))); - }; - for c in &sh.charts { - grow(&c.anchor); - } - for i in &sh.images { - grow(&i.anchor); - } - for s in &sh.shapes { - grow(&s.anchor); - } - // Printing starts at A1 like Excel when the data starts nearby? Excel prints from the first - // used cell; keep the used range as is. - r -} - -/// Whole rows/columns are trimmed to the used range. -fn trim_full(sh: &Sheet, r: RangeRef) -> RangeRef { - if !(r.is_full_cols() || r.is_full_rows()) { - return r; - } - match sh.used_range() { - Some(u) => r.intersection(&RangeRef::new(CellRef::new(0, 0), u.end)).unwrap_or(RangeRef::cell(r.start)), - None => RangeRef::cell(r.start), - } -} - -/// Splits `start..=end` into bands whose sizes fit `avail(band_start)`; `breaks` force a new -/// band before a line. Stops after `cap` bands. -fn bands(start: u32, end: u32, size: impl Fn(u32) -> f32, avail: impl Fn(u32) -> f32, breaks: &[u32], cap: usize) -> Vec<(u32, u32)> { - let mut out = Vec::new(); - let mut cur = start; - let mut used = 0.0f32; - let mut room = avail(start); - let mut line = start; - loop { - let sz = size(line); - let manual = line > cur && breaks.binary_search(&line).is_ok(); - if line > cur && (manual || used + sz > room + 0.01) { - out.push((cur, line - 1)); - if out.len() >= cap { - return out; - } - cur = line; - used = 0.0; - room = avail(line); - } - used += sz; - if line >= end { - break; - } - line += 1; - } - out.push((cur, end)); - out -} - -fn title_span(t: Option<(u32, u32)>, max: u32) -> Option<(u32, u32)> { - let (a, b) = t?; - let (a, b) = (a.min(b), a.max(b)); - if a >= max { - return None; - } - // Repeated titles are capped (Excel needs them to fit on a page anyway). - Some((a, b.min(max - 1).min(a.saturating_add(499)))) -} - -/// Paginates one sheet. -pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_override: Option) -> Option { - let sh = wb.sheet(si)?; - let st = sh.print.clone(); - let (mut pw, mut ph) = paper_size(&st.paper); - if st.orientation == Orientation::Landscape { - std::mem::swap(&mut pw, &mut ph); - } - let inch = |i: usize| st.margins.get(i).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 20.0) * 72.0; - let mut margins = [inch(0), inch(1), inch(2), inch(3), inch(4), inch(5)]; - // Keep at least an inch of printable space. - for (a, b, total) in [(0usize, 1usize, pw), (2, 3, ph)] { - let sum = margins[a] + margins[b]; - if total - sum < 72.0 { - let f = ((total - 72.0).max(0.0)) / sum.max(1.0); - margins[a] *= f; - margins[b] *= f; - } - } - let area = match range_override { - Some(r) => trim_full(sh, r), - None => print_range(sh).unwrap_or(RangeRef::cell(CellRef::new(0, 0))), - }; - let title_rows = title_span(st.title_rows, MAX_ROWS); - let title_cols = title_span(st.title_cols, MAX_COLS); - let digits = format!("{}", area.end.row + 1).len() as f32; - let (heading_w, heading_h) = if st.headings { (10.0 + 7.0 * digits, sh.default_row_height.clamp(10.0, 60.0)) } else { (0.0, 0.0) }; - let fit = match opts.fit_to_page { - Some(true) => Some((1u16, 1u16)), - Some(false) => None, - None => st.fit_to.filter(|(w, h)| *w > 0 || *h > 0), - }; - let mut lay = SheetLayout { - sheet: si, - settings: st.clone(), - page_w: pw, - page_h: ph, - margins, - scale: (st.scale.clamp(10, 400) as f32) / 100.0, - area, - row_bands: vec![], - col_bands: vec![], - pages: vec![], - title_rows, - title_cols, - heading_w, - heading_h, - manual_breaks: fit.is_none(), - }; - let paginate = |lay: &mut SheetLayout, manual: bool| { - lay.manual_breaks = manual; - (lay.row_bands, lay.col_bands) = split_bands(sh, lay, MAX_PAGES); - lay.pages.clear(); - 'outer: for ci in 0..lay.col_bands.len() { - for ri in 0..lay.row_bands.len() { - if lay.pages.len() >= MAX_PAGES { - break 'outer; - } - lay.pages.push((ri, ci)); - } - } - }; - match fit { - Some((fw, fh)) => { - let total_w: f32 = (area.start.col..=area.end.col).map(|c| sh.col_width(c)).sum::() + heading_w; - let total_h: f32 = if area.height() > 200_000 { - f32::INFINITY - } else { - (area.start.row..=area.end.row).map(|r| sh.row_height(r)).sum::() + heading_h - }; - let (aw, ah) = lay.avail(); - let sw = if fw > 0 { aw * fw as f32 / (total_w * PX_TO_PT).max(1.0) } else { f32::INFINITY }; - let sh_ = if fh > 0 { ah * fh as f32 / (total_h * PX_TO_PT).max(1.0) } else { f32::INFINITY }; - lay.scale = sw.min(sh_).min(1.0).clamp(0.1, 1.0); - for _ in 0..40 { - paginate(&mut lay, false); - let ok_w = fw == 0 || lay.col_bands.len() <= fw as usize; - let ok_h = fh == 0 || lay.row_bands.len() <= fh as usize; - if (ok_w && ok_h) || lay.scale <= 0.1 { - break; - } - lay.scale = (lay.scale * 0.97).max(0.1); - } - } - None => paginate(&mut lay, true), - } - Some(lay) -} - -/// 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) -> (Vec<(u32, u32)>, Vec<(u32, u32)>) { - let k = lay.k(); - let (aw, ah) = lay.avail(); - let (aw, ah) = (aw / k, ah / k); - let st = &lay.settings; - let mut rb: Vec = if lay.manual_breaks { st.row_breaks.clone() } else { vec![] }; - let mut cb: Vec = if lay.manual_breaks { st.col_breaks.clone() } else { vec![] }; - rb.sort_unstable(); - cb.sort_unstable(); - let (title_rows, title_cols) = (lay.title_rows, lay.title_cols); - let title_h = |start: u32| -> f32 { - match title_rows { - Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|r| sh.row_height(r)).sum(), - _ => 0.0, - } - }; - let title_w = |start: u32| -> f32 { - match title_cols { - Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|c| sh.col_width(c)).sum(), - _ => 0.0, - } - }; - let area = lay.area; - let rows = bands(area.start.row, area.end.row, |r| sh.row_height(r), |start| (ah - lay.heading_h - title_h(start)).max(1.0), &rb, cap); - let cols = bands(area.start.col, area.end.col, |c| sh.col_width(c), |start| (aw - lay.heading_w - title_w(start)).max(1.0), &cb, cap); - (rows, cols) -} - -/// Lays out every sheet a print command covers. -pub fn layouts(wb: &Workbook, opts: &PrintOptions) -> Vec { - let mut out = Vec::new(); - let mut pages = 0usize; - let sheets: Vec = if opts.all_sheets { - (0..wb.sheets.len()).filter(|i| wb.sheet(*i).is_some_and(|s| s.visibility == Visibility::Visible)).collect() - } else { - vec![wb.active_sheet()] - }; - for si in sheets { - if pages >= MAX_PAGES { - break; - } - let ovr = if si == wb.active_sheet() { opts.range } else { None }; - if opts.all_sheets && ovr.is_none() && wb.sheet(si).is_some_and(|s| print_range(s).is_none()) { - continue; - } - if let Some(mut l) = layout_sheet(wb, si, opts, ovr) { - l.pages.truncate(MAX_PAGES - pages); - pages += l.pages.len(); - out.push(l); - } - } - if out.is_empty() && opts.all_sheets { - out.extend(layout_sheet(wb, wb.active_sheet(), opts, None)); - } - out -} - -// ---------------------------------------------------------------- header / footer - -/// Values for header/footer codes. -#[derive(Clone, Debug, Default)] -pub struct HfContext { - pub page: usize, - pub pages: usize, - pub sheet: String, - pub file: String, - pub path: String, - pub date: String, - pub time: String, -} - -/// Today's date and the current time as headers and footers show them (`&D`, `&T`). -pub fn hf_date_time(wb: &Workbook) -> (String, String) { - let now = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial()); - let date = crate::display::format(&Value::Number(now.floor()), "m/d/yyyy", wb).text; - let time = crate::display::format(&Value::Number(now), "h:mm AM/PM", wb).text; - (date, time) -} - -/// Header/footer codes and the field names the Page Layout view shows for them while editing. -pub const HF_FIELDS: [(char, &str); 8] = [ - ('P', "&[Page]"), - ('N', "&[Pages]"), - ('D', "&[Date]"), - ('T', "&[Time]"), - ('Z', "&[Path]"), - ('F', "&[File]"), - ('A', "&[Tab]"), - ('G', "&[Picture]"), -]; - -/// Splits a header/footer string into its raw left, centre and right sections (codes kept). -/// Text before the first `&L`/`&C`/`&R` belongs to the centre, as when printing. -pub fn split_header_footer(spec: &str) -> [String; 3] { - let mut out: [String; 3] = Default::default(); - let mut cur = 1usize; - let chars: Vec = spec.chars().take(10_000).collect(); - let mut i = 0; - while let Some(&c) = chars.get(i) { - let next = chars.get(i + 1).copied(); - let sec = cur.min(2); - match (c, next) { - ('&', Some('L' | 'l')) => cur = 0, - ('&', Some('C' | 'c')) => cur = 1, - ('&', Some('R' | 'r')) => cur = 2, - ('&', Some('"')) => { - // &"Font,Style" is kept whole. - let end = chars[i + 2..].iter().position(|ch| *ch == '"').map_or(chars.len(), |p| i + 3 + p); - out[sec].extend(&chars[i..end.min(chars.len())]); - i = end; - continue; - } - ('&', Some(n)) => { - out[sec].push('&'); - out[sec].push(n); - } - ('&', None) => {} - (ch, _) => { - out[sec].push(ch); - i += 1; - continue; - } - } - i += 2; - } - out -} - -/// Joins raw left, centre and right sections into one header/footer string. -pub fn join_header_footer(sections: &[String; 3]) -> String { - let mut s = String::new(); - for (code, text) in ["&L", "&C", "&R"].iter().zip(sections) { - if !text.is_empty() { - s.push_str(code); - s.push_str(text); - } - } - s -} - -/// A raw section as the user edits it: `&P` shows as `&[Page]`, `&N` as `&[Pages]`, and so on. -pub fn codes_to_fields(raw: &str) -> String { - let mut out = String::new(); - let mut it = raw.chars().peekable(); - while let Some(c) = it.next() { - if c != '&' { - out.push(c); - continue; - } - // A dangling `&` at the end is dropped (printing ignores it too). - if let Some(n) = it.next() { - match HF_FIELDS.iter().find(|(code, _)| *code == n.to_ascii_uppercase()) { - Some((_, field)) => out.push_str(field), - None => { - out.push('&'); - out.push(n); - } - } - } - } - out -} - -/// The inverse of [`codes_to_fields`]: field names become codes again; a dangling `&` at the end -/// becomes a literal ampersand so it can't swallow the next section's code. -pub fn fields_to_codes(text: &str) -> String { - let chars: Vec = text.chars().take(10_000).collect(); - let mut out = String::new(); - let mut i = 0; - while let Some(&c) = chars.get(i) { - if c != '&' { - out.push(c); - i += 1; - continue; - } - if chars.get(i + 1) == Some(&'&') { - out.push_str("&&"); - i += 2; - continue; - } - let rest: String = chars[i..].iter().take(12).collect(); - if let Some((code, field)) = HF_FIELDS.iter().find(|(_, f)| rest.starts_with(f)) { - out.push('&'); - out.push(*code); - i += field.chars().count(); - continue; - } - if i + 1 == chars.len() { - out.push_str("&&"); - } else { - out.push('&'); - } - i += 1; - } - out -} - -/// One header/footer section: its text and whether `&B` made it bold. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct HfSection { - pub text: String, - pub bold: bool, - pub italic: bool, - pub size: Option, -} - -/// Expands a header/footer string into its left, centre and right sections. -pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] { - let mut out: [HfSection; 3] = Default::default(); - let mut cur = 1usize; - let chars: Vec = spec.chars().take(10_000).collect(); - let mut i = 0; - while i < chars.len() { - let c = chars[i]; - if c != '&' { - if let Some(s) = out.get_mut(cur) { - s.text.push(c); - } - i += 1; - continue; - } - let Some(&code) = chars.get(i + 1) else { break }; - i += 2; - let sec = &mut out[cur.min(2)]; - match code { - 'L' | 'l' => cur = 0, - 'C' | 'c' => cur = 1, - 'R' | 'r' => cur = 2, - 'P' | 'p' => { - // &P, &P+n, &P-n - let mut n = cx.page as i64; - if let Some(&sign) = chars.get(i) - && (sign == '+' || sign == '-') - { - let digits: String = chars[i + 1..].iter().take_while(|d| d.is_ascii_digit()).take(6).collect(); - if let Ok(d) = digits.parse::() { - n = if sign == '+' { n + d } else { n - d }; - i += 1 + digits.len(); - } - } - sec.text.push_str(&n.to_string()); - } - 'N' | 'n' => sec.text.push_str(&cx.pages.to_string()), - 'D' | 'd' => sec.text.push_str(&cx.date), - 'T' | 't' => sec.text.push_str(&cx.time), - 'A' | 'a' => sec.text.push_str(&cx.sheet), - 'F' | 'f' => sec.text.push_str(&cx.file), - 'Z' | 'z' => sec.text.push_str(&cx.path), - '&' => sec.text.push('&'), - 'B' | 'b' => sec.bold = !sec.bold, - 'I' | 'i' => sec.italic = !sec.italic, - '"' => { - // &"Font,Style": skip the font name; honour Bold/Italic in the style. - let rest: String = chars[i..].iter().take_while(|c| **c != '"').collect(); - let lower = rest.to_ascii_lowercase(); - if lower.contains("bold") { - sec.bold = true; - } - if lower.contains("italic") || lower.contains("oblique") { - sec.italic = true; - } - i += rest.chars().count() + 1; - } - d if d.is_ascii_digit() => { - let digits: String = std::iter::once(d).chain(chars[i..].iter().copied().take_while(char::is_ascii_digit)).take(3).collect(); - i += digits.len() - 1; - if let Ok(v) = digits.parse::() { - sec.size = Some(v.clamp(1.0, 409.0)); - } - } - // Underline, strike, super/subscript, colour, picture: not rendered. - 'K' | 'k' => { - i = (i + 6).min(chars.len()); - } - _ => {} - } - } - out -} - -// ---------------------------------------------------------------- drawing - -fn rgb_of(c: Color, wb: &Workbook) -> Option { - c.resolve(&wb.theme) -} - -fn blend(fg: [u8; 4]) -> Rgb { - let a = fg[3] as u16; - let mix = |c: u8| ((c as u16 * a + 255 * (255 - a)) / 255) as u8; - [mix(fg[0]), mix(fg[1]), mix(fg[2])] -} - -/// The effective look of a cell: own style, table style, conditional formats. -struct Look { - style: Style, - fill: Option, - cf: Option, -} - -fn cell_look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut Option) -> Look { - let mut style = match sh.table_at(c) { - Some(t) => crate::tables::table_cell_style(wb, si, t, c), - None => wb.styles.get(sh.style_id(c)).clone(), - }; - let cfl = cf.as_mut().and_then(|cache| cache.look(wb, si, c)); - if let Some(st) = cfl.as_ref().and_then(|l| l.style.as_ref()) { - if st.fill.pattern != PatternType::None { - style.fill = st.fill; - } - if st.font.color != Color::Auto { - style.font.color = st.font.color; - } - style.font.bold |= st.font.bold; - style.font.italic |= st.font.italic; - style.font.strike |= st.font.strike; - if st.font.underline != Underline::None { - style.font.underline = st.font.underline; - } - // In a differential style General means "not set" (#1163). - if st.num_fmt.as_str() != "General" { - style.num_fmt = st.num_fmt.clone(); - } - for (dst, src) in [ - (&mut style.borders.left, st.borders.left), - (&mut style.borders.right, st.borders.right), - (&mut style.borders.top, st.borders.top), - (&mut style.borders.bottom, st.borders.bottom), - ] { - if !src.is_none() { - *dst = src; - } - } - } - let mut fill = match style.fill.pattern { - PatternType::None => None, - PatternType::Solid => rgb_of(style.fill.fg, wb), - _ => { - let fg = rgb_of(style.fill.fg, wb).unwrap_or([0, 0, 0]); - let bg = rgb_of(style.fill.bg, wb).unwrap_or([255, 255, 255]); - Some([0, 1, 2].map(|i| ((fg[i] as u16 + bg[i] as u16 * 3) / 4) as u8)) - } - }; - if let Some(rgb) = cfl.as_ref().and_then(|l| l.scale_fill) { - fill = Some(rgb); - } - Look { style, fill, cf: cfl } -} - -/// PDF measurement for chart layout. -struct PdfMeasure; - -impl gridcraft_chart::Measure for PdfMeasure { - fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 { - gridcraft_pdf::text_width(if bold { Font::HelveticaBold } else { Font::Helvetica }, size, text) - } -} - -/// Maps chart-space coordinates to the page. -#[derive(Clone, Copy)] -struct Xf { - ox: f32, - oy: f32, - k: f32, -} - -impl Xf { - fn p(&self, x: f32, y: f32) -> (f32, f32) { - (self.ox + x * self.k, self.oy + y * self.k) - } - fn pts(&self, pts: &[[f32; 2]]) -> Vec<[f32; 2]> { - pts.iter().map(|q| [self.ox + q[0] * self.k, self.oy + q[1] * self.k]).collect() - } -} - -/// Draws chart primitives onto a page. -fn draw_prims(pg: &mut Page, prims: &[gridcraft_chart::Prim], xf: Xf) { - use gridcraft_chart::{HAlign as H, Prim, VAlign as V}; - let k = xf.k; - for p in prims { - match p { - Prim::Rect { x, y, w, h, fill, stroke, .. } => { - let (px, py) = xf.p(*x, *y); - if let Some(f) = fill { - pg.fill_rect(px, py, w * k, h * k, blend(*f)); - } - if let Some((c, lw)) = stroke { - pg.rect_stroke(px, py, w * k, h * k, (lw * k).max(0.1), blend(*c)); - } - } - Prim::Line { pts, color, width, dash } => { - let d = if *dash { vec![3.0 * k, 2.0 * k] } else { vec![] }; - pg.polyline(&xf.pts(pts), (width * k).max(0.1), blend(*color), &d); - } - Prim::Polygon { pts, fill, stroke } => { - pg.polygon(&xf.pts(pts), Some(blend(*fill)), stroke.map(|(c, w)| (blend(c), (w * k).max(0.1)))); - } - Prim::Wedge { cx, cy, r_outer, r_inner, a0, a1, fill, stroke } => { - let span = (a1 - a0).abs().min(std::f32::consts::TAU); - let n = ((span / (std::f32::consts::PI / 36.0)).ceil() as usize).clamp(2, 400); - let at = |a: f32, r: f32| [cx + r * a.sin(), cy - r * a.cos()]; - let mut pts: Vec<[f32; 2]> = (0..=n).map(|i| at(a0 + (a1 - a0) * i as f32 / n as f32, *r_outer)).collect(); - if *r_inner > 0.0 { - pts.extend((0..=n).rev().map(|i| at(a0 + (a1 - a0) * i as f32 / n as f32, *r_inner))); - } else { - pts.push([*cx, *cy]); - } - pg.polygon(&xf.pts(&pts), Some(blend(*fill)), stroke.map(|(c, w)| (blend(c), (w * k).max(0.1)))); - } - Prim::Circle { cx, cy, r, fill, stroke } => { - let (px, py) = xf.p(*cx, *cy); - pg.circle(px, py, r * k, fill.map(blend), stroke.map(|(c, w)| (blend(c), (w * k).max(0.1)))); - } - Prim::Text { x, y, text, size, color, bold, align, valign, rotation } => { - let font = if *bold { Font::HelveticaBold } else { Font::Helvetica }; - let size = size * k; - let w = gridcraft_pdf::text_width(font, size, text); - let dx = match align { - H::Left => 0.0, - H::Center => -w / 2.0, - H::Right => -w, - }; - let dy = match valign { - V::Top => size * 0.78, - V::Middle => size * 0.35, - V::Bottom => -size * 0.21, - }; - 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(ax + dx * cs - dy * sn, ay + dx * sn + dy * cs, size, font, blend(*color), text, *rotation); - } - } - } -} - -const PAD_PX: f32 = 2.0; - -/// Word-wraps `text` to `width` points. -fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec { - let mut out = Vec::new(); - for para in text.split('\n') { - let mut line = String::new(); - for word in para.split(' ') { - let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; - if line.is_empty() || gridcraft_pdf::text_width(font, size, &cand) <= width { - line = cand; - } else { - out.push(std::mem::take(&mut line)); - line = word.to_string(); - } - // Break a single over-long word by characters. The cut that fits is found by binary - // search (each try measures one candidate prefix), so an over-long word costs - // O(n log n) measurements instead of re-measuring the whole cut per character for - // every emitted line (`a` × 100k pasted text would be cubic worst case). - while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 { - let chars: Vec = line.chars().collect(); - let (mut lo, mut hi) = (1usize, chars.len()); - while lo < hi { - let mid = lo + (hi - lo).div_ceil(2); - let cut: String = chars[..mid].iter().collect(); - if gridcraft_pdf::text_width(font, size, &cut) <= width { - lo = mid; - } else { - hi = mid - 1; - } - } - if lo == chars.len() { - break; - } - out.push(chars[..lo].iter().collect()); - line = chars[lo..].iter().collect(); - } - if out.len() > 2000 { - return out; - } - } - out.push(line); - } - out -} - -/// Everything needed to draw one page block (a run of rows × a run of columns). -struct Block<'a> { - wb: &'a Workbook, - si: usize, - sh: &'a Sheet, - rows: (u32, u32), - cols: (u32, u32), - /// Page x of each column's left edge, plus the right edge of the last. - xs: Vec, - ys: Vec, - k: f32, - scale: f32, -} - -impl Block<'_> { - fn x(&self, col: u32) -> f32 { - if col >= self.cols.0 && col <= self.cols.1 + 1 { - return self.xs.get((col - self.cols.0) as usize).copied().unwrap_or(0.0); - } - let x0 = self.xs.first().copied().unwrap_or(0.0); - x0 + ((self.sh.col_left(col) - self.sh.col_left(self.cols.0)) as f32) * self.k - } - fn y(&self, row: u32) -> f32 { - if row >= self.rows.0 && row <= self.rows.1 + 1 { - return self.ys.get((row - self.rows.0) as usize).copied().unwrap_or(0.0); - } - let y0 = self.ys.first().copied().unwrap_or(0.0); - y0 + ((self.sh.row_top(row) - self.sh.row_top(self.rows.0)) as f32) * self.k - } - fn rect(&self, r: RangeRef) -> (f32, f32, f32, f32) { - let (x0, y0) = (self.x(r.start.col), self.y(r.start.row)); - (x0, y0, self.x(r.end.col.saturating_add(1)) - x0, self.y(r.end.row.saturating_add(1)) - y0) - } - fn range(&self) -> RangeRef { - RangeRef::new(CellRef::new(self.rows.0, self.cols.0), CellRef::new(self.rows.1, self.cols.1)) - } -} - -fn border_dash(style: BorderStyle, k: f32) -> Vec { - let u = k.max(0.2); - match style { - BorderStyle::Dashed | BorderStyle::MediumDashed => vec![4.0 * u, 2.0 * u], - BorderStyle::Dotted => vec![1.0 * u, 1.0 * u], - BorderStyle::Hair => vec![0.75 * u, 0.75 * u], - BorderStyle::DashDot | BorderStyle::MediumDashDot | BorderStyle::SlantDashDot => vec![4.0 * u, 1.5 * u, 1.0 * u, 1.5 * u], - BorderStyle::DashDotDot | BorderStyle::MediumDashDotDot => vec![4.0 * u, 1.5 * u, 1.0 * u, 1.5 * u, 1.0 * u, 1.5 * u], - _ => vec![], - } -} - -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 color = rgb_of(b.color, wb).unwrap_or([0, 0, 0]); - if b.style == BorderStyle::Double { - let off = 1.0 * k; - let lw = (0.75 * k).max(0.2); - let (dx, dy) = if (y0 - y1).abs() < 0.001 { (0.0, off) } else { (off, 0.0) }; - pg.stroke_line(x0 - dx, y0 - dy, x1 - dx, y1 - dy, lw, color); - pg.stroke_line(x0 + dx, y0 + dy, x1 + dx, y1 + dy, lw, color); - return; - } - let lw = (b.style.width() * k).clamp(0.25, 6.0); - pg.stroke_line_dashed(x0, y0, x1, y1, lw, color, &border_dash(b.style, k)); -} - -fn font_for(style: &Style) -> Font { - Font::styled(Font::family_for(&style.font.name), style.font.bold, style.font.italic) -} - -/// Draws the cells of a block: fills, gridlines, data bars, borders and text. -fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option) { - let (bx, by) = (b.xs.first().copied().unwrap_or(0.0), b.ys.first().copied().unwrap_or(0.0)); - let (bw, bh) = (b.xs.last().copied().unwrap_or(bx) - bx, b.ys.last().copied().unwrap_or(by) - by); - if bw <= 0.0 || bh <= 0.0 { - return; - } - let block = b.range(); - let sh = b.sh; - let merges: Vec = sh.merges.iter().filter(|m| m.intersects(&block)).copied().collect(); - let covered = |c: CellRef| merges.iter().any(|m| m.contains(c)); - // Cells worth visiting. - let cells: Vec = if block.count() <= DENSE_BLOCK { - block.iter().filter(|c| !covered(*c)).collect() - } else { - let mut set: Vec = sh.cells.iter_range(block).map(|(c, _)| c).collect(); - set.extend(sh.spill.range(block.start..=block.end).map(|(c, _)| *c).filter(|c| block.contains(*c))); - set.sort_unstable(); - set.dedup(); - set.retain(|c| !covered(*c)); - set - }; - pg.save(); - pg.clip_rect(bx, by, bw, bh); - if gridlines { - let grey = [0xB0, 0xB0, 0xB0]; - let lw = (0.35 * b.scale).clamp(0.1, 0.5); - for x in &b.xs { - pg.stroke_line(*x, by, *x, by + bh, lw, grey); - } - for y in &b.ys { - pg.stroke_line(bx, *y, bx + bw, *y, lw, grey); - } - } - // Cheap pre-filter: blank, unstyled cells outside tables and conditional formats draw nothing. - let skip = |c: &CellRef| { - sh.cond_formats.is_empty() - && sh.value_ref(*c).is_none_or(|v| v.is_empty()) - && sh.style_id(*c) == StyleId::DEFAULT - && (sh.tables.is_empty() || sh.table_at(*c).is_none()) - }; - let looks: Vec<(CellRef, Look)> = cells - .iter() - .filter(|c| !skip(c)) - .chain(merges.iter().map(|m| &m.start)) - .map(|c| (*c, cell_look(b.wb, b.si, sh, *c, cf))) - .filter(|(c, l)| { - l.fill.is_some() - || l.cf.is_some() - || merges.iter().any(|m| m.start == *c) - || !sh.value(*c).is_empty() - || l.style.borders != Borders::default() - }) - .collect(); - let rect_of = |c: CellRef| -> (f32, f32, f32, f32, bool) { - match merges.iter().find(|m| m.start == c) { - Some(m) => { - let (x, y, w, h) = b.rect(*m); - (x, y, w, h, true) - } - None => { - let (x, y, w, h) = b.rect(RangeRef::cell(c)); - (x, y, w, h, false) - } - } - }; - // Fills (merged blocks hide the gridlines inside them). - for (c, l) in &looks { - let (x, y, w, h, merged) = rect_of(*c); - if w <= 0.0 || h <= 0.0 { - continue; - } - if let Some(f) = l.fill { - pg.fill_rect(x, y, w, h, f); - } else if merged && gridlines { - pg.fill_rect(x, y, w, h, [255, 255, 255]); - } - if merged && gridlines { - pg.rect_stroke(x, y, w, h, (0.35 * b.scale).clamp(0.1, 0.5), [0xB0, 0xB0, 0xB0]); - } - if let Some((frac, rgb, _)) = l.cf.as_ref().and_then(|cf| cf.bar) { - let pad = 1.5 * b.k; - let bwid = ((w - 2.0 * pad) * frac.clamp(0.0, 1.0)).max(0.0); - pg.fill_rect(x + pad, y + pad, bwid, (h - 2.0 * pad).max(0.0), rgb); - } - } - // Borders. - for (c, l) in &looks { - let (x, y, w, h, merged) = rect_of(*c); - if w <= 0.0 || h <= 0.0 { - continue; - } - let bd = &l.style.borders; - let (right, bottom) = match merges.iter().find(|m| m.start == *c).filter(|_| merged) { - Some(m) => ( - b.wb.styles.get(sh.style_id(CellRef::new(m.start.row, m.end.col))).borders.right, - b.wb.styles.get(sh.style_id(CellRef::new(m.end.row, m.start.col))).borders.bottom, - ), - None => (bd.right, bd.bottom), - }; - 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 { - let (x, y, w, h, merged) = rect_of(*c); - if w <= 0.0 || h <= 0.0 { - continue; - } - 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, *c, l, (x, y, w, h), merged, &merges, (bx, bx + bw)); - } - } - pg.restore(); -} - -struct PendingPdfLink { - page: usize, - sheet: usize, - rect: (f32, f32, f32, f32), - target: String, -} - -struct PrintedBlock { - page: usize, - sheet: usize, - rows: (u32, u32), - cols: (u32, u32), - x: f32, - y: f32, - k: f32, -} - -/// PDF annotations are not affected by graphics clipping, so intersect each cell with its page -/// block before adding a clickable rectangle. A repeated print-title cell may appear on several -/// pages and receives one annotation on each of them. -fn draw_block_links(pg: &mut Page, b: &Block, page: usize, pending: &mut Vec) { - let range = b.range(); - let (Some(&bx), Some(&by), Some(&right), Some(&bottom)) = (b.xs.first(), b.ys.first(), b.xs.last(), b.ys.last()) else { return }; - for (cell, link) in b.sh.hyperlinks.range(range.start..=range.end) { - if !range.contains(*cell) { - continue; - } - let cell_range = b.sh.merges.iter().find(|m| m.start == *cell).copied().unwrap_or(RangeRef::cell(*cell)); - let (x, y, w, h) = b.rect(cell_range); - let (x0, y0, x1, y1) = (x.max(bx), y.max(by), (x + w).min(right), (y + h).min(bottom)); - if x1 <= x0 || y1 <= y0 { - continue; - } - let rect = (x0, y0, x1 - x0, y1 - y0); - match crate::links::classify(&link.target) { - crate::links::LinkKind::External(uri) => pg.link_uri(rect.0, rect.1, rect.2, rect.3, &uri), - crate::links::LinkKind::Internal(target) if !link.external => { - pending.push(PendingPdfLink { page, sheet: b.si, rect, target }); - } - _ => {} - } - } -} - -fn printed_target(wb: &Workbook, blocks: &[PrintedBlock], source_sheet: usize, target: &str) -> Option<(usize, f32, f32)> { - let target = target.trim().trim_start_matches('#').trim_start_matches('='); - let (sheet_name, range) = split_sheet(target); - let sheet = sheet_name.map_or(Some(source_sheet), |name| wb.sheet_index(&name))?; - let cell = RangeRef::parse(&range.replace('$', ""))?.start; - let sh = wb.sheet(sheet)?; - let block = blocks.iter().find(|b| b.sheet == sheet && (b.rows.0..=b.rows.1).contains(&cell.row) && (b.cols.0..=b.cols.1).contains(&cell.col))?; - let x = block.x + (sh.col_left(cell.col) - sh.col_left(block.cols.0)) as f32 * block.k; - let y = block.y + (sh.row_top(cell.row) - sh.row_top(block.rows.0)) as f32 * block.k; - Some((block.page, x, y)) -} - -/// Cell pictures fit the current cell (or merged-cell) rectangle without changing aspect ratio. -fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32, f32), k: f32) { - let (x, y, w, h) = rect; - let pad = 2.0 * k; - let (w, h) = ((w - 2.0 * pad).max(0.0), (h - 2.0 * pad).max(0.0)); - if w <= 0.0 || h <= 0.0 || data.len() > 16 * 1024 * 1024 { - return; - } - let Some((iw, ih)) = crate::io::image_size(data) else { return }; - if iw == 0 || ih == 0 || iw > 8192 || ih > 8192 || u64::from(iw) * u64::from(ih) > 16_000_000 { - return; - } - let Ok(mut reader) = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format() else { return }; - let mut limits = image::Limits::default(); - limits.max_image_width = Some(8192); - limits.max_image_height = Some(8192); - limits.max_alloc = Some(64 * 1024 * 1024); - reader.limits(limits); - let Ok(image) = reader.decode() else { return }; - let image = - 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(x + pad + (w - pw) / 2.0, y + pad + (h - ph) / 2.0, pw, ph, image.as_raw(), image.width(), image.height()); -} - -/// 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)) -} - -/// Unscaled width of the empty Center Across Selection cells right of `c`, which its text is centred over too. -fn center_across_width(styles: &StyleTable, sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> f32 { - let mut ext = 0.0; - let mut col = c.col + 1; - while col < MAX_COLS && ext < 20_000.0 { - let nc = CellRef::new(c.row, col); - if !empty_for_overflow(sh, nc, merges) || styles.get(sh.style_id(nc)).align.h != HAlign::CenterAcross { - break; - } - ext += sh.col_width(col); - col += 1; - } - ext -} - -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; - let v = sh.value(c); - if v.is_empty() || l.cf.as_ref().is_some_and(|cf| cf.hide_value) { - return; - } - if !sh.show_zeros && v.as_f64() == Some(0.0) { - return; - } - let st = &l.style; - let mut size = st.font.size.clamp(1.0, 409.0) * b.scale; - let font = font_for(st); - let pad = PAD_PX * b.k; - let indent = st.align.indent as f32 * 9.0 * b.k; - let avail = (w - 2.0 * pad - indent).max(0.0); - let code = st.num_fmt.as_str(); - let formatted = crate::display::format(&v, code, wb); - let mut color = formatted.color.and_then(gridcraft_numfmt::FormatColor::rgb).or_else(|| rgb_of(st.font.color, wb)).unwrap_or([0, 0, 0]); - if l.cf.as_ref().and_then(|x| x.style.as_ref()).is_some_and(|s| s.font.color != Color::Auto) { - color = rgb_of(st.font.color, wb).unwrap_or(color); - } - let is_num = matches!(v, Value::Number(_)); - let wrap = st.align.wrap; - let mut text = if sh.show_formulas { - crate::display::cell_text(wb, sh, c) - } else if code == "General" - && let Value::Number(n) = v - { - let digit = gridcraft_pdf::text_width(font, size, "0").max(0.1); - let max_chars = (avail / digit).floor().max(1.0) as usize; - gridcraft_numfmt::format_general_fit(n, max_chars.min(11)).unwrap_or_else(|| "#".repeat(max_chars.min(255))) - } else { - // The look's code, which a conditional format can override (#1163). - crate::display::value_text_as(wb, &v, code) - }; - if !wrap { - text = text.replace(['\n', '\r'], " "); - } - if text.is_empty() { - return; - } - let mut tw = gridcraft_pdf::text_width(font, size, &text); - if st.align.shrink && !wrap && tw > avail && tw > 0.0 { - size = (size * avail / tw).max(1.0); - tw = gridcraft_pdf::text_width(font, size, &text); - } - if is_num && !wrap && tw > avail + 0.01 && !sh.show_formulas { - let hash = gridcraft_pdf::text_width(font, size, "#").max(0.1); - text = "#".repeat(((avail / hash).floor().max(1.0) as usize).min(255)); - tw = gridcraft_pdf::text_width(font, size, &text); - } - 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. - let lines: Vec = match (wrap, rotated) { - (true, false) => wrap_lines(&text, font, size, avail.max(size)), - (true, true) if rot.abs() == 90 => wrap_lines(&text, font, size, (h - 2.0 * pad).max(size)), - (true, true) => vec![text.lines().next().unwrap_or_default().to_string()], - (false, _) => vec![text], - }; - let line_h = size * 1.2; - let n = lines.len().max(1) as f32; - let first_baseline = match st.align.v { - VAlign::Top => y + pad + size * 0.8, - VAlign::Center | VAlign::Justify | VAlign::Distributed => y + (h - n * line_h) / 2.0 + line_h * 0.5 + size * 0.33, - VAlign::Bottom => y + h - pad - size * 0.22 - (n - 1.0) * line_h, - }; - // Overflow into empty neighbours (single-line text, not merged). - // Center Across Selection spans the empty Center Across cells to its right; the text stays within that run. - let spans = st.align.h == HAlign::CenterAcross && !wrap && !merged && rot == 0; - let across = if spans { center_across_width(&wb.styles, sh, c, merges) * b.k } else { 0.0 }; - 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; - 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); - pg.save(); - pg.clip_rect(clip_l, y, (clip_r - clip_l).max(0.0), h); - if rotated { - // Rotated text: anchored at the bottom-left (upward) or top-left (downward) of the cell. - // Wrapped lines stack rightward for upward text and leftward for downward text, so - // downward text starts its first line far enough in for the last to end at the left pad. - let deg = (rot as f32).clamp(-90.0, 90.0); - let angle = -deg.to_radians(); - let last = lines.len().saturating_sub(1) as f32; - for (i, t) in lines.iter().enumerate() { - let i = i as f32; - let (ox, oy) = if deg > 0.0 { - (x + pad + size * 0.8 * deg.to_radians().sin() + i * line_h, y + h - pad) - } else { - (x + pad + (last - i) * line_h, y + pad + size * 0.8) - }; - pg.text_rotated(ox, oy, size, font, color, t, angle); - } - pg.restore(); - return; - } - if rot == 255 { - // Vertical stacked text. - let chars: Vec = lines.concat().chars().take(500).collect(); - let mut yy = y + pad + size * 0.8; - for ch in chars { - let s = ch.to_string(); - let cw = gridcraft_pdf::text_width(font, size, &s); - pg.text(x + (w - cw) / 2.0, yy, size, font, color, &s); - yy += line_h; - if yy > y + h + line_h { - break; - } - } - pg.restore(); - return; - } - for (i, line) in lines.iter().enumerate() { - let lw = gridcraft_pdf::text_width(font, size, line); - let lx = match h_align { - HAlign::Right => x + w - pad - lw - indent, - HAlign::Center => x + (w + across - lw) / 2.0, - _ => x + pad + indent, - }; - let ly = first_baseline + i as f32 * line_h; - if ly - size > y + h || ly < y - line_h { - continue; - } - pg.text(lx, ly, size, font, color, line); - if st.font.underline != Underline::None { - let uw = (size * 0.06).max(0.2); - pg.stroke_line(lx, ly + size * 0.12, lx + lw, ly + size * 0.12, uw, color); - if matches!(st.font.underline, Underline::Double | Underline::DoubleAccounting) { - pg.stroke_line(lx, ly + size * 0.24, lx + lw, ly + size * 0.24, uw, color); - } - } - if st.font.strike { - pg.stroke_line(lx, ly - size * 0.3, lx + lw, ly - size * 0.3, (size * 0.06).max(0.2), color); - } - } - pg.restore(); -} - -/// Sheet pixel rectangle of an anchor. -fn anchor_rect(sh: &Sheet, a: &Anchor) -> (f64, f64, f64, f64) { - (sh.col_left(a.cell.col) + a.dx as f64, sh.row_top(a.cell.row) + a.dy as f64, a.width.max(0.0) as f64, a.height.max(0.0) as f64) -} - -/// Decodes an embedded picture to RGBA, downscaled to at most `max_w`×`max_h` pixels. -fn decode_image(data: &[u8], max_w: u32, max_h: u32) -> Option<(Vec, u32, u32)> { - let (w, h) = crate::io::image_size(data)?; - if w == 0 || h == 0 || w as u64 * h as u64 > 60_000_000 { - return None; - } - let img = image::load_from_memory(data).ok()?; - let img = if img.width() > max_w.max(1) || img.height() > max_h.max(1) { img.thumbnail(max_w.max(1), max_h.max(1)) } else { img }; - let rgba = img.to_rgba8(); - let (pw, ph) = (rgba.width(), rgba.height()); - Some((rgba.into_raw(), pw, ph)) -} - -/// 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). -fn draw_objects( - pg: &mut Page, - wb: &Workbook, - si: usize, - sh: &Sheet, - b: &Block, - pics: &mut std::collections::HashMap<(usize, usize), Option<(Vec, 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))); - let (bx, by) = (b.xs.first().copied().unwrap_or(0.0), b.ys.first().copied().unwrap_or(0.0)); - let (bw, bh) = (b.xs.last().copied().unwrap_or(bx) - bx, b.ys.last().copied().unwrap_or(by) - by); - let visible = |r: (f64, f64, f64, f64)| r.2 > 0.0 && r.3 > 0.0 && r.0 < sx1 && r.0 + r.2 > sx0 && r.1 < sy1 && r.1 + r.3 > sy0; - let place = |r: (f64, f64, f64, f64)| (bx + ((r.0 - sx0) as f32) * b.k, by + ((r.1 - sy0) as f32) * b.k, r.2 as f32 * b.k, r.3 as f32 * b.k); - pg.save(); - pg.clip_rect(bx, by, bw, bh); - draw_sparklines(pg, wb, si, sh, b, block); - for (imi, im) in sh.images.iter().enumerate() { - let r = anchor_rect(sh, &im.anchor); - if !visible(r) { - continue; - } - let (x, y, w, h) = place(r); - // About 150 dpi is plenty for print. - let (mw, mh) = (((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32); - let decoded = pics.entry((si, imi)).or_insert_with(|| decode_image(&im.data, mw, mh)); - if let Some((px, pw, ph)) = decoded.as_ref() { - pg.image_rgba(x, y, w, h, px, *pw, *ph); - } - } - for shp in &sh.shapes { - let r = anchor_rect(sh, &shp.anchor); - if !visible(r) { - continue; - } - let (x, y, w, h) = place(r); - draw_shape(pg, wb, shp, b, (x, y, w, h)); - } - for ch in &sh.charts { - let r = anchor_rect(sh, &ch.anchor); - if !visible(r) { - continue; - } - let (x, y, _, _) = place(r); - let data = gridcraft_chart::resolve(wb, si, ch); - let prims = gridcraft_chart::render(ch, &data, r.2 as f32, r.3 as f32, &PdfMeasure); - draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k }); - } - pg.restore(); -} - -fn draw_sparklines(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block, block: RangeRef) { - for sp in &sh.sparklines { - if !block.contains(sp.cell) { - continue; - } - let vals: Vec> = match gridcraft_calc::evaluate(wb, si, sp.cell, &sp.source) { - Value::Array(a) => a.iter().take(10_000).map(Value::as_f64).collect(), - v => vec![v.as_f64()], - }; - let color = rgb_of(sp.color, wb).unwrap_or([0x15, 0x60, 0x82]); - let (x, y, w, h) = b.rect(RangeRef::cell(sp.cell)); - let (wpx, hpx) = (w / b.k, h / b.k); - let prims = gridcraft_chart::render_sparkline(sp.kind, &vals, [color[0], color[1], color[2], 255], sp.markers, wpx, hpx); - draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k }); - } -} - -fn draw_shape(pg: &mut Page, wb: &Workbook, shp: &gridcraft_model::Shape, b: &Block, (x, y, w, h): (f32, f32, f32, f32)) { - let fill = rgb_of(shp.fill, wb); - let line = rgb_of(shp.line, wb).filter(|_| !shp.format.no_line).map(|c| (c, (0.75 * b.k).max(0.25))); - match shp.kind { - ShapeKind::Ellipse => { - let pts: Vec<[f32; 2]> = (0..72) - .map(|i| { - let a = i as f32 / 72.0 * std::f32::consts::TAU; - [x + w / 2.0 + w / 2.0 * a.cos(), y + h / 2.0 + h / 2.0 * a.sin()] - }) - .collect(); - pg.polygon(&pts, fill, line); - } - ShapeKind::Triangle => pg.polygon(&[[x + w / 2.0, y], [x + w, y + h], [x, y + h]], fill, line), - ShapeKind::Line | ShapeKind::Arrow => { - let c = line.map(|l| l.0).or(fill).unwrap_or([0, 0, 0]); - pg.stroke_line(x, y, x + w, y + h, (1.0 * b.k).max(0.3), c); - if shp.kind == ShapeKind::Arrow { - let ang = h.atan2(w); - let al = 8.0 * b.k; - let p = |da: f32| [x + w - al * (ang + da).cos(), y + h - al * (ang + da).sin()]; - pg.polygon(&[[x + w, y + h], p(0.4), p(-0.4)], Some(c), None); - } - } - _ => { - pg.polygon(&[[x, y], [x + w, y], [x + w, y + h], [x, y + h]], fill, line); - } - } - if shp.text.is_empty() { - return; - } - use gridcraft_model::{ShapeTextAlign, ShapeTextAnchor}; - let fm = &shp.format; - let text_box = shp.kind == ShapeKind::TextBox; - let size = fm.font_size.unwrap_or(11.0).clamp(1.0, 4000.0) * b.scale; - let font = Font::styled(Font::family_for(fm.font.as_deref().unwrap_or("")), fm.bold, fm.italic); - let color = fm.text_color.and_then(|c| rgb_of(c, wb)).unwrap_or([0, 0, 0]); - let align = fm.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center }); - let anchor = fm.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle }); - let lines = wrap_lines(&shp.text, font, size, (w - 4.0 * b.k).max(size)); - let block_h = lines.len().min(500) as f32 * size * 1.2; - let mut yy = match anchor { - ShapeTextAnchor::Top => y + 2.0 * b.k, - ShapeTextAnchor::Middle => y + (h - block_h) / 2.0, - ShapeTextAnchor::Bottom => y + h - 2.0 * b.k - block_h, - } + size * 0.8; - for l in lines.iter().take(500) { - let lw = gridcraft_pdf::text_width(font, size, l); - let lx = match align { - ShapeTextAlign::Left => x + 2.0 * b.k, - ShapeTextAlign::Center => x + (w - lw) / 2.0, - ShapeTextAlign::Right => x + w - 2.0 * b.k - lw, - }; - pg.text(lx, yy, size, font, color, l); - yy += size * 1.2; - } -} - -fn draw_headings(pg: &mut Page, b: &Block, col_heads: bool, row_heads: bool, at: (f32, f32), size: (f32, f32)) { - let fill = [0xF2, 0xF2, 0xF2]; - let line = [0xA0, 0xA0, 0xA0]; - let fsize = 9.0 * b.scale.max(0.1); - let lw = (0.35 * b.scale).clamp(0.1, 0.5); - if col_heads { - let y = at.1; - for col in b.cols.0..=b.cols.1 { - let (x0, x1) = (b.x(col), b.x(col + 1)); - if x1 - x0 <= 0.0 { - continue; - } - pg.fill_rect(x0, y, x1 - x0, size.1, fill); - pg.rect_stroke(x0, y, x1 - x0, size.1, lw, line); - let t = gridcraft_core::col_to_letters(col); - let tw = gridcraft_pdf::text_width(Font::Helvetica, fsize, &t); - pg.text(x0 + (x1 - x0 - tw) / 2.0, y + size.1 / 2.0 + fsize * 0.35, fsize, Font::Helvetica, [0x40, 0x40, 0x40], &t); - } - } - if row_heads { - let x = at.0; - for row in b.rows.0..=b.rows.1 { - let (y0, y1) = (b.y(row), b.y(row + 1)); - if y1 - y0 <= 0.0 { - continue; - } - pg.fill_rect(x, y0, size.0, y1 - y0, fill); - pg.rect_stroke(x, y0, size.0, y1 - y0, lw, line); - let t = (row + 1).to_string(); - let tw = gridcraft_pdf::text_width(Font::Helvetica, fsize, &t); - pg.text(x + (size.0 - tw) / 2.0, y0 + (y1 - y0) / 2.0 + fsize * 0.35, fsize, Font::Helvetica, [0x40, 0x40, 0x40], &t); - } - } -} - -fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, which: gridcraft_model::HfPage, cx: &HfContext) { - let [_, _, _, _, hm, fm] = lay.margins; - let (l, r) = lay.hf_sides(); - let text_scale = lay.hf_text_scale(); - let pw = lay.page_w; - let ph = lay.page_h; - for (spec, header) in [(lay.settings.hf(which, false), true), (lay.settings.hf(which, true), false)] { - if spec.is_empty() { - continue; - } - let secs = expand_header_footer(spec, cx); - for (i, sec) in secs.iter().enumerate() { - if sec.text.trim().is_empty() { - continue; - } - let size = sec.size.unwrap_or(10.0).clamp(1.0, 72.0) * text_scale; - let font = Font::styled(gridcraft_pdf::Family::Sans, sec.bold, sec.italic); - let lines: Vec<&str> = sec.text.split('\n').take(20).collect(); - let n = lines.len() as f32; - for (j, line) in lines.iter().enumerate() { - let lw = gridcraft_pdf::text_width(font, size, line); - let x = match i { - 0 => l, - 1 => (pw - lw) / 2.0, - _ => pw - r - lw, - }; - let y = if header { hm + size * 0.8 + j as f32 * size * 1.2 } else { ph - fm - size * 0.22 - (n - 1.0 - j as f32) * size * 1.2 }; - pg.text(x, y, size, font, [0, 0, 0], line); - } - } - } -} - -/// Builds a block for rows × cols positioned at (x, y) on the page. -fn make_block<'a>(wb: &'a Workbook, si: usize, sh: &'a Sheet, rows: (u32, u32), cols: (u32, u32), x: f32, y: f32, lay: &SheetLayout) -> Block<'a> { - let k = lay.k(); - let mut xs = Vec::with_capacity((cols.1 - cols.0 + 2) as usize); - let mut acc = x; - xs.push(acc); - for c in cols.0..=cols.1 { - acc += sh.col_width(c) * k; - xs.push(acc); - } - let mut ys = Vec::with_capacity((rows.1 - rows.0 + 2) as usize); - let mut acc = y; - ys.push(acc); - for r in rows.0..=rows.1 { - acc += sh.row_height(r) * k; - ys.push(acc); - } - Block { wb, si, sh, rows, cols, xs, ys, k, scale: lay.scale } -} - -/// Segments of a page along one axis: repeated titles (before the band) then the band. -fn segments(titles: Option<(u32, u32)>, band: (u32, u32)) -> Vec<(u32, u32)> { - let mut v = Vec::new(); - if let Some((a, b)) = titles - && a < band.0 - { - v.push((a, b.min(band.0 - 1))); - } - v.push(band); - v -} - -fn seg_size(sh: &Sheet, seg: (u32, u32), rows: bool) -> f32 { - if rows { (seg.0..=seg.1).map(|r| sh.row_height(r)).sum() } else { (seg.0..=seg.1).map(|c| sh.col_width(c)).sum() } -} /// Renders laid-out sheets to a PDF. pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str) -> (Vec, usize) { diff --git a/crates/engine/src/cmd/print/draw.rs b/crates/engine/src/cmd/print/draw.rs new file mode 100644 index 000000000..4f67f1449 --- /dev/null +++ b/crates/engine/src/cmd/print/draw.rs @@ -0,0 +1,467 @@ +use super::layout::{DENSE_BLOCK, SheetLayout}; +use super::objects::draw_cell_picture; +use super::text::draw_text; +use super::*; + +// ---------------------------------------------------------------- drawing + +pub(super) fn rgb_of(c: Color, wb: &Workbook) -> Option { + c.resolve(&wb.theme) +} + +fn blend(fg: [u8; 4]) -> Rgb { + let a = fg[3] as u16; + let mix = |c: u8| ((c as u16 * a + 255 * (255 - a)) / 255) as u8; + [mix(fg[0]), mix(fg[1]), mix(fg[2])] +} + +/// The effective look of a cell: own style, table style, conditional formats. +pub(super) struct Look { + pub(super) style: Style, + fill: Option, + pub(super) cf: Option, +} + +fn cell_look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut Option) -> Look { + let mut style = match sh.table_at(c) { + Some(t) => crate::tables::table_cell_style(wb, si, t, c), + None => wb.styles.get(sh.style_id(c)).clone(), + }; + let cfl = cf.as_mut().and_then(|cache| cache.look(wb, si, c)); + if let Some(st) = cfl.as_ref().and_then(|l| l.style.as_ref()) { + if st.fill.pattern != PatternType::None { + style.fill = st.fill; + } + if st.font.color != Color::Auto { + style.font.color = st.font.color; + } + style.font.bold |= st.font.bold; + style.font.italic |= st.font.italic; + style.font.strike |= st.font.strike; + if st.font.underline != Underline::None { + style.font.underline = st.font.underline; + } + // In a differential style General means "not set" (#1163). + if st.num_fmt.as_str() != "General" { + style.num_fmt = st.num_fmt.clone(); + } + for (dst, src) in [ + (&mut style.borders.left, st.borders.left), + (&mut style.borders.right, st.borders.right), + (&mut style.borders.top, st.borders.top), + (&mut style.borders.bottom, st.borders.bottom), + ] { + if !src.is_none() { + *dst = src; + } + } + } + let mut fill = match style.fill.pattern { + PatternType::None => None, + PatternType::Solid => rgb_of(style.fill.fg, wb), + _ => { + let fg = rgb_of(style.fill.fg, wb).unwrap_or([0, 0, 0]); + let bg = rgb_of(style.fill.bg, wb).unwrap_or([255, 255, 255]); + Some([0, 1, 2].map(|i| ((fg[i] as u16 + bg[i] as u16 * 3) / 4) as u8)) + } + }; + if let Some(rgb) = cfl.as_ref().and_then(|l| l.scale_fill) { + fill = Some(rgb); + } + Look { style, fill, cf: cfl } +} + +/// PDF measurement for chart layout. +pub(super) struct PdfMeasure; + +impl gridcraft_chart::Measure for PdfMeasure { + fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 { + gridcraft_pdf::text_width(if bold { Font::HelveticaBold } else { Font::Helvetica }, size, text) + } +} + +/// Maps chart-space coordinates to the page. +#[derive(Clone, Copy)] +pub(super) struct Xf { + pub(super) ox: f32, + pub(super) oy: f32, + pub(super) k: f32, +} + +impl Xf { + fn p(&self, x: f32, y: f32) -> (f32, f32) { + (self.ox + x * self.k, self.oy + y * self.k) + } + fn pts(&self, pts: &[[f32; 2]]) -> Vec<[f32; 2]> { + pts.iter().map(|q| [self.ox + q[0] * self.k, self.oy + q[1] * self.k]).collect() + } +} + +/// Draws chart primitives onto a page. +pub(super) fn draw_prims(pg: &mut Page, prims: &[gridcraft_chart::Prim], xf: Xf) { + use gridcraft_chart::{HAlign as H, Prim, VAlign as V}; + let k = xf.k; + for p in prims { + match p { + Prim::Rect { x, y, w, h, fill, stroke, .. } => { + let (px, py) = xf.p(*x, *y); + if let Some(f) = fill { + pg.fill_rect(px, py, w * k, h * k, blend(*f)); + } + if let Some((c, lw)) = stroke { + pg.rect_stroke(px, py, w * k, h * k, (lw * k).max(0.1), blend(*c)); + } + } + Prim::Line { pts, color, width, dash } => { + let d = if *dash { vec![3.0 * k, 2.0 * k] } else { vec![] }; + pg.polyline(&xf.pts(pts), (width * k).max(0.1), blend(*color), &d); + } + Prim::Polygon { pts, fill, stroke } => { + pg.polygon(&xf.pts(pts), Some(blend(*fill)), stroke.map(|(c, w)| (blend(c), (w * k).max(0.1)))); + } + Prim::Wedge { cx, cy, r_outer, r_inner, a0, a1, fill, stroke } => { + let span = (a1 - a0).abs().min(std::f32::consts::TAU); + let n = ((span / (std::f32::consts::PI / 36.0)).ceil() as usize).clamp(2, 400); + let at = |a: f32, r: f32| [cx + r * a.sin(), cy - r * a.cos()]; + let mut pts: Vec<[f32; 2]> = (0..=n).map(|i| at(a0 + (a1 - a0) * i as f32 / n as f32, *r_outer)).collect(); + if *r_inner > 0.0 { + pts.extend((0..=n).rev().map(|i| at(a0 + (a1 - a0) * i as f32 / n as f32, *r_inner))); + } else { + pts.push([*cx, *cy]); + } + pg.polygon(&xf.pts(&pts), Some(blend(*fill)), stroke.map(|(c, w)| (blend(c), (w * k).max(0.1)))); + } + Prim::Circle { cx, cy, r, fill, stroke } => { + let (px, py) = xf.p(*cx, *cy); + pg.circle(px, py, r * k, fill.map(blend), stroke.map(|(c, w)| (blend(c), (w * k).max(0.1)))); + } + Prim::Text { x, y, text, size, color, bold, align, valign, rotation } => { + let font = if *bold { Font::HelveticaBold } else { Font::Helvetica }; + let size = size * k; + let w = gridcraft_pdf::text_width(font, size, text); + let dx = match align { + H::Left => 0.0, + H::Center => -w / 2.0, + H::Right => -w, + }; + let dy = match valign { + V::Top => size * 0.78, + V::Middle => size * 0.35, + V::Bottom => -size * 0.21, + }; + 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(ax + dx * cs - dy * sn, ay + dx * sn + dy * cs, size, font, blend(*color), text, *rotation); + } + } + } +} + +/// Everything needed to draw one page block (a run of rows × a run of columns). +pub(super) struct Block<'a> { + pub(super) wb: &'a Workbook, + si: usize, + pub(super) sh: &'a Sheet, + rows: (u32, u32), + cols: (u32, u32), + /// Page x of each column's left edge, plus the right edge of the last. + pub(super) xs: Vec, + pub(super) ys: Vec, + pub(super) k: f32, + pub(super) scale: f32, +} + +impl Block<'_> { + fn x(&self, col: u32) -> f32 { + if col >= self.cols.0 && col <= self.cols.1 + 1 { + return self.xs.get((col - self.cols.0) as usize).copied().unwrap_or(0.0); + } + let x0 = self.xs.first().copied().unwrap_or(0.0); + x0 + ((self.sh.col_left(col) - self.sh.col_left(self.cols.0)) as f32) * self.k + } + fn y(&self, row: u32) -> f32 { + if row >= self.rows.0 && row <= self.rows.1 + 1 { + return self.ys.get((row - self.rows.0) as usize).copied().unwrap_or(0.0); + } + let y0 = self.ys.first().copied().unwrap_or(0.0); + y0 + ((self.sh.row_top(row) - self.sh.row_top(self.rows.0)) as f32) * self.k + } + pub(super) fn rect(&self, r: RangeRef) -> (f32, f32, f32, f32) { + let (x0, y0) = (self.x(r.start.col), self.y(r.start.row)); + (x0, y0, self.x(r.end.col.saturating_add(1)) - x0, self.y(r.end.row.saturating_add(1)) - y0) + } + pub(super) fn range(&self) -> RangeRef { + RangeRef::new(CellRef::new(self.rows.0, self.cols.0), CellRef::new(self.rows.1, self.cols.1)) + } +} + +fn border_dash(style: BorderStyle, k: f32) -> Vec { + let u = k.max(0.2); + match style { + BorderStyle::Dashed | BorderStyle::MediumDashed => vec![4.0 * u, 2.0 * u], + BorderStyle::Dotted => vec![1.0 * u, 1.0 * u], + BorderStyle::Hair => vec![0.75 * u, 0.75 * u], + BorderStyle::DashDot | BorderStyle::MediumDashDot | BorderStyle::SlantDashDot => vec![4.0 * u, 1.5 * u, 1.0 * u, 1.5 * u], + BorderStyle::DashDotDot | BorderStyle::MediumDashDotDot => vec![4.0 * u, 1.5 * u, 1.0 * u, 1.5 * u, 1.0 * u, 1.5 * u], + _ => vec![], + } +} + +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 color = rgb_of(b.color, wb).unwrap_or([0, 0, 0]); + if b.style == BorderStyle::Double { + let off = 1.0 * k; + let lw = (0.75 * k).max(0.2); + let (dx, dy) = if (y0 - y1).abs() < 0.001 { (0.0, off) } else { (off, 0.0) }; + pg.stroke_line(x0 - dx, y0 - dy, x1 - dx, y1 - dy, lw, color); + pg.stroke_line(x0 + dx, y0 + dy, x1 + dx, y1 + dy, lw, color); + return; + } + let lw = (b.style.width() * k).clamp(0.25, 6.0); + 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. +pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option) { + let (bx, by) = (b.xs.first().copied().unwrap_or(0.0), b.ys.first().copied().unwrap_or(0.0)); + let (bw, bh) = (b.xs.last().copied().unwrap_or(bx) - bx, b.ys.last().copied().unwrap_or(by) - by); + if bw <= 0.0 || bh <= 0.0 { + return; + } + let block = b.range(); + let sh = b.sh; + let merges: Vec = sh.merges.iter().filter(|m| m.intersects(&block)).copied().collect(); + let covered = |c: CellRef| merges.iter().any(|m| m.contains(c)); + // Cells worth visiting. + let cells: Vec = if block.count() <= DENSE_BLOCK { + block.iter().filter(|c| !covered(*c)).collect() + } else { + let mut set: Vec = sh.cells.iter_range(block).map(|(c, _)| c).collect(); + set.extend(sh.spill.range(block.start..=block.end).map(|(c, _)| *c).filter(|c| block.contains(*c))); + set.sort_unstable(); + set.dedup(); + set.retain(|c| !covered(*c)); + set + }; + pg.save(); + pg.clip_rect(bx, by, bw, bh); + if gridlines { + let grey = [0xB0, 0xB0, 0xB0]; + let lw = (0.35 * b.scale).clamp(0.1, 0.5); + for x in &b.xs { + pg.stroke_line(*x, by, *x, by + bh, lw, grey); + } + for y in &b.ys { + pg.stroke_line(bx, *y, bx + bw, *y, lw, grey); + } + } + // Cheap pre-filter: blank, unstyled cells outside tables and conditional formats draw nothing. + let skip = |c: &CellRef| { + sh.cond_formats.is_empty() + && sh.value_ref(*c).is_none_or(|v| v.is_empty()) + && sh.style_id(*c) == StyleId::DEFAULT + && (sh.tables.is_empty() || sh.table_at(*c).is_none()) + }; + let looks: Vec<(CellRef, Look)> = cells + .iter() + .filter(|c| !skip(c)) + .chain(merges.iter().map(|m| &m.start)) + .map(|c| (*c, cell_look(b.wb, b.si, sh, *c, cf))) + .filter(|(c, l)| { + l.fill.is_some() + || l.cf.is_some() + || merges.iter().any(|m| m.start == *c) + || !sh.value(*c).is_empty() + || l.style.borders != Borders::default() + }) + .collect(); + let rect_of = |c: CellRef| -> (f32, f32, f32, f32, bool) { + match merges.iter().find(|m| m.start == c) { + Some(m) => { + let (x, y, w, h) = b.rect(*m); + (x, y, w, h, true) + } + None => { + let (x, y, w, h) = b.rect(RangeRef::cell(c)); + (x, y, w, h, false) + } + } + }; + // Fills (merged blocks hide the gridlines inside them). + for (c, l) in &looks { + let (x, y, w, h, merged) = rect_of(*c); + if w <= 0.0 || h <= 0.0 { + continue; + } + if let Some(f) = l.fill { + pg.fill_rect(x, y, w, h, f); + } else if merged && gridlines { + pg.fill_rect(x, y, w, h, [255, 255, 255]); + } + if merged && gridlines { + pg.rect_stroke(x, y, w, h, (0.35 * b.scale).clamp(0.1, 0.5), [0xB0, 0xB0, 0xB0]); + } + if let Some((frac, rgb, _)) = l.cf.as_ref().and_then(|cf| cf.bar) { + let pad = 1.5 * b.k; + let bwid = ((w - 2.0 * pad) * frac.clamp(0.0, 1.0)).max(0.0); + pg.fill_rect(x + pad, y + pad, bwid, (h - 2.0 * pad).max(0.0), rgb); + } + } + // Borders. + for (c, l) in &looks { + let (x, y, w, h, merged) = rect_of(*c); + if w <= 0.0 || h <= 0.0 { + continue; + } + let bd = &l.style.borders; + let (right, bottom) = match merges.iter().find(|m| m.start == *c).filter(|_| merged) { + Some(m) => ( + b.wb.styles.get(sh.style_id(CellRef::new(m.start.row, m.end.col))).borders.right, + b.wb.styles.get(sh.style_id(CellRef::new(m.end.row, m.start.col))).borders.bottom, + ), + None => (bd.right, bd.bottom), + }; + 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 { + let (x, y, w, h, merged) = rect_of(*c); + if w <= 0.0 || h <= 0.0 { + continue; + } + 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, *c, l, (x, y, w, h), merged, &merges, (bx, bx + bw)); + } + } + pg.restore(); +} + +pub(super) struct PendingPdfLink { + pub(super) page: usize, + pub(super) sheet: usize, + pub(super) rect: (f32, f32, f32, f32), + pub(super) target: String, +} + +pub(super) struct PrintedBlock { + pub(super) page: usize, + pub(super) sheet: usize, + pub(super) rows: (u32, u32), + pub(super) cols: (u32, u32), + pub(super) x: f32, + pub(super) y: f32, + pub(super) k: f32, +} + +/// PDF annotations are not affected by graphics clipping, so intersect each cell with its page +/// block before adding a clickable rectangle. A repeated print-title cell may appear on several +/// pages and receives one annotation on each of them. +pub(super) fn draw_block_links(pg: &mut Page, b: &Block, page: usize, pending: &mut Vec) { + let range = b.range(); + let (Some(&bx), Some(&by), Some(&right), Some(&bottom)) = (b.xs.first(), b.ys.first(), b.xs.last(), b.ys.last()) else { return }; + for (cell, link) in b.sh.hyperlinks.range(range.start..=range.end) { + if !range.contains(*cell) { + continue; + } + let cell_range = b.sh.merges.iter().find(|m| m.start == *cell).copied().unwrap_or(RangeRef::cell(*cell)); + let (x, y, w, h) = b.rect(cell_range); + let (x0, y0, x1, y1) = (x.max(bx), y.max(by), (x + w).min(right), (y + h).min(bottom)); + if x1 <= x0 || y1 <= y0 { + continue; + } + let rect = (x0, y0, x1 - x0, y1 - y0); + match crate::links::classify(&link.target) { + crate::links::LinkKind::External(uri) => pg.link_uri(rect.0, rect.1, rect.2, rect.3, &uri), + crate::links::LinkKind::Internal(target) if !link.external => { + pending.push(PendingPdfLink { page, sheet: b.si, rect, target }); + } + _ => {} + } + } +} + +pub(super) fn printed_target(wb: &Workbook, blocks: &[PrintedBlock], source_sheet: usize, target: &str) -> Option<(usize, f32, f32)> { + let target = target.trim().trim_start_matches('#').trim_start_matches('='); + let (sheet_name, range) = split_sheet(target); + let sheet = sheet_name.map_or(Some(source_sheet), |name| wb.sheet_index(&name))?; + let cell = RangeRef::parse(&range.replace('$', ""))?.start; + let sh = wb.sheet(sheet)?; + let block = blocks.iter().find(|b| b.sheet == sheet && (b.rows.0..=b.rows.1).contains(&cell.row) && (b.cols.0..=b.cols.1).contains(&cell.col))?; + let x = block.x + (sh.col_left(cell.col) - sh.col_left(block.cols.0)) as f32 * block.k; + let y = block.y + (sh.row_top(cell.row) - sh.row_top(block.rows.0)) as f32 * block.k; + Some((block.page, x, y)) +} + +pub(super) fn draw_headings(pg: &mut Page, b: &Block, col_heads: bool, row_heads: bool, at: (f32, f32), size: (f32, f32)) { + let fill = [0xF2, 0xF2, 0xF2]; + let line = [0xA0, 0xA0, 0xA0]; + let fsize = 9.0 * b.scale.max(0.1); + let lw = (0.35 * b.scale).clamp(0.1, 0.5); + if col_heads { + let y = at.1; + for col in b.cols.0..=b.cols.1 { + let (x0, x1) = (b.x(col), b.x(col + 1)); + if x1 - x0 <= 0.0 { + continue; + } + pg.fill_rect(x0, y, x1 - x0, size.1, fill); + pg.rect_stroke(x0, y, x1 - x0, size.1, lw, line); + let t = gridcraft_core::col_to_letters(col); + let tw = gridcraft_pdf::text_width(Font::Helvetica, fsize, &t); + pg.text(x0 + (x1 - x0 - tw) / 2.0, y + size.1 / 2.0 + fsize * 0.35, fsize, Font::Helvetica, [0x40, 0x40, 0x40], &t); + } + } + if row_heads { + let x = at.0; + for row in b.rows.0..=b.rows.1 { + let (y0, y1) = (b.y(row), b.y(row + 1)); + if y1 - y0 <= 0.0 { + continue; + } + pg.fill_rect(x, y0, size.0, y1 - y0, fill); + pg.rect_stroke(x, y0, size.0, y1 - y0, lw, line); + let t = (row + 1).to_string(); + let tw = gridcraft_pdf::text_width(Font::Helvetica, fsize, &t); + pg.text(x + (size.0 - tw) / 2.0, y0 + (y1 - y0) / 2.0 + fsize * 0.35, fsize, Font::Helvetica, [0x40, 0x40, 0x40], &t); + } + } +} + +/// Builds a block for rows × cols positioned at (x, y) on the page. +pub(super) fn make_block<'a>( + wb: &'a Workbook, + si: usize, + sh: &'a Sheet, + rows: (u32, u32), + cols: (u32, u32), + x: f32, + y: f32, + lay: &SheetLayout, +) -> Block<'a> { + let k = lay.k(); + let mut xs = Vec::with_capacity((cols.1 - cols.0 + 2) as usize); + let mut acc = x; + xs.push(acc); + for c in cols.0..=cols.1 { + acc += sh.col_width(c) * k; + xs.push(acc); + } + let mut ys = Vec::with_capacity((rows.1 - rows.0 + 2) as usize); + let mut acc = y; + ys.push(acc); + for r in rows.0..=rows.1 { + acc += sh.row_height(r) * k; + ys.push(acc); + } + Block { wb, si, sh, rows, cols, xs, ys, k, scale: lay.scale } +} diff --git a/crates/engine/src/cmd/print/header_footer.rs b/crates/engine/src/cmd/print/header_footer.rs new file mode 100644 index 000000000..769e3b3ad --- /dev/null +++ b/crates/engine/src/cmd/print/header_footer.rs @@ -0,0 +1,261 @@ +use super::layout::SheetLayout; +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; + +// ---------------------------------------------------------------- header / footer + +/// Values for header/footer codes. +#[derive(Clone, Debug, Default)] +pub struct HfContext { + pub page: usize, + pub pages: usize, + pub sheet: String, + pub file: String, + pub path: String, + pub date: String, + pub time: String, +} + +/// Today's date and the current time as headers and footers show them (`&D`, `&T`). +pub fn hf_date_time(wb: &Workbook) -> (String, String) { + let now = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial()); + let date = crate::display::format(&Value::Number(now.floor()), "m/d/yyyy", wb).text; + let time = crate::display::format(&Value::Number(now), "h:mm AM/PM", wb).text; + (date, time) +} + +/// Header/footer codes and the field names the Page Layout view shows for them while editing. +pub const HF_FIELDS: [(char, &str); 8] = [ + ('P', "&[Page]"), + ('N', "&[Pages]"), + ('D', "&[Date]"), + ('T', "&[Time]"), + ('Z', "&[Path]"), + ('F', "&[File]"), + ('A', "&[Tab]"), + ('G', "&[Picture]"), +]; + +/// Splits a header/footer string into its raw left, centre and right sections (codes kept). +/// Text before the first `&L`/`&C`/`&R` belongs to the centre, as when printing. +pub fn split_header_footer(spec: &str) -> [String; 3] { + let mut out: [String; 3] = Default::default(); + let mut cur = 1usize; + let chars: Vec = spec.chars().take(10_000).collect(); + let mut i = 0; + while let Some(&c) = chars.get(i) { + let next = chars.get(i + 1).copied(); + let sec = cur.min(2); + match (c, next) { + ('&', Some('L' | 'l')) => cur = 0, + ('&', Some('C' | 'c')) => cur = 1, + ('&', Some('R' | 'r')) => cur = 2, + ('&', Some('"')) => { + // &"Font,Style" is kept whole. + let end = chars[i + 2..].iter().position(|ch| *ch == '"').map_or(chars.len(), |p| i + 3 + p); + out[sec].extend(&chars[i..end.min(chars.len())]); + i = end; + continue; + } + ('&', Some(n)) => { + out[sec].push('&'); + out[sec].push(n); + } + ('&', None) => {} + (ch, _) => { + out[sec].push(ch); + i += 1; + continue; + } + } + i += 2; + } + out +} + +/// Joins raw left, centre and right sections into one header/footer string. +pub fn join_header_footer(sections: &[String; 3]) -> String { + let mut s = String::new(); + for (code, text) in ["&L", "&C", "&R"].iter().zip(sections) { + if !text.is_empty() { + s.push_str(code); + s.push_str(text); + } + } + s +} + +/// A raw section as the user edits it: `&P` shows as `&[Page]`, `&N` as `&[Pages]`, and so on. +pub fn codes_to_fields(raw: &str) -> String { + let mut out = String::new(); + let mut it = raw.chars().peekable(); + while let Some(c) = it.next() { + if c != '&' { + out.push(c); + continue; + } + // A dangling `&` at the end is dropped (printing ignores it too). + if let Some(n) = it.next() { + match HF_FIELDS.iter().find(|(code, _)| *code == n.to_ascii_uppercase()) { + Some((_, field)) => out.push_str(field), + None => { + out.push('&'); + out.push(n); + } + } + } + } + out +} + +/// The inverse of [`codes_to_fields`]: field names become codes again; a dangling `&` at the end +/// becomes a literal ampersand so it can't swallow the next section's code. +pub fn fields_to_codes(text: &str) -> String { + let chars: Vec = text.chars().take(10_000).collect(); + let mut out = String::new(); + let mut i = 0; + while let Some(&c) = chars.get(i) { + if c != '&' { + out.push(c); + i += 1; + continue; + } + if chars.get(i + 1) == Some(&'&') { + out.push_str("&&"); + i += 2; + continue; + } + let rest: String = chars[i..].iter().take(12).collect(); + if let Some((code, field)) = HF_FIELDS.iter().find(|(_, f)| rest.starts_with(f)) { + out.push('&'); + out.push(*code); + i += field.chars().count(); + continue; + } + if i + 1 == chars.len() { + out.push_str("&&"); + } else { + out.push('&'); + } + i += 1; + } + out +} + +/// One header/footer section: its text and whether `&B` made it bold. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct HfSection { + pub text: String, + pub bold: bool, + pub italic: bool, + pub size: Option, +} + +/// Expands a header/footer string into its left, centre and right sections. +pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] { + let mut out: [HfSection; 3] = Default::default(); + let mut cur = 1usize; + let chars: Vec = spec.chars().take(10_000).collect(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if c != '&' { + if let Some(s) = out.get_mut(cur) { + s.text.push(c); + } + i += 1; + continue; + } + let Some(&code) = chars.get(i + 1) else { break }; + i += 2; + let sec = &mut out[cur.min(2)]; + match code { + 'L' | 'l' => cur = 0, + 'C' | 'c' => cur = 1, + 'R' | 'r' => cur = 2, + 'P' | 'p' => { + // &P, &P+n, &P-n + let mut n = cx.page as i64; + if let Some(&sign) = chars.get(i) + && (sign == '+' || sign == '-') + { + let digits: String = chars[i + 1..].iter().take_while(|d| d.is_ascii_digit()).take(6).collect(); + if let Ok(d) = digits.parse::() { + n = if sign == '+' { n + d } else { n - d }; + i += 1 + digits.len(); + } + } + sec.text.push_str(&n.to_string()); + } + 'N' | 'n' => sec.text.push_str(&cx.pages.to_string()), + 'D' | 'd' => sec.text.push_str(&cx.date), + 'T' | 't' => sec.text.push_str(&cx.time), + 'A' | 'a' => sec.text.push_str(&cx.sheet), + 'F' | 'f' => sec.text.push_str(&cx.file), + 'Z' | 'z' => sec.text.push_str(&cx.path), + '&' => sec.text.push('&'), + 'B' | 'b' => sec.bold = !sec.bold, + 'I' | 'i' => sec.italic = !sec.italic, + '"' => { + // &"Font,Style": skip the font name; honour Bold/Italic in the style. + let rest: String = chars[i..].iter().take_while(|c| **c != '"').collect(); + let lower = rest.to_ascii_lowercase(); + if lower.contains("bold") { + sec.bold = true; + } + if lower.contains("italic") || lower.contains("oblique") { + sec.italic = true; + } + i += rest.chars().count() + 1; + } + d if d.is_ascii_digit() => { + let digits: String = std::iter::once(d).chain(chars[i..].iter().copied().take_while(char::is_ascii_digit)).take(3).collect(); + i += digits.len() - 1; + if let Ok(v) = digits.parse::() { + sec.size = Some(v.clamp(1.0, 409.0)); + } + } + // Underline, strike, super/subscript, colour, picture: not rendered. + 'K' | 'k' => { + i = (i + 6).min(chars.len()); + } + _ => {} + } + } + out +} + +pub(super) fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, which: gridcraft_model::HfPage, cx: &HfContext) { + let [_, _, _, _, hm, fm] = lay.margins; + let (l, r) = lay.hf_sides(); + let text_scale = lay.hf_text_scale(); + let pw = lay.page_w; + let ph = lay.page_h; + for (spec, header) in [(lay.settings.hf(which, false), true), (lay.settings.hf(which, true), false)] { + if spec.is_empty() { + continue; + } + let secs = expand_header_footer(spec, cx); + for (i, sec) in secs.iter().enumerate() { + if sec.text.trim().is_empty() { + continue; + } + let size = sec.size.unwrap_or(10.0).clamp(1.0, 72.0) * text_scale; + let font = Font::styled(gridcraft_pdf::Family::Sans, sec.bold, sec.italic); + let lines: Vec<&str> = sec.text.split('\n').take(20).collect(); + let n = lines.len() as f32; + for (j, line) in lines.iter().enumerate() { + let lw = gridcraft_pdf::text_width(font, size, line); + let x = match i { + 0 => l, + 1 => (pw - lw) / 2.0, + _ => pw - r - lw, + }; + let y = if header { hm + size * 0.8 + j as f32 * size * 1.2 } else { ph - fm - size * 0.22 - (n - 1.0 - j as f32) * size * 1.2 }; + pg.text(x, y, size, font, [0, 0, 0], line); + } + } + } +} diff --git a/crates/engine/src/cmd/print/layout.rs b/crates/engine/src/cmd/print/layout.rs new file mode 100644 index 000000000..55e874de7 --- /dev/null +++ b/crates/engine/src/cmd/print/layout.rs @@ -0,0 +1,364 @@ +use super::header_footer::HF_FIXED_SIDE_MARGIN; +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; + +// ---------------------------------------------------------------- paper + +/// Paper size in points (portrait) for a paper name; unknown names fall back to Letter. +pub fn paper_size(name: &str) -> (f32, f32) { + known_paper(name).unwrap_or((612.0, 792.0)) +} + +/// Paper sizes in points, portrait. `B4`/`B5`/`B6` are the **JIS** sizes Japanese Excel and its +/// e-mail attachments use (257×364, 182×257 and 128×182 mm); the ISO B series stays reachable as +/// `B4 (ISO)`/`B5 (ISO)`/`B6 (ISO)`. The Japanese postcards and envelopes are the JIS ones too. +pub(super) fn known_paper(name: &str) -> Option<(f32, f32)> { + // Spaces, hyphens, underscores and parentheses don't matter: `B5 (JIS)`, `b5-jis` and `b5` + // are one name. + let n = name.trim().to_ascii_lowercase().replace([' ', '-', '_', '(', ')'], ""); + Some(match n.as_str() { + "letter" | "usletter" => (612.0, 792.0), + "legal" | "uslegal" => (612.0, 1008.0), + "a4" => (595.28, 841.89), + "a3" => (841.89, 1190.55), + "a5" => (419.53, 595.28), + "a6" => (297.64, 419.53), + "tabloid" | "11x17" => (792.0, 1224.0), + "ledger" => (1224.0, 792.0), + "executive" => (522.0, 756.0), + "b4" | "b4jis" => (728.50, 1031.81), + "b5" | "b5jis" => (515.91, 728.50), + "b6" | "b6jis" => (362.83, 515.91), + "b4iso" => (708.66, 1000.63), + "b5iso" => (498.90, 708.66), + "b6iso" => (354.33, 498.90), + "japanesepostcard" | "postcard" => (283.46, 419.53), + "japanesedoublepostcard" | "doublepostcard" => (419.53, 566.93), + "japaneseenvelope#3" | "chou#3" => (340.16, 666.14), + // 長形4号 is 90×205 mm; 90×225 mm is 長形40号, a different envelope, so it stays out. + "japaneseenvelope#4" | "chou#4" => (255.12, 581.10), + "japaneseenvelopekaku#2" | "kaku#2" => (680.31, 941.10), + "japaneseenvelopekaku#3" | "kaku#3" => (612.28, 785.20), + _ => return None, + }) +} + +// ---------------------------------------------------------------- layout + +/// The pagination of one sheet. +#[derive(Clone, Debug)] +pub struct SheetLayout { + pub sheet: usize, + pub settings: PrintSettings, + pub page_w: f32, + pub page_h: f32, + /// Left, right, top, bottom, header, footer margins in points. + pub margins: [f32; 6], + pub scale: f32, + pub area: RangeRef, + pub row_bands: Vec<(u32, u32)>, + pub col_bands: Vec<(u32, u32)>, + /// Pages in print order: (row band, column band). + pub pages: Vec<(usize, usize)>, + pub title_rows: Option<(u32, u32)>, + pub title_cols: Option<(u32, u32)>, + /// Heading sizes in sheet pixels (0 when headings are off). + pub heading_w: f32, + pub heading_h: f32, + /// Manual page breaks apply (they don't when the sheet is fitted to pages). + pub manual_breaks: bool, +} + +impl SheetLayout { + pub(super) fn k(&self) -> f32 { + PX_TO_PT * self.scale + } + pub(crate) fn avail(&self) -> (f32, f32) { + let [l, r, t, b, ..] = self.margins; + ((self.page_w - l - r).max(36.0), (self.page_h - t - b).max(36.0)) + } + /// Left and right margins, in points, that header and footer sections line up with: the + /// page's side margins with Align with Page Margins on, otherwise 0.7 inch on each side, at + /// most a quarter of the page width. + pub(crate) fn hf_sides(&self) -> (f32, f32) { + if self.settings.hf_align_with_margins { + (self.margins[0], self.margins[1]) + } else { + let side = HF_FIXED_SIDE_MARGIN.min(self.page_w / 4.0); + (side, side) + } + } + /// Scale of header and footer text: the print scale with Scale with Document on, otherwise 1. + pub(crate) fn hf_text_scale(&self) -> f32 { + if self.settings.hf_scale_with_doc { self.scale } else { 1.0 } + } +} + +/// Options shared by the print commands. +#[derive(Clone, Debug, Default)] +pub struct PrintOptions { + pub all_sheets: bool, + pub range: Option, + pub fit_to_page: Option, +} + +impl PrintOptions { + pub(super) fn from_params(p: &Json, cmd: &str) -> Result { + let all_sheets = match str_param(p, "sheets") { + None | Some("active") => false, + Some("all") | Some("workbook") => true, + Some(other) => return Err(bad(cmd, format!("`sheets` must be active or all, not `{other}`"))), + }; + let range = match str_param(p, "range").map(str::trim).filter(|r| !r.is_empty()) { + Some(r) => Some(RangeRef::parse(split_sheet(r).1).ok_or_else(|| bad(cmd, format!("not a range: `{r}`")))?), + None => None, + }; + Ok(PrintOptions { all_sheets, range, fit_to_page: bool_param(p, "fitToPage") }) + } +} + +/// The range a sheet prints: its print area, else the used range grown to cover drawing +/// objects. `None` when there is nothing to print. +pub fn print_range(sh: &Sheet) -> Option { + if let Some(a) = sh.print.print_area { + return Some(trim_full(sh, a)); + } + let mut r = sh.used_range(); + let mut grow = |a: &Anchor| { + let x = sh.col_left(a.cell.col) + a.dx as f64; + let y = sh.row_top(a.cell.row) + a.dy as f64; + let end = CellRef::new(sh.row_at(y + a.height.max(0.0) as f64), sh.col_at(x + a.width.max(0.0) as f64)); + let ar = RangeRef::new(a.cell, end); + r = Some(r.map_or(ar, |u| u.union(&ar))); + }; + for c in &sh.charts { + grow(&c.anchor); + } + for i in &sh.images { + grow(&i.anchor); + } + for s in &sh.shapes { + grow(&s.anchor); + } + // Printing starts at A1 like Excel when the data starts nearby? Excel prints from the first + // used cell; keep the used range as is. + r +} + +/// Whole rows/columns are trimmed to the used range. +fn trim_full(sh: &Sheet, r: RangeRef) -> RangeRef { + if !(r.is_full_cols() || r.is_full_rows()) { + return r; + } + match sh.used_range() { + Some(u) => r.intersection(&RangeRef::new(CellRef::new(0, 0), u.end)).unwrap_or(RangeRef::cell(r.start)), + None => RangeRef::cell(r.start), + } +} + +/// Splits `start..=end` into bands whose sizes fit `avail(band_start)`; `breaks` force a new +/// band before a line. Stops after `cap` bands. +pub(super) fn bands(start: u32, end: u32, size: impl Fn(u32) -> f32, avail: impl Fn(u32) -> f32, breaks: &[u32], cap: usize) -> Vec<(u32, u32)> { + let mut out = Vec::new(); + let mut cur = start; + let mut used = 0.0f32; + let mut room = avail(start); + let mut line = start; + loop { + let sz = size(line); + let manual = line > cur && breaks.binary_search(&line).is_ok(); + if line > cur && (manual || used + sz > room + 0.01) { + out.push((cur, line - 1)); + if out.len() >= cap { + return out; + } + cur = line; + used = 0.0; + room = avail(line); + } + used += sz; + if line >= end { + break; + } + line += 1; + } + out.push((cur, end)); + out +} + +fn title_span(t: Option<(u32, u32)>, max: u32) -> Option<(u32, u32)> { + let (a, b) = t?; + let (a, b) = (a.min(b), a.max(b)); + if a >= max { + return None; + } + // Repeated titles are capped (Excel needs them to fit on a page anyway). + Some((a, b.min(max - 1).min(a.saturating_add(499)))) +} + +/// Paginates one sheet. +pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_override: Option) -> Option { + let sh = wb.sheet(si)?; + let st = sh.print.clone(); + let (mut pw, mut ph) = paper_size(&st.paper); + if st.orientation == Orientation::Landscape { + std::mem::swap(&mut pw, &mut ph); + } + let inch = |i: usize| st.margins.get(i).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 20.0) * 72.0; + let mut margins = [inch(0), inch(1), inch(2), inch(3), inch(4), inch(5)]; + // Keep at least an inch of printable space. + for (a, b, total) in [(0usize, 1usize, pw), (2, 3, ph)] { + let sum = margins[a] + margins[b]; + if total - sum < 72.0 { + let f = ((total - 72.0).max(0.0)) / sum.max(1.0); + margins[a] *= f; + margins[b] *= f; + } + } + let area = match range_override { + Some(r) => trim_full(sh, r), + None => print_range(sh).unwrap_or(RangeRef::cell(CellRef::new(0, 0))), + }; + let title_rows = title_span(st.title_rows, MAX_ROWS); + let title_cols = title_span(st.title_cols, MAX_COLS); + let digits = format!("{}", area.end.row + 1).len() as f32; + let (heading_w, heading_h) = if st.headings { (10.0 + 7.0 * digits, sh.default_row_height.clamp(10.0, 60.0)) } else { (0.0, 0.0) }; + let fit = match opts.fit_to_page { + Some(true) => Some((1u16, 1u16)), + Some(false) => None, + None => st.fit_to.filter(|(w, h)| *w > 0 || *h > 0), + }; + let mut lay = SheetLayout { + sheet: si, + settings: st.clone(), + page_w: pw, + page_h: ph, + margins, + scale: (st.scale.clamp(10, 400) as f32) / 100.0, + area, + row_bands: vec![], + col_bands: vec![], + pages: vec![], + title_rows, + title_cols, + heading_w, + heading_h, + manual_breaks: fit.is_none(), + }; + let paginate = |lay: &mut SheetLayout, manual: bool| { + lay.manual_breaks = manual; + (lay.row_bands, lay.col_bands) = split_bands(sh, lay, MAX_PAGES); + lay.pages.clear(); + 'outer: for ci in 0..lay.col_bands.len() { + for ri in 0..lay.row_bands.len() { + if lay.pages.len() >= MAX_PAGES { + break 'outer; + } + lay.pages.push((ri, ci)); + } + } + }; + match fit { + Some((fw, fh)) => { + let total_w: f32 = (area.start.col..=area.end.col).map(|c| sh.col_width(c)).sum::() + heading_w; + let total_h: f32 = if area.height() > 200_000 { + f32::INFINITY + } else { + (area.start.row..=area.end.row).map(|r| sh.row_height(r)).sum::() + heading_h + }; + let (aw, ah) = lay.avail(); + let sw = if fw > 0 { aw * fw as f32 / (total_w * PX_TO_PT).max(1.0) } else { f32::INFINITY }; + let sh_ = if fh > 0 { ah * fh as f32 / (total_h * PX_TO_PT).max(1.0) } else { f32::INFINITY }; + lay.scale = sw.min(sh_).min(1.0).clamp(0.1, 1.0); + for _ in 0..40 { + paginate(&mut lay, false); + let ok_w = fw == 0 || lay.col_bands.len() <= fw as usize; + let ok_h = fh == 0 || lay.row_bands.len() <= fh as usize; + if (ok_w && ok_h) || lay.scale <= 0.1 { + break; + } + lay.scale = (lay.scale * 0.97).max(0.1); + } + } + None => paginate(&mut lay, true), + } + Some(lay) +} + +/// 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) -> (Vec<(u32, u32)>, Vec<(u32, u32)>) { + let k = lay.k(); + let (aw, ah) = lay.avail(); + let (aw, ah) = (aw / k, ah / k); + let st = &lay.settings; + let mut rb: Vec = if lay.manual_breaks { st.row_breaks.clone() } else { vec![] }; + let mut cb: Vec = if lay.manual_breaks { st.col_breaks.clone() } else { vec![] }; + rb.sort_unstable(); + cb.sort_unstable(); + let (title_rows, title_cols) = (lay.title_rows, lay.title_cols); + let title_h = |start: u32| -> f32 { + match title_rows { + Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|r| sh.row_height(r)).sum(), + _ => 0.0, + } + }; + let title_w = |start: u32| -> f32 { + match title_cols { + Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|c| sh.col_width(c)).sum(), + _ => 0.0, + } + }; + let area = lay.area; + let rows = bands(area.start.row, area.end.row, |r| sh.row_height(r), |start| (ah - lay.heading_h - title_h(start)).max(1.0), &rb, cap); + let cols = bands(area.start.col, area.end.col, |c| sh.col_width(c), |start| (aw - lay.heading_w - title_w(start)).max(1.0), &cb, cap); + (rows, cols) +} + +/// Lays out every sheet a print command covers. +pub fn layouts(wb: &Workbook, opts: &PrintOptions) -> Vec { + let mut out = Vec::new(); + let mut pages = 0usize; + let sheets: Vec = if opts.all_sheets { + (0..wb.sheets.len()).filter(|i| wb.sheet(*i).is_some_and(|s| s.visibility == Visibility::Visible)).collect() + } else { + vec![wb.active_sheet()] + }; + for si in sheets { + if pages >= MAX_PAGES { + break; + } + let ovr = if si == wb.active_sheet() { opts.range } else { None }; + if opts.all_sheets && ovr.is_none() && wb.sheet(si).is_some_and(|s| print_range(s).is_none()) { + continue; + } + if let Some(mut l) = layout_sheet(wb, si, opts, ovr) { + l.pages.truncate(MAX_PAGES - pages); + pages += l.pages.len(); + out.push(l); + } + } + if out.is_empty() && opts.all_sheets { + out.extend(layout_sheet(wb, wb.active_sheet(), opts, None)); + } + out +} + +/// Segments of a page along one axis: repeated titles (before the band) then the band. +pub(super) fn segments(titles: Option<(u32, u32)>, band: (u32, u32)) -> Vec<(u32, u32)> { + let mut v = Vec::new(); + if let Some((a, b)) = titles + && a < band.0 + { + v.push((a, b.min(band.0 - 1))); + } + v.push(band); + v +} + +pub(super) fn seg_size(sh: &Sheet, seg: (u32, u32), rows: bool) -> f32 { + if rows { (seg.0..=seg.1).map(|r| sh.row_height(r)).sum() } else { (seg.0..=seg.1).map(|c| sh.col_width(c)).sum() } +} diff --git a/crates/engine/src/cmd/print/objects.rs b/crates/engine/src/cmd/print/objects.rs new file mode 100644 index 000000000..83044836e --- /dev/null +++ b/crates/engine/src/cmd/print/objects.rs @@ -0,0 +1,177 @@ +use super::draw::{Block, PdfMeasure, Xf, draw_prims, rgb_of}; +use super::text::wrap_lines; +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) { + let (x, y, w, h) = rect; + let pad = 2.0 * k; + let (w, h) = ((w - 2.0 * pad).max(0.0), (h - 2.0 * pad).max(0.0)); + if w <= 0.0 || h <= 0.0 || data.len() > 16 * 1024 * 1024 { + return; + } + let Some((iw, ih)) = crate::io::image_size(data) else { return }; + if iw == 0 || ih == 0 || iw > 8192 || ih > 8192 || u64::from(iw) * u64::from(ih) > 16_000_000 { + return; + } + let Ok(mut reader) = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format() else { return }; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(8192); + limits.max_image_height = Some(8192); + limits.max_alloc = Some(64 * 1024 * 1024); + reader.limits(limits); + let Ok(image) = reader.decode() else { return }; + let image = + 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(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. +fn anchor_rect(sh: &Sheet, a: &Anchor) -> (f64, f64, f64, f64) { + (sh.col_left(a.cell.col) + a.dx as f64, sh.row_top(a.cell.row) + a.dy as f64, a.width.max(0.0) as f64, a.height.max(0.0) as f64) +} + +/// Decodes an embedded picture to RGBA, downscaled to at most `max_w`×`max_h` pixels. +fn decode_image(data: &[u8], max_w: u32, max_h: u32) -> Option<(Vec, u32, u32)> { + let (w, h) = crate::io::image_size(data)?; + if w == 0 || h == 0 || w as u64 * h as u64 > 60_000_000 { + return None; + } + let img = image::load_from_memory(data).ok()?; + let img = if img.width() > max_w.max(1) || img.height() > max_h.max(1) { img.thumbnail(max_w.max(1), max_h.max(1)) } else { img }; + let rgba = img.to_rgba8(); + let (pw, ph) = (rgba.width(), rgba.height()); + Some((rgba.into_raw(), pw, ph)) +} + +/// 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 std::collections::HashMap<(usize, usize), Option<(Vec, 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))); + let (bx, by) = (b.xs.first().copied().unwrap_or(0.0), b.ys.first().copied().unwrap_or(0.0)); + let (bw, bh) = (b.xs.last().copied().unwrap_or(bx) - bx, b.ys.last().copied().unwrap_or(by) - by); + let visible = |r: (f64, f64, f64, f64)| r.2 > 0.0 && r.3 > 0.0 && r.0 < sx1 && r.0 + r.2 > sx0 && r.1 < sy1 && r.1 + r.3 > sy0; + let place = |r: (f64, f64, f64, f64)| (bx + ((r.0 - sx0) as f32) * b.k, by + ((r.1 - sy0) as f32) * b.k, r.2 as f32 * b.k, r.3 as f32 * b.k); + pg.save(); + pg.clip_rect(bx, by, bw, bh); + draw_sparklines(pg, wb, si, sh, b, block); + for (imi, im) in sh.images.iter().enumerate() { + let r = anchor_rect(sh, &im.anchor); + if !visible(r) { + continue; + } + let (x, y, w, h) = place(r); + // About 150 dpi is plenty for print. + let (mw, mh) = (((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32); + let decoded = pics.entry((si, imi)).or_insert_with(|| decode_image(&im.data, mw, mh)); + if let Some((px, pw, ph)) = decoded.as_ref() { + pg.image_rgba(x, y, w, h, px, *pw, *ph); + } + } + for shp in &sh.shapes { + let r = anchor_rect(sh, &shp.anchor); + if !visible(r) { + continue; + } + let (x, y, w, h) = place(r); + draw_shape(pg, wb, shp, b, (x, y, w, h)); + } + for ch in &sh.charts { + let r = anchor_rect(sh, &ch.anchor); + if !visible(r) { + continue; + } + let (x, y, _, _) = place(r); + let data = gridcraft_chart::resolve(wb, si, ch); + let prims = gridcraft_chart::render(ch, &data, r.2 as f32, r.3 as f32, &PdfMeasure); + draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k }); + } + pg.restore(); +} + +fn draw_sparklines(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block, block: RangeRef) { + for sp in &sh.sparklines { + if !block.contains(sp.cell) { + continue; + } + let vals: Vec> = match gridcraft_calc::evaluate(wb, si, sp.cell, &sp.source) { + Value::Array(a) => a.iter().take(10_000).map(Value::as_f64).collect(), + v => vec![v.as_f64()], + }; + let color = rgb_of(sp.color, wb).unwrap_or([0x15, 0x60, 0x82]); + let (x, y, w, h) = b.rect(RangeRef::cell(sp.cell)); + let (wpx, hpx) = (w / b.k, h / b.k); + let prims = gridcraft_chart::render_sparkline(sp.kind, &vals, [color[0], color[1], color[2], 255], sp.markers, wpx, hpx); + draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k }); + } +} + +fn draw_shape(pg: &mut Page, wb: &Workbook, shp: &gridcraft_model::Shape, b: &Block, (x, y, w, h): (f32, f32, f32, f32)) { + let fill = rgb_of(shp.fill, wb); + let line = rgb_of(shp.line, wb).filter(|_| !shp.format.no_line).map(|c| (c, (0.75 * b.k).max(0.25))); + match shp.kind { + ShapeKind::Ellipse => { + let pts: Vec<[f32; 2]> = (0..72) + .map(|i| { + let a = i as f32 / 72.0 * std::f32::consts::TAU; + [x + w / 2.0 + w / 2.0 * a.cos(), y + h / 2.0 + h / 2.0 * a.sin()] + }) + .collect(); + pg.polygon(&pts, fill, line); + } + ShapeKind::Triangle => pg.polygon(&[[x + w / 2.0, y], [x + w, y + h], [x, y + h]], fill, line), + ShapeKind::Line | ShapeKind::Arrow => { + let c = line.map(|l| l.0).or(fill).unwrap_or([0, 0, 0]); + pg.stroke_line(x, y, x + w, y + h, (1.0 * b.k).max(0.3), c); + if shp.kind == ShapeKind::Arrow { + let ang = h.atan2(w); + let al = 8.0 * b.k; + let p = |da: f32| [x + w - al * (ang + da).cos(), y + h - al * (ang + da).sin()]; + pg.polygon(&[[x + w, y + h], p(0.4), p(-0.4)], Some(c), None); + } + } + _ => { + pg.polygon(&[[x, y], [x + w, y], [x + w, y + h], [x, y + h]], fill, line); + } + } + if shp.text.is_empty() { + return; + } + use gridcraft_model::{ShapeTextAlign, ShapeTextAnchor}; + let fm = &shp.format; + let text_box = shp.kind == ShapeKind::TextBox; + let size = fm.font_size.unwrap_or(11.0).clamp(1.0, 4000.0) * b.scale; + let font = Font::styled(Font::family_for(fm.font.as_deref().unwrap_or("")), fm.bold, fm.italic); + let color = fm.text_color.and_then(|c| rgb_of(c, wb)).unwrap_or([0, 0, 0]); + let align = fm.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center }); + let anchor = fm.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle }); + let lines = wrap_lines(&shp.text, font, size, (w - 4.0 * b.k).max(size)); + let block_h = lines.len().min(500) as f32 * size * 1.2; + let mut yy = match anchor { + ShapeTextAnchor::Top => y + 2.0 * b.k, + ShapeTextAnchor::Middle => y + (h - block_h) / 2.0, + ShapeTextAnchor::Bottom => y + h - 2.0 * b.k - block_h, + } + size * 0.8; + for l in lines.iter().take(500) { + let lw = gridcraft_pdf::text_width(font, size, l); + let lx = match align { + ShapeTextAlign::Left => x + 2.0 * b.k, + ShapeTextAlign::Center => x + (w - lw) / 2.0, + ShapeTextAlign::Right => x + w - 2.0 * b.k - lw, + }; + pg.text(lx, yy, size, font, color, l); + yy += size * 1.2; + } +} diff --git a/crates/engine/src/cmd/print/text.rs b/crates/engine/src/cmd/print/text.rs new file mode 100644 index 000000000..2e1029b48 --- /dev/null +++ b/crates/engine/src/cmd/print/text.rs @@ -0,0 +1,263 @@ +use super::draw::{Block, Look, rgb_of}; +use super::*; + +const PAD_PX: f32 = 2.0; + +/// Word-wraps `text` to `width` points. +pub(super) fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec { + let mut out = Vec::new(); + for para in text.split('\n') { + let mut line = String::new(); + for word in para.split(' ') { + let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; + if line.is_empty() || gridcraft_pdf::text_width(font, size, &cand) <= width { + line = cand; + } else { + out.push(std::mem::take(&mut line)); + line = word.to_string(); + } + // Break a single over-long word by characters. The cut that fits is found by binary + // search (each try measures one candidate prefix), so an over-long word costs + // O(n log n) measurements instead of re-measuring the whole cut per character for + // every emitted line (`a` × 100k pasted text would be cubic worst case). + while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 { + let chars: Vec = line.chars().collect(); + let (mut lo, mut hi) = (1usize, chars.len()); + while lo < hi { + let mid = lo + (hi - lo).div_ceil(2); + let cut: String = chars[..mid].iter().collect(); + if gridcraft_pdf::text_width(font, size, &cut) <= width { + lo = mid; + } else { + hi = mid - 1; + } + } + if lo == chars.len() { + break; + } + out.push(chars[..lo].iter().collect()); + line = chars[lo..].iter().collect(); + } + if out.len() > 2000 { + return out; + } + } + out.push(line); + } + out +} + +fn font_for(style: &Style) -> Font { + Font::styled(Font::family_for(&style.font.name), style.font.bold, style.font.italic) +} + +/// 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)) +} + +/// 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; + let mut col = c.col + 1; + while col < MAX_COLS && ext < 20_000.0 { + let nc = CellRef::new(c.row, col); + if !empty_for_overflow(sh, nc, merges) || styles.get(sh.style_id(nc)).align.h != HAlign::CenterAcross { + break; + } + ext += sh.col_width(col); + col += 1; + } + ext +} + +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; + let v = sh.value(c); + if v.is_empty() || l.cf.as_ref().is_some_and(|cf| cf.hide_value) { + return; + } + if !sh.show_zeros && v.as_f64() == Some(0.0) { + return; + } + let st = &l.style; + let mut size = st.font.size.clamp(1.0, 409.0) * b.scale; + let font = font_for(st); + let pad = PAD_PX * b.k; + let indent = st.align.indent as f32 * 9.0 * b.k; + let avail = (w - 2.0 * pad - indent).max(0.0); + let code = st.num_fmt.as_str(); + let formatted = crate::display::format(&v, code, wb); + let mut color = formatted.color.and_then(gridcraft_numfmt::FormatColor::rgb).or_else(|| rgb_of(st.font.color, wb)).unwrap_or([0, 0, 0]); + if l.cf.as_ref().and_then(|x| x.style.as_ref()).is_some_and(|s| s.font.color != Color::Auto) { + color = rgb_of(st.font.color, wb).unwrap_or(color); + } + let is_num = matches!(v, Value::Number(_)); + let wrap = st.align.wrap; + let mut text = if sh.show_formulas { + crate::display::cell_text(wb, sh, c) + } else if code == "General" + && let Value::Number(n) = v + { + let digit = gridcraft_pdf::text_width(font, size, "0").max(0.1); + let max_chars = (avail / digit).floor().max(1.0) as usize; + gridcraft_numfmt::format_general_fit(n, max_chars.min(11)).unwrap_or_else(|| "#".repeat(max_chars.min(255))) + } else { + // The look's code, which a conditional format can override (#1163). + crate::display::value_text_as(wb, &v, code) + }; + if !wrap { + text = text.replace(['\n', '\r'], " "); + } + if text.is_empty() { + return; + } + let mut tw = gridcraft_pdf::text_width(font, size, &text); + if st.align.shrink && !wrap && tw > avail && tw > 0.0 { + size = (size * avail / tw).max(1.0); + tw = gridcraft_pdf::text_width(font, size, &text); + } + if is_num && !wrap && tw > avail + 0.01 && !sh.show_formulas { + let hash = gridcraft_pdf::text_width(font, size, "#").max(0.1); + text = "#".repeat(((avail / hash).floor().max(1.0) as usize).min(255)); + tw = gridcraft_pdf::text_width(font, size, &text); + } + 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. + let lines: Vec = match (wrap, rotated) { + (true, false) => wrap_lines(&text, font, size, avail.max(size)), + (true, true) if rot.abs() == 90 => wrap_lines(&text, font, size, (h - 2.0 * pad).max(size)), + (true, true) => vec![text.lines().next().unwrap_or_default().to_string()], + (false, _) => vec![text], + }; + let line_h = size * 1.2; + let n = lines.len().max(1) as f32; + let first_baseline = match st.align.v { + VAlign::Top => y + pad + size * 0.8, + VAlign::Center | VAlign::Justify | VAlign::Distributed => y + (h - n * line_h) / 2.0 + line_h * 0.5 + size * 0.33, + VAlign::Bottom => y + h - pad - size * 0.22 - (n - 1.0) * line_h, + }; + // Overflow into empty neighbours (single-line text, not merged). + // Center Across Selection spans the empty Center Across cells to its right; the text stays within that run. + let spans = st.align.h == HAlign::CenterAcross && !wrap && !merged && rot == 0; + let across = if spans { center_across_width(&wb.styles, sh, c, merges) * b.k } else { 0.0 }; + 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; + 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); + pg.save(); + pg.clip_rect(clip_l, y, (clip_r - clip_l).max(0.0), h); + if rotated { + // Rotated text: anchored at the bottom-left (upward) or top-left (downward) of the cell. + // Wrapped lines stack rightward for upward text and leftward for downward text, so + // downward text starts its first line far enough in for the last to end at the left pad. + let deg = (rot as f32).clamp(-90.0, 90.0); + let angle = -deg.to_radians(); + let last = lines.len().saturating_sub(1) as f32; + for (i, t) in lines.iter().enumerate() { + let i = i as f32; + let (ox, oy) = if deg > 0.0 { + (x + pad + size * 0.8 * deg.to_radians().sin() + i * line_h, y + h - pad) + } else { + (x + pad + (last - i) * line_h, y + pad + size * 0.8) + }; + pg.text_rotated(ox, oy, size, font, color, t, angle); + } + pg.restore(); + return; + } + if rot == 255 { + // Vertical stacked text. + let chars: Vec = lines.concat().chars().take(500).collect(); + let mut yy = y + pad + size * 0.8; + for ch in chars { + let s = ch.to_string(); + let cw = gridcraft_pdf::text_width(font, size, &s); + pg.text(x + (w - cw) / 2.0, yy, size, font, color, &s); + yy += line_h; + if yy > y + h + line_h { + break; + } + } + pg.restore(); + return; + } + for (i, line) in lines.iter().enumerate() { + let lw = gridcraft_pdf::text_width(font, size, line); + let lx = match h_align { + HAlign::Right => x + w - pad - lw - indent, + HAlign::Center => x + (w + across - lw) / 2.0, + _ => x + pad + indent, + }; + let ly = first_baseline + i as f32 * line_h; + if ly - size > y + h || ly < y - line_h { + continue; + } + pg.text(lx, ly, size, font, color, line); + if st.font.underline != Underline::None { + let uw = (size * 0.06).max(0.2); + pg.stroke_line(lx, ly + size * 0.12, lx + lw, ly + size * 0.12, uw, color); + if matches!(st.font.underline, Underline::Double | Underline::DoubleAccounting) { + pg.stroke_line(lx, ly + size * 0.24, lx + lw, ly + size * 0.24, uw, color); + } + } + if st.font.strike { + pg.stroke_line(lx, ly - size * 0.3, lx + lw, ly - size * 0.3, (size * 0.06).max(0.2), color); + } + } + pg.restore(); +} From 99f0be5f2972d12da21dbd4047f4b9171c5e585a Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:29:52 +0000 Subject: [PATCH 51/76] refactor(engine): move cmd/pivot.rs items to cmd/pivot/ --- crates/engine/src/cmd/pivot.rs | 1453 +------------------------ crates/engine/src/cmd/pivot/charts.rs | 174 +++ crates/engine/src/cmd/pivot/create.rs | 340 ++++++ crates/engine/src/cmd/pivot/fields.rs | 541 +++++++++ crates/engine/src/cmd/pivot/layout.rs | 289 +++++ crates/engine/src/cmd/pivot/params.rs | 111 ++ 6 files changed, 1466 insertions(+), 1442 deletions(-) create mode 100644 crates/engine/src/cmd/pivot/charts.rs create mode 100644 crates/engine/src/cmd/pivot/create.rs create mode 100644 crates/engine/src/cmd/pivot/fields.rs create mode 100644 crates/engine/src/cmd/pivot/layout.rs create mode 100644 crates/engine/src/cmd/pivot/params.rs diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index feb548702..bbc66fa0c 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -14,6 +14,17 @@ use super::*; use crate::pivot as pv; use crate::selection::current_region; +mod charts; +mod create; +mod fields; +mod layout; +mod params; + +use charts::{insert_pivot_chart, recommended}; +use create::{insert_pivot, options, show_details, summarize_table}; +use fields::{add_field, calculated_field, fields, list, move_field, remove_field, value_settings}; +use layout::{change_source, collapse, delete, filter, grand_totals, group, layout, refresh, refresh_all, sort}; + pub fn specs() -> Vec { vec![ cmd!( @@ -252,1447 +263,5 @@ fn find_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> { Err(EngineError::Disabled("pivot".into(), "select a cell in a PivotTable (or pass `pivot`)".into())) } -fn area_param(cmd: &str, v: Option<&str>) -> Result<&'static str> { - match v.map(str::to_ascii_lowercase) { - Some(a) if a == "rows" || a == "row" => Ok("rows"), - Some(a) if a == "columns" || a == "column" || a == "cols" => Ok("columns"), - Some(a) if a == "values" || a == "value" || a == "data" => Ok("values"), - Some(a) if a == "filters" || a == "filter" => Ok("filters"), - Some(a) => Err(bad(cmd, format!("unknown area `{a}` (rows|columns|values|filters)"))), - None => Err(bad(cmd, "missing `area` (rows|columns|values|filters)")), - } -} - -fn func_param(v: Option<&str>) -> Option { - Some(match v?.to_ascii_lowercase().as_str() { - "sum" => PivotFunc::Sum, - "count" | "counta" => PivotFunc::Count, - "average" | "avg" | "mean" => PivotFunc::Average, - "max" => PivotFunc::Max, - "min" => PivotFunc::Min, - "product" => PivotFunc::Product, - "countnumbers" | "countnums" | "count numbers" => PivotFunc::CountNumbers, - "stddev" => PivotFunc::StdDev, - "stddevp" => PivotFunc::StdDevP, - "var" => PivotFunc::Var, - "varp" => PivotFunc::VarP, - _ => return None, - }) -} - -fn func_name(f: PivotFunc) -> &'static str { - match f { - PivotFunc::Sum => "sum", - PivotFunc::Count => "count", - PivotFunc::Average => "average", - PivotFunc::Max => "max", - PivotFunc::Min => "min", - PivotFunc::Product => "product", - PivotFunc::CountNumbers => "countNumbers", - PivotFunc::StdDev => "stdDev", - PivotFunc::StdDevP => "stdDevP", - PivotFunc::Var => "var", - PivotFunc::VarP => "varP", - } -} - -fn show_as_param(v: &str) -> Option { - Some(match v.to_ascii_lowercase().as_str() { - "normal" | "none" => PivotShowAs::Normal, - "percentofgrandtotal" => PivotShowAs::PercentOfGrandTotal, - "percentofcolumntotal" => PivotShowAs::PercentOfColumnTotal, - "percentofrowtotal" => PivotShowAs::PercentOfRowTotal, - "runningtotal" => PivotShowAs::RunningTotal, - "rank" => PivotShowAs::Rank, - _ => return None, - }) -} - -fn show_as_name(v: PivotShowAs) -> &'static str { - match v { - PivotShowAs::Normal => "normal", - PivotShowAs::PercentOfGrandTotal => "percentOfGrandTotal", - PivotShowAs::PercentOfColumnTotal => "percentOfColumnTotal", - PivotShowAs::PercentOfRowTotal => "percentOfRowTotal", - PivotShowAs::RunningTotal => "runningTotal", - PivotShowAs::Rank => "rank", - } -} - -fn layout_name(l: PivotLayout) -> &'static str { - match l { - PivotLayout::Compact => "compact", - PivotLayout::Outline => "outline", - PivotLayout::Tabular => "tabular", - } -} - -fn group_name(g: PivotDateGroup) -> &'static str { - match g { - PivotDateGroup::None => "none", - PivotDateGroup::Years => "years", - PivotDateGroup::Quarters => "quarters", - PivotDateGroup::Months => "months", - PivotDateGroup::Days => "days", - } -} - -fn sort_name(o: PivotSort) -> &'static str { - match o { - PivotSort::Asc => "asc", - PivotSort::Desc => "desc", - PivotSort::None => "none", - } -} - -// ---------------------------------------------------------------- layout editing (pure) - -fn in_axes(pt: &PivotTable, field: &str) -> bool { - pt.rows.iter().chain(pt.columns.iter()).any(|f| f.source_col.eq_ignore_ascii_case(field)) -} - -/// Name of the Σ Values pseudo-field (where the value fields sit when there are two or more). -const VALUES_FIELD: &str = "Σ Values"; - -/// Whether `field` names Σ Values: `Σ Values`, or `Values` unless a source field has that name. -fn is_values_field(src: &pv::Source, field: &str) -> bool { - let f = field.trim(); - f == VALUES_FIELD || (f.eq_ignore_ascii_case("values") && src.col(f).is_none()) -} - -/// Keeps Σ Values in place after a field left (`inserted` false) or arrived at index `i` of -/// Rows (`rows`) or Columns. -fn shift_values(pt: &mut PivotTable, rows: bool, i: usize, inserted: bool) { - if pt.values_on_rows != rows { - return; - } - let Some(p) = pt.values_position else { return }; - // Σ Values stays just ahead of the field it preceded. - let p = match inserted { - true if i <= p => p + 1, - false if i < p => p - 1, - _ => p, - }; - let n = if rows { pt.rows.len() } else { pt.columns.len() }; - pt.values_position = (p < n).then_some(p); -} - -/// Takes `field` out of Rows and Columns (Σ Values keeps its place); returns its first entry. -fn take_from_axes(pt: &mut PivotTable, field: &str) -> Option { - let mut first = None; - for rows in [true, false] { - loop { - let list = if rows { &mut pt.rows } else { &mut pt.columns }; - let Some(i) = list.iter().position(|f| f.source_col.eq_ignore_ascii_case(field)) else { break }; - let f = list.remove(i); - shift_values(pt, rows, i, false); - first.get_or_insert(f); - } - } - first -} - -/// Inserts `f` into Rows (`rows`) or Columns at `position` (default: the end). -fn insert_axis_field(pt: &mut PivotTable, rows: bool, f: PivotField, position: Option) { - let list = if rows { &mut pt.rows } else { &mut pt.columns }; - let at = position.unwrap_or(list.len()).min(list.len()); - list.insert(at, f); - shift_values(pt, rows, at, true); -} - -/// Moves Σ Values between Rows and Columns, or within Rows, to `position` (default: the end). -/// In Columns it always comes after the last column field: the column layout doesn't place it -/// anywhere else yet. -fn move_values_field(cmd: &str, pt: &mut PivotTable, from: &str, to: &str, position: Option) -> Result { - let current = if pt.values_on_rows { "rows" } else { "columns" }; - if from != current { - return Err(bad(cmd, format!("`{VALUES_FIELD}` isn't in the {from} area"))); - } - if !matches!(to, "rows" | "columns") { - return Err(other("The field you are moving cannot be placed in that PivotTable area.")); - } - pt.values_on_rows = to == "rows"; - pt.values_position = position.filter(|p| pt.values_on_rows && *p < pt.rows.len()); - Ok(json!({"field": VALUES_FIELD, "to": to})) -} - -/// The canonical source field name for `field`. -fn canonical(cmd: &str, src: &pv::Source, field: &str) -> Result { - src.col(field).and_then(|c| src.headers.get(c).cloned()).ok_or_else(|| bad(cmd, format!("no field `{field}` in the source data"))) -} - -/// The calculated field named `field`, if any. -fn calc_field<'a>(pt: &'a PivotTable, field: &str) -> Option<&'a PivotCalcField> { - pt.calculated_fields.iter().find(|c| c.name.eq_ignore_ascii_case(field.trim())) -} - -/// The canonical name of a source or calculated field. -fn canonical_any(cmd: &str, pt: &PivotTable, src: &pv::Source, field: &str) -> Result { - match calc_field(pt, field) { - Some(c) if src.col(field).is_none() => Ok(c.name.clone()), - _ => canonical(cmd, src, field), - } -} - -fn unique_value_name(pt: &PivotTable, base: &str, except: Option) -> String { - let taken = |n: &str| pt.values.iter().enumerate().any(|(i, v)| Some(i) != except && v.name.eq_ignore_ascii_case(n)); - if !taken(base) { - return base.to_string(); - } - (2..10_000).map(|k| format!("{base}{k}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) -} - -fn auto_name(f: PivotFunc, field: &str) -> String { - format!("{} of {field}", pv::func_caption(f)) -} - -/// Adds `field` to an area (moving it out of the other axis areas). -fn add_to_area( - cmd: &str, - pt: &mut PivotTable, - src: &pv::Source, - field: &str, - area: &str, - func: Option, - name: Option<&str>, - position: Option, -) -> 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.")); - } - let field = canonical_any(cmd, pt, src, field)?; - match area { - "rows" | "columns" => { - if pt.rows.len() + pt.columns.len() >= pv::MAX_AXIS_FIELDS && !in_axes(pt, &field) { - return Err(other(format!("A PivotTable can have at most {} row and column fields.", pv::MAX_AXIS_FIELDS))); - } - let existing = take_from_axes(pt, &field).unwrap_or_else(|| PivotField { source_col: field.clone(), ..Default::default() }); - // A report filter that selects nothing in particular goes away; a selection stays - // as the field's hidden items. - pt.filters.retain(|f| !(f.source_col.eq_ignore_ascii_case(&field) && f.selected.is_none())); - insert_axis_field(pt, area == "rows", existing, position); - } - "values" => { - if pt.values.len() >= pv::MAX_VALUE_FIELDS { - return Err(other(format!("A PivotTable can have at most {} value fields.", pv::MAX_VALUE_FIELDS))); - } - let numeric = is_calc || src.col(&field).and_then(|c| src.numeric.get(c).copied()).unwrap_or(false); - let f = func.unwrap_or(if numeric { PivotFunc::Sum } else { PivotFunc::Count }); - let base = match name { - Some(n) if !n.trim().is_empty() => { - if src.col(n).is_some() { - return Err(other("PivotTable field name already exists.")); - } - n.to_string() - } - _ => auto_name(f, &field), - }; - let nm = unique_value_name(pt, &base, None); - let v = PivotValue { source_col: field, func: f, name: nm, show_as: PivotShowAs::Normal, number_format: None }; - let at = position.unwrap_or(pt.values.len()).min(pt.values.len()); - pt.values.insert(at, v); - } - _ => { - take_from_axes(pt, &field); - let existing = pt.filters.iter().position(|f| f.source_col.eq_ignore_ascii_case(&field)); - let entry = match existing { - Some(i) => pt.filters.remove(i), - None => PivotFilter { source_col: field, selected: None }, - }; - let at = position.unwrap_or(pt.filters.len()).min(pt.filters.len()); - pt.filters.insert(at, entry); - } - } - Ok(()) -} - -/// Removes `field` from an area; returns the source field it referred to. -fn remove_from_area(cmd: &str, pt: &mut PivotTable, field: &str, area: &str) -> Result { - match area { - "rows" | "columns" => { - let rows = area == "rows"; - let list = if rows { &mut pt.rows } else { &mut pt.columns }; - let i = list - .iter() - .position(|f| f.source_col.eq_ignore_ascii_case(field)) - .ok_or_else(|| bad(cmd, format!("`{field}` isn't in the {area} area")))?; - let f = list.remove(i); - shift_values(pt, rows, i, false); - pt.filters.retain(|x| !x.source_col.eq_ignore_ascii_case(&f.source_col)); - Ok(f.source_col) - } - "values" => { - let i = pt - .values - .iter() - .position(|v| v.name.eq_ignore_ascii_case(field)) - .or_else(|| pt.values.iter().position(|v| v.source_col.eq_ignore_ascii_case(field))) - .ok_or_else(|| bad(cmd, format!("`{field}` isn't in the values area")))?; - Ok(pt.values.remove(i).source_col) - } - _ => { - let i = pt - .filters - .iter() - .position(|f| f.source_col.eq_ignore_ascii_case(field) && !in_axes(pt, &f.source_col)) - .ok_or_else(|| bad(cmd, format!("`{field}` isn't in the filters area")))?; - Ok(pt.filters.remove(i).source_col) - } - } -} - -// ---------------------------------------------------------------- shared update - -/// Applies `f` to the targeted pivot and re-renders it, as one undo step. -fn update(s: &mut Session, p: &Json, f: impl FnOnce(&mut PivotTable, &pv::Source, &Workbook) -> Result) -> Result { - let (si, pi) = find_pivot(s, p)?; - let replace = bool_param(p, "replace").unwrap_or(false); - edit(s, |cx| { - let pt = cx.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).cloned().ok_or_else(|| other("There's no such PivotTable."))?; - let src = pv::read_source(&cx.wb, &pt.source, si).map_err(other)?; - let mut new = pt.clone(); - let mut out = f(&mut new, &src, &cx.wb)?; - if let Some(slot) = cx.sheet_mut(si)?.pivots.get_mut(pi) { - *slot = new; - } - let area = pv::refresh(&mut cx.wb, si, pi, replace).map_err(other)?; - cx.structural = true; - if out.is_null() { - out = json!({}); - } - if let Some(m) = out.as_object_mut() { - m.insert("range".into(), json!(area.a1())); - } - Ok(out) - }) -} - -// ---------------------------------------------------------------- insert - -/// The default source: the table at the selection, else the selected block / current region. -fn default_source(s: &Session) -> Result { - let d = s.doc()?; - let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - if let Some(t) = sh.table_at(d.selection.active) { - return Ok(t.name.clone()); - } - let sel = d.selection.current(); - let r = if sel.is_single() { - current_region(sh, d.selection.active) - } else { - let used = sh.used_range().unwrap_or(sel); - sel.intersection(&used).unwrap_or(sel) - }; - if r.is_single() && sh.value(r.start).is_empty() { - return Err(other("The reference isn't valid. Select a cell in a range with labeled columns, or pass `source`.")); - } - Ok(pv::range_text(&sh.name, r)) -} - -fn source_param(s: &Session, p: &Json) -> Result { - match str_param(p, "source").or_else(|| str_param(p, "range")) { - Some(t) if !t.trim().is_empty() => { - // Bare ranges are on the active sheet; store them with the sheet name. - let d = s.doc()?; - let t = t.trim().trim_start_matches('='); - if !t.contains('!') - && RangeRef::parse(t).is_some() - && let Some(sh) = d.wb.active() - && let Some(r) = RangeRef::parse(t) - { - return Ok(pv::range_text(&sh.name, r)); - } - Ok(t.to_string()) - } - _ => default_source(s), - } -} - -fn next_pivot_name(wb: &Workbook) -> String { - (1..) - .map(|n| format!("PivotTable{n}")) - .find(|n| !wb.sheets.iter().any(|s| s.pivots.iter().any(|p| p.name.eq_ignore_ascii_case(n)))) - .unwrap_or_default() -} - -fn next_pivot_id(wb: &Workbook) -> u32 { - let m = wb.sheets.iter().flat_map(|s| s.pivots.iter().map(|p| p.id)).max().unwrap_or(0); - wb.next_object_id().max(m.saturating_add(1)) -} - -fn field_list(v: Option<&Json>) -> Vec { - match v { - Some(Json::Array(a)) => a.iter().take(512).cloned().collect(), - Some(Json::String(s)) => vec![Json::String(s.clone())], - _ => vec![], - } -} - -fn insert_pivot(s: &mut Session, p: &Json) -> Result { - if s.doc()?.wb.protected_structure && matches!(str_param(p, "destination"), None | Some("new")) { - return Err(other("The workbook is protected and cannot be changed.")); - } - let source = source_param(s, p)?; - let active = s.doc()?.wb.active_sheet(); - // Validate the source now (header names, size). - let src = pv::read_source(&s.doc()?.wb, &source, active).map_err(other)?; - let name = match str_param(p, "name") { - Some(n) if !n.trim().is_empty() => { - let n = n.trim().to_string(); - if s.doc()?.wb.sheets.iter().any(|sh| sh.pivots.iter().any(|x| x.name.eq_ignore_ascii_case(&n))) { - return Err(other("A PivotTable with that name already exists.")); - } - n - } - _ => next_pivot_name(&s.doc()?.wb), - }; - let dest = str_param(p, "destination").or_else(|| str_param(p, "location")).unwrap_or("new").trim().to_string(); - // Existing sheet destination: (sheet index, anchor). - let target: Option<(usize, CellRef)> = if dest.eq_ignore_ascii_case("new") || dest.is_empty() { - None - } else { - let d = s.doc()?; - let (sh, body) = super::split_sheet(&dest); - let si = match sh { - Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("insert.pivotTable", format!("no sheet named `{n}`")))?, - None => active, - }; - let at = RangeRef::parse(body).map(|r| r.start).ok_or_else(|| bad("insert.pivotTable", format!("not a cell: `{dest}`")))?; - Some((si, at)) - }; - let replace = bool_param(p, "replace").unwrap_or(false); - let rows = field_list(p.get("rows")); - let columns = field_list(p.get("columns")); - let values = field_list(p.get("values")); - let filters = field_list(p.get("filters")); - let mut new_sheet_at: Option = None; - let res = edit(s, |cx| { - let id = next_pivot_id(&cx.wb); - let (si, anchor) = match target { - Some(t) => t, - None => { - let at = insert_sheet_before_active(cx); - new_sheet_at = Some(at); - (at, CellRef::new(2, 0)) - } - }; - let mut pt = PivotTable { id, name: name.clone(), source: source.clone(), anchor, ..Default::default() }; - let add = |pt: &mut PivotTable, list: &[Json], area: &str| -> Result<()> { - for f in list { - match f { - 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") { - Some(fname) => { - Some(func_param(Some(fname)).ok_or_else(|| bad("insert.pivotTable", format!("unknown function `{fname}`")))?) - } - None => 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") - && src.col(n).and_then(|c| src.numeric.get(c).copied()).unwrap_or(false) - && let Some(af) = pt.rows.iter_mut().chain(pt.columns.iter_mut()).find(|x| x.source_col.eq_ignore_ascii_case(n)) - { - af.date_group = by; - } - } - _ => return Err(bad("insert.pivotTable", "fields are names or {field, func?, name?}")), - } - } - Ok(()) - }; - add(&mut pt, &rows, "rows")?; - add(&mut pt, &columns, "columns")?; - add(&mut pt, &values, "values")?; - add(&mut pt, &filters, "filters")?; - let sh = cx.sheet_mut(si)?; - sh.pivots.push(pt); - let pi = sh.pivots.len() - 1; - let area = pv::refresh(&mut cx.wb, si, pi, replace).map_err(other)?; - cx.wb.set_active_sheet(si); - *cx.sel = crate::Selection::at(anchor); - cx.structural = true; - let sheet_name = cx.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default(); - Ok(json!({ - "id": id, - "name": name, - "sheet": sheet_name, - "anchor": anchor.a1(), - "range": area.a1(), - "source": source, - "fields": src.headers, - })) - })?; - if let Some(at) = new_sheet_at - && let Ok(d) = s.doc_mut() - { - d.sheet_selections.insert(at.min(d.sheet_selections.len()), crate::Selection::default()); - } - Ok(res) -} - -/// Inserts a new, empty sheet before the active one (Excel's place for a new PivotTable or a -/// Show Details sheet); returns its index. -fn insert_sheet_before_active(cx: &mut Ctx) -> usize { - let at = cx.wb.active_sheet().min(cx.wb.sheets.len()); - let nm = cx.wb.next_sheet_name(); - cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm))); - for n in cx.wb.names.iter_mut() { - if let Some(sc) = n.scope.as_mut() - && *sc >= at - { - *sc += 1; - } - } - at -} - -/// Show Details (double-click a value cell): the source records behind it, as values with their -/// column's number format, under the source headers on a new sheet. -fn show_details(s: &mut Session, p: &Json) -> Result { - let d = s.doc()?; - let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); - let active = d.wb.active_sheet(); - let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - let pt = sh - .pivots - .iter() - .find(|pt| pt.last_range.is_some_and(|r| r.contains(at))) - .ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?; - let origin = pt.last_range.map(|r| r.start).unwrap_or_default(); - let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)); - // With the option off, a report that is out of date or a source that cannot be read is - // reported as the option; only a cell that is not a value cell keeps its own message. - if !pt.enable_show_details && !matches!(found, Ok(None)) { - return Err(other(SHOW_DETAILS_OFF)); - } - let (src, records) = found.map_err(other)?.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?; - if d.wb.protected_structure { - return Err(other("The workbook is protected and cannot be changed.")); - } - let count = records.len(); - let mut new_sheet_at: Option = None; - let res = edit(s, |cx| { - let si = insert_sheet_before_active(cx); - new_sheet_at = Some(si); - let base = cx.wb.styles.get(StyleId::default()).clone(); - let fmts: Vec = src - .formats - .iter() - .map(|f| { - let mut st = base.clone(); - st.num_fmt = NumFmt::new(f); - cx.wb.styles.intern(st) - }) - .collect(); - // Columns wide enough for their text, as the PivotTable report's are (dates would show - // as ####); the first rows are enough to judge a column. - let font = base.font.clone(); - let widths: Vec = src - .headers - .iter() - .enumerate() - .map(|(c, h)| { - let fmt = src.formats.get(c).map(String::as_str).unwrap_or("General"); - let texts = records.iter().take(1_000).filter_map(|i| src.rows.get(*i).and_then(|r| r.get(c))); - let widest = texts - .map(|v| crate::cmd::format::approx_text_width(&crate::display::format(v, fmt, &cx.wb).text, font.size, false)) - .fold(crate::cmd::format::approx_text_width(h, font.size, false), f32::max); - (widest + 14.0).min(320.0) - }) - .collect(); - let sh = cx.sheet_mut(si)?; - for (c, w) in widths.into_iter().enumerate() { - if w > sh.col_width(c as u32) { - sh.cols.entry(c as u32).or_default().size = Some(w); - } - } - for (c, h) in src.headers.iter().enumerate() { - sh.set_cell(CellRef::new(0, c as u32), Cell { value: gridcraft_core::Value::text(h.as_str()), formula: None, style: StyleId::default() }); - } - for (r, i) in records.iter().enumerate() { - let Some(rec) = src.rows.get(*i) else { continue }; - for (c, v) in rec.iter().enumerate() { - let style = fmts.get(c).copied().unwrap_or_default(); - if v.is_empty() && style == StyleId::default() { - continue; - } - sh.set_cell(CellRef::new(r as u32 + 1, c as u32), Cell { value: v.clone(), formula: None, style }); - } - } - let name = sh.name.clone(); - cx.wb.set_active_sheet(si); - *cx.sel = crate::Selection::at(CellRef::new(0, 0)); - cx.structural = true; - Ok(json!({"sheet": name, "records": count})) - })?; - if let Some(at) = new_sheet_at - && let Ok(d) = s.doc_mut() - { - d.sheet_selections.insert(at.min(d.sheet_selections.len()), crate::Selection::default()); - } - Ok(res) -} - -/// The message Show Details gives while Enable show details is off. -const SHOW_DETAILS_OFF: &str = "Show Details is turned off for this PivotTable."; - -/// PivotTable Options: Enable show details (Data). Changes only the option; the report is not -/// refreshed. Without params it returns the current setting. -fn options(s: &mut Session, p: &Json) -> Result { - let (si, pi) = find_pivot(s, p)?; - let enable = bool_param(p, "enableShowDetails"); - if p.get("enableShowDetails").is_some_and(|v| !v.is_null()) && enable.is_none() { - return Err(bad("pivot.options", "`enableShowDetails` must be true or false")); - } - let current = |s: &Session| -> Result { - let pt = s.doc()?.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?; - Ok(json!({"enableShowDetails": pt.enable_show_details})) - }; - let Some(on) = enable else { return current(s) }; - if current(s)?["enableShowDetails"] == json!(on) { - return current(s); - } - edit(s, |cx| { - let pt = cx.sheet_mut(si)?.pivots.get_mut(pi).ok_or_else(|| other("There's no such PivotTable."))?; - pt.enable_show_details = on; - Ok(json!({"enableShowDetails": on})) - }) -} - -fn summarize_table(s: &mut Session, p: &Json) -> Result { - let d = s.doc()?; - let name = match str_param(p, "table") { - Some(n) => d.wb.table(n).and_then(|(si, ti)| d.wb.sheet(si).and_then(|sh| sh.tables.get(ti))).map(|t| t.name.clone()), - None => d.wb.active().and_then(|sh| sh.table_at(d.selection.active)).map(|t| t.name.clone()), - } - .ok_or_else(|| EngineError::Disabled("table.summarizePivot".into(), "select a cell in a table".into()))?; - let mut q = with(p, "source", json!(name)); - if let Some(m) = q.as_object_mut() { - m.remove("table"); - } - insert_pivot(s, &q) -} - -// ---------------------------------------------------------------- queries - -fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json { - json!({ - "id": pt.id, - "name": pt.name, - "sheet": wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default(), - "anchor": pt.anchor.a1(), - "range": pt.last_range.map(|r| r.a1()), - "source": pt.source, - "rows": pt.rows.iter().map(|f| json!({"field": f.source_col, "sort": sort_name(f.sort), "group": group_name(f.date_group), "collapsed": f.collapsed_items})).collect::>(), - "columns": pt.columns.iter().map(|f| json!({"field": f.source_col, "sort": sort_name(f.sort), "group": group_name(f.date_group), "collapsed": f.collapsed_items})).collect::>(), - "values": pt.values.iter().map(|v| json!({"field": v.source_col, "func": func_name(v.func), "name": v.name, "showAs": show_as_name(v.show_as), "numberFormat": v.number_format})).collect::>(), - "filters": pt.filters.iter().map(|f| json!({"field": f.source_col, "selected": f.selected, "report": !in_axes(pt, &f.source_col)})).collect::>(), - "layout": layout_name(pt.layout), - "grandTotalsRows": pt.grand_totals_rows, - "grandTotalsCols": pt.grand_totals_cols, - "subtotals": if !pt.subtotals { "none" } else if pt.subtotals_top { "top" } else { "bottom" }, - "style": pt.style, - "showHeaders": pt.show_headers, - "calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::>(), - "valuesOnRows": pt.values_on_rows, - "valuesPosition": pt.values_position, - "enableShowDetails": pt.enable_show_details, - }) -} - -fn fields(s: &mut Session, p: &Json) -> Result { - let (si, pi) = find_pivot(s, p)?; - let d = s.doc()?; - let pt = d.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?; - let mut out = pivot_json(&d.wb, si, pt); - let src = pv::read_source(&d.wb, &pt.source, si); - let list: Vec = match &src { - Ok(src) => src - .headers - .iter() - .enumerate() - .map(|(c, h)| { - let mut areas = vec![]; - if pt.rows.iter().any(|f| f.source_col.eq_ignore_ascii_case(h)) { - areas.push("rows"); - } - if pt.columns.iter().any(|f| f.source_col.eq_ignore_ascii_case(h)) { - areas.push("columns"); - } - if pt.values.iter().any(|f| f.source_col.eq_ignore_ascii_case(h)) { - areas.push("values"); - } - if pt.filters.iter().any(|f| f.source_col.eq_ignore_ascii_case(h)) && !in_axes(pt, h) { - areas.push("filters"); - } - json!({ - "name": h, - "numeric": src.numeric.get(c).copied().unwrap_or(false), - "date": src.dates.get(c).copied().unwrap_or(false), - "samples": pv::distinct_labels(&d.wb, src, c, PivotDateGroup::None, 5), - "areas": areas, - }) - }) - .collect(), - Err(_) => vec![], - }; - let mut list = list; - for c in &pt.calculated_fields { - let areas: Vec<&str> = if pt.values.iter().any(|v| v.source_col.eq_ignore_ascii_case(&c.name)) { vec!["values"] } else { vec![] }; - list.push(json!({"name": c.name, "numeric": true, "date": false, "samples": [], "areas": areas, "calculated": true, "formula": c.formula})); - } - if let Some(m) = out.as_object_mut() { - m.insert("fields".into(), Json::Array(list)); - if let Ok(src) = &src { - m.insert("records".into(), json!(src.rows.len())); - } - if let Err(e) = src { - m.insert("error".into(), json!(e)); - } - } - Ok(out) -} - -fn list(s: &mut Session, _: &Json) -> Result { - let d = s.doc()?; - let mut v = vec![]; - for (si, sh) in d.wb.sheets.iter().enumerate() { - for pt in &sh.pivots { - v.push(pivot_json(&d.wb, si, pt)); - } - } - Ok(Json::Array(v)) -} - -// ---------------------------------------------------------------- field commands - -fn position_param(p: &Json) -> Option { - u32_param(p, "position").map(|v| v as usize) -} - -fn add_field(s: &mut Session, p: &Json) -> Result { - const C: &str = "pivot.addField"; - let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); - let area = area_param(C, str_param(p, "area"))?; - let func = match str_param(p, "func") { - Some(f) => Some(func_param(Some(f)).ok_or_else(|| bad(C, format!("unknown function `{f}`")))?), - None => None, - }; - let name = str_param(p, "name").map(str::to_string); - let pos = position_param(p); - update(s, p, |pt, src, _| { - add_to_area(C, pt, src, &field, area, func, name.as_deref(), pos)?; - Ok(json!({"field": canonical_any(C, pt, src, &field)?, "area": area})) - }) -} - -fn calculated_field(s: &mut Session, p: &Json) -> Result { - const C: &str = "pivot.calculatedField"; - let Some(name) = str_param(p, "name").map(|n| n.trim().to_string()) else { - // Menu-style call: the Insert Calculated Field dialog. - let (si, pi) = find_pivot(s, p)?; - let d = s.doc()?; - let pt = d.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?; - let n = (1..).map(|k| format!("Field{k}")).find(|n| calc_field(pt, n).is_none()).unwrap_or_else(|| "Field1".into()); - let pivot = pt.name.clone(); - s.ui_requests.push(crate::UiRequest::Dialog("pivotCalculatedField".into(), json!({"pivot": pivot, "name": n, "formula": "= 0"}))); - return ok(); - }; - if name.is_empty() || name.chars().count() > 255 { - return Err(bad(C, "`name` must be 1 to 255 characters")); - } - let delete = bool_param(p, "delete").unwrap_or(false); - let formula = str_param(p, "formula").map(|f| { - let f = f.trim(); - if f.starts_with('=') { f.to_string() } else { format!("={f}") } - }); - let to_values = bool_param(p, "values").unwrap_or(true); - if !delete && formula.is_none() { - return Err(bad(C, "missing `formula`")); - } - update(s, p, |pt, src, _| { - let existing = pt.calculated_fields.iter().position(|c| c.name.eq_ignore_ascii_case(&name)); - if delete { - let i = existing.ok_or_else(|| bad(C, format!("no calculated field `{name}`")))?; - let gone = pt.calculated_fields.remove(i); - pt.values.retain(|v| !v.source_col.eq_ignore_ascii_case(&gone.name)); - return Ok(json!({"name": gone.name, "deleted": true})); - } - let formula = formula.clone().unwrap_or_default(); - if src.col(&name).is_some() { - return Err(other("PivotTable field name already exists.")); - } - let canon = match existing { - Some(i) => { - let c = pt.calculated_fields.get_mut(i).ok_or_else(|| other("There's no such calculated field."))?; - c.formula = formula.clone(); - c.name.clone() - } - None => { - if pt.calculated_fields.len() >= crate::pivot_calc::MAX_CALC_FIELDS { - return Err(other(format!("A PivotTable can have at most {} calculated fields.", crate::pivot_calc::MAX_CALC_FIELDS))); - } - pt.calculated_fields.push(PivotCalcField { name: name.clone(), formula: formula.clone() }); - name.clone() - } - }; - let set = crate::pivot_calc::CalcSet::new(pt, &src.headers); - let i = set.index(&canon).ok_or_else(|| other("There's no such calculated field."))?; - if let Some(e) = set.error(i) { - return Err(bad(C, e.to_string())); - } - if set.is_cyclic(i) { - 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(C, pt, src, &canon, "values", Some(PivotFunc::Sum), None, None)?; - } - Ok(json!({ - "name": canon, - "formula": formula, - "calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::>(), - })) - }) -} - -fn remove_field(s: &mut Session, p: &Json) -> Result { - const C: &str = "pivot.removeField"; - let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); - let area = match str_param(p, "area") { - Some(a) => Some(area_param(C, Some(a))?), - None => None, - }; - update(s, p, |pt, _, _| { - match area { - Some(a) => { - remove_from_area(C, pt, &field, a)?; - } - None => { - let mut any = false; - for a in ["rows", "columns", "values", "filters"] { - any |= remove_from_area(C, pt, &field, a).is_ok(); - } - if !any { - return Err(bad(C, format!("`{field}` isn't in the PivotTable"))); - } - } - } - Ok(json!({})) - }) -} - -fn move_field(s: &mut Session, p: &Json) -> Result { - const C: &str = "pivot.moveField"; - let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); - let from = area_param(C, str_param(p, "from"))?; - let to = area_param(C, str_param(p, "to"))?; - let pos = position_param(p); - update(s, p, |pt, src, _| { - if is_values_field(src, &field) { - return move_values_field(C, pt, from, to, pos); - } - if from == to { - // Reorder within the area. - match from { - "rows" | "columns" => { - let rows = from == "rows"; - let list = if rows { &mut pt.rows } else { &mut pt.columns }; - let i = list - .iter() - .position(|f| f.source_col.eq_ignore_ascii_case(&field)) - .ok_or_else(|| bad(C, format!("`{field}` isn't in the {from} area")))?; - let f = list.remove(i); - shift_values(pt, rows, i, false); - insert_axis_field(pt, rows, f, pos); - } - "values" => { - let i = pt - .values - .iter() - .position(|v| v.name.eq_ignore_ascii_case(&field)) - .or_else(|| pt.values.iter().position(|v| v.source_col.eq_ignore_ascii_case(&field))) - .ok_or_else(|| bad(C, format!("`{field}` isn't in the values area")))?; - let v = pt.values.remove(i); - let at = pos.unwrap_or(pt.values.len()).min(pt.values.len()); - pt.values.insert(at, v); - } - _ => { - let i = pt - .filters - .iter() - .position(|f| f.source_col.eq_ignore_ascii_case(&field)) - .ok_or_else(|| bad(C, format!("`{field}` isn't in the filters area")))?; - let f = pt.filters.remove(i); - let at = pos.unwrap_or(pt.filters.len()).min(pt.filters.len()); - pt.filters.insert(at, f); - } - } - return Ok(json!({})); - } - // Keep axis settings (sort, grouping) when moving between rows and columns. - 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(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))) { - *slot = k; - } - if let Some(h) = hidden.filter(|h| h.selected.is_some()) - && !pt.filters.iter().any(|f| f.source_col.eq_ignore_ascii_case(&src_field)) - { - pt.filters.push(h); - } - } - Ok(json!({"field": src_field, "to": to})) - }) -} - -fn value_settings(s: &mut Session, p: &Json) -> Result { - const C: &str = "pivot.valueSettings"; - let field = str_param(p, "field").map(str::to_string); - let index = u32_param(p, "index").map(|v| v as usize); - let func = match str_param(p, "func") { - Some(f) => Some(func_param(Some(f)).ok_or_else(|| bad(C, format!("unknown function `{f}`")))?), - None => None, - }; - let show = match str_param(p, "showAs") { - Some(v) => Some(show_as_param(v).ok_or_else(|| bad(C, format!("unknown showAs `{v}`")))?), - None => None, - }; - let name = str_param(p, "name").map(str::to_string); - let fmt = p.get("numberFormat").cloned(); - update(s, p, |pt, src, _| { - let i = match (index, &field) { - (Some(i), _) => i, - (None, Some(f)) => pt - .values - .iter() - .position(|v| v.name.eq_ignore_ascii_case(f)) - .or_else(|| pt.values.iter().position(|v| v.source_col.eq_ignore_ascii_case(f))) - .ok_or_else(|| bad(C, format!("`{f}` isn't in the values area")))?, - (None, None) => { - if pt.values.len() == 1 { - 0 - } else { - return Err(bad(C, "pass `field` or `index`")); - } - } - }; - let cur = pt.values.get(i).cloned().ok_or_else(|| bad(C, "no value field at that index"))?; - let mut v = cur.clone(); - if let Some(f) = func { - v.func = f; - // An automatic caption follows the function. - let auto = auto_name(cur.func, &cur.source_col); - if cur.name.eq_ignore_ascii_case(&auto) || cur.name.strip_prefix(auto.as_str()).is_some_and(|r| r.chars().all(|c| c.is_ascii_digit())) { - v.name = unique_value_name(pt, &auto_name(f, &cur.source_col), Some(i)); - } - } - if let Some(n) = &name { - let n = n.trim(); - if n.is_empty() { - return Err(other("The name isn't valid.")); - } - if src.col(n).is_some() { - return Err(other("PivotTable field name already exists.")); - } - if pt.values.iter().enumerate().any(|(j, x)| j != i && x.name.eq_ignore_ascii_case(n)) { - return Err(other("PivotTable field name already exists.")); - } - v.name = n.to_string(); - } - if let Some(sa) = show { - v.show_as = sa; - } - match &fmt { - Some(Json::String(f)) if !f.is_empty() && f.len() <= 255 => v.number_format = Some(f.clone()), - Some(Json::Null) => v.number_format = None, - Some(Json::String(f)) if f.is_empty() => v.number_format = None, - Some(_) => return Err(bad(C, "`numberFormat` is a format code or null")), - None => {} - } - let name = v.name.clone(); - if let Some(slot) = pt.values.get_mut(i) { - *slot = v; - } - Ok(json!({"name": name})) - }) -} - -fn filter(s: &mut Session, p: &Json) -> Result { - const C: &str = "pivot.filter"; - let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); - let selected: Option> = match p.get("selected") { - None | Some(Json::Null) => None, - Some(Json::Array(a)) => { - if a.len() > 100_000 { - return Err(bad(C, "too many items")); - } - let v: Vec = a - .iter() - .map(|x| match x { - Json::String(s) => s.clone(), - Json::Null => "(blank)".into(), - other => other.to_string(), - }) - .collect(); - if v.is_empty() { - return Err(other("You must select at least one item.")); - } - Some(v) - } - Some(Json::String(s)) => Some(vec![s.clone()]), - Some(_) => return Err(bad(C, "`selected` is a list of item labels or null")), - }; - update(s, p, |pt, src, _| { - let field = canonical(C, src, &field)?; - let i = pt.filters.iter().position(|f| f.source_col.eq_ignore_ascii_case(&field)); - match (i, selected) { - (Some(i), None) => { - if in_axes(pt, &field) { - pt.filters.remove(i); - } else if let Some(f) = pt.filters.get_mut(i) { - f.selected = None; - } - } - (Some(i), Some(sel)) => { - if let Some(f) = pt.filters.get_mut(i) { - f.selected = Some(sel); - } - } - (None, Some(sel)) => pt.filters.push(PivotFilter { source_col: field.clone(), selected: Some(sel) }), - (None, None) => {} - } - Ok(json!({"field": field})) - }) -} - -fn axis_field<'a>(cmd: &str, pt: &'a mut PivotTable, field: &str) -> Result<&'a mut PivotField> { - pt.rows - .iter_mut() - .chain(pt.columns.iter_mut()) - .find(|f| f.source_col.eq_ignore_ascii_case(field)) - .ok_or_else(|| bad(cmd, format!("`{field}` isn't in the rows or columns area"))) -} - -fn sort(s: &mut Session, p: &Json) -> Result { - const C: &str = "pivot.sort"; - let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); - let order = match str_param(p, "order").unwrap_or("asc").to_ascii_lowercase().as_str() { - "asc" | "ascending" | "a-z" => PivotSort::Asc, - "desc" | "descending" | "z-a" => PivotSort::Desc, - "none" | "manual" | "source" => PivotSort::None, - o => return Err(bad(C, format!("unknown order `{o}` (asc|desc|none)"))), - }; - update(s, p, |pt, _, _| { - axis_field(C, pt, &field)?.sort = order; - Ok(json!({})) - }) -} - -fn group_param(v: Option<&str>) -> Option { - Some(match v?.to_ascii_lowercase().as_str() { - "years" | "year" => PivotDateGroup::Years, - "quarters" | "quarter" => PivotDateGroup::Quarters, - "months" | "month" => PivotDateGroup::Months, - "days" | "day" => PivotDateGroup::Days, - "none" | "ungroup" => PivotDateGroup::None, - _ => return None, - }) -} - -fn group(s: &mut Session, p: &Json) -> Result { - const C: &str = "pivot.group"; - let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); - let raw = str_param(p, "by").unwrap_or("months"); - let by = group_param(Some(raw)).ok_or_else(|| bad(C, format!("unknown grouping `{raw}` (years|quarters|months|days|none)")))?; - update(s, p, |pt, src, _| { - if by != PivotDateGroup::None { - let c = src.col(&field).ok_or_else(|| bad(C, format!("no field `{field}` in the source data")))?; - if !src.numeric.get(c).copied().unwrap_or(false) { - return Err(other("Cannot group that selection.")); - } - } - let f = axis_field(C, pt, &field)?; - f.date_group = by; - f.collapsed_items.clear(); - Ok(json!({})) - }) -} - -fn collapse(s: &mut Session, p: &Json) -> Result { - const C: &str = "pivot.collapse"; - let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); - let on = bool_param(p, "collapse").unwrap_or(true); - let items: Option> = - p.get("items").and_then(Json::as_array).map(|a| a.iter().take(100_000).filter_map(|x| x.as_str().map(str::to_string)).collect()); - update(s, p, |pt, src, wb| { - let all = match &items { - Some(v) => v.clone(), - None => { - let f = pt.rows.iter().chain(pt.columns.iter()).find(|f| f.source_col.eq_ignore_ascii_case(&field)); - let g = f.map(|f| f.date_group).unwrap_or_default(); - match src.col(&field) { - Some(c) => pv::distinct_labels(wb, src, c, g, 100_000), - None => vec![], - } - } - }; - let f = axis_field(C, pt, &field)?; - if on { - for it in all { - if !f.collapsed_items.iter().any(|x| x.eq_ignore_ascii_case(&it)) { - f.collapsed_items.push(it); - } - } - } else if items.is_none() { - f.collapsed_items.clear(); - } else { - f.collapsed_items.retain(|x| !all.iter().any(|a| a.eq_ignore_ascii_case(x))); - } - Ok(json!({"collapsed": f.collapsed_items})) - }) -} - -fn layout(s: &mut Session, p: &Json) -> Result { - const C: &str = "pivot.layout"; - let lay = match str_param(p, "layout").map(|x| x.to_ascii_lowercase()) { - Some(l) if l == "compact" => Some(PivotLayout::Compact), - Some(l) if l == "outline" => Some(PivotLayout::Outline), - Some(l) if l == "tabular" => Some(PivotLayout::Tabular), - Some(l) => return Err(bad(C, format!("unknown layout `{l}` (compact|outline|tabular)"))), - None => None, - }; - let subtotals: Option<(bool, Option)> = match p.get("subtotals") { - Some(Json::Bool(b)) => Some((*b, None)), - Some(Json::String(m)) => match m.to_ascii_lowercase().as_str() { - "top" => Some((true, Some(true))), - "bottom" => Some((true, Some(false))), - "none" | "off" => Some((false, None)), - o => return Err(bad(C, format!("unknown subtotals `{o}` (top|bottom|none)"))), - }, - _ => None, - }; - let style = str_param(p, "style").map(str::to_string); - if style.as_ref().is_some_and(|s| s.len() > 64) { - return Err(bad(C, "style name too long")); - } - let gr = bool_param(p, "grandTotalsRows"); - let gc = bool_param(p, "grandTotalsCols"); - let hd = bool_param(p, "showHeaders"); - update(s, p, |pt, _, _| { - if let Some(l) = lay { - pt.layout = l; - if l == PivotLayout::Tabular { - pt.subtotals_top = false; - } - } - if let Some((on, top)) = subtotals { - pt.subtotals = on; - if let Some(t) = top { - pt.subtotals_top = t; - } - } - if let Some(st) = &style { - pt.style = st.clone(); - } - if let Some(v) = gr { - pt.grand_totals_rows = v; - } - if let Some(v) = gc { - pt.grand_totals_cols = v; - } - if let Some(v) = hd { - pt.show_headers = v; - } - Ok(json!({"layout": layout_name(pt.layout)})) - }) -} - -fn grand_totals(s: &mut Session, p: &Json) -> Result { - let (r, c) = match str_param(p, "mode").map(|m| m.to_ascii_lowercase()) { - Some(m) if m == "off" => (Some(false), Some(false)), - Some(m) if m == "on" => (Some(true), Some(true)), - Some(m) if m == "rows" => (Some(true), Some(false)), - Some(m) if m == "columns" || m == "cols" => (Some(false), Some(true)), - Some(m) => return Err(bad("pivot.grandTotals", format!("unknown mode `{m}` (off|on|rows|columns)"))), - None => (bool_param(p, "rows"), bool_param(p, "cols")), - }; - let mut q = p.as_object().cloned().unwrap_or_default(); - if let Some(v) = r { - q.insert("grandTotalsRows".into(), json!(v)); - } - if let Some(v) = c { - q.insert("grandTotalsCols".into(), json!(v)); - } - layout(s, &Json::Object(q)) -} - -fn refresh(s: &mut Session, p: &Json) -> Result { - update(s, p, |_, src, _| Ok(json!({"records": src.rows.len()}))) -} - -fn refresh_all(s: &mut Session, p: &Json) -> Result { - let replace = bool_param(p, "replace").unwrap_or(false); - edit(s, |cx| { - let mut n = 0; - let mut errors = vec![]; - for si in 0..cx.wb.sheets.len() { - let count = cx.wb.sheet(si).map(|sh| sh.pivots.len()).unwrap_or(0); - for pi in 0..count { - let name = cx.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).map(|p| p.name.clone()).unwrap_or_default(); - match pv::refresh(&mut cx.wb, si, pi, replace) { - Ok(_) => n += 1, - Err(e) => errors.push(json!({"pivot": name, "error": e})), - } - } - } - cx.structural = n > 0; - Ok(json!({"refreshed": n, "errors": errors})) - }) -} - -fn change_source(s: &mut Session, p: &Json) -> Result { - const C: &str = "pivot.changeSource"; - if str_param(p, "source").or_else(|| str_param(p, "range")).is_none() { - return Err(bad(C, "missing `source`")); - } - let source = source_param(s, p)?; - let (si, _) = find_pivot(s, p)?; - let src = pv::read_source(&s.doc()?.wb, &source, si).map_err(other)?; - update(s, p, move |pt, _, _| { - pt.source = source.clone(); - let mut dropped: Vec = vec![]; - let keep = |name: &str, dropped: &mut Vec| { - let ok = src.col(name).is_some(); - if !ok { - dropped.push(name.to_string()); - } - ok - }; - // Axis fields leave one by one so Σ Values keeps its place among the rest. - for rows in [true, false] { - let mut i = 0; - loop { - let list = if rows { &mut pt.rows } else { &mut pt.columns }; - let Some(f) = list.get(i) else { break }; - if keep(&f.source_col, &mut dropped) { - i += 1; - } else { - list.remove(i); - shift_values(pt, rows, i, false); - } - } - } - pt.values.retain(|f| keep(&f.source_col, &mut dropped)); - pt.filters.retain(|f| keep(&f.source_col, &mut dropped)); - Ok(json!({"source": source, "dropped": dropped})) - }) -} - -fn delete(s: &mut Session, p: &Json) -> Result { - let (si, pi) = find_pivot(s, p)?; - edit(s, |cx| { - // Its PivotCharts become ordinary charts. - let link = cx.wb.sheet(si).and_then(|sh| sh.pivots.get(pi).map(|pt| (sh.name.clone(), pt.name.clone()))); - if let Some((sheet, name)) = link { - for i in 0..cx.wb.sheets.len() { - let linked = |c: &Chart| c.pivot.as_deref().is_some_and(|l| pv::links_to(l, &sheet, &name)); - if cx.wb.sheet(i).is_some_and(|sh| sh.charts.iter().any(linked)) { - for c in cx.sheet_mut(i)?.charts.iter_mut().filter(|c| linked(c)) { - c.pivot = None; - } - } - } - } - pv::clear_output(&mut cx.wb, si, pi); - let sh = cx.sheet_mut(si)?; - if pi < sh.pivots.len() { - sh.pivots.remove(pi); - } - cx.structural = true; - Ok(json!({})) - }) -} - -// ---------------------------------------------------------------- PivotChart - -/// The PivotTable a PivotChart call targets: `pivot` may also be a cell of its report. -fn chart_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> { - if let Some(t) = str_param(p, "pivot") { - let d = s.doc()?; - let (sheet, addr) = match t.rsplit_once('!') { - Some((sh, a)) => { - let sh = sh.trim(); - let sh = gridcraft_core::unquote_sheet(sh); - (d.wb.sheet_index(&sh), a) - } - None => (Some(d.wb.active_sheet()), t), - }; - if let (Some(si), Some(c)) = (sheet, CellRef::parse(addr.trim().replace('$', "").as_str())) { - let hit = d.wb.sheet(si).and_then(|sh| sh.pivots.iter().position(|pt| pt.last_range.is_some_and(|r| r.contains(c)) || pt.anchor == c)); - return hit.map(|pi| (si, pi)).ok_or_else(|| bad("insert.pivotChart", format!("{t} is not in a PivotTable"))); - } - } - find_pivot(s, p) -} - -fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result { - let (si, pi) = chart_pivot(s, p)?; - let d = s.doc()?; - let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; - let pt = sh.pivots.get(pi).ok_or_else(|| other("There's no such PivotTable."))?; - let area = pt.last_range.ok_or_else(|| other("Refresh the PivotTable before adding a PivotChart."))?; - let out = pv::compute(&d.wb, pt, si).map_err(other)?; - if out.chart_rows.is_empty() || out.chart_cols.is_empty() { - return Err(other("Add a field to the Values area and one to Rows or Columns before creating a PivotChart.")); - } - let series = pv::chart_series(&sh.name, area.start, &out); - let t = str_param(p, "type").unwrap_or("column"); - let kind = super::insert::chart_kind(t, str_param(p, "subtype").unwrap_or("")); - let at = - cell_param(s, p, "at")?.unwrap_or_else(|| CellRef::new(area.start.row, area.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1))); - let title = match p.get("title") { - Some(Json::String(t)) => Some(t.clone()), - Some(Json::Null) => None, - _ => Some(if series.len() == 1 { "Total".to_string() } else { "Chart Title".to_string() }), - }; - let id = d.wb.next_object_id(); - let link = pv::chart_link(&sh.name, &pt.name); - let pivot_name = pt.name.clone(); - let n = series.len(); - let chart = Chart { - id, - kind, - anchor: Anchor { - cell: at, - dx: 0.0, - dy: 0.0, - width: f64_param(p, "width").unwrap_or(480.0).clamp(10.0, 10_000.0) as f32, - height: f64_param(p, "height").unwrap_or(288.0).clamp(10.0, 10_000.0) as f32, - mode: anchor_mode_param(p).unwrap_or_default(), - }, - title, - series, - legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom }, - data_labels: false, - gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel), - style: 1, - x_title: None, - y_title: None, - source: None, - by_rows: false, - pivot: Some(link), - }; - edit(s, |cx| { - cx.sheet_mut(si)?.charts.push(chart); - Ok(json!({"chart": id, "pivot": pivot_name, "series": n})) - }) -} - -// ---------------------------------------------------------------- recommended - -fn suggestions(wb: &Workbook, src: &pv::Source) -> Vec { - let n = src.headers.len(); - let h = |i: usize| src.headers.get(i).cloned().unwrap_or_default(); - let distinct = |i: usize| pv::distinct_labels(wb, src, i, PivotDateGroup::None, 1001).len(); - let records = src.rows.len().max(1); - let is_num = |i: usize| src.numeric.get(i).copied().unwrap_or(false); - let is_date = |i: usize| src.dates.get(i).copied().unwrap_or(false); - // Category columns: text with repeats. - let cats: Vec = (0..n) - .filter(|&i| { - !is_num(i) && { - let d = distinct(i); - d >= 1 && (d < records || records <= 2) && d <= 1000 - } - }) - .collect(); - let nums: Vec = (0..n).filter(|&i| is_num(i) && !is_date(i)).collect(); - let dates: Vec = (0..n).filter(|&i| is_date(i)).collect(); - let mut out = vec![]; - let mut push = |title: String, rows: Vec, cols: Vec, vals: Vec| { - if out.len() < 5 { - let idx = out.len(); - out.push(json!({"index": idx, "title": title, "rows": rows, "columns": cols, "values": vals})); - } - }; - if let (Some(&c), Some(&v)) = (cats.first(), nums.first()) { - push(format!("Sum of {} by {}", h(v), h(c)), vec![json!(h(c))], vec![], vec![json!({"field": h(v), "func": "sum"})]); - } - if let (Some(&c), Some(&c2), Some(&v)) = (cats.first(), cats.get(1), nums.first()) { - push( - format!("Sum of {} by {} and {}", h(v), h(c), h(c2)), - vec![json!(h(c))], - vec![json!(h(c2))], - vec![json!({"field": h(v), "func": "sum"})], - ); - } - if let (Some(&d), Some(&v)) = (dates.first(), nums.first()) { - push( - format!("Sum of {} by {} (quarters)", h(v), h(d)), - vec![json!({"field": h(d), "group": "quarters"})], - vec![], - vec![json!({"field": h(v), "func": "sum"})], - ); - } - if let (Some(&c), Some(&v), Some(&v2)) = (cats.first(), nums.first(), nums.get(1)) { - push( - format!("Sum of {} and {} by {}", h(v), h(v2), h(c)), - vec![json!(h(c))], - vec![], - vec![json!({"field": h(v), "func": "sum"}), json!({"field": h(v2), "func": "sum"})], - ); - } - if let Some(&c) = cats.first() { - push(format!("Count of {} by {}", h(c), h(c)), vec![json!(h(c))], vec![], vec![json!({"field": h(c), "func": "count"})]); - } - if let (Some(&c), Some(&v)) = (cats.get(1), nums.first()) { - push(format!("Average of {} by {}", h(v), h(c)), vec![json!(h(c))], vec![], vec![json!({"field": h(v), "func": "average"})]); - } - if cats.is_empty() - && dates.is_empty() - && let Some(&v) = nums.first() - { - push(format!("Sum of {}", h(v)), vec![], vec![], vec![json!({"field": h(v), "func": "sum"})]); - } - out -} - -fn recommended(s: &mut Session, p: &Json) -> Result { - let source = source_param(s, p)?; - let active = s.doc()?.wb.active_sheet(); - let src = pv::read_source(&s.doc()?.wb, &source, active).map_err(other)?; - let list = suggestions(&s.doc()?.wb, &src); - let Some(apply) = p.get("apply").and_then(Json::as_u64) else { - return Ok(json!({"source": source, "suggestions": list})); - }; - let pick = list.get(apply as usize).cloned().ok_or_else(|| bad("insert.recommendedPivotTables", "no suggestion with that index"))?; - let mut q = json!({ - "source": source, - "rows": pick.get("rows").cloned().unwrap_or(json!([])), - "columns": pick.get("columns").cloned().unwrap_or(json!([])), - "values": pick.get("values").cloned().unwrap_or(json!([])), - }); - if let (Some(m), Some(d)) = (q.as_object_mut(), p.get("destination")) { - m.insert("destination".into(), d.clone()); - } - if let (Some(m), Some(r)) = (q.as_object_mut(), p.get("replace")) { - m.insert("replace".into(), r.clone()); - } - let mut res = insert_pivot(s, &q)?; - if let Some(m) = res.as_object_mut() { - m.insert("suggestion".into(), pick); - } - Ok(res) -} - #[cfg(test)] mod tests; diff --git a/crates/engine/src/cmd/pivot/charts.rs b/crates/engine/src/cmd/pivot/charts.rs new file mode 100644 index 000000000..0a0d72ed4 --- /dev/null +++ b/crates/engine/src/cmd/pivot/charts.rs @@ -0,0 +1,174 @@ +use super::create::{insert_pivot, source_param}; +use super::*; + +// ---------------------------------------------------------------- PivotChart + +/// The PivotTable a PivotChart call targets: `pivot` may also be a cell of its report. +fn chart_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> { + if let Some(t) = str_param(p, "pivot") { + let d = s.doc()?; + let (sheet, addr) = match t.rsplit_once('!') { + Some((sh, a)) => { + let sh = sh.trim(); + let sh = gridcraft_core::unquote_sheet(sh); + (d.wb.sheet_index(&sh), a) + } + None => (Some(d.wb.active_sheet()), t), + }; + if let (Some(si), Some(c)) = (sheet, CellRef::parse(addr.trim().replace('$', "").as_str())) { + let hit = d.wb.sheet(si).and_then(|sh| sh.pivots.iter().position(|pt| pt.last_range.is_some_and(|r| r.contains(c)) || pt.anchor == c)); + return hit.map(|pi| (si, pi)).ok_or_else(|| bad("insert.pivotChart", format!("{t} is not in a PivotTable"))); + } + } + find_pivot(s, p) +} + +pub(super) fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result { + let (si, pi) = chart_pivot(s, p)?; + let d = s.doc()?; + let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; + let pt = sh.pivots.get(pi).ok_or_else(|| other("There's no such PivotTable."))?; + let area = pt.last_range.ok_or_else(|| other("Refresh the PivotTable before adding a PivotChart."))?; + let out = pv::compute(&d.wb, pt, si).map_err(other)?; + if out.chart_rows.is_empty() || out.chart_cols.is_empty() { + return Err(other("Add a field to the Values area and one to Rows or Columns before creating a PivotChart.")); + } + let series = pv::chart_series(&sh.name, area.start, &out); + let t = str_param(p, "type").unwrap_or("column"); + let kind = crate::cmd::insert::chart_kind(t, str_param(p, "subtype").unwrap_or("")); + let at = + cell_param(s, p, "at")?.unwrap_or_else(|| CellRef::new(area.start.row, area.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1))); + let title = match p.get("title") { + Some(Json::String(t)) => Some(t.clone()), + Some(Json::Null) => None, + _ => Some(if series.len() == 1 { "Total".to_string() } else { "Chart Title".to_string() }), + }; + let id = d.wb.next_object_id(); + let link = pv::chart_link(&sh.name, &pt.name); + let pivot_name = pt.name.clone(); + let n = series.len(); + let chart = Chart { + id, + kind, + anchor: Anchor { + cell: at, + dx: 0.0, + dy: 0.0, + width: f64_param(p, "width").unwrap_or(480.0).clamp(10.0, 10_000.0) as f32, + height: f64_param(p, "height").unwrap_or(288.0).clamp(10.0, 10_000.0) as f32, + mode: anchor_mode_param(p).unwrap_or_default(), + }, + title, + series, + legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom }, + data_labels: false, + gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel), + style: 1, + x_title: None, + y_title: None, + source: None, + by_rows: false, + pivot: Some(link), + }; + edit(s, |cx| { + cx.sheet_mut(si)?.charts.push(chart); + Ok(json!({"chart": id, "pivot": pivot_name, "series": n})) + }) +} + +// ---------------------------------------------------------------- recommended + +fn suggestions(wb: &Workbook, src: &pv::Source) -> Vec { + let n = src.headers.len(); + let h = |i: usize| src.headers.get(i).cloned().unwrap_or_default(); + let distinct = |i: usize| pv::distinct_labels(wb, src, i, PivotDateGroup::None, 1001).len(); + let records = src.rows.len().max(1); + let is_num = |i: usize| src.numeric.get(i).copied().unwrap_or(false); + let is_date = |i: usize| src.dates.get(i).copied().unwrap_or(false); + // Category columns: text with repeats. + let cats: Vec = (0..n) + .filter(|&i| { + !is_num(i) && { + let d = distinct(i); + d >= 1 && (d < records || records <= 2) && d <= 1000 + } + }) + .collect(); + let nums: Vec = (0..n).filter(|&i| is_num(i) && !is_date(i)).collect(); + let dates: Vec = (0..n).filter(|&i| is_date(i)).collect(); + let mut out = vec![]; + let mut push = |title: String, rows: Vec, cols: Vec, vals: Vec| { + if out.len() < 5 { + let idx = out.len(); + out.push(json!({"index": idx, "title": title, "rows": rows, "columns": cols, "values": vals})); + } + }; + if let (Some(&c), Some(&v)) = (cats.first(), nums.first()) { + push(format!("Sum of {} by {}", h(v), h(c)), vec![json!(h(c))], vec![], vec![json!({"field": h(v), "func": "sum"})]); + } + if let (Some(&c), Some(&c2), Some(&v)) = (cats.first(), cats.get(1), nums.first()) { + push( + format!("Sum of {} by {} and {}", h(v), h(c), h(c2)), + vec![json!(h(c))], + vec![json!(h(c2))], + vec![json!({"field": h(v), "func": "sum"})], + ); + } + if let (Some(&d), Some(&v)) = (dates.first(), nums.first()) { + push( + format!("Sum of {} by {} (quarters)", h(v), h(d)), + vec![json!({"field": h(d), "group": "quarters"})], + vec![], + vec![json!({"field": h(v), "func": "sum"})], + ); + } + if let (Some(&c), Some(&v), Some(&v2)) = (cats.first(), nums.first(), nums.get(1)) { + push( + format!("Sum of {} and {} by {}", h(v), h(v2), h(c)), + vec![json!(h(c))], + vec![], + vec![json!({"field": h(v), "func": "sum"}), json!({"field": h(v2), "func": "sum"})], + ); + } + if let Some(&c) = cats.first() { + push(format!("Count of {} by {}", h(c), h(c)), vec![json!(h(c))], vec![], vec![json!({"field": h(c), "func": "count"})]); + } + if let (Some(&c), Some(&v)) = (cats.get(1), nums.first()) { + push(format!("Average of {} by {}", h(v), h(c)), vec![json!(h(c))], vec![], vec![json!({"field": h(v), "func": "average"})]); + } + if cats.is_empty() + && dates.is_empty() + && let Some(&v) = nums.first() + { + push(format!("Sum of {}", h(v)), vec![], vec![], vec![json!({"field": h(v), "func": "sum"})]); + } + out +} + +pub(super) fn recommended(s: &mut Session, p: &Json) -> Result { + let source = source_param(s, p)?; + let active = s.doc()?.wb.active_sheet(); + let src = pv::read_source(&s.doc()?.wb, &source, active).map_err(other)?; + let list = suggestions(&s.doc()?.wb, &src); + let Some(apply) = p.get("apply").and_then(Json::as_u64) else { + return Ok(json!({"source": source, "suggestions": list})); + }; + let pick = list.get(apply as usize).cloned().ok_or_else(|| bad("insert.recommendedPivotTables", "no suggestion with that index"))?; + let mut q = json!({ + "source": source, + "rows": pick.get("rows").cloned().unwrap_or(json!([])), + "columns": pick.get("columns").cloned().unwrap_or(json!([])), + "values": pick.get("values").cloned().unwrap_or(json!([])), + }); + if let (Some(m), Some(d)) = (q.as_object_mut(), p.get("destination")) { + m.insert("destination".into(), d.clone()); + } + if let (Some(m), Some(r)) = (q.as_object_mut(), p.get("replace")) { + m.insert("replace".into(), r.clone()); + } + let mut res = insert_pivot(s, &q)?; + if let Some(m) = res.as_object_mut() { + m.insert("suggestion".into(), pick); + } + Ok(res) +} diff --git a/crates/engine/src/cmd/pivot/create.rs b/crates/engine/src/cmd/pivot/create.rs new file mode 100644 index 000000000..48770f3a0 --- /dev/null +++ b/crates/engine/src/cmd/pivot/create.rs @@ -0,0 +1,340 @@ +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::*; + +// ---------------------------------------------------------------- insert + +/// The default source: the table at the selection, else the selected block / current region. +fn default_source(s: &Session) -> Result { + let d = s.doc()?; + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + if let Some(t) = sh.table_at(d.selection.active) { + return Ok(t.name.clone()); + } + let sel = d.selection.current(); + let r = if sel.is_single() { + current_region(sh, d.selection.active) + } else { + let used = sh.used_range().unwrap_or(sel); + sel.intersection(&used).unwrap_or(sel) + }; + if r.is_single() && sh.value(r.start).is_empty() { + return Err(other("The reference isn't valid. Select a cell in a range with labeled columns, or pass `source`.")); + } + Ok(pv::range_text(&sh.name, r)) +} + +pub(super) fn source_param(s: &Session, p: &Json) -> Result { + match str_param(p, "source").or_else(|| str_param(p, "range")) { + Some(t) if !t.trim().is_empty() => { + // Bare ranges are on the active sheet; store them with the sheet name. + let d = s.doc()?; + let t = t.trim().trim_start_matches('='); + if !t.contains('!') + && RangeRef::parse(t).is_some() + && let Some(sh) = d.wb.active() + && let Some(r) = RangeRef::parse(t) + { + return Ok(pv::range_text(&sh.name, r)); + } + Ok(t.to_string()) + } + _ => default_source(s), + } +} + +fn next_pivot_name(wb: &Workbook) -> String { + (1..) + .map(|n| format!("PivotTable{n}")) + .find(|n| !wb.sheets.iter().any(|s| s.pivots.iter().any(|p| p.name.eq_ignore_ascii_case(n)))) + .unwrap_or_default() +} + +fn next_pivot_id(wb: &Workbook) -> u32 { + let m = wb.sheets.iter().flat_map(|s| s.pivots.iter().map(|p| p.id)).max().unwrap_or(0); + wb.next_object_id().max(m.saturating_add(1)) +} + +fn field_list(v: Option<&Json>) -> Vec { + match v { + Some(Json::Array(a)) => a.iter().take(512).cloned().collect(), + Some(Json::String(s)) => vec![Json::String(s.clone())], + _ => vec![], + } +} + +pub(super) fn insert_pivot(s: &mut Session, p: &Json) -> Result { + if s.doc()?.wb.protected_structure && matches!(str_param(p, "destination"), None | Some("new")) { + return Err(other("The workbook is protected and cannot be changed.")); + } + let source = source_param(s, p)?; + let active = s.doc()?.wb.active_sheet(); + // Validate the source now (header names, size). + let src = pv::read_source(&s.doc()?.wb, &source, active).map_err(other)?; + let name = match str_param(p, "name") { + Some(n) if !n.trim().is_empty() => { + let n = n.trim().to_string(); + if s.doc()?.wb.sheets.iter().any(|sh| sh.pivots.iter().any(|x| x.name.eq_ignore_ascii_case(&n))) { + return Err(other("A PivotTable with that name already exists.")); + } + n + } + _ => next_pivot_name(&s.doc()?.wb), + }; + let dest = str_param(p, "destination").or_else(|| str_param(p, "location")).unwrap_or("new").trim().to_string(); + // Existing sheet destination: (sheet index, anchor). + let target: Option<(usize, CellRef)> = if dest.eq_ignore_ascii_case("new") || dest.is_empty() { + None + } else { + let d = s.doc()?; + let (sh, body) = crate::cmd::split_sheet(&dest); + let si = match sh { + Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("insert.pivotTable", format!("no sheet named `{n}`")))?, + None => active, + }; + let at = RangeRef::parse(body).map(|r| r.start).ok_or_else(|| bad("insert.pivotTable", format!("not a cell: `{dest}`")))?; + Some((si, at)) + }; + let replace = bool_param(p, "replace").unwrap_or(false); + let rows = field_list(p.get("rows")); + let columns = field_list(p.get("columns")); + let values = field_list(p.get("values")); + let filters = field_list(p.get("filters")); + let mut new_sheet_at: Option = None; + let res = edit(s, |cx| { + let id = next_pivot_id(&cx.wb); + let (si, anchor) = match target { + Some(t) => t, + None => { + let at = insert_sheet_before_active(cx); + new_sheet_at = Some(at); + (at, CellRef::new(2, 0)) + } + }; + let mut pt = PivotTable { id, name: name.clone(), source: source.clone(), anchor, ..Default::default() }; + let add = |pt: &mut PivotTable, list: &[Json], area: &str| -> Result<()> { + for f in list { + match f { + 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") { + Some(fname) => { + Some(func_param(Some(fname)).ok_or_else(|| bad("insert.pivotTable", format!("unknown function `{fname}`")))?) + } + None => 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") + && src.col(n).and_then(|c| src.numeric.get(c).copied()).unwrap_or(false) + && let Some(af) = pt.rows.iter_mut().chain(pt.columns.iter_mut()).find(|x| x.source_col.eq_ignore_ascii_case(n)) + { + af.date_group = by; + } + } + _ => return Err(bad("insert.pivotTable", "fields are names or {field, func?, name?}")), + } + } + Ok(()) + }; + add(&mut pt, &rows, "rows")?; + add(&mut pt, &columns, "columns")?; + add(&mut pt, &values, "values")?; + add(&mut pt, &filters, "filters")?; + let sh = cx.sheet_mut(si)?; + sh.pivots.push(pt); + let pi = sh.pivots.len() - 1; + let area = pv::refresh(&mut cx.wb, si, pi, replace).map_err(other)?; + cx.wb.set_active_sheet(si); + *cx.sel = crate::Selection::at(anchor); + cx.structural = true; + let sheet_name = cx.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default(); + Ok(json!({ + "id": id, + "name": name, + "sheet": sheet_name, + "anchor": anchor.a1(), + "range": area.a1(), + "source": source, + "fields": src.headers, + })) + })?; + if let Some(at) = new_sheet_at + && let Ok(d) = s.doc_mut() + { + d.sheet_selections.insert(at.min(d.sheet_selections.len()), crate::Selection::default()); + } + Ok(res) +} + +/// Inserts a new, empty sheet before the active one (Excel's place for a new PivotTable or a +/// Show Details sheet); returns its index. +fn insert_sheet_before_active(cx: &mut Ctx) -> usize { + let at = cx.wb.active_sheet().min(cx.wb.sheets.len()); + let nm = cx.wb.next_sheet_name(); + cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm))); + for n in cx.wb.names.iter_mut() { + if let Some(sc) = n.scope.as_mut() + && *sc >= at + { + *sc += 1; + } + } + at +} + +/// Show Details (double-click a value cell): the source records behind it, as values with their +/// column's number format, under the source headers on a new sheet. +pub(super) fn show_details(s: &mut Session, p: &Json) -> Result { + let d = s.doc()?; + let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); + let active = d.wb.active_sheet(); + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let pt = sh + .pivots + .iter() + .find(|pt| pt.last_range.is_some_and(|r| r.contains(at))) + .ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?; + let origin = pt.last_range.map(|r| r.start).unwrap_or_default(); + let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)); + // With the option off, a report that is out of date or a source that cannot be read is + // reported as the option; only a cell that is not a value cell keeps its own message. + if !pt.enable_show_details && !matches!(found, Ok(None)) { + return Err(other(SHOW_DETAILS_OFF)); + } + let (src, records) = found.map_err(other)?.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?; + if d.wb.protected_structure { + return Err(other("The workbook is protected and cannot be changed.")); + } + let count = records.len(); + let mut new_sheet_at: Option = None; + let res = edit(s, |cx| { + let si = insert_sheet_before_active(cx); + new_sheet_at = Some(si); + let base = cx.wb.styles.get(StyleId::default()).clone(); + let fmts: Vec = src + .formats + .iter() + .map(|f| { + let mut st = base.clone(); + st.num_fmt = NumFmt::new(f); + cx.wb.styles.intern(st) + }) + .collect(); + // Columns wide enough for their text, as the PivotTable report's are (dates would show + // as ####); the first rows are enough to judge a column. + let font = base.font.clone(); + let widths: Vec = src + .headers + .iter() + .enumerate() + .map(|(c, h)| { + let fmt = src.formats.get(c).map(String::as_str).unwrap_or("General"); + let texts = records.iter().take(1_000).filter_map(|i| src.rows.get(*i).and_then(|r| r.get(c))); + let widest = texts + .map(|v| crate::cmd::format::approx_text_width(&crate::display::format(v, fmt, &cx.wb).text, font.size, false)) + .fold(crate::cmd::format::approx_text_width(h, font.size, false), f32::max); + (widest + 14.0).min(320.0) + }) + .collect(); + let sh = cx.sheet_mut(si)?; + for (c, w) in widths.into_iter().enumerate() { + if w > sh.col_width(c as u32) { + sh.cols.entry(c as u32).or_default().size = Some(w); + } + } + for (c, h) in src.headers.iter().enumerate() { + sh.set_cell(CellRef::new(0, c as u32), Cell { value: gridcraft_core::Value::text(h.as_str()), formula: None, style: StyleId::default() }); + } + for (r, i) in records.iter().enumerate() { + let Some(rec) = src.rows.get(*i) else { continue }; + for (c, v) in rec.iter().enumerate() { + let style = fmts.get(c).copied().unwrap_or_default(); + if v.is_empty() && style == StyleId::default() { + continue; + } + sh.set_cell(CellRef::new(r as u32 + 1, c as u32), Cell { value: v.clone(), formula: None, style }); + } + } + let name = sh.name.clone(); + cx.wb.set_active_sheet(si); + *cx.sel = crate::Selection::at(CellRef::new(0, 0)); + cx.structural = true; + Ok(json!({"sheet": name, "records": count})) + })?; + if let Some(at) = new_sheet_at + && let Ok(d) = s.doc_mut() + { + d.sheet_selections.insert(at.min(d.sheet_selections.len()), crate::Selection::default()); + } + Ok(res) +} + +/// The message Show Details gives while Enable show details is off. +const SHOW_DETAILS_OFF: &str = "Show Details is turned off for this PivotTable."; + +/// PivotTable Options: Enable show details (Data). Changes only the option; the report is not +/// refreshed. Without params it returns the current setting. +pub(super) fn options(s: &mut Session, p: &Json) -> Result { + let (si, pi) = find_pivot(s, p)?; + let enable = bool_param(p, "enableShowDetails"); + if p.get("enableShowDetails").is_some_and(|v| !v.is_null()) && enable.is_none() { + return Err(bad("pivot.options", "`enableShowDetails` must be true or false")); + } + let current = |s: &Session| -> Result { + let pt = s.doc()?.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?; + Ok(json!({"enableShowDetails": pt.enable_show_details})) + }; + let Some(on) = enable else { return current(s) }; + if current(s)?["enableShowDetails"] == json!(on) { + return current(s); + } + edit(s, |cx| { + let pt = cx.sheet_mut(si)?.pivots.get_mut(pi).ok_or_else(|| other("There's no such PivotTable."))?; + pt.enable_show_details = on; + Ok(json!({"enableShowDetails": on})) + }) +} + +pub(super) fn summarize_table(s: &mut Session, p: &Json) -> Result { + let d = s.doc()?; + let name = match str_param(p, "table") { + Some(n) => d.wb.table(n).and_then(|(si, ti)| d.wb.sheet(si).and_then(|sh| sh.tables.get(ti))).map(|t| t.name.clone()), + None => d.wb.active().and_then(|sh| sh.table_at(d.selection.active)).map(|t| t.name.clone()), + } + .ok_or_else(|| EngineError::Disabled("table.summarizePivot".into(), "select a cell in a table".into()))?; + let mut q = with(p, "source", json!(name)); + if let Some(m) = q.as_object_mut() { + m.remove("table"); + } + insert_pivot(s, &q) +} + +// ---------------------------------------------------------------- queries + +pub(super) fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json { + json!({ + "id": pt.id, + "name": pt.name, + "sheet": wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default(), + "anchor": pt.anchor.a1(), + "range": pt.last_range.map(|r| r.a1()), + "source": pt.source, + "rows": pt.rows.iter().map(|f| json!({"field": f.source_col, "sort": sort_name(f.sort), "group": group_name(f.date_group), "collapsed": f.collapsed_items})).collect::>(), + "columns": pt.columns.iter().map(|f| json!({"field": f.source_col, "sort": sort_name(f.sort), "group": group_name(f.date_group), "collapsed": f.collapsed_items})).collect::>(), + "values": pt.values.iter().map(|v| json!({"field": v.source_col, "func": func_name(v.func), "name": v.name, "showAs": show_as_name(v.show_as), "numberFormat": v.number_format})).collect::>(), + "filters": pt.filters.iter().map(|f| json!({"field": f.source_col, "selected": f.selected, "report": !in_axes(pt, &f.source_col)})).collect::>(), + "layout": layout_name(pt.layout), + "grandTotalsRows": pt.grand_totals_rows, + "grandTotalsCols": pt.grand_totals_cols, + "subtotals": if !pt.subtotals { "none" } else if pt.subtotals_top { "top" } else { "bottom" }, + "style": pt.style, + "showHeaders": pt.show_headers, + "calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::>(), + "valuesOnRows": pt.values_on_rows, + "valuesPosition": pt.values_position, + "enableShowDetails": pt.enable_show_details, + }) +} diff --git a/crates/engine/src/cmd/pivot/fields.rs b/crates/engine/src/cmd/pivot/fields.rs new file mode 100644 index 000000000..344a60af3 --- /dev/null +++ b/crates/engine/src/cmd/pivot/fields.rs @@ -0,0 +1,541 @@ +use super::create::pivot_json; +use super::params::{area_param, func_param, position_param, show_as_param}; +use super::*; + +// ---------------------------------------------------------------- layout editing (pure) + +pub(super) fn in_axes(pt: &PivotTable, field: &str) -> bool { + pt.rows.iter().chain(pt.columns.iter()).any(|f| f.source_col.eq_ignore_ascii_case(field)) +} + +/// Name of the Σ Values pseudo-field (where the value fields sit when there are two or more). +const VALUES_FIELD: &str = "Σ Values"; + +/// Whether `field` names Σ Values: `Σ Values`, or `Values` unless a source field has that name. +fn is_values_field(src: &pv::Source, field: &str) -> bool { + let f = field.trim(); + f == VALUES_FIELD || (f.eq_ignore_ascii_case("values") && src.col(f).is_none()) +} + +/// Keeps Σ Values in place after a field left (`inserted` false) or arrived at index `i` of +/// Rows (`rows`) or Columns. +pub(super) fn shift_values(pt: &mut PivotTable, rows: bool, i: usize, inserted: bool) { + if pt.values_on_rows != rows { + return; + } + let Some(p) = pt.values_position else { return }; + // Σ Values stays just ahead of the field it preceded. + let p = match inserted { + true if i <= p => p + 1, + false if i < p => p - 1, + _ => p, + }; + let n = if rows { pt.rows.len() } else { pt.columns.len() }; + pt.values_position = (p < n).then_some(p); +} + +/// Takes `field` out of Rows and Columns (Σ Values keeps its place); returns its first entry. +fn take_from_axes(pt: &mut PivotTable, field: &str) -> Option { + let mut first = None; + for rows in [true, false] { + loop { + let list = if rows { &mut pt.rows } else { &mut pt.columns }; + let Some(i) = list.iter().position(|f| f.source_col.eq_ignore_ascii_case(field)) else { break }; + let f = list.remove(i); + shift_values(pt, rows, i, false); + first.get_or_insert(f); + } + } + first +} + +/// Inserts `f` into Rows (`rows`) or Columns at `position` (default: the end). +fn insert_axis_field(pt: &mut PivotTable, rows: bool, f: PivotField, position: Option) { + let list = if rows { &mut pt.rows } else { &mut pt.columns }; + let at = position.unwrap_or(list.len()).min(list.len()); + list.insert(at, f); + shift_values(pt, rows, at, true); +} + +/// Moves Σ Values between Rows and Columns, or within Rows, to `position` (default: the end). +/// In Columns it always comes after the last column field: the column layout doesn't place it +/// anywhere else yet. +fn move_values_field(cmd: &str, pt: &mut PivotTable, from: &str, to: &str, position: Option) -> Result { + let current = if pt.values_on_rows { "rows" } else { "columns" }; + if from != current { + return Err(bad(cmd, format!("`{VALUES_FIELD}` isn't in the {from} area"))); + } + if !matches!(to, "rows" | "columns") { + return Err(other("The field you are moving cannot be placed in that PivotTable area.")); + } + pt.values_on_rows = to == "rows"; + pt.values_position = position.filter(|p| pt.values_on_rows && *p < pt.rows.len()); + Ok(json!({"field": VALUES_FIELD, "to": to})) +} + +/// The canonical source field name for `field`. +pub(super) fn canonical(cmd: &str, src: &pv::Source, field: &str) -> Result { + src.col(field).and_then(|c| src.headers.get(c).cloned()).ok_or_else(|| bad(cmd, format!("no field `{field}` in the source data"))) +} + +/// The calculated field named `field`, if any. +fn calc_field<'a>(pt: &'a PivotTable, field: &str) -> Option<&'a PivotCalcField> { + pt.calculated_fields.iter().find(|c| c.name.eq_ignore_ascii_case(field.trim())) +} + +/// The canonical name of a source or calculated field. +fn canonical_any(cmd: &str, pt: &PivotTable, src: &pv::Source, field: &str) -> Result { + match calc_field(pt, field) { + Some(c) if src.col(field).is_none() => Ok(c.name.clone()), + _ => canonical(cmd, src, field), + } +} + +fn unique_value_name(pt: &PivotTable, base: &str, except: Option) -> String { + let taken = |n: &str| pt.values.iter().enumerate().any(|(i, v)| Some(i) != except && v.name.eq_ignore_ascii_case(n)); + if !taken(base) { + return base.to_string(); + } + (2..10_000).map(|k| format!("{base}{k}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) +} + +fn auto_name(f: PivotFunc, field: &str) -> String { + format!("{} of {field}", pv::func_caption(f)) +} + +/// Adds `field` to an area (moving it out of the other axis areas). +pub(super) fn add_to_area( + cmd: &str, + pt: &mut PivotTable, + src: &pv::Source, + field: &str, + area: &str, + func: Option, + name: Option<&str>, + position: Option, +) -> 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.")); + } + let field = canonical_any(cmd, pt, src, field)?; + match area { + "rows" | "columns" => { + if pt.rows.len() + pt.columns.len() >= pv::MAX_AXIS_FIELDS && !in_axes(pt, &field) { + return Err(other(format!("A PivotTable can have at most {} row and column fields.", pv::MAX_AXIS_FIELDS))); + } + let existing = take_from_axes(pt, &field).unwrap_or_else(|| PivotField { source_col: field.clone(), ..Default::default() }); + // A report filter that selects nothing in particular goes away; a selection stays + // as the field's hidden items. + pt.filters.retain(|f| !(f.source_col.eq_ignore_ascii_case(&field) && f.selected.is_none())); + insert_axis_field(pt, area == "rows", existing, position); + } + "values" => { + if pt.values.len() >= pv::MAX_VALUE_FIELDS { + return Err(other(format!("A PivotTable can have at most {} value fields.", pv::MAX_VALUE_FIELDS))); + } + let numeric = is_calc || src.col(&field).and_then(|c| src.numeric.get(c).copied()).unwrap_or(false); + let f = func.unwrap_or(if numeric { PivotFunc::Sum } else { PivotFunc::Count }); + let base = match name { + Some(n) if !n.trim().is_empty() => { + if src.col(n).is_some() { + return Err(other("PivotTable field name already exists.")); + } + n.to_string() + } + _ => auto_name(f, &field), + }; + let nm = unique_value_name(pt, &base, None); + let v = PivotValue { source_col: field, func: f, name: nm, show_as: PivotShowAs::Normal, number_format: None }; + let at = position.unwrap_or(pt.values.len()).min(pt.values.len()); + pt.values.insert(at, v); + } + _ => { + take_from_axes(pt, &field); + let existing = pt.filters.iter().position(|f| f.source_col.eq_ignore_ascii_case(&field)); + let entry = match existing { + Some(i) => pt.filters.remove(i), + None => PivotFilter { source_col: field, selected: None }, + }; + let at = position.unwrap_or(pt.filters.len()).min(pt.filters.len()); + pt.filters.insert(at, entry); + } + } + Ok(()) +} + +/// Removes `field` from an area; returns the source field it referred to. +fn remove_from_area(cmd: &str, pt: &mut PivotTable, field: &str, area: &str) -> Result { + match area { + "rows" | "columns" => { + let rows = area == "rows"; + let list = if rows { &mut pt.rows } else { &mut pt.columns }; + let i = list + .iter() + .position(|f| f.source_col.eq_ignore_ascii_case(field)) + .ok_or_else(|| bad(cmd, format!("`{field}` isn't in the {area} area")))?; + let f = list.remove(i); + shift_values(pt, rows, i, false); + pt.filters.retain(|x| !x.source_col.eq_ignore_ascii_case(&f.source_col)); + Ok(f.source_col) + } + "values" => { + let i = pt + .values + .iter() + .position(|v| v.name.eq_ignore_ascii_case(field)) + .or_else(|| pt.values.iter().position(|v| v.source_col.eq_ignore_ascii_case(field))) + .ok_or_else(|| bad(cmd, format!("`{field}` isn't in the values area")))?; + Ok(pt.values.remove(i).source_col) + } + _ => { + let i = pt + .filters + .iter() + .position(|f| f.source_col.eq_ignore_ascii_case(field) && !in_axes(pt, &f.source_col)) + .ok_or_else(|| bad(cmd, format!("`{field}` isn't in the filters area")))?; + Ok(pt.filters.remove(i).source_col) + } + } +} + +// ---------------------------------------------------------------- shared update + +/// Applies `f` to the targeted pivot and re-renders it, as one undo step. +pub(super) fn update(s: &mut Session, p: &Json, f: impl FnOnce(&mut PivotTable, &pv::Source, &Workbook) -> Result) -> Result { + let (si, pi) = find_pivot(s, p)?; + let replace = bool_param(p, "replace").unwrap_or(false); + edit(s, |cx| { + let pt = cx.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).cloned().ok_or_else(|| other("There's no such PivotTable."))?; + let src = pv::read_source(&cx.wb, &pt.source, si).map_err(other)?; + let mut new = pt.clone(); + let mut out = f(&mut new, &src, &cx.wb)?; + if let Some(slot) = cx.sheet_mut(si)?.pivots.get_mut(pi) { + *slot = new; + } + let area = pv::refresh(&mut cx.wb, si, pi, replace).map_err(other)?; + cx.structural = true; + if out.is_null() { + out = json!({}); + } + if let Some(m) = out.as_object_mut() { + m.insert("range".into(), json!(area.a1())); + } + Ok(out) + }) +} + +pub(super) fn fields(s: &mut Session, p: &Json) -> Result { + let (si, pi) = find_pivot(s, p)?; + let d = s.doc()?; + let pt = d.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?; + let mut out = pivot_json(&d.wb, si, pt); + let src = pv::read_source(&d.wb, &pt.source, si); + let list: Vec = match &src { + Ok(src) => src + .headers + .iter() + .enumerate() + .map(|(c, h)| { + let mut areas = vec![]; + if pt.rows.iter().any(|f| f.source_col.eq_ignore_ascii_case(h)) { + areas.push("rows"); + } + if pt.columns.iter().any(|f| f.source_col.eq_ignore_ascii_case(h)) { + areas.push("columns"); + } + if pt.values.iter().any(|f| f.source_col.eq_ignore_ascii_case(h)) { + areas.push("values"); + } + if pt.filters.iter().any(|f| f.source_col.eq_ignore_ascii_case(h)) && !in_axes(pt, h) { + areas.push("filters"); + } + json!({ + "name": h, + "numeric": src.numeric.get(c).copied().unwrap_or(false), + "date": src.dates.get(c).copied().unwrap_or(false), + "samples": pv::distinct_labels(&d.wb, src, c, PivotDateGroup::None, 5), + "areas": areas, + }) + }) + .collect(), + Err(_) => vec![], + }; + let mut list = list; + for c in &pt.calculated_fields { + let areas: Vec<&str> = if pt.values.iter().any(|v| v.source_col.eq_ignore_ascii_case(&c.name)) { vec!["values"] } else { vec![] }; + list.push(json!({"name": c.name, "numeric": true, "date": false, "samples": [], "areas": areas, "calculated": true, "formula": c.formula})); + } + if let Some(m) = out.as_object_mut() { + m.insert("fields".into(), Json::Array(list)); + if let Ok(src) = &src { + m.insert("records".into(), json!(src.rows.len())); + } + if let Err(e) = src { + m.insert("error".into(), json!(e)); + } + } + Ok(out) +} + +pub(super) fn list(s: &mut Session, _: &Json) -> Result { + let d = s.doc()?; + let mut v = vec![]; + for (si, sh) in d.wb.sheets.iter().enumerate() { + for pt in &sh.pivots { + v.push(pivot_json(&d.wb, si, pt)); + } + } + Ok(Json::Array(v)) +} + +pub(super) fn add_field(s: &mut Session, p: &Json) -> Result { + const C: &str = "pivot.addField"; + let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); + let area = area_param(C, str_param(p, "area"))?; + let func = match str_param(p, "func") { + Some(f) => Some(func_param(Some(f)).ok_or_else(|| bad(C, format!("unknown function `{f}`")))?), + None => None, + }; + let name = str_param(p, "name").map(str::to_string); + let pos = position_param(p); + update(s, p, |pt, src, _| { + add_to_area(C, pt, src, &field, area, func, name.as_deref(), pos)?; + Ok(json!({"field": canonical_any(C, pt, src, &field)?, "area": area})) + }) +} + +pub(super) fn calculated_field(s: &mut Session, p: &Json) -> Result { + const C: &str = "pivot.calculatedField"; + let Some(name) = str_param(p, "name").map(|n| n.trim().to_string()) else { + // Menu-style call: the Insert Calculated Field dialog. + let (si, pi) = find_pivot(s, p)?; + let d = s.doc()?; + let pt = d.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?; + let n = (1..).map(|k| format!("Field{k}")).find(|n| calc_field(pt, n).is_none()).unwrap_or_else(|| "Field1".into()); + let pivot = pt.name.clone(); + s.ui_requests.push(crate::UiRequest::Dialog("pivotCalculatedField".into(), json!({"pivot": pivot, "name": n, "formula": "= 0"}))); + return ok(); + }; + if name.is_empty() || name.chars().count() > 255 { + return Err(bad(C, "`name` must be 1 to 255 characters")); + } + let delete = bool_param(p, "delete").unwrap_or(false); + let formula = str_param(p, "formula").map(|f| { + let f = f.trim(); + if f.starts_with('=') { f.to_string() } else { format!("={f}") } + }); + let to_values = bool_param(p, "values").unwrap_or(true); + if !delete && formula.is_none() { + return Err(bad(C, "missing `formula`")); + } + update(s, p, |pt, src, _| { + let existing = pt.calculated_fields.iter().position(|c| c.name.eq_ignore_ascii_case(&name)); + if delete { + let i = existing.ok_or_else(|| bad(C, format!("no calculated field `{name}`")))?; + let gone = pt.calculated_fields.remove(i); + pt.values.retain(|v| !v.source_col.eq_ignore_ascii_case(&gone.name)); + return Ok(json!({"name": gone.name, "deleted": true})); + } + let formula = formula.clone().unwrap_or_default(); + if src.col(&name).is_some() { + return Err(other("PivotTable field name already exists.")); + } + let canon = match existing { + Some(i) => { + let c = pt.calculated_fields.get_mut(i).ok_or_else(|| other("There's no such calculated field."))?; + c.formula = formula.clone(); + c.name.clone() + } + None => { + if pt.calculated_fields.len() >= crate::pivot_calc::MAX_CALC_FIELDS { + return Err(other(format!("A PivotTable can have at most {} calculated fields.", crate::pivot_calc::MAX_CALC_FIELDS))); + } + pt.calculated_fields.push(PivotCalcField { name: name.clone(), formula: formula.clone() }); + name.clone() + } + }; + let set = crate::pivot_calc::CalcSet::new(pt, &src.headers); + let i = set.index(&canon).ok_or_else(|| other("There's no such calculated field."))?; + if let Some(e) = set.error(i) { + return Err(bad(C, e.to_string())); + } + if set.is_cyclic(i) { + 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(C, pt, src, &canon, "values", Some(PivotFunc::Sum), None, None)?; + } + Ok(json!({ + "name": canon, + "formula": formula, + "calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::>(), + })) + }) +} + +pub(super) fn remove_field(s: &mut Session, p: &Json) -> Result { + const C: &str = "pivot.removeField"; + let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); + let area = match str_param(p, "area") { + Some(a) => Some(area_param(C, Some(a))?), + None => None, + }; + update(s, p, |pt, _, _| { + match area { + Some(a) => { + remove_from_area(C, pt, &field, a)?; + } + None => { + let mut any = false; + for a in ["rows", "columns", "values", "filters"] { + any |= remove_from_area(C, pt, &field, a).is_ok(); + } + if !any { + return Err(bad(C, format!("`{field}` isn't in the PivotTable"))); + } + } + } + Ok(json!({})) + }) +} + +pub(super) fn move_field(s: &mut Session, p: &Json) -> Result { + const C: &str = "pivot.moveField"; + let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); + let from = area_param(C, str_param(p, "from"))?; + let to = area_param(C, str_param(p, "to"))?; + let pos = position_param(p); + update(s, p, |pt, src, _| { + if is_values_field(src, &field) { + return move_values_field(C, pt, from, to, pos); + } + if from == to { + // Reorder within the area. + match from { + "rows" | "columns" => { + let rows = from == "rows"; + let list = if rows { &mut pt.rows } else { &mut pt.columns }; + let i = list + .iter() + .position(|f| f.source_col.eq_ignore_ascii_case(&field)) + .ok_or_else(|| bad(C, format!("`{field}` isn't in the {from} area")))?; + let f = list.remove(i); + shift_values(pt, rows, i, false); + insert_axis_field(pt, rows, f, pos); + } + "values" => { + let i = pt + .values + .iter() + .position(|v| v.name.eq_ignore_ascii_case(&field)) + .or_else(|| pt.values.iter().position(|v| v.source_col.eq_ignore_ascii_case(&field))) + .ok_or_else(|| bad(C, format!("`{field}` isn't in the values area")))?; + let v = pt.values.remove(i); + let at = pos.unwrap_or(pt.values.len()).min(pt.values.len()); + pt.values.insert(at, v); + } + _ => { + let i = pt + .filters + .iter() + .position(|f| f.source_col.eq_ignore_ascii_case(&field)) + .ok_or_else(|| bad(C, format!("`{field}` isn't in the filters area")))?; + let f = pt.filters.remove(i); + let at = pos.unwrap_or(pt.filters.len()).min(pt.filters.len()); + pt.filters.insert(at, f); + } + } + return Ok(json!({})); + } + // Keep axis settings (sort, grouping) when moving between rows and columns. + 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(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))) { + *slot = k; + } + if let Some(h) = hidden.filter(|h| h.selected.is_some()) + && !pt.filters.iter().any(|f| f.source_col.eq_ignore_ascii_case(&src_field)) + { + pt.filters.push(h); + } + } + Ok(json!({"field": src_field, "to": to})) + }) +} + +pub(super) fn value_settings(s: &mut Session, p: &Json) -> Result { + const C: &str = "pivot.valueSettings"; + let field = str_param(p, "field").map(str::to_string); + let index = u32_param(p, "index").map(|v| v as usize); + let func = match str_param(p, "func") { + Some(f) => Some(func_param(Some(f)).ok_or_else(|| bad(C, format!("unknown function `{f}`")))?), + None => None, + }; + let show = match str_param(p, "showAs") { + Some(v) => Some(show_as_param(v).ok_or_else(|| bad(C, format!("unknown showAs `{v}`")))?), + None => None, + }; + let name = str_param(p, "name").map(str::to_string); + let fmt = p.get("numberFormat").cloned(); + update(s, p, |pt, src, _| { + let i = match (index, &field) { + (Some(i), _) => i, + (None, Some(f)) => pt + .values + .iter() + .position(|v| v.name.eq_ignore_ascii_case(f)) + .or_else(|| pt.values.iter().position(|v| v.source_col.eq_ignore_ascii_case(f))) + .ok_or_else(|| bad(C, format!("`{f}` isn't in the values area")))?, + (None, None) => { + if pt.values.len() == 1 { + 0 + } else { + return Err(bad(C, "pass `field` or `index`")); + } + } + }; + let cur = pt.values.get(i).cloned().ok_or_else(|| bad(C, "no value field at that index"))?; + let mut v = cur.clone(); + if let Some(f) = func { + v.func = f; + // An automatic caption follows the function. + let auto = auto_name(cur.func, &cur.source_col); + if cur.name.eq_ignore_ascii_case(&auto) || cur.name.strip_prefix(auto.as_str()).is_some_and(|r| r.chars().all(|c| c.is_ascii_digit())) { + v.name = unique_value_name(pt, &auto_name(f, &cur.source_col), Some(i)); + } + } + if let Some(n) = &name { + let n = n.trim(); + if n.is_empty() { + return Err(other("The name isn't valid.")); + } + if src.col(n).is_some() { + return Err(other("PivotTable field name already exists.")); + } + if pt.values.iter().enumerate().any(|(j, x)| j != i && x.name.eq_ignore_ascii_case(n)) { + return Err(other("PivotTable field name already exists.")); + } + v.name = n.to_string(); + } + if let Some(sa) = show { + v.show_as = sa; + } + match &fmt { + Some(Json::String(f)) if !f.is_empty() && f.len() <= 255 => v.number_format = Some(f.clone()), + Some(Json::Null) => v.number_format = None, + Some(Json::String(f)) if f.is_empty() => v.number_format = None, + Some(_) => return Err(bad(C, "`numberFormat` is a format code or null")), + None => {} + } + let name = v.name.clone(); + if let Some(slot) = pt.values.get_mut(i) { + *slot = v; + } + Ok(json!({"name": name})) + }) +} diff --git a/crates/engine/src/cmd/pivot/layout.rs b/crates/engine/src/cmd/pivot/layout.rs new file mode 100644 index 000000000..b3582ea46 --- /dev/null +++ b/crates/engine/src/cmd/pivot/layout.rs @@ -0,0 +1,289 @@ +use super::create::source_param; +use super::fields::{canonical, in_axes, shift_values, update}; +use super::params::{group_param, layout_name}; +use super::*; + +pub(super) fn filter(s: &mut Session, p: &Json) -> Result { + const C: &str = "pivot.filter"; + let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); + let selected: Option> = match p.get("selected") { + None | Some(Json::Null) => None, + Some(Json::Array(a)) => { + if a.len() > 100_000 { + return Err(bad(C, "too many items")); + } + let v: Vec = a + .iter() + .map(|x| match x { + Json::String(s) => s.clone(), + Json::Null => "(blank)".into(), + other => other.to_string(), + }) + .collect(); + if v.is_empty() { + return Err(other("You must select at least one item.")); + } + Some(v) + } + Some(Json::String(s)) => Some(vec![s.clone()]), + Some(_) => return Err(bad(C, "`selected` is a list of item labels or null")), + }; + update(s, p, |pt, src, _| { + let field = canonical(C, src, &field)?; + let i = pt.filters.iter().position(|f| f.source_col.eq_ignore_ascii_case(&field)); + match (i, selected) { + (Some(i), None) => { + if in_axes(pt, &field) { + pt.filters.remove(i); + } else if let Some(f) = pt.filters.get_mut(i) { + f.selected = None; + } + } + (Some(i), Some(sel)) => { + if let Some(f) = pt.filters.get_mut(i) { + f.selected = Some(sel); + } + } + (None, Some(sel)) => pt.filters.push(PivotFilter { source_col: field.clone(), selected: Some(sel) }), + (None, None) => {} + } + Ok(json!({"field": field})) + }) +} + +fn axis_field<'a>(cmd: &str, pt: &'a mut PivotTable, field: &str) -> Result<&'a mut PivotField> { + pt.rows + .iter_mut() + .chain(pt.columns.iter_mut()) + .find(|f| f.source_col.eq_ignore_ascii_case(field)) + .ok_or_else(|| bad(cmd, format!("`{field}` isn't in the rows or columns area"))) +} + +pub(super) fn sort(s: &mut Session, p: &Json) -> Result { + const C: &str = "pivot.sort"; + let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); + let order = match str_param(p, "order").unwrap_or("asc").to_ascii_lowercase().as_str() { + "asc" | "ascending" | "a-z" => PivotSort::Asc, + "desc" | "descending" | "z-a" => PivotSort::Desc, + "none" | "manual" | "source" => PivotSort::None, + o => return Err(bad(C, format!("unknown order `{o}` (asc|desc|none)"))), + }; + update(s, p, |pt, _, _| { + axis_field(C, pt, &field)?.sort = order; + Ok(json!({})) + }) +} + +pub(super) fn group(s: &mut Session, p: &Json) -> Result { + const C: &str = "pivot.group"; + let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); + let raw = str_param(p, "by").unwrap_or("months"); + let by = group_param(Some(raw)).ok_or_else(|| bad(C, format!("unknown grouping `{raw}` (years|quarters|months|days|none)")))?; + update(s, p, |pt, src, _| { + if by != PivotDateGroup::None { + let c = src.col(&field).ok_or_else(|| bad(C, format!("no field `{field}` in the source data")))?; + if !src.numeric.get(c).copied().unwrap_or(false) { + return Err(other("Cannot group that selection.")); + } + } + let f = axis_field(C, pt, &field)?; + f.date_group = by; + f.collapsed_items.clear(); + Ok(json!({})) + }) +} + +pub(super) fn collapse(s: &mut Session, p: &Json) -> Result { + const C: &str = "pivot.collapse"; + let field = str_param(p, "field").ok_or_else(|| bad(C, "missing `field`"))?.to_string(); + let on = bool_param(p, "collapse").unwrap_or(true); + let items: Option> = + p.get("items").and_then(Json::as_array).map(|a| a.iter().take(100_000).filter_map(|x| x.as_str().map(str::to_string)).collect()); + update(s, p, |pt, src, wb| { + let all = match &items { + Some(v) => v.clone(), + None => { + let f = pt.rows.iter().chain(pt.columns.iter()).find(|f| f.source_col.eq_ignore_ascii_case(&field)); + let g = f.map(|f| f.date_group).unwrap_or_default(); + match src.col(&field) { + Some(c) => pv::distinct_labels(wb, src, c, g, 100_000), + None => vec![], + } + } + }; + let f = axis_field(C, pt, &field)?; + if on { + for it in all { + if !f.collapsed_items.iter().any(|x| x.eq_ignore_ascii_case(&it)) { + f.collapsed_items.push(it); + } + } + } else if items.is_none() { + f.collapsed_items.clear(); + } else { + f.collapsed_items.retain(|x| !all.iter().any(|a| a.eq_ignore_ascii_case(x))); + } + Ok(json!({"collapsed": f.collapsed_items})) + }) +} + +pub(super) fn layout(s: &mut Session, p: &Json) -> Result { + const C: &str = "pivot.layout"; + let lay = match str_param(p, "layout").map(|x| x.to_ascii_lowercase()) { + Some(l) if l == "compact" => Some(PivotLayout::Compact), + Some(l) if l == "outline" => Some(PivotLayout::Outline), + Some(l) if l == "tabular" => Some(PivotLayout::Tabular), + Some(l) => return Err(bad(C, format!("unknown layout `{l}` (compact|outline|tabular)"))), + None => None, + }; + let subtotals: Option<(bool, Option)> = match p.get("subtotals") { + Some(Json::Bool(b)) => Some((*b, None)), + Some(Json::String(m)) => match m.to_ascii_lowercase().as_str() { + "top" => Some((true, Some(true))), + "bottom" => Some((true, Some(false))), + "none" | "off" => Some((false, None)), + o => return Err(bad(C, format!("unknown subtotals `{o}` (top|bottom|none)"))), + }, + _ => None, + }; + let style = str_param(p, "style").map(str::to_string); + if style.as_ref().is_some_and(|s| s.len() > 64) { + return Err(bad(C, "style name too long")); + } + let gr = bool_param(p, "grandTotalsRows"); + let gc = bool_param(p, "grandTotalsCols"); + let hd = bool_param(p, "showHeaders"); + update(s, p, |pt, _, _| { + if let Some(l) = lay { + pt.layout = l; + if l == PivotLayout::Tabular { + pt.subtotals_top = false; + } + } + if let Some((on, top)) = subtotals { + pt.subtotals = on; + if let Some(t) = top { + pt.subtotals_top = t; + } + } + if let Some(st) = &style { + pt.style = st.clone(); + } + if let Some(v) = gr { + pt.grand_totals_rows = v; + } + if let Some(v) = gc { + pt.grand_totals_cols = v; + } + if let Some(v) = hd { + pt.show_headers = v; + } + Ok(json!({"layout": layout_name(pt.layout)})) + }) +} + +pub(super) fn grand_totals(s: &mut Session, p: &Json) -> Result { + let (r, c) = match str_param(p, "mode").map(|m| m.to_ascii_lowercase()) { + Some(m) if m == "off" => (Some(false), Some(false)), + Some(m) if m == "on" => (Some(true), Some(true)), + Some(m) if m == "rows" => (Some(true), Some(false)), + Some(m) if m == "columns" || m == "cols" => (Some(false), Some(true)), + Some(m) => return Err(bad("pivot.grandTotals", format!("unknown mode `{m}` (off|on|rows|columns)"))), + None => (bool_param(p, "rows"), bool_param(p, "cols")), + }; + let mut q = p.as_object().cloned().unwrap_or_default(); + if let Some(v) = r { + q.insert("grandTotalsRows".into(), json!(v)); + } + if let Some(v) = c { + q.insert("grandTotalsCols".into(), json!(v)); + } + layout(s, &Json::Object(q)) +} + +pub(super) fn refresh(s: &mut Session, p: &Json) -> Result { + update(s, p, |_, src, _| Ok(json!({"records": src.rows.len()}))) +} + +pub(super) fn refresh_all(s: &mut Session, p: &Json) -> Result { + let replace = bool_param(p, "replace").unwrap_or(false); + edit(s, |cx| { + let mut n = 0; + let mut errors = vec![]; + for si in 0..cx.wb.sheets.len() { + let count = cx.wb.sheet(si).map(|sh| sh.pivots.len()).unwrap_or(0); + for pi in 0..count { + let name = cx.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).map(|p| p.name.clone()).unwrap_or_default(); + match pv::refresh(&mut cx.wb, si, pi, replace) { + Ok(_) => n += 1, + Err(e) => errors.push(json!({"pivot": name, "error": e})), + } + } + } + cx.structural = n > 0; + Ok(json!({"refreshed": n, "errors": errors})) + }) +} + +pub(super) fn change_source(s: &mut Session, p: &Json) -> Result { + const C: &str = "pivot.changeSource"; + if str_param(p, "source").or_else(|| str_param(p, "range")).is_none() { + return Err(bad(C, "missing `source`")); + } + let source = source_param(s, p)?; + let (si, _) = find_pivot(s, p)?; + let src = pv::read_source(&s.doc()?.wb, &source, si).map_err(other)?; + update(s, p, move |pt, _, _| { + pt.source = source.clone(); + let mut dropped: Vec = vec![]; + let keep = |name: &str, dropped: &mut Vec| { + let ok = src.col(name).is_some(); + if !ok { + dropped.push(name.to_string()); + } + ok + }; + // Axis fields leave one by one so Σ Values keeps its place among the rest. + for rows in [true, false] { + let mut i = 0; + loop { + let list = if rows { &mut pt.rows } else { &mut pt.columns }; + let Some(f) = list.get(i) else { break }; + if keep(&f.source_col, &mut dropped) { + i += 1; + } else { + list.remove(i); + shift_values(pt, rows, i, false); + } + } + } + pt.values.retain(|f| keep(&f.source_col, &mut dropped)); + pt.filters.retain(|f| keep(&f.source_col, &mut dropped)); + Ok(json!({"source": source, "dropped": dropped})) + }) +} + +pub(super) fn delete(s: &mut Session, p: &Json) -> Result { + let (si, pi) = find_pivot(s, p)?; + edit(s, |cx| { + // Its PivotCharts become ordinary charts. + let link = cx.wb.sheet(si).and_then(|sh| sh.pivots.get(pi).map(|pt| (sh.name.clone(), pt.name.clone()))); + if let Some((sheet, name)) = link { + for i in 0..cx.wb.sheets.len() { + let linked = |c: &Chart| c.pivot.as_deref().is_some_and(|l| pv::links_to(l, &sheet, &name)); + if cx.wb.sheet(i).is_some_and(|sh| sh.charts.iter().any(linked)) { + for c in cx.sheet_mut(i)?.charts.iter_mut().filter(|c| linked(c)) { + c.pivot = None; + } + } + } + } + pv::clear_output(&mut cx.wb, si, pi); + let sh = cx.sheet_mut(si)?; + if pi < sh.pivots.len() { + sh.pivots.remove(pi); + } + cx.structural = true; + Ok(json!({})) + }) +} diff --git a/crates/engine/src/cmd/pivot/params.rs b/crates/engine/src/cmd/pivot/params.rs new file mode 100644 index 000000000..8aa098479 --- /dev/null +++ b/crates/engine/src/cmd/pivot/params.rs @@ -0,0 +1,111 @@ +use super::*; + +pub(super) fn area_param(cmd: &str, v: Option<&str>) -> Result<&'static str> { + match v.map(str::to_ascii_lowercase) { + Some(a) if a == "rows" || a == "row" => Ok("rows"), + Some(a) if a == "columns" || a == "column" || a == "cols" => Ok("columns"), + Some(a) if a == "values" || a == "value" || a == "data" => Ok("values"), + Some(a) if a == "filters" || a == "filter" => Ok("filters"), + Some(a) => Err(bad(cmd, format!("unknown area `{a}` (rows|columns|values|filters)"))), + None => Err(bad(cmd, "missing `area` (rows|columns|values|filters)")), + } +} + +pub(super) fn func_param(v: Option<&str>) -> Option { + Some(match v?.to_ascii_lowercase().as_str() { + "sum" => PivotFunc::Sum, + "count" | "counta" => PivotFunc::Count, + "average" | "avg" | "mean" => PivotFunc::Average, + "max" => PivotFunc::Max, + "min" => PivotFunc::Min, + "product" => PivotFunc::Product, + "countnumbers" | "countnums" | "count numbers" => PivotFunc::CountNumbers, + "stddev" => PivotFunc::StdDev, + "stddevp" => PivotFunc::StdDevP, + "var" => PivotFunc::Var, + "varp" => PivotFunc::VarP, + _ => return None, + }) +} + +pub(super) fn func_name(f: PivotFunc) -> &'static str { + match f { + PivotFunc::Sum => "sum", + PivotFunc::Count => "count", + PivotFunc::Average => "average", + PivotFunc::Max => "max", + PivotFunc::Min => "min", + PivotFunc::Product => "product", + PivotFunc::CountNumbers => "countNumbers", + PivotFunc::StdDev => "stdDev", + PivotFunc::StdDevP => "stdDevP", + PivotFunc::Var => "var", + PivotFunc::VarP => "varP", + } +} + +pub(super) fn show_as_param(v: &str) -> Option { + Some(match v.to_ascii_lowercase().as_str() { + "normal" | "none" => PivotShowAs::Normal, + "percentofgrandtotal" => PivotShowAs::PercentOfGrandTotal, + "percentofcolumntotal" => PivotShowAs::PercentOfColumnTotal, + "percentofrowtotal" => PivotShowAs::PercentOfRowTotal, + "runningtotal" => PivotShowAs::RunningTotal, + "rank" => PivotShowAs::Rank, + _ => return None, + }) +} + +pub(super) fn show_as_name(v: PivotShowAs) -> &'static str { + match v { + PivotShowAs::Normal => "normal", + PivotShowAs::PercentOfGrandTotal => "percentOfGrandTotal", + PivotShowAs::PercentOfColumnTotal => "percentOfColumnTotal", + PivotShowAs::PercentOfRowTotal => "percentOfRowTotal", + PivotShowAs::RunningTotal => "runningTotal", + PivotShowAs::Rank => "rank", + } +} + +pub(super) fn layout_name(l: PivotLayout) -> &'static str { + match l { + PivotLayout::Compact => "compact", + PivotLayout::Outline => "outline", + PivotLayout::Tabular => "tabular", + } +} + +pub(super) fn group_name(g: PivotDateGroup) -> &'static str { + match g { + PivotDateGroup::None => "none", + PivotDateGroup::Years => "years", + PivotDateGroup::Quarters => "quarters", + PivotDateGroup::Months => "months", + PivotDateGroup::Days => "days", + } +} + +pub(super) fn sort_name(o: PivotSort) -> &'static str { + match o { + PivotSort::Asc => "asc", + PivotSort::Desc => "desc", + PivotSort::None => "none", + } +} + +// ---------------------------------------------------------------- field commands + +pub(super) fn position_param(p: &Json) -> Option { + u32_param(p, "position").map(|v| v as usize) +} + +pub(super) fn group_param(v: Option<&str>) -> Option { + Some(match v?.to_ascii_lowercase().as_str() { + "years" | "year" => PivotDateGroup::Years, + "quarters" | "quarter" => PivotDateGroup::Quarters, + "months" | "month" => PivotDateGroup::Months, + "days" | "day" => PivotDateGroup::Days, + "none" | "ungroup" => PivotDateGroup::None, + _ => return None, + }) +} From 0e2335531d61609d55d84731c0c099fcd540eadf Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:44:34 +0000 Subject: [PATCH 52/76] refactor(engine): move cmd/data.rs items to cmd/data/ --- crates/engine/src/cmd/data.rs | 1395 +--------------------- crates/engine/src/cmd/data/filter.rs | 316 +++++ crates/engine/src/cmd/data/grouping.rs | 267 +++++ crates/engine/src/cmd/data/sort.rs | 322 +++++ crates/engine/src/cmd/data/tools.rs | 190 +++ crates/engine/src/cmd/data/validation.rs | 290 +++++ 6 files changed, 1403 insertions(+), 1377 deletions(-) create mode 100644 crates/engine/src/cmd/data/filter.rs create mode 100644 crates/engine/src/cmd/data/grouping.rs create mode 100644 crates/engine/src/cmd/data/sort.rs create mode 100644 crates/engine/src/cmd/data/tools.rs create mode 100644 crates/engine/src/cmd/data/validation.rs diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 646d44bd9..5fa076e64 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -11,6 +11,24 @@ use super::*; use crate::outline; use crate::selection::current_region; +mod filter; +mod grouping; +mod sort; +mod tools; +mod validation; + +pub use filter::filter_values; +pub(crate) use filter::{apply_filter, grow_autofilter}; +use filter::{clear_filter, filter_by, reapply, toggle_filter}; +use grouping::{detail, group, outline_settings, show_levels, subtotal}; +use sort::{quick_sort, sort}; +#[cfg(test)] +use tools::split_delimited; +use tools::{remove_duplicates, text_to_columns}; +pub use validation::{check_validation, list_items}; +use validation::{circle_invalid, validate_cmd, validation}; +pub(super) use validation::{move_rule_formula, remove_validation_cells}; + pub fn specs() -> Vec { vec![ cmd!("data.sortAscending", "Sort A to Z", ["Data", "Sort & Filter"], None, "{range?, column?: \"B\"}", has_doc, |s, p| quick_sort( @@ -200,1382 +218,5 @@ fn guess_header(wb: &Workbook, sh: &Sheet, r: RangeRef) -> bool { votes > 0 } -#[derive(Clone)] -struct SortKey { - col: u32, - desc: bool, - by: String, - list: Option>, -} - -/// The sheet and range a sort works on: the sheet `sheet` or a `Sheet!` prefix on `range` names, -/// else the active one. The selection belongs to the active sheet, so another sheet needs `range`. -fn sort_target(s: &Session, p: &Json) -> Result<(usize, RangeRef)> { - let sheet = target_sheet(s, p)?; - let d = s.doc()?; - if let Some(t) = str_param(p, "range") - && let (Some(name), _) = split_sheet(t) - && d.wb.sheet_index(&name) != Some(sheet) - { - return Err(bad("data.sort", format!("`{t}` names a sheet other than `sheet`"))); - } - if sheet != d.wb.active_sheet() && p.get("range").is_none() { - return Err(bad("data.sort", "missing `range`: the selection is on the active sheet")); - } - Ok((sheet, data_range(s, p)?)) -} - -fn quick_sort(s: &mut Session, p: &Json, asc: bool) -> Result { - let (sheet, r) = sort_target(s, p)?; - let col = col_param(p.get("column"), r.start.col).unwrap_or(s.doc()?.selection.active.col).clamp(r.start.col, r.end.col); - let d = s.doc()?; - let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; - let header = guess_header(&d.wb, sh, r); - do_sort(s, sheet, r, header, vec![SortKey { col, desc: !asc, by: "values".into(), list: None }], false, false) -} - -fn sort(s: &mut Session, p: &Json) -> Result { - let (sheet, r) = sort_target(s, p)?; - let d = s.doc()?; - let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; - let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r)); - let by_cols = str_param(p, "orientation") == Some("columns"); - let case = bool_param(p, "matchCase").unwrap_or(false); - let mut keys: Vec = Vec::new(); - for k in p.get("keys").and_then(Json::as_array).map(Vec::as_slice).unwrap_or_default() { - let col = if by_cols { - let row = match k.get("row").and_then(Json::as_u64) { - // Worksheet rows are 1-based; 0 (or a value past u32) has no coordinate. - Some(n) => { - let row = u32::try_from(n).ok().and_then(|n| n.checked_sub(1)); - Some(row.ok_or_else(|| bad("data.sort", "sort key row must be 1 or greater"))?) - } - None => None, - }; - row.or_else(|| col_param(k.get("column"), r.start.row)) - } else { - col_param(k.get("column"), r.start.col) - }; - let Some(col) = col else { continue }; - keys.push(SortKey { - col, - desc: k.get("order").and_then(Json::as_str) == Some("desc"), - by: k.get("by").and_then(Json::as_str).unwrap_or("values").to_string(), - list: k.get("customList").and_then(Json::as_array).map(|l| l.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()), - }); - } - if keys.is_empty() { - s.ui_requests.push(crate::UiRequest::Dialog("sort".into(), json!({"range": r.a1(), "header": header}))); - return ok(); - } - if keys.len() > 64 { - return Err(bad("data.sort", "too many sort levels (64 max)")); - } - do_sort(s, sheet, r, header, keys, by_cols, case) -} - -fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec, by_cols: bool, case: bool) -> Result { - // Protect Sheet ▸ Sort. Both Sort… and the A-to-Z buttons come through here. - super::refuse_unless_allowed(s, sheet, "Sorting", |p| p.sort)?; - let body = if by_cols { - if header && r.width() > 1 { RangeRef::new(CellRef::new(r.start.row, r.start.col + 1), r.end) } else { r } - } else if header && r.height() > 1 { - RangeRef::new(CellRef::new(r.start.row + 1, r.start.col), r.end) - } else { - r - }; - if body.count() > 20_000_000 { - return Err(bad("data.sort", "range too large")); - } - edit(s, |cx| { - let Some(sh) = cx.wb.sheet(sheet) else { return Ok(Json::Null) }; - if sh.merges.iter().any(|m| m.intersects(&body) && !m.is_single()) { - return Err(EngineError::Other("To do this, all the merged cells need to be the same size.".into())); - } - let n = if by_cols { body.width() } else { body.height() }; - let line = |i: u32, k: u32| if by_cols { CellRef::new(k, body.start.col + i) } else { CellRef::new(body.start.row + i, k) }; - let theme = cx.wb.theme.clone(); - let styles = &cx.wb.styles; - let key_val = |i: u32, k: &SortKey| -> (Value, u32) { - let c = line(i, k.col); - let v = sh.value(c); - let color = match k.by.as_str() { - "cellColor" => { - styles.get(sh.style_id(c)).fill.fg.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX) - } - "fontColor" => { - styles.get(sh.style_id(c)).font.color.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX) - } - _ => 0, - }; - (v, color) - }; - let mut order: Vec = (0..n).collect(); - let keyed: Vec> = (0..n).map(|i| keys.iter().map(|k| key_val(i, k)).collect()).collect(); - order.sort_by(|&a, &b| { - for (ki, k) in keys.iter().enumerate() { - let (va, ca) = &keyed[a as usize][ki]; - let (vb, cb) = &keyed[b as usize][ki]; - let o = if k.by != "values" { - ca.cmp(cb) - } else if let Some(list) = &k.list { - let pos = |v: &Value| v.as_text().and_then(|t| list.iter().position(|x| x.eq_ignore_ascii_case(t))).unwrap_or(usize::MAX); - pos(va).cmp(&pos(vb)) - } else if case && va.is_text() && vb.is_text() { - sort_compare(va, vb).then_with(|| case_order(va.as_text().unwrap_or(""), vb.as_text().unwrap_or(""))) - } else { - sort_compare(va, vb) - }; - // Blanks always sort last, whatever the order. - let o = if k.desc && !(va.is_empty() || vb.is_empty()) { o.reverse() } else { o }; - if o != Ordering::Equal { - return o; - } - } - Ordering::Equal - }); - if order.iter().enumerate().all(|(i, &o)| i as u32 == o) { - return Ok(Json::Null); - } - // Move whole lines (cells with formulas adjusted for relative references). - let lanes: Vec = if by_cols { (body.start.row..=body.end.row).collect() } else { (body.start.col..=body.end.col).collect() }; - // Notes and hyperlinks hang off a cell, not a row, so they travel with the row they - // describe — the way cell pictures and row heights do. Collect them before inserting, - // so a swap can't resurrect one from a destination that is also a source. - let side: Vec<(CellRef, Comment)> = order - .iter() - .enumerate() - .flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k))) - .filter_map(|(src_i, dst_i, k)| sh.comments.get(&line(src_i, k)).map(|cm| (line(dst_i, k), cm.clone()))) - .collect(); - let links: Vec<(CellRef, Hyperlink)> = order - .iter() - .enumerate() - .flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k))) - .filter_map(|(src_i, dst_i, k)| sh.hyperlinks.get(&line(src_i, k)).map(|h| (line(dst_i, k), h.clone()))) - .collect(); - let mut moved: Vec<(CellRef, Option)> = Vec::with_capacity(body.count() as usize); - let mut pictures = Vec::new(); - for (dst_i, &src_i) in order.iter().enumerate() { - for &k in &lanes { - let src = line(src_i, k); - let dst = line(dst_i as u32, k); - let mut cell = sh.cell(src).cloned(); - if let Some(c) = cell.as_mut() - && let Some(f) = &c.formula - && let Some(e) = f.expr() - { - let shifted = gridcraft_formula::adjust::shift_relative(e, dst.row as i64 - src.row as i64, dst.col as i64 - src.col as i64); - c.formula = Some(Arc::new(Formula::from_expr(shifted))); - } - if let Some(picture) = sh.cell_pictures.get(&src) { - pictures.push((dst, picture.clone())); - } - moved.push((dst, cell)); - } - } - // Row heights travel with rows. - let heights: Vec> = - if by_cols { vec![] } else { order.iter().map(|&i| sh.rows.get(&(body.start.row + i)).copied()).collect() }; - // Floating objects anchored inside the sorted body travel with their lines, unless they - // are pinned absolutely ("Don't move or size with cells", `editAs="absolute"`). Reordering - // is the inverse of `order`: `dst_of[src] = dst`. - let dst_of: Vec = { - let mut v = vec![0u32; order.len()]; - for (dst_i, &src_i) in order.iter().enumerate() { - if let Some(slot) = v.get_mut(src_i as usize) { - *slot = dst_i as u32; - } - } - v - }; - // The object's own geometry (its top-left anchor cell, offset and size) is read from the - // pre-sort `sh`; a two-cell object's bottom-right cell is derived from its size here so it - // can be re-fitted to the lines it spans after the sort. - let objs: Vec = - sh.images.iter().map(|o| o.anchor).chain(sh.charts.iter().map(|o| o.anchor)).chain(sh.shapes.iter().map(|o| o.anchor)).collect(); - let descs: Vec<(u32, u32, f32, f32)> = objs - .iter() - .map(|a| { - let x2 = sh.col_left(a.cell.col) + a.dx as f64 + a.width.max(0.0) as f64; - let y2 = sh.row_top(a.cell.row) + a.dy as f64 + a.height.max(0.0) as f64; - let tc = sh.col_at(x2); - let tr = sh.row_at(y2); - (tc, tr, (x2 - sh.col_left(tc)) as f32, (y2 - sh.row_top(tr)) as f32) - }) - .collect(); - let shm = cx.sheet_mut(sheet)?; - for (c, cell) in &moved { - match cell { - Some(x) => shm.set_cell(*c, x.clone()), - None => { - shm.remove_cell(*c); - } - } - } - // Every cell of the body is the destination of exactly one source cell, so clearing the - // body and inserting the permuted entries is the same permutation the cells went through. - for c in body.iter() { - shm.comments.remove(&c); - shm.hyperlinks.remove(&c); - } - for (at, cm) in side { - shm.comments.insert(at, cm); - } - for (at, h) in links { - shm.hyperlinks.insert(at, h); - } - shm.cell_pictures.extend(pictures); - for (i, h) in heights.into_iter().enumerate() { - let row = body.start.row + i as u32; - match h { - Some(info) => { - shm.rows.insert(row, LineInfo { hidden: shm.rows.get(&row).is_some_and(|x| x.hidden), ..info }); - } - None => { - if let Some(e) = shm.rows.get_mut(&row) { - e.size = None; - } - } - } - } - // Re-anchor floating objects now that the row heights are final. `objs` was collected as - // images ++ charts ++ shapes, so the same index maps straight onto each vec. - // Only objects anchored inside the sorted block move: within its lines on the sort axis - // and within its span on the other axis (a picture beside the block stays put). - let (b0, b1) = if by_cols { (body.start.col, body.end.col) } else { (body.start.row, body.end.row) }; - let (o0, o1) = if by_cols { (body.start.row, body.end.row) } else { (body.start.col, body.end.col) }; - let ni = shm.images.len(); - let nch = shm.charts.len(); - for (i, (a, desc)) in objs.into_iter().zip(descs).enumerate() { - let other = if by_cols { a.cell.row } else { a.cell.col }; - if other < o0 || other > o1 { - continue; - } - let na = sorted_anchor(shm, a, desc, by_cols, b0, b1, &dst_of); - if na != a { - if i < ni { - if let Some(o) = shm.images.get_mut(i) { - o.anchor = na; - } - } else if i < ni + nch { - if let Some(o) = shm.charts.get_mut(i - ni) { - o.anchor = na; - } - } else if let Some(o) = shm.shapes.get_mut(i - ni - nch) { - o.anchor = na; - } - } - } - for (c, _) in moved { - cx.changed.push((sheet, c)); - } - cx.structural = true; - Ok(Json::Null) - })?; - Ok(json!({"sorted": body.a1()})) -} - -/// Re-anchor one floating object for a sort. `bottom` is the object's bottom-right line and the -/// offset of that corner inside it, measured before the sort. `map` (via `dst_of`) translates a -/// line index on the moving axis and is the identity outside `b0..=b1`. `sh` is the post-sort -/// grid (row heights already reordered), so re-fitting uses the heights the object lands on. -/// Objects pinned absolutely ("Don't move or size with cells") never move; "move but don't size" -/// keeps its size; "move and size" is re-fitted when its whole extent stays inside the body. -fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: bool, b0: u32, b1: u32, dst_of: &[u32]) -> Anchor { - if a.mode == AnchorMode::Absolute { - return a; - } - let (tc, tr, tdx, tdy) = bottom; - let map = |v: u32| -> u32 { if v >= b0 && v <= b1 { dst_of.get((v - b0) as usize).map_or(v, |&d| b0 + d) } else { v } }; - let (axis_start, axis_end) = if by_cols { (a.cell.col, tc) } else { (a.cell.row, tr) }; - if axis_start < b0 || axis_start > b1 { - return a; - } - let (nr, nc) = if by_cols { (a.cell.row, map(a.cell.col)) } else { (map(a.cell.row), a.cell.col) }; - if a.mode == AnchorMode::MoveAndSize && axis_end >= b0 && axis_end <= b1 { - let (nbr, nbc) = if by_cols { (tr, map(tc)) } else { (map(tr), tc) }; - let x1 = sh.col_left(nc) + a.dx as f64; - let y1 = sh.row_top(nr) + a.dy as f64; - let x2 = sh.col_left(nbc) + tdx as f64; - let y2 = sh.row_top(nbr) + tdy as f64; - Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: (x2 - x1).max(0.0) as f32, height: (y2 - y1).max(0.0) as f32, mode: a.mode } - } else { - Anchor { cell: CellRef::new(nr, nc), ..a } - } -} - -// ---------------------------------------------------------------- filter - -fn toggle_filter(s: &mut Session, p: &Json) -> Result { - let (sheet, r) = data_target(s, p, "data.filter")?; - super::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?; - // The active cell belongs to the active sheet: on another sheet the table is the one at `range`. - let at = if sheet == s.doc()?.wb.active_sheet() { s.doc()?.selection.active } else { r.start }; - let has = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.autofilter.is_some()); - let in_table = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(at).map(|t| t.name.clone())); - if let Some(name) = in_table { - return edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - if let Some(t) = sh.tables.iter_mut().find(|t| t.name == name) { - t.filter_button = !t.filter_button; - if !t.filter_button { - t.filter.clear(); - } - } - Ok(Json::Null) - }); - } - if has { - return edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - // Reveal only rows the filter hid; rows hidden by hand or by a collapsed group stay hidden. - let filtered: Vec = sh.rows.keys().copied().filter(|r| sh.is_row_filtered(*r)).collect(); - sh.autofilter = None; - for r in filtered { - if let Some(info) = sh.rows.get_mut(&r) { - info.hidden = false; - } - } - cx.structural = true; - Ok(json!({"filter": false})) - }); - } - install_filter(s, p) -} - -/// Turn a sheet AutoFilter on over `p.range`, or over the data range of the selection. -fn install_filter(s: &mut Session, p: &Json) -> Result { - let (sheet, mut r) = data_target(s, p, "data.filter")?; - // A selected header row filters the data below it (#201). - if p.get("range").is_none() && r.height() == 1 { - let d = s.doc()?; - let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - let below = current_region(sh, r.start).end.row; - if below > r.end.row { - r = RangeRef::new(r.start, CellRef::new(below, r.end.col)); - } - } - edit(s, |cx| { - cx.sheet_mut(sheet)?.autofilter = Some(AutoFilter { range: r, criteria: vec![] }); - Ok(json!({"filter": true, "range": r.a1()})) - }) -} - -fn filter_by(s: &mut Session, p: &Json) -> Result { - let sheet = s.doc()?.wb.active_sheet(); - super::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?; - let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; - let af = match &sh.autofilter { - Some(a) => a.clone(), - None => { - install_filter(s, &json!({}))?; - s.doc()?.wb.sheet(sheet).and_then(|sh| sh.autofilter.clone()).ok_or_else(|| EngineError::Other("no filter".into()))? - } - }; - let col = match col_param(p.get("column"), af.range.start.col) { - Some(c) => c, - // A number too large to be a column is outside the filter range, not a missing parameter. - None if p.get("column").is_some_and(|v| v.as_u64().is_some()) => return Err(bad("data.filterBy", "column outside the filter range")), - None => return Err(bad("data.filterBy", "missing `column`")), - }; - if col < af.range.start.col || col > af.range.end.col { - return Err(bad("data.filterBy", "column outside the filter range")); - } - let off = col - af.range.start.col; - let crit = if bool_param(p, "clear").unwrap_or(false) { - None - } else if let Some(vals) = p.get("values").and_then(Json::as_array) { - Some(FilterCriterion::Values { - values: vals.iter().map(super::edit::json_to_input).collect(), - blanks: bool_param(p, "blanks").unwrap_or(false), - }) - } else if let Some(c) = p.get("custom") { - let a = (c.get("op").and_then(Json::as_str).unwrap_or("=").to_string(), c.get("value").map(super::edit::json_to_input).unwrap_or_default()); - let b = c.get("op2").and_then(Json::as_str).map(|o| (o.to_string(), c.get("value2").map(super::edit::json_to_input).unwrap_or_default())); - Some(FilterCriterion::Custom { a, b, and: c.get("and").and_then(Json::as_bool).unwrap_or(true) }) - } else if let Some(t) = p.get("top") { - Some(FilterCriterion::Top10 { - bottom: t.get("bottom").and_then(Json::as_bool).unwrap_or(false), - percent: t.get("percent").and_then(Json::as_bool).unwrap_or(false), - count: t.get("count").and_then(Json::as_u64).unwrap_or(10) as u32, - }) - } else { - bool_param(p, "aboveAverage").map(FilterCriterion::AboveAverage) - }; - edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - if let Some(a) = sh.autofilter.as_mut() { - a.criteria.retain(|(o, _)| *o != off); - if let Some(c) = crit.clone() { - a.criteria.push((off, c)); - } - } - apply_filter(&mut cx.wb, sheet); - cx.structural = true; - Ok(Json::Null) - })?; - let hidden = s.doc()?.wb.sheet(sheet).map(|sh| sh.rows.values().filter(|r| r.hidden).count()).unwrap_or(0); - Ok(json!({"hiddenRows": hidden})) -} - -/// Does a value pass a custom comparison (`>`, `=*abc*`, `<>x`…)? -fn custom_ok(v: &Value, text: &str, op: &str, crit: &str) -> bool { - let num = gridcraft_core::parse::parse_number_text(crit); - let cmp = match (v.as_f64(), num) { - (Some(a), Some(b)) => a.partial_cmp(&b), - // A numeric ordering comparison only matches numbers; blanks and text never do. - (None, Some(_)) if matches!(op, "<" | "<=" | ">" | ">=") => None, - _ => Some(gridcraft_core::compare_text(text, crit)), - }; - let wild = || super::edit::wildcard(&text.to_lowercase(), &crit.to_lowercase()); - match op { - "=" => { - if crit.contains(['*', '?']) { - wild() - } else { - cmp == Some(Ordering::Equal) - } - } - "<>" => { - if crit.contains(['*', '?']) { - !wild() - } else { - cmp != Some(Ordering::Equal) - } - } - ">" => cmp == Some(Ordering::Greater), - "<" => cmp == Some(Ordering::Less), - ">=" => matches!(cmp, Some(Ordering::Greater | Ordering::Equal)), - "<=" => matches!(cmp, Some(Ordering::Less | Ordering::Equal)), - "beginsWith" => text.to_lowercase().starts_with(&crit.to_lowercase()), - "endsWith" => text.to_lowercase().ends_with(&crit.to_lowercase()), - "contains" => text.to_lowercase().contains(&crit.to_lowercase()), - "notContains" => !text.to_lowercase().contains(&crit.to_lowercase()), - _ => true, - } -} - -/// The filter's "(Blanks)" group: truly empty cells and zero-length text such as a `=""` result. -fn filter_blank(v: &Value) -> bool { - v.is_empty() || matches!(v, Value::Text(t) if t.is_empty()) -} - -/// Excel grows an AutoFilter range when a value is entered in the row right below it, in one of its columns (#2342). -/// A table's own filter is left to the table, and a row typed after a blank row does not count. -/// A formula counts before it is calculated: this runs inside the edit, ahead of the recalc. -pub(crate) fn grow_autofilter(sh: &mut Sheet, at: CellRef) { - if !sh.cells.get(at).is_some_and(|c| !filter_blank(&c.value) || c.formula.is_some()) { - return; - } - let Some(af) = sh.autofilter.as_ref() else { return }; - let r = af.range; - if r.end.row.checked_add(1) != Some(at.row) || at.col < r.start.col || at.col > r.end.col || sh.tables.iter().any(|t| t.range == r) { - return; - } - let grown = RangeRef::new(r.start, CellRef::new(at.row, r.end.col)); - if let Some(af) = sh.autofilter.as_mut() { - af.range = grown; - } -} - -/// Hides rows of the autofilter range that fail any criterion. -pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { - let Some(sh) = wb.sheet(sheet) else { return }; - let Some(af) = sh.autofilter.clone() else { return }; - let r = af.range; - let mut hide = Vec::new(); - // Precompute per-criterion top10 threshold and average once, not per row. - let mut col_stats: std::collections::HashMap, f64)> = std::collections::HashMap::new(); - for (i, (off, c)) in af.criteria.iter().enumerate() { - if matches!(c, FilterCriterion::Top10 { .. } | FilterCriterion::AboveAverage(_)) { - let col = r.start.col + off; - let mut nums: Vec = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect(); - let avg = if nums.is_empty() { 0.0 } else { nums.iter().sum::() / nums.len() as f64 }; - let thr = match c { - FilterCriterion::Top10 { bottom, percent, count } => { - nums.sort_by(|a, b| if *bottom { a.partial_cmp(b) } else { b.partial_cmp(a) }.unwrap_or(Ordering::Equal)); - let k = if *percent { ((nums.len() as f64) * (*count as f64) / 100.0).ceil() as usize } else { *count as usize }; - let k = k.clamp(1, nums.len().max(1)); - nums.get(k - 1).copied() - } - _ => None, - }; - col_stats.insert(i, (thr, avg)); - } - } - for row in r.start.row + 1..=r.end.row { - let mut visible = true; - for (i, (off, crit)) in af.criteria.iter().enumerate() { - let c = CellRef::new(row, r.start.col + off); - let v = sh.value(c); - let text = crate::display::cell_text(wb, sh, c); - let ok = match crit { - FilterCriterion::Values { values, blanks } => { - if filter_blank(&v) { - *blanks - } else { - values.iter().any(|x| x.eq_ignore_ascii_case(&text) || x.eq_ignore_ascii_case(&v.display())) - } - } - FilterCriterion::Custom { a, b, and } => { - let x = custom_ok(&v, &text, &a.0, &a.1); - match b { - Some(b) => { - let y = custom_ok(&v, &text, &b.0, &b.1); - if *and { x && y } else { x || y } - } - None => x, - } - } - FilterCriterion::Top10 { bottom, .. } => match (v.as_f64(), col_stats.get(&i)) { - (Some(n), Some((thr, _))) => { - let thr = thr.unwrap_or(n); - if *bottom { n <= thr } else { n >= thr } - } - _ => false, - }, - FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(&i)) { - (Some(n), Some((_, avg))) => { - if *above { - n > *avg - } else { - n < *avg - } - } - _ => false, - }, - FilterCriterion::FillColor(col) => wb.styles.get(sh.style_id(c)).fill.fg == *col, - FilterCriterion::FontColor(col) => wb.styles.get(sh.style_id(c)).font.color == *col, - // Icons are not evaluated here: the row keeps its current visibility. - FilterCriterion::Icon { .. } => !sh.is_row_hidden(row), - }; - if !ok { - visible = false; - break; - } - } - hide.push((row, !visible)); - } - let Some(shm) = wb.sheet_mut(sheet) else { return }; - for (row, h) in hide { - if h { - shm.rows.entry(row).or_default().hidden = true; - } else if let Some(e) = shm.rows.get_mut(&row) { - e.hidden = false; - } - } -} - -fn clear_filter(s: &mut Session, _: &Json) -> Result { - let sheet = s.doc()?.wb.active_sheet(); - edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - if let Some(a) = sh.autofilter.as_mut() { - a.criteria.clear(); - } - apply_filter(&mut cx.wb, sheet); - cx.structural = true; - Ok(Json::Null) - }) -} - -fn reapply(s: &mut Session, _: &Json) -> Result { - let sheet = s.doc()?.wb.active_sheet(); - edit(s, |cx| { - apply_filter(&mut cx.wb, sheet); - cx.structural = true; - Ok(Json::Null) - }) -} - -/// Distinct display values of a filter column (for the dropdown list). -pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool)> { - let Some(sh) = wb.sheet(sheet) else { return vec![] }; - let Some(af) = &sh.autofilter else { return vec![] }; - let off = col.saturating_sub(af.range.start.col); - let selected: Option<&FilterCriterion> = af.criteria.iter().find(|(o, _)| *o == off).map(|(_, c)| c); - let mut seen = std::collections::BTreeMap::new(); - for row in af.range.start.row + 1..=af.range.end.row.min(af.range.start.row + 1_000_000) { - let c = CellRef::new(row, col); - let v = sh.value(c); - let blank = filter_blank(&v); - let t = if blank { "(Blanks)".to_string() } else { crate::display::cell_text(wb, sh, c) }; - let on = match selected { - Some(FilterCriterion::Values { values, blanks }) => { - if blank { - *blanks - } else { - values.iter().any(|x| x.eq_ignore_ascii_case(&t)) - } - } - _ => true, - }; - let key = if blank { sort_key(&Value::Empty) } else { sort_key(&v) }; - seen.entry((key, t)).or_insert(on); - } - seen.into_iter().map(|((_, t), on)| (t, on)).collect() -} - -/// Case-sensitive tie-break for text that is equal ignoring case: as in Excel, lowercase sorts -/// before uppercase at the first letter that differs in case. -fn case_order(a: &str, b: &str) -> Ordering { - a.chars().zip(b.chars()).find(|(x, y)| x != y).map_or(Ordering::Equal, |(x, y)| y.is_lowercase().cmp(&x.is_lowercase())) -} - -fn sort_key(v: &Value) -> (u8, i64, String) { - match v { - Value::Number(n) => (0, (n * 1e6) as i64, String::new()), - Value::Text(t) => (1, 0, t.to_lowercase()), - Value::Bool(b) => (2, *b as i64, String::new()), - Value::Empty => (9, 0, String::new()), - _ => (3, 0, v.display()), - } -} - -// ---------------------------------------------------------------- tools - -fn remove_duplicates(s: &mut Session, p: &Json) -> Result { - let (sheet, r) = data_target(s, p, "data.removeDuplicates")?; - let d = s.doc()?; - let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; - // Rows and columns past the data are blank: only the part with data is compared (whole - // columns compared a million blank rows and reported them as removed duplicates). - let r = sh.used_range().and_then(|u| r.intersection(&u)).unwrap_or(RangeRef::cell(r.start)); - let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r)); - let cols: Vec = p - .get("columns") - .and_then(Json::as_array) - .map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect()) - .unwrap_or_else(|| (r.start.col..=r.end.col).collect()); - // With no column to compare, every row key is empty: the first row would survive and every - // other row would be deleted as a duplicate. A column outside the range compares data the - // user didn't select. Excel refuses both. - if cols.is_empty() || cols.iter().any(|c| !(r.start.col..=r.end.col).contains(c)) { - return Err(bad("data.removeDuplicates", "`columns` must name at least one column of the range")); - } - let start = r.start.row + header as u32; - let mut removed = 0; - edit(s, |cx| { - let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) }; - let mut seen = std::collections::HashSet::new(); - let mut keep: Vec = Vec::new(); - // Cells are compared by the text they show, as in Excel: the same date in two formats - // isn't a duplicate, while 1.01 and 1.02 shown as 1.0 are. - let shown = |c: CellRef| crate::display::format(&sh.value(c), cx.wb.styles.get(sh.style_id(c)).num_fmt.as_str(), &cx.wb).text; - for row in start..=r.end.row { - let key: Vec = cols.iter().map(|c| shown(CellRef::new(row, *c)).to_lowercase()).collect(); - if seen.insert(key) { - keep.push(row); - } else { - removed += 1; - } - } - let pictures: Vec<_> = keep - .iter() - .enumerate() - .flat_map(|(i, row)| { - (r.start.col..=r.end.col) - .filter_map(move |col| sh.cell_pictures.get(&CellRef::new(*row, col)).map(|p| (CellRef::new(start + i as u32, col), p.clone()))) - }) - .collect(); - // Rows a filter keeps are compacted to `start + i`, so the notes and hyperlinks that - // describe them move with them; the ones on removed rows are dropped with the row. - let notes: Vec<_> = keep - .iter() - .enumerate() - .flat_map(|(i, row)| { - (r.start.col..=r.end.col) - .filter_map(move |col| sh.comments.get(&CellRef::new(*row, col)).map(|cm| (CellRef::new(start + i as u32, col), cm.clone()))) - }) - .collect(); - let links: Vec<_> = keep - .iter() - .enumerate() - .flat_map(|(i, row)| { - (r.start.col..=r.end.col) - .filter_map(move |col| sh.hyperlinks.get(&CellRef::new(*row, col)).map(|h| (CellRef::new(start + i as u32, col), h.clone()))) - }) - .collect(); - let rows: Vec>> = - keep.iter().map(|row| (r.start.col..=r.end.col).map(|c| sh.cell(CellRef::new(*row, c)).cloned()).collect()).collect(); - let shm = cx.sheet_mut(sheet)?; - for row in start..=r.end.row { - for c in r.start.col..=r.end.col { - shm.remove_cell(CellRef::new(row, c)); - } - } - for row in start..=r.end.row { - for c in r.start.col..=r.end.col { - let at = CellRef::new(row, c); - shm.comments.remove(&at); - shm.hyperlinks.remove(&at); - } - } - for (i, cells) in rows.into_iter().enumerate() { - for (j, cell) in cells.into_iter().enumerate() { - if let Some(cell) = cell { - shm.set_cell(CellRef::new(start + i as u32, r.start.col + j as u32), cell); - } - } - } - shm.cell_pictures.extend(pictures); - for (at, cm) in notes { - shm.comments.insert(at, cm); - } - for (at, h) in links { - shm.hyperlinks.insert(at, h); - } - for c in RangeRef::new(CellRef::new(start, r.start.col), r.end).iter() { - cx.changed.push((sheet, c)); - } - Ok(()) - })?; - let remaining = (r.end.row + 1 - start) as usize - removed; - Ok(json!({"removed": removed, "remaining": remaining})) -} - -fn text_to_columns(s: &mut Session, p: &Json) -> Result { - let r = target_range(s, p)?; - let sheet = target_sheet(s, p)?; - let mut delims: Vec = p - .get("delimiters") - .and_then(Json::as_array) - .map(|a| a.iter().filter_map(|d| d.as_str().and_then(|x| x.chars().next())).collect()) - .unwrap_or_else(|| vec!['\t']); - if let Some(o) = str_param(p, "other").and_then(|o| o.chars().next()) { - delims.push(o); - } - let fixed: Option> = - p.get("fixedWidths").and_then(Json::as_array).map(|a| a.iter().filter_map(|x| x.as_u64().map(|v| v as usize)).collect()); - let consecutive = bool_param(p, "treatConsecutive").unwrap_or(false); - let quote = str_param(p, "textQualifier").and_then(|q| q.chars().next()).unwrap_or('"'); - let dest = cell_param_on(s, p, "destination", sheet)?.unwrap_or(r.start); - edit(s, |cx| { - let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) }; - let mut out: Vec<(CellRef, String)> = Vec::new(); - for (i, row) in (r.start.row..=r.end.row).enumerate() { - let text = sh.value(CellRef::new(row, r.start.col)).display(); - let parts: Vec = match &fixed { - Some(w) => { - let chars: Vec = text.chars().collect(); - let mut cuts = vec![0]; - let mut breaks: Vec = w.iter().copied().filter(|x| *x > 0 && *x < chars.len()).collect(); - breaks.sort_unstable(); - breaks.dedup(); - cuts.extend(breaks); - cuts.push(chars.len()); - cuts.windows(2) - .map(|p| chars.get(p[0]..p[1]).map(|s| s.iter().collect::().trim().to_string()).unwrap_or_default()) - .collect() - } - None => split_delimited(&text, &delims, consecutive, quote), - }; - for (j, part) in parts.into_iter().enumerate() { - out.push((CellRef::new(dest.row + i as u32, dest.col + j as u32), part)); - } - } - for (c, t) in out { - let cell = super::edit::input_to_cell(&t, sheet, c, &mut cx.wb).unwrap_or(None); - let shm = cx.sheet_mut(sheet)?; - match cell { - Some(x) => shm.set_cell(c, x), - None => { - shm.remove_cell(c); - } - } - cx.changed.push((sheet, c)); - } - Ok(()) - })?; - ok() -} - -fn split_delimited(text: &str, delims: &[char], consecutive: bool, quote: char) -> Vec { - let mut out = Vec::new(); - let mut cur = String::new(); - let mut in_q = false; - let mut last_delim = false; - let mut chars = text.chars().peekable(); - while let Some(c) = chars.next() { - if c == quote { - if in_q && chars.peek() == Some("e) { - cur.push(quote); - chars.next(); - } else { - in_q = !in_q; - } - continue; - } - if !in_q && delims.contains(&c) { - if consecutive && last_delim { - continue; - } - out.push(std::mem::take(&mut cur)); - last_delim = true; - continue; - } - last_delim = false; - cur.push(c); - } - out.push(cur); - out -} - -// ---------------------------------------------------------------- validation - -fn op_param(s: Option<&str>) -> CfOperator { - match s.unwrap_or("between") { - "notBetween" => CfOperator::NotBetween, - "equal" => CfOperator::Equal, - "notEqual" => CfOperator::NotEqual, - "greater" | "greaterThan" => CfOperator::Greater, - "less" | "lessThan" => CfOperator::Less, - "greaterOrEqual" | "greaterThanOrEqual" => CfOperator::GreaterOrEqual, - "lessOrEqual" | "lessThanOrEqual" => CfOperator::LessOrEqual, - _ => CfOperator::Between, - } -} - -/// `r` minus `cut`, as up to four non-overlapping rectangles. -fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec { - let Some(i) = r.intersection(cut) else { return vec![r] }; - let mut out = Vec::new(); - if i.start.row > r.start.row { - out.push(RangeRef::new(r.start, CellRef::new(i.start.row - 1, r.end.col))); - } - if i.end.row < r.end.row { - out.push(RangeRef::new(CellRef::new(i.end.row + 1, r.start.col), r.end)); - } - if i.start.col > r.start.col { - out.push(RangeRef::new(CellRef::new(i.start.row, r.start.col), CellRef::new(i.end.row, i.start.col - 1))); - } - if i.end.col < r.end.col { - out.push(RangeRef::new(CellRef::new(i.start.row, i.end.col + 1), CellRef::new(i.end.row, r.end.col))); - } - out -} - -/// Removes the cells of `cuts` from every validation, keeping the rest of each rule's ranges. -pub(super) fn remove_validation_cells(validations: &mut Vec, cuts: &[RangeRef]) { - for dv in validations.iter_mut() { - for cut in cuts { - dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect(); - } - } - validations.retain(|d| !d.ranges.is_empty()); -} - -fn validation(s: &mut Session, p: &Json) -> Result { - let ranges = target_ranges(s, p)?; - let sheet = target_sheet(s, p)?; - if bool_param(p, "clear").unwrap_or(false) { - return edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - remove_validation_cells(&mut sh.validations, &ranges); - Ok(Json::Null) - }); - } - let Some(kind) = str_param(p, "type") else { - s.ui_requests.push(crate::UiRequest::Dialog("dataValidation".into(), json!({}))); - return ok(); - }; - let kind = match kind { - "whole" => ValidationKind::Whole, - "decimal" => ValidationKind::Decimal, - "list" => ValidationKind::List, - "date" => ValidationKind::Date, - "time" => ValidationKind::Time, - "textLength" => ValidationKind::TextLength, - "custom" => ValidationKind::Custom, - "any" => ValidationKind::Any, - // An unknown type used to give an "any value" rule, saved as `type="none"`: no validation. - other => return Err(bad("data.validation", format!("unknown type `{other}` (any, whole, decimal, list, date, time, textLength, custom)"))), - }; - if kind == ValidationKind::List && str_param(p, "formula1").is_none_or(|f| f.trim().trim_start_matches('=').is_empty()) { - return Err(bad("data.validation", "a list validation needs `formula1`")); - } - const OPERATORS: [&str; 12] = [ - "between", - "notBetween", - "equal", - "notEqual", - "greater", - "greaterThan", - "less", - "lessThan", - "greaterOrEqual", - "greaterThanOrEqual", - "lessOrEqual", - "lessThanOrEqual", - ]; - if let Some(op) = str_param(p, "operator").filter(|op| !OPERATORS.contains(op)) { - return Err(bad("data.validation", format!("unknown operator `{op}` ({})", OPERATORS.join(", ")))); - } - let error_style = match str_param(p, "errorStyle") { - None | Some("stop") => ErrorStyle::Stop, - Some("warning") => ErrorStyle::Warning, - Some("information") => ErrorStyle::Information, - Some(other) => return Err(bad("data.validation", format!("unknown errorStyle `{other}` (stop, warning, information)"))), - }; - let f1 = str_param(p, "formula1").unwrap_or(""); - let f1 = if kind == ValidationKind::List { list_source(f1) } else { f1.trim_start_matches('=').to_string() }; - let dv = Validation { - ranges: ranges.clone(), - kind, - op: op_param(str_param(p, "operator")), - f1, - f2: str_param(p, "formula2").map(|f| f.trim_start_matches('=').to_string()), - allow_blank: bool_param(p, "ignoreBlank").unwrap_or(true), - in_cell_dropdown: bool_param(p, "dropdown").unwrap_or(true), - input_title: str_param(p, "inputTitle").unwrap_or("").into(), - input_message: str_param(p, "inputMessage").unwrap_or("").into(), - show_input: true, - error_title: str_param(p, "errorTitle").unwrap_or("").into(), - error_message: str_param(p, "errorMessage").unwrap_or("").into(), - error_style, - show_error: true, - }; - edit(s, |cx| { - let sh = cx.sheet_mut(sheet)?; - remove_validation_cells(&mut sh.validations, &ranges); - sh.validations.push(dv.clone()); - Ok(Json::Null) - }) -} - -/// Whether a list rule's source is a literal list (`"a,b"`, or `a,b` typed without quotes) rather -/// than a reference or a name to evaluate. -fn is_literal_list(f: &str) -> bool { - f.starts_with('"') - || (!f.contains('!') - && gridcraft_formula::parse(f).map(|e| !matches!(e, gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_))).unwrap_or(true)) -} - -/// A list rule's source as a file stores it. A literal list typed without quotes, as in Excel's -/// Source box (`Open,In progress,Closed`), becomes the quoted string Excel saves -/// (`"Open,In progress,Closed"`, inner quotes doubled); unquoted, the file would read it as a -/// formula. A formula (`=…`), a reference, a name or an already quoted list is kept as before. -fn list_source(f: &str) -> String { - let t = f.trim(); - if t.is_empty() || t.starts_with('=') || t.starts_with('"') || !is_literal_list(t) { - return f.trim_start_matches('=').to_string(); - } - format!("\"{}\"", t.replace('"', "\"\"")) -} - -/// List entries of a list validation (literal or from a range). -pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec { - let f = dv.f1.trim(); - if is_literal_list(f) { - // A quoted list is a string literal: `""` inside stands for one `"`. - let items = match f.strip_prefix('"').and_then(|x| x.strip_suffix('"')) { - Some(body) => body.replace("\"\"", "\""), - None => f.trim_matches('"').to_string(), - }; - return items.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(); - } - let at = dv.ranges.first().map(|r| r.start).unwrap_or_default(); - match gridcraft_calc::evaluate(wb, sheet, at, f) { - Value::Array(a) => a.data.iter().filter(|v| !v.is_empty()).map(Value::display).collect(), - v if !v.is_empty() && !v.is_error() => vec![v.display()], - _ => vec![], - } -} - -/// Checks typed input against the validation of a cell. `Ok(None)` = valid. -pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) -> Option<(Validation, String)> { - let sh = wb.sheet(sheet)?; - let dv = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(at)))?; - if dv.kind == ValidationKind::Any || !dv.show_error { - return None; - } - if input.is_empty() && dv.allow_blank { - return None; - } - let v = gridcraft_core::parse::parse_input(input, wb.date_system).value; - // The formulas are written for the first cell of the range and see the value being entered, - // as in Excel (a custom `=A1>10` on A1:A9 checks A5's new value at A5). - let first = dv.ranges.first().map(|r| r.start).unwrap_or(at); - let entered; - let wb = if sh.value(at) == v { - wb - } else { - let mut w = wb.clone(); - if let Some(sh) = w.sheet_mut(sheet) { - let style = sh.style_id(at); - sh.cells.set(at, Cell { value: v.clone(), style, ..Default::default() }); - } - entered = w; - &entered - }; - let eval = |f: &str| gridcraft_calc::evaluate(wb, sheet, at, &formula_at(f, first, at)); - let num = |f: &str| eval(f).to_number().ok(); - let ok = match dv.kind { - ValidationKind::List => list_items(wb, sheet, dv).iter().any(|x| x.eq_ignore_ascii_case(input)), - ValidationKind::Custom => eval(&dv.f1).to_bool().unwrap_or(false), - ValidationKind::TextLength => { - let n = input.chars().count() as f64; - compare_op(dv.op, n, num(&dv.f1), dv.f2.as_deref().and_then(num)) - } - _ => match v.as_f64() { - Some(n) => { - if dv.kind == ValidationKind::Whole && n.fract() != 0.0 { - false - } else { - compare_op(dv.op, n, num(&dv.f1), dv.f2.as_deref().and_then(num)) - } - } - None => false, - }, - }; - if ok { - return None; - } - let msg = if dv.error_message.is_empty() { - "This value doesn't match the data validation restrictions defined for this cell.".to_string() - } else { - dv.error_message.clone() - }; - Some((dv.clone(), msg)) -} - -/// A formula written for `first`, as seen from `at` (relative references shifted). -fn formula_at(f: &str, first: CellRef, at: CellRef) -> String { - if first == at { - return f.to_string(); - } - match gridcraft_formula::parse(f.trim_start_matches('=')) { - Ok(e) => gridcraft_formula::print(&gridcraft_formula::adjust::shift_relative( - e, - at.row as i64 - first.row as i64, - at.col as i64 - first.col as i64, - )), - Err(_) => f.to_string(), - } -} - -/// Moves the relative references of a rule formula written for `first` so it reads the same cells from `to` -/// (pasting a rule); text without relative references (constants, literal lists) stays as typed. -pub(super) fn move_rule_formula(f: &str, first: CellRef, to: CellRef) -> String { - if first == to { - return f.to_string(); - } - let moved = formula_at(f, first, to); - let has_relative = gridcraft_formula::parse(f.trim_start_matches('=')).is_ok_and(|e| gridcraft_formula::print(&e) != moved); - let eq = if f.starts_with('=') { "=" } else { "" }; - if has_relative { format!("{eq}{moved}") } else { f.to_string() } -} - -fn compare_op(op: CfOperator, n: f64, a: Option, b: Option) -> bool { - let Some(a) = a else { return false }; - match op { - CfOperator::Between => b.is_some_and(|b| n >= a.min(b) && n <= a.max(b)), - CfOperator::NotBetween => b.is_some_and(|b| n < a.min(b) || n > a.max(b)), - CfOperator::Equal => n == a, - CfOperator::NotEqual => n != a, - CfOperator::Greater => n > a, - CfOperator::Less => n < a, - CfOperator::GreaterOrEqual => n >= a, - CfOperator::LessOrEqual => n <= a, - } -} - -fn validate_cmd(s: &mut Session, p: &Json) -> Result { - let d = s.doc()?; - let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); - let input = str_param(p, "input").unwrap_or(""); - match check_validation(&d.wb, d.wb.active_sheet(), at, input) { - None => Ok(json!({"ok": true})), - Some((dv, msg)) => Ok(json!({"ok": false, "message": msg, "title": dv.error_title, "style": format!("{:?}", dv.error_style)})), - } -} - -fn circle_invalid(s: &mut Session, _: &Json) -> Result { - let d = s.doc()?; - let sheet = d.wb.active_sheet(); - let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; - let mut bad_cells = Vec::new(); - for dv in &sh.validations { - for r in &dv.ranges { - let r = sh.used_range().and_then(|u| r.intersection(&u)); - let Some(r) = r else { continue }; - for c in r.iter().take(100_000) { - let text = sh.input_text(c); - if !text.is_empty() && check_validation(&d.wb, sheet, c, &text).is_some() { - bad_cells.push(c.a1()); - } - } - } - } - Ok(json!({"invalid": bad_cells})) -} - -// ---------------------------------------------------------------- outline - -fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> { - if let Some(r) = str_param(p, "rows").and_then(parse_lines) { - return Ok((true, r.start.row, r.end.row)); - } - if let Some(r) = str_param(p, "cols").and_then(parse_lines) { - return Ok((false, r.start.col, r.end.col)); - } - let r = s.doc()?.selection.current(); - match str_param(p, "by") { - Some("rows") => Ok((true, r.start.row, r.end.row)), - Some("cols" | "columns") => Ok((false, r.start.col, r.end.col)), - _ if r.is_full_cols() && !r.is_full_rows() => Ok((false, r.start.col, r.end.col)), - _ => Ok((true, r.start.row, r.end.row)), - } -} - -fn group(s: &mut Session, p: &Json, on: bool) -> Result { - let si = target_sheet(s, p)?; - // The selection belongs to the active sheet, so another sheet needs its lines. - if si != s.doc()?.wb.active_sheet() && p.get("rows").is_none() && p.get("cols").is_none() { - return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet")); - } - let (rows, a, b) = outline_lines(s, p)?; - edit(s, |cx| { - let sh = cx.sheet_mut(si)?; - let before = outline::summary_before(sh, rows); - let map = if rows { &mut sh.rows } else { &mut sh.cols }; - let b = b.min(a.saturating_add(1_048_575)); - for i in a..=b { - let e = map.entry(i).or_default(); - e.outline = if on { (e.outline + 1).min(outline::MAX_LEVEL) } else { e.outline.saturating_sub(1) }; - if !on && e.outline == 0 { - e.hidden = false; - } - } - // A summary line whose group is gone has nothing left to collapse. - let summary = if before { a.checked_sub(1) } else { b.checked_add(1) }; - if !on - && !outline::groups_with(map, before).iter().any(|g| if before { g.start == a } else { g.end == b }) - && let Some(e) = summary.and_then(|i| map.get_mut(&i)) - { - e.collapsed = false; - } - cx.structural = true; // ungrouping reveals hidden rows: SUBTOTAL(10x) depends on it - Ok(Json::Null) - }) -} - -/// Hide Detail / Show Detail: the innermost group (or the one at `level`) that holds the lines, or -/// failing that the one they are the summary of. Hiding marks the summary line collapsed (the -/// + button, saved to XLSX); showing keeps inner groups that were collapsed on their own collapsed. -fn detail(s: &mut Session, p: &Json, hide: bool) -> Result { - let (rows, a, b) = outline_lines(s, p)?; - let want = p.get("level").and_then(Json::as_u64).map(|l| l.min(outline::MAX_LEVEL as u64) as u8); - edit(s, |cx| { - let si = cx.sheet_index(); - let sh = cx.sheet_mut(si)?; - let before = outline::summary_before(sh, rows); - let map = if rows { &mut sh.rows } else { &mut sh.cols }; - let all = outline::groups_with(map, before); - let level_ok = |g: &outline::OutlineGroup| want.is_none_or(|l| g.level == l); - let pick = |hit: &dyn Fn(&outline::OutlineGroup) -> bool| -> Vec { - let hits: Vec<_> = all.iter().filter(|g| level_ok(g) && hit(g)).copied().collect(); - let deepest = hits.iter().map(|g| g.level).max().unwrap_or(0); - hits.into_iter().filter(|g| g.level == deepest).collect() - }; - let mut chosen = pick(&|g| g.start <= b && g.end >= a); - if chosen.is_empty() { - chosen = pick(&|g| g.has_summary() && g.summary() >= a && g.summary() <= b); - } - for g in chosen { - // Inner groups collapsed on their own (their summary marked), before this one changes. - let inner: Vec = all - .iter() - .filter(|o| o.level > g.level && o.start >= g.start && o.end <= g.end && o.has_summary() && o.summary() != g.summary()) - .filter(|o| map.get(&o.summary()).is_some_and(|e| e.collapsed)) - .copied() - .collect(); - for i in g.start..=g.end { - if let Some(e) = map.get_mut(&i) { - set_hidden(e, hide || inner.iter().any(|o| o.contains(i))); - } - } - if !g.has_summary() { - continue; - } - if hide { - map.entry(g.summary()).or_default().collapsed = true; - } else if let Some(e) = map.get_mut(&g.summary()) { - e.collapsed = false; - } - } - cx.structural = true; - Ok(Json::Null) - }) -} - -/// Hides or shows an outline line. A line shown with a zero size (files that hide columns as -/// `width="0" hidden="1"`) gets the default size back, as Unhide does; otherwise + would mark the -/// group expanded and leave it invisible (#1714). -fn set_hidden(e: &mut LineInfo, hidden: bool) { - e.hidden = hidden; - if !hidden && e.size == Some(0.0) { - e.size = None; - } -} - -/// The outline's level buttons: show rows / columns down to outline level `n` (1 = only the -/// top-level summaries); deeper detail is hidden and its summary lines marked collapsed. -fn show_levels(s: &mut Session, p: &Json) -> Result { - let level = |k: &str| p.get(k).and_then(Json::as_u64).map(|n| n.clamp(1, outline::MAX_LEVEL as u64 + 1) as u8); - let (rows, cols) = (level("rows"), level("cols")); - if rows.is_none() && cols.is_none() { - return Err(EngineError::BadParams { - cmd: "data.showLevels".into(), - msg: "give `rows` or `cols`: the outline level to show (1 = top-level summaries only)".into(), - }); - } - edit(s, |cx| { - let si = cx.sheet_index(); - let sh = cx.sheet_mut(si)?; - let before = (outline::summary_before(sh, true), outline::summary_before(sh, false)); - for (map, n, before) in [(&mut sh.rows, rows, before.0), (&mut sh.cols, cols, before.1)] { - let Some(n) = n else { continue }; - let groups = outline::groups_with(map, before); - for e in map.values_mut().filter(|e| e.outline > 0) { - set_hidden(e, e.outline >= n); - } - // Inner groups come first, so a summary line shared with the group around it ends up - // as that outer group says. - for g in groups.into_iter().filter(|g| g.has_summary()) { - map.entry(g.summary()).or_default().collapsed = g.level >= n; - } - } - cx.structural = true; - Ok(Json::Null) - }) -} - -/// Data ▸ Outline ▸ Settings: whether summary rows sit below their detail and summary columns -/// right of it. A changed direction moves each collapsed group's mark (+) to its new summary line. -/// Moves the collapsed marks of a line axis to the summary line on its new side (`on` is the new -/// setting, `was_before` the old one). A line that nested groups share keeps its mark on the outermost -/// group whose detail is all hidden, or the outermost when none is. -fn move_marks(map: &mut std::collections::BTreeMap, was_before: bool, on: bool) { - let mut marked: Vec = - outline::groups_with(map, was_before).into_iter().filter(|g| g.has_summary() && map.get(&g.summary()).is_some_and(|e| e.collapsed)).collect(); - // A mark on a line that nested groups share moves with the outermost of them whose - // detail is all hidden, or with the outermost when none is. - let mut owner: std::collections::BTreeMap = std::collections::BTreeMap::new(); - for g in &marked { - let key = (!g.collapsed, g.level); - let best = owner.entry(g.summary()).or_insert(key); - *best = (*best).min(key); - } - marked.retain(|g| owner.get(&g.summary()) == Some(&(!g.collapsed, g.level))); - for g in &marked { - if let Some(e) = map.get_mut(&g.summary()) { - e.collapsed = false; - } - } - for g in marked { - let g = outline::OutlineGroup { summary_before: !on, ..g }; - if g.has_summary() { - map.entry(g.summary()).or_default().collapsed = true; - } - } -} - -fn outline_settings(s: &mut Session, p: &Json) -> Result { - let si = target_sheet(s, p)?; - edit(s, |cx| { - let sh = cx.sheet_mut(si)?; - for (rows, key) in [(true, "summaryBelow"), (false, "summaryRight")] { - let Some(on) = bool_param(p, key) else { continue }; - let was_before = outline::summary_before(sh, rows); - if rows { - sh.summary_below = on; - } else { - sh.summary_right = on; - } - if was_before != on { - continue; - } - move_marks(if rows { &mut sh.rows } else { &mut sh.cols }, was_before, on); - } - Ok(json!({"summaryBelow": sh.summary_below, "summaryRight": sh.summary_right})) - }) -} - -fn subtotal(s: &mut Session, p: &Json) -> Result { - let (sheet, r) = data_target(s, p, "data.subtotal")?; - let header = bool_param(p, "header").unwrap_or(true); - let group_col = col_param(p.get("groupBy"), r.start.col).unwrap_or(r.start.col); - let func = str_param(p, "function").unwrap_or("sum"); - let code = match func { - "count" => 3, - "average" => 1, - "max" => 4, - "min" => 5, - "product" => 6, - _ => 9, - }; - let cols: Vec = p - .get("columns") - .and_then(Json::as_array) - .map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect()) - .unwrap_or_else(|| vec![r.end.col]); - let label = match func { - "count" => "Count", - "average" => "Average", - "max" => "Max", - "min" => "Min", - "product" => "Product", - _ => "Total", - }; - // Group boundaries (header in the first row). - let d = s.doc()?; - let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; - let mut groups: Vec<(u32, u32, String)> = Vec::new(); - let first = r.start.row + u32::from(header); - let mut start = first; - for row in first..=r.end.row + 1 { - let cur = sh.value(CellRef::new(row, group_col)).display(); - let prev = sh.value(CellRef::new(start, group_col)).display(); - if row > r.end.row || cur != prev { - groups.push((start, row - 1, prev)); - start = row; - } - } - // The totals go below each group, so the outline puts summary rows below their detail (the - // Subtotal dialog's "Summary below data"), in the same undo step. - if !sh.summary_below { - s.execute("data.outlineSettings", json!({"sheet": sheet, "summaryBelow": true}))?; - } - // Insert a total row after each group, bottom-up so indices stay valid. - let mut inserted = 0; - for (g0, g1, name) in groups.iter().rev() { - s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", g1 + 2, g1 + 2)}))?; - let row = g1 + 1; - let lbl = CellRef::new(row, group_col).a1(); - s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("{name} {label}")}))?; - for c in &cols { - let range = RangeRef::new(CellRef::new(*g0, *c), CellRef::new(*g1, *c)).a1(); - s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(row, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?; - } - s.execute( - "home.bold", - json!({"sheet": sheet, "range": RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)).a1(), "on": true}), - )?; - s.execute("data.group", json!({"sheet": sheet, "rows": format!("{}:{}", g0 + 1, g1 + 1)}))?; - inserted += 1; - } - let last = r.end.row + inserted + 1; - let lbl = CellRef::new(last, group_col).a1(); - s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", last + 1, last + 1)}))?; - s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("Grand {label}")}))?; - for c in &cols { - let range = RangeRef::new(CellRef::new(first, *c), CellRef::new(last - 1, *c)).a1(); - s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(last, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?; - } - Ok(json!({"groups": inserted})) -} - #[cfg(test)] mod tests; diff --git a/crates/engine/src/cmd/data/filter.rs b/crates/engine/src/cmd/data/filter.rs new file mode 100644 index 000000000..71aa698f4 --- /dev/null +++ b/crates/engine/src/cmd/data/filter.rs @@ -0,0 +1,316 @@ +use super::sort::sort_key; +use super::*; + +// ---------------------------------------------------------------- filter + +pub(super) fn toggle_filter(s: &mut Session, p: &Json) -> Result { + let (sheet, r) = data_target(s, p, "data.filter")?; + crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?; + // The active cell belongs to the active sheet: on another sheet the table is the one at `range`. + let at = if sheet == s.doc()?.wb.active_sheet() { s.doc()?.selection.active } else { r.start }; + let has = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.autofilter.is_some()); + let in_table = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(at).map(|t| t.name.clone())); + if let Some(name) = in_table { + return edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + if let Some(t) = sh.tables.iter_mut().find(|t| t.name == name) { + t.filter_button = !t.filter_button; + if !t.filter_button { + t.filter.clear(); + } + } + Ok(Json::Null) + }); + } + if has { + return edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + // Reveal only rows the filter hid; rows hidden by hand or by a collapsed group stay hidden. + let filtered: Vec = sh.rows.keys().copied().filter(|r| sh.is_row_filtered(*r)).collect(); + sh.autofilter = None; + for r in filtered { + if let Some(info) = sh.rows.get_mut(&r) { + info.hidden = false; + } + } + cx.structural = true; + Ok(json!({"filter": false})) + }); + } + install_filter(s, p) +} + +/// Turn a sheet AutoFilter on over `p.range`, or over the data range of the selection. +fn install_filter(s: &mut Session, p: &Json) -> Result { + let (sheet, mut r) = data_target(s, p, "data.filter")?; + // A selected header row filters the data below it (#201). + if p.get("range").is_none() && r.height() == 1 { + let d = s.doc()?; + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let below = current_region(sh, r.start).end.row; + if below > r.end.row { + r = RangeRef::new(r.start, CellRef::new(below, r.end.col)); + } + } + edit(s, |cx| { + cx.sheet_mut(sheet)?.autofilter = Some(AutoFilter { range: r, criteria: vec![] }); + Ok(json!({"filter": true, "range": r.a1()})) + }) +} + +pub(super) fn filter_by(s: &mut Session, p: &Json) -> Result { + let sheet = s.doc()?.wb.active_sheet(); + crate::cmd::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?; + let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; + let af = match &sh.autofilter { + Some(a) => a.clone(), + None => { + install_filter(s, &json!({}))?; + s.doc()?.wb.sheet(sheet).and_then(|sh| sh.autofilter.clone()).ok_or_else(|| EngineError::Other("no filter".into()))? + } + }; + let col = match col_param(p.get("column"), af.range.start.col) { + Some(c) => c, + // A number too large to be a column is outside the filter range, not a missing parameter. + None if p.get("column").is_some_and(|v| v.as_u64().is_some()) => return Err(bad("data.filterBy", "column outside the filter range")), + None => return Err(bad("data.filterBy", "missing `column`")), + }; + if col < af.range.start.col || col > af.range.end.col { + return Err(bad("data.filterBy", "column outside the filter range")); + } + let off = col - af.range.start.col; + let crit = if bool_param(p, "clear").unwrap_or(false) { + None + } else if let Some(vals) = p.get("values").and_then(Json::as_array) { + Some(FilterCriterion::Values { + values: vals.iter().map(crate::cmd::edit::json_to_input).collect(), + blanks: bool_param(p, "blanks").unwrap_or(false), + }) + } else if let Some(c) = p.get("custom") { + let a = + (c.get("op").and_then(Json::as_str).unwrap_or("=").to_string(), c.get("value").map(crate::cmd::edit::json_to_input).unwrap_or_default()); + let b = + c.get("op2").and_then(Json::as_str).map(|o| (o.to_string(), c.get("value2").map(crate::cmd::edit::json_to_input).unwrap_or_default())); + Some(FilterCriterion::Custom { a, b, and: c.get("and").and_then(Json::as_bool).unwrap_or(true) }) + } else if let Some(t) = p.get("top") { + Some(FilterCriterion::Top10 { + bottom: t.get("bottom").and_then(Json::as_bool).unwrap_or(false), + percent: t.get("percent").and_then(Json::as_bool).unwrap_or(false), + count: t.get("count").and_then(Json::as_u64).unwrap_or(10) as u32, + }) + } else { + bool_param(p, "aboveAverage").map(FilterCriterion::AboveAverage) + }; + edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + if let Some(a) = sh.autofilter.as_mut() { + a.criteria.retain(|(o, _)| *o != off); + if let Some(c) = crit.clone() { + a.criteria.push((off, c)); + } + } + apply_filter(&mut cx.wb, sheet); + cx.structural = true; + Ok(Json::Null) + })?; + let hidden = s.doc()?.wb.sheet(sheet).map(|sh| sh.rows.values().filter(|r| r.hidden).count()).unwrap_or(0); + Ok(json!({"hiddenRows": hidden})) +} + +/// Does a value pass a custom comparison (`>`, `=*abc*`, `<>x`…)? +fn custom_ok(v: &Value, text: &str, op: &str, crit: &str) -> bool { + let num = gridcraft_core::parse::parse_number_text(crit); + let cmp = match (v.as_f64(), num) { + (Some(a), Some(b)) => a.partial_cmp(&b), + // A numeric ordering comparison only matches numbers; blanks and text never do. + (None, Some(_)) if matches!(op, "<" | "<=" | ">" | ">=") => None, + _ => Some(gridcraft_core::compare_text(text, crit)), + }; + let wild = || crate::cmd::edit::wildcard(&text.to_lowercase(), &crit.to_lowercase()); + match op { + "=" => { + if crit.contains(['*', '?']) { + wild() + } else { + cmp == Some(Ordering::Equal) + } + } + "<>" => { + if crit.contains(['*', '?']) { + !wild() + } else { + cmp != Some(Ordering::Equal) + } + } + ">" => cmp == Some(Ordering::Greater), + "<" => cmp == Some(Ordering::Less), + ">=" => matches!(cmp, Some(Ordering::Greater | Ordering::Equal)), + "<=" => matches!(cmp, Some(Ordering::Less | Ordering::Equal)), + "beginsWith" => text.to_lowercase().starts_with(&crit.to_lowercase()), + "endsWith" => text.to_lowercase().ends_with(&crit.to_lowercase()), + "contains" => text.to_lowercase().contains(&crit.to_lowercase()), + "notContains" => !text.to_lowercase().contains(&crit.to_lowercase()), + _ => true, + } +} + +/// The filter's "(Blanks)" group: truly empty cells and zero-length text such as a `=""` result. +fn filter_blank(v: &Value) -> bool { + v.is_empty() || matches!(v, Value::Text(t) if t.is_empty()) +} + +/// Excel grows an AutoFilter range when a value is entered in the row right below it, in one of its columns (#2342). +/// A table's own filter is left to the table, and a row typed after a blank row does not count. +/// A formula counts before it is calculated: this runs inside the edit, ahead of the recalc. +pub(crate) fn grow_autofilter(sh: &mut Sheet, at: CellRef) { + if !sh.cells.get(at).is_some_and(|c| !filter_blank(&c.value) || c.formula.is_some()) { + return; + } + let Some(af) = sh.autofilter.as_ref() else { return }; + let r = af.range; + if r.end.row.checked_add(1) != Some(at.row) || at.col < r.start.col || at.col > r.end.col || sh.tables.iter().any(|t| t.range == r) { + return; + } + let grown = RangeRef::new(r.start, CellRef::new(at.row, r.end.col)); + if let Some(af) = sh.autofilter.as_mut() { + af.range = grown; + } +} + +/// Hides rows of the autofilter range that fail any criterion. +pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { + let Some(sh) = wb.sheet(sheet) else { return }; + let Some(af) = sh.autofilter.clone() else { return }; + let r = af.range; + let mut hide = Vec::new(); + // Precompute per-criterion top10 threshold and average once, not per row. + let mut col_stats: std::collections::HashMap, f64)> = std::collections::HashMap::new(); + for (i, (off, c)) in af.criteria.iter().enumerate() { + if matches!(c, FilterCriterion::Top10 { .. } | FilterCriterion::AboveAverage(_)) { + let col = r.start.col + off; + let mut nums: Vec = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect(); + let avg = if nums.is_empty() { 0.0 } else { nums.iter().sum::() / nums.len() as f64 }; + let thr = match c { + FilterCriterion::Top10 { bottom, percent, count } => { + nums.sort_by(|a, b| if *bottom { a.partial_cmp(b) } else { b.partial_cmp(a) }.unwrap_or(Ordering::Equal)); + let k = if *percent { ((nums.len() as f64) * (*count as f64) / 100.0).ceil() as usize } else { *count as usize }; + let k = k.clamp(1, nums.len().max(1)); + nums.get(k - 1).copied() + } + _ => None, + }; + col_stats.insert(i, (thr, avg)); + } + } + for row in r.start.row + 1..=r.end.row { + let mut visible = true; + for (i, (off, crit)) in af.criteria.iter().enumerate() { + let c = CellRef::new(row, r.start.col + off); + let v = sh.value(c); + let text = crate::display::cell_text(wb, sh, c); + let ok = match crit { + FilterCriterion::Values { values, blanks } => { + if filter_blank(&v) { + *blanks + } else { + values.iter().any(|x| x.eq_ignore_ascii_case(&text) || x.eq_ignore_ascii_case(&v.display())) + } + } + FilterCriterion::Custom { a, b, and } => { + let x = custom_ok(&v, &text, &a.0, &a.1); + match b { + Some(b) => { + let y = custom_ok(&v, &text, &b.0, &b.1); + if *and { x && y } else { x || y } + } + None => x, + } + } + FilterCriterion::Top10 { bottom, .. } => match (v.as_f64(), col_stats.get(&i)) { + (Some(n), Some((thr, _))) => { + let thr = thr.unwrap_or(n); + if *bottom { n <= thr } else { n >= thr } + } + _ => false, + }, + FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(&i)) { + (Some(n), Some((_, avg))) => { + if *above { + n > *avg + } else { + n < *avg + } + } + _ => false, + }, + FilterCriterion::FillColor(col) => wb.styles.get(sh.style_id(c)).fill.fg == *col, + FilterCriterion::FontColor(col) => wb.styles.get(sh.style_id(c)).font.color == *col, + // Icons are not evaluated here: the row keeps its current visibility. + FilterCriterion::Icon { .. } => !sh.is_row_hidden(row), + }; + if !ok { + visible = false; + break; + } + } + hide.push((row, !visible)); + } + let Some(shm) = wb.sheet_mut(sheet) else { return }; + for (row, h) in hide { + if h { + shm.rows.entry(row).or_default().hidden = true; + } else if let Some(e) = shm.rows.get_mut(&row) { + e.hidden = false; + } + } +} + +pub(super) fn clear_filter(s: &mut Session, _: &Json) -> Result { + let sheet = s.doc()?.wb.active_sheet(); + edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + if let Some(a) = sh.autofilter.as_mut() { + a.criteria.clear(); + } + apply_filter(&mut cx.wb, sheet); + cx.structural = true; + Ok(Json::Null) + }) +} + +pub(super) fn reapply(s: &mut Session, _: &Json) -> Result { + let sheet = s.doc()?.wb.active_sheet(); + edit(s, |cx| { + apply_filter(&mut cx.wb, sheet); + cx.structural = true; + Ok(Json::Null) + }) +} + +/// Distinct display values of a filter column (for the dropdown list). +pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool)> { + let Some(sh) = wb.sheet(sheet) else { return vec![] }; + let Some(af) = &sh.autofilter else { return vec![] }; + let off = col.saturating_sub(af.range.start.col); + let selected: Option<&FilterCriterion> = af.criteria.iter().find(|(o, _)| *o == off).map(|(_, c)| c); + let mut seen = std::collections::BTreeMap::new(); + for row in af.range.start.row + 1..=af.range.end.row.min(af.range.start.row + 1_000_000) { + let c = CellRef::new(row, col); + let v = sh.value(c); + let blank = filter_blank(&v); + let t = if blank { "(Blanks)".to_string() } else { crate::display::cell_text(wb, sh, c) }; + let on = match selected { + Some(FilterCriterion::Values { values, blanks }) => { + if blank { + *blanks + } else { + values.iter().any(|x| x.eq_ignore_ascii_case(&t)) + } + } + _ => true, + }; + let key = if blank { sort_key(&Value::Empty) } else { sort_key(&v) }; + seen.entry((key, t)).or_insert(on); + } + seen.into_iter().map(|((_, t), on)| (t, on)).collect() +} diff --git a/crates/engine/src/cmd/data/grouping.rs b/crates/engine/src/cmd/data/grouping.rs new file mode 100644 index 000000000..7b5a2254c --- /dev/null +++ b/crates/engine/src/cmd/data/grouping.rs @@ -0,0 +1,267 @@ +use super::*; + +// ---------------------------------------------------------------- outline + +fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> { + if let Some(r) = str_param(p, "rows").and_then(parse_lines) { + return Ok((true, r.start.row, r.end.row)); + } + if let Some(r) = str_param(p, "cols").and_then(parse_lines) { + return Ok((false, r.start.col, r.end.col)); + } + let r = s.doc()?.selection.current(); + match str_param(p, "by") { + Some("rows") => Ok((true, r.start.row, r.end.row)), + Some("cols" | "columns") => Ok((false, r.start.col, r.end.col)), + _ if r.is_full_cols() && !r.is_full_rows() => Ok((false, r.start.col, r.end.col)), + _ => Ok((true, r.start.row, r.end.row)), + } +} + +pub(super) fn group(s: &mut Session, p: &Json, on: bool) -> Result { + let si = target_sheet(s, p)?; + // The selection belongs to the active sheet, so another sheet needs its lines. + if si != s.doc()?.wb.active_sheet() && p.get("rows").is_none() && p.get("cols").is_none() { + return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet")); + } + let (rows, a, b) = outline_lines(s, p)?; + edit(s, |cx| { + let sh = cx.sheet_mut(si)?; + let before = outline::summary_before(sh, rows); + let map = if rows { &mut sh.rows } else { &mut sh.cols }; + let b = b.min(a.saturating_add(1_048_575)); + for i in a..=b { + let e = map.entry(i).or_default(); + e.outline = if on { (e.outline + 1).min(outline::MAX_LEVEL) } else { e.outline.saturating_sub(1) }; + if !on && e.outline == 0 { + e.hidden = false; + } + } + // A summary line whose group is gone has nothing left to collapse. + let summary = if before { a.checked_sub(1) } else { b.checked_add(1) }; + if !on + && !outline::groups_with(map, before).iter().any(|g| if before { g.start == a } else { g.end == b }) + && let Some(e) = summary.and_then(|i| map.get_mut(&i)) + { + e.collapsed = false; + } + cx.structural = true; // ungrouping reveals hidden rows: SUBTOTAL(10x) depends on it + Ok(Json::Null) + }) +} + +/// Hide Detail / Show Detail: the innermost group (or the one at `level`) that holds the lines, or +/// failing that the one they are the summary of. Hiding marks the summary line collapsed (the +/// + button, saved to XLSX); showing keeps inner groups that were collapsed on their own collapsed. +pub(super) fn detail(s: &mut Session, p: &Json, hide: bool) -> Result { + let (rows, a, b) = outline_lines(s, p)?; + let want = p.get("level").and_then(Json::as_u64).map(|l| l.min(outline::MAX_LEVEL as u64) as u8); + edit(s, |cx| { + let si = cx.sheet_index(); + let sh = cx.sheet_mut(si)?; + let before = outline::summary_before(sh, rows); + let map = if rows { &mut sh.rows } else { &mut sh.cols }; + let all = outline::groups_with(map, before); + let level_ok = |g: &outline::OutlineGroup| want.is_none_or(|l| g.level == l); + let pick = |hit: &dyn Fn(&outline::OutlineGroup) -> bool| -> Vec { + let hits: Vec<_> = all.iter().filter(|g| level_ok(g) && hit(g)).copied().collect(); + let deepest = hits.iter().map(|g| g.level).max().unwrap_or(0); + hits.into_iter().filter(|g| g.level == deepest).collect() + }; + let mut chosen = pick(&|g| g.start <= b && g.end >= a); + if chosen.is_empty() { + chosen = pick(&|g| g.has_summary() && g.summary() >= a && g.summary() <= b); + } + for g in chosen { + // Inner groups collapsed on their own (their summary marked), before this one changes. + let inner: Vec = all + .iter() + .filter(|o| o.level > g.level && o.start >= g.start && o.end <= g.end && o.has_summary() && o.summary() != g.summary()) + .filter(|o| map.get(&o.summary()).is_some_and(|e| e.collapsed)) + .copied() + .collect(); + for i in g.start..=g.end { + if let Some(e) = map.get_mut(&i) { + set_hidden(e, hide || inner.iter().any(|o| o.contains(i))); + } + } + if !g.has_summary() { + continue; + } + if hide { + map.entry(g.summary()).or_default().collapsed = true; + } else if let Some(e) = map.get_mut(&g.summary()) { + e.collapsed = false; + } + } + cx.structural = true; + Ok(Json::Null) + }) +} + +/// Hides or shows an outline line. A line shown with a zero size (files that hide columns as +/// `width="0" hidden="1"`) gets the default size back, as Unhide does; otherwise + would mark the +/// group expanded and leave it invisible (#1714). +fn set_hidden(e: &mut LineInfo, hidden: bool) { + e.hidden = hidden; + if !hidden && e.size == Some(0.0) { + e.size = None; + } +} + +/// The outline's level buttons: show rows / columns down to outline level `n` (1 = only the +/// top-level summaries); deeper detail is hidden and its summary lines marked collapsed. +pub(super) fn show_levels(s: &mut Session, p: &Json) -> Result { + let level = |k: &str| p.get(k).and_then(Json::as_u64).map(|n| n.clamp(1, outline::MAX_LEVEL as u64 + 1) as u8); + let (rows, cols) = (level("rows"), level("cols")); + if rows.is_none() && cols.is_none() { + return Err(EngineError::BadParams { + cmd: "data.showLevels".into(), + msg: "give `rows` or `cols`: the outline level to show (1 = top-level summaries only)".into(), + }); + } + edit(s, |cx| { + let si = cx.sheet_index(); + let sh = cx.sheet_mut(si)?; + let before = (outline::summary_before(sh, true), outline::summary_before(sh, false)); + for (map, n, before) in [(&mut sh.rows, rows, before.0), (&mut sh.cols, cols, before.1)] { + let Some(n) = n else { continue }; + let groups = outline::groups_with(map, before); + for e in map.values_mut().filter(|e| e.outline > 0) { + set_hidden(e, e.outline >= n); + } + // Inner groups come first, so a summary line shared with the group around it ends up + // as that outer group says. + for g in groups.into_iter().filter(|g| g.has_summary()) { + map.entry(g.summary()).or_default().collapsed = g.level >= n; + } + } + cx.structural = true; + Ok(Json::Null) + }) +} + +/// Data ▸ Outline ▸ Settings: whether summary rows sit below their detail and summary columns +/// right of it. A changed direction moves each collapsed group's mark (+) to its new summary line. +/// Moves the collapsed marks of a line axis to the summary line on its new side (`on` is the new +/// setting, `was_before` the old one). A line that nested groups share keeps its mark on the outermost +/// group whose detail is all hidden, or the outermost when none is. +fn move_marks(map: &mut std::collections::BTreeMap, was_before: bool, on: bool) { + let mut marked: Vec = + outline::groups_with(map, was_before).into_iter().filter(|g| g.has_summary() && map.get(&g.summary()).is_some_and(|e| e.collapsed)).collect(); + // A mark on a line that nested groups share moves with the outermost of them whose + // detail is all hidden, or with the outermost when none is. + let mut owner: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for g in &marked { + let key = (!g.collapsed, g.level); + let best = owner.entry(g.summary()).or_insert(key); + *best = (*best).min(key); + } + marked.retain(|g| owner.get(&g.summary()) == Some(&(!g.collapsed, g.level))); + for g in &marked { + if let Some(e) = map.get_mut(&g.summary()) { + e.collapsed = false; + } + } + for g in marked { + let g = outline::OutlineGroup { summary_before: !on, ..g }; + if g.has_summary() { + map.entry(g.summary()).or_default().collapsed = true; + } + } +} + +pub(super) fn outline_settings(s: &mut Session, p: &Json) -> Result { + let si = target_sheet(s, p)?; + edit(s, |cx| { + let sh = cx.sheet_mut(si)?; + for (rows, key) in [(true, "summaryBelow"), (false, "summaryRight")] { + let Some(on) = bool_param(p, key) else { continue }; + let was_before = outline::summary_before(sh, rows); + if rows { + sh.summary_below = on; + } else { + sh.summary_right = on; + } + if was_before != on { + continue; + } + move_marks(if rows { &mut sh.rows } else { &mut sh.cols }, was_before, on); + } + Ok(json!({"summaryBelow": sh.summary_below, "summaryRight": sh.summary_right})) + }) +} + +pub(super) fn subtotal(s: &mut Session, p: &Json) -> Result { + let (sheet, r) = data_target(s, p, "data.subtotal")?; + let header = bool_param(p, "header").unwrap_or(true); + let group_col = col_param(p.get("groupBy"), r.start.col).unwrap_or(r.start.col); + let func = str_param(p, "function").unwrap_or("sum"); + let code = match func { + "count" => 3, + "average" => 1, + "max" => 4, + "min" => 5, + "product" => 6, + _ => 9, + }; + let cols: Vec = p + .get("columns") + .and_then(Json::as_array) + .map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect()) + .unwrap_or_else(|| vec![r.end.col]); + let label = match func { + "count" => "Count", + "average" => "Average", + "max" => "Max", + "min" => "Min", + "product" => "Product", + _ => "Total", + }; + // Group boundaries (header in the first row). + let d = s.doc()?; + let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + let mut groups: Vec<(u32, u32, String)> = Vec::new(); + let first = r.start.row + u32::from(header); + let mut start = first; + for row in first..=r.end.row + 1 { + let cur = sh.value(CellRef::new(row, group_col)).display(); + let prev = sh.value(CellRef::new(start, group_col)).display(); + if row > r.end.row || cur != prev { + groups.push((start, row - 1, prev)); + start = row; + } + } + // The totals go below each group, so the outline puts summary rows below their detail (the + // Subtotal dialog's "Summary below data"), in the same undo step. + if !sh.summary_below { + s.execute("data.outlineSettings", json!({"sheet": sheet, "summaryBelow": true}))?; + } + // Insert a total row after each group, bottom-up so indices stay valid. + let mut inserted = 0; + for (g0, g1, name) in groups.iter().rev() { + s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", g1 + 2, g1 + 2)}))?; + let row = g1 + 1; + let lbl = CellRef::new(row, group_col).a1(); + s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("{name} {label}")}))?; + for c in &cols { + let range = RangeRef::new(CellRef::new(*g0, *c), CellRef::new(*g1, *c)).a1(); + s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(row, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?; + } + s.execute( + "home.bold", + json!({"sheet": sheet, "range": RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)).a1(), "on": true}), + )?; + s.execute("data.group", json!({"sheet": sheet, "rows": format!("{}:{}", g0 + 1, g1 + 1)}))?; + inserted += 1; + } + let last = r.end.row + inserted + 1; + let lbl = CellRef::new(last, group_col).a1(); + s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", last + 1, last + 1)}))?; + s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("Grand {label}")}))?; + for c in &cols { + let range = RangeRef::new(CellRef::new(first, *c), CellRef::new(last - 1, *c)).a1(); + s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(last, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?; + } + Ok(json!({"groups": inserted})) +} diff --git a/crates/engine/src/cmd/data/sort.rs b/crates/engine/src/cmd/data/sort.rs new file mode 100644 index 000000000..919de1734 --- /dev/null +++ b/crates/engine/src/cmd/data/sort.rs @@ -0,0 +1,322 @@ +use super::*; + +#[derive(Clone)] +struct SortKey { + col: u32, + desc: bool, + by: String, + list: Option>, +} + +/// The sheet and range a sort works on: the sheet `sheet` or a `Sheet!` prefix on `range` names, +/// else the active one. The selection belongs to the active sheet, so another sheet needs `range`. +fn sort_target(s: &Session, p: &Json) -> Result<(usize, RangeRef)> { + let sheet = target_sheet(s, p)?; + let d = s.doc()?; + if let Some(t) = str_param(p, "range") + && let (Some(name), _) = split_sheet(t) + && d.wb.sheet_index(&name) != Some(sheet) + { + return Err(bad("data.sort", format!("`{t}` names a sheet other than `sheet`"))); + } + if sheet != d.wb.active_sheet() && p.get("range").is_none() { + return Err(bad("data.sort", "missing `range`: the selection is on the active sheet")); + } + Ok((sheet, data_range(s, p)?)) +} + +pub(super) fn quick_sort(s: &mut Session, p: &Json, asc: bool) -> Result { + let (sheet, r) = sort_target(s, p)?; + let col = col_param(p.get("column"), r.start.col).unwrap_or(s.doc()?.selection.active.col).clamp(r.start.col, r.end.col); + let d = s.doc()?; + let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + let header = guess_header(&d.wb, sh, r); + do_sort(s, sheet, r, header, vec![SortKey { col, desc: !asc, by: "values".into(), list: None }], false, false) +} + +pub(super) fn sort(s: &mut Session, p: &Json) -> Result { + let (sheet, r) = sort_target(s, p)?; + let d = s.doc()?; + let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r)); + let by_cols = str_param(p, "orientation") == Some("columns"); + let case = bool_param(p, "matchCase").unwrap_or(false); + let mut keys: Vec = Vec::new(); + for k in p.get("keys").and_then(Json::as_array).map(Vec::as_slice).unwrap_or_default() { + let col = if by_cols { + let row = match k.get("row").and_then(Json::as_u64) { + // Worksheet rows are 1-based; 0 (or a value past u32) has no coordinate. + Some(n) => { + let row = u32::try_from(n).ok().and_then(|n| n.checked_sub(1)); + Some(row.ok_or_else(|| bad("data.sort", "sort key row must be 1 or greater"))?) + } + None => None, + }; + row.or_else(|| col_param(k.get("column"), r.start.row)) + } else { + col_param(k.get("column"), r.start.col) + }; + let Some(col) = col else { continue }; + keys.push(SortKey { + col, + desc: k.get("order").and_then(Json::as_str) == Some("desc"), + by: k.get("by").and_then(Json::as_str).unwrap_or("values").to_string(), + list: k.get("customList").and_then(Json::as_array).map(|l| l.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()), + }); + } + if keys.is_empty() { + s.ui_requests.push(crate::UiRequest::Dialog("sort".into(), json!({"range": r.a1(), "header": header}))); + return ok(); + } + if keys.len() > 64 { + return Err(bad("data.sort", "too many sort levels (64 max)")); + } + do_sort(s, sheet, r, header, keys, by_cols, case) +} + +fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec, by_cols: bool, case: bool) -> Result { + // Protect Sheet ▸ Sort. Both Sort… and the A-to-Z buttons come through here. + crate::cmd::refuse_unless_allowed(s, sheet, "Sorting", |p| p.sort)?; + let body = if by_cols { + if header && r.width() > 1 { RangeRef::new(CellRef::new(r.start.row, r.start.col + 1), r.end) } else { r } + } else if header && r.height() > 1 { + RangeRef::new(CellRef::new(r.start.row + 1, r.start.col), r.end) + } else { + r + }; + if body.count() > 20_000_000 { + return Err(bad("data.sort", "range too large")); + } + edit(s, |cx| { + let Some(sh) = cx.wb.sheet(sheet) else { return Ok(Json::Null) }; + if sh.merges.iter().any(|m| m.intersects(&body) && !m.is_single()) { + return Err(EngineError::Other("To do this, all the merged cells need to be the same size.".into())); + } + let n = if by_cols { body.width() } else { body.height() }; + let line = |i: u32, k: u32| if by_cols { CellRef::new(k, body.start.col + i) } else { CellRef::new(body.start.row + i, k) }; + let theme = cx.wb.theme.clone(); + let styles = &cx.wb.styles; + let key_val = |i: u32, k: &SortKey| -> (Value, u32) { + let c = line(i, k.col); + let v = sh.value(c); + let color = match k.by.as_str() { + "cellColor" => { + styles.get(sh.style_id(c)).fill.fg.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX) + } + "fontColor" => { + styles.get(sh.style_id(c)).font.color.resolve(&theme).map(|[r, g, b]| u32::from_be_bytes([0, r, g, b])).unwrap_or(u32::MAX) + } + _ => 0, + }; + (v, color) + }; + let mut order: Vec = (0..n).collect(); + let keyed: Vec> = (0..n).map(|i| keys.iter().map(|k| key_val(i, k)).collect()).collect(); + order.sort_by(|&a, &b| { + for (ki, k) in keys.iter().enumerate() { + let (va, ca) = &keyed[a as usize][ki]; + let (vb, cb) = &keyed[b as usize][ki]; + let o = if k.by != "values" { + ca.cmp(cb) + } else if let Some(list) = &k.list { + let pos = |v: &Value| v.as_text().and_then(|t| list.iter().position(|x| x.eq_ignore_ascii_case(t))).unwrap_or(usize::MAX); + pos(va).cmp(&pos(vb)) + } else if case && va.is_text() && vb.is_text() { + sort_compare(va, vb).then_with(|| case_order(va.as_text().unwrap_or(""), vb.as_text().unwrap_or(""))) + } else { + sort_compare(va, vb) + }; + // Blanks always sort last, whatever the order. + let o = if k.desc && !(va.is_empty() || vb.is_empty()) { o.reverse() } else { o }; + if o != Ordering::Equal { + return o; + } + } + Ordering::Equal + }); + if order.iter().enumerate().all(|(i, &o)| i as u32 == o) { + return Ok(Json::Null); + } + // Move whole lines (cells with formulas adjusted for relative references). + let lanes: Vec = if by_cols { (body.start.row..=body.end.row).collect() } else { (body.start.col..=body.end.col).collect() }; + // Notes and hyperlinks hang off a cell, not a row, so they travel with the row they + // describe — the way cell pictures and row heights do. Collect them before inserting, + // so a swap can't resurrect one from a destination that is also a source. + let side: Vec<(CellRef, Comment)> = order + .iter() + .enumerate() + .flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k))) + .filter_map(|(src_i, dst_i, k)| sh.comments.get(&line(src_i, k)).map(|cm| (line(dst_i, k), cm.clone()))) + .collect(); + let links: Vec<(CellRef, Hyperlink)> = order + .iter() + .enumerate() + .flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k))) + .filter_map(|(src_i, dst_i, k)| sh.hyperlinks.get(&line(src_i, k)).map(|h| (line(dst_i, k), h.clone()))) + .collect(); + let mut moved: Vec<(CellRef, Option)> = Vec::with_capacity(body.count() as usize); + let mut pictures = Vec::new(); + for (dst_i, &src_i) in order.iter().enumerate() { + for &k in &lanes { + let src = line(src_i, k); + let dst = line(dst_i as u32, k); + let mut cell = sh.cell(src).cloned(); + if let Some(c) = cell.as_mut() + && let Some(f) = &c.formula + && let Some(e) = f.expr() + { + let shifted = gridcraft_formula::adjust::shift_relative(e, dst.row as i64 - src.row as i64, dst.col as i64 - src.col as i64); + c.formula = Some(Arc::new(Formula::from_expr(shifted))); + } + if let Some(picture) = sh.cell_pictures.get(&src) { + pictures.push((dst, picture.clone())); + } + moved.push((dst, cell)); + } + } + // Row heights travel with rows. + let heights: Vec> = + if by_cols { vec![] } else { order.iter().map(|&i| sh.rows.get(&(body.start.row + i)).copied()).collect() }; + // Floating objects anchored inside the sorted body travel with their lines, unless they + // are pinned absolutely ("Don't move or size with cells", `editAs="absolute"`). Reordering + // is the inverse of `order`: `dst_of[src] = dst`. + let dst_of: Vec = { + let mut v = vec![0u32; order.len()]; + for (dst_i, &src_i) in order.iter().enumerate() { + if let Some(slot) = v.get_mut(src_i as usize) { + *slot = dst_i as u32; + } + } + v + }; + // The object's own geometry (its top-left anchor cell, offset and size) is read from the + // pre-sort `sh`; a two-cell object's bottom-right cell is derived from its size here so it + // can be re-fitted to the lines it spans after the sort. + let objs: Vec = + sh.images.iter().map(|o| o.anchor).chain(sh.charts.iter().map(|o| o.anchor)).chain(sh.shapes.iter().map(|o| o.anchor)).collect(); + let descs: Vec<(u32, u32, f32, f32)> = objs + .iter() + .map(|a| { + let x2 = sh.col_left(a.cell.col) + a.dx as f64 + a.width.max(0.0) as f64; + let y2 = sh.row_top(a.cell.row) + a.dy as f64 + a.height.max(0.0) as f64; + let tc = sh.col_at(x2); + let tr = sh.row_at(y2); + (tc, tr, (x2 - sh.col_left(tc)) as f32, (y2 - sh.row_top(tr)) as f32) + }) + .collect(); + let shm = cx.sheet_mut(sheet)?; + for (c, cell) in &moved { + match cell { + Some(x) => shm.set_cell(*c, x.clone()), + None => { + shm.remove_cell(*c); + } + } + } + // Every cell of the body is the destination of exactly one source cell, so clearing the + // body and inserting the permuted entries is the same permutation the cells went through. + for c in body.iter() { + shm.comments.remove(&c); + shm.hyperlinks.remove(&c); + } + for (at, cm) in side { + shm.comments.insert(at, cm); + } + for (at, h) in links { + shm.hyperlinks.insert(at, h); + } + shm.cell_pictures.extend(pictures); + for (i, h) in heights.into_iter().enumerate() { + let row = body.start.row + i as u32; + match h { + Some(info) => { + shm.rows.insert(row, LineInfo { hidden: shm.rows.get(&row).is_some_and(|x| x.hidden), ..info }); + } + None => { + if let Some(e) = shm.rows.get_mut(&row) { + e.size = None; + } + } + } + } + // Re-anchor floating objects now that the row heights are final. `objs` was collected as + // images ++ charts ++ shapes, so the same index maps straight onto each vec. + // Only objects anchored inside the sorted block move: within its lines on the sort axis + // and within its span on the other axis (a picture beside the block stays put). + let (b0, b1) = if by_cols { (body.start.col, body.end.col) } else { (body.start.row, body.end.row) }; + let (o0, o1) = if by_cols { (body.start.row, body.end.row) } else { (body.start.col, body.end.col) }; + let ni = shm.images.len(); + let nch = shm.charts.len(); + for (i, (a, desc)) in objs.into_iter().zip(descs).enumerate() { + let other = if by_cols { a.cell.row } else { a.cell.col }; + if other < o0 || other > o1 { + continue; + } + let na = sorted_anchor(shm, a, desc, by_cols, b0, b1, &dst_of); + if na != a { + if i < ni { + if let Some(o) = shm.images.get_mut(i) { + o.anchor = na; + } + } else if i < ni + nch { + if let Some(o) = shm.charts.get_mut(i - ni) { + o.anchor = na; + } + } else if let Some(o) = shm.shapes.get_mut(i - ni - nch) { + o.anchor = na; + } + } + } + for (c, _) in moved { + cx.changed.push((sheet, c)); + } + cx.structural = true; + Ok(Json::Null) + })?; + Ok(json!({"sorted": body.a1()})) +} + +/// Re-anchor one floating object for a sort. `bottom` is the object's bottom-right line and the +/// offset of that corner inside it, measured before the sort. `map` (via `dst_of`) translates a +/// line index on the moving axis and is the identity outside `b0..=b1`. `sh` is the post-sort +/// grid (row heights already reordered), so re-fitting uses the heights the object lands on. +/// Objects pinned absolutely ("Don't move or size with cells") never move; "move but don't size" +/// keeps its size; "move and size" is re-fitted when its whole extent stays inside the body. +fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: bool, b0: u32, b1: u32, dst_of: &[u32]) -> Anchor { + if a.mode == AnchorMode::Absolute { + return a; + } + let (tc, tr, tdx, tdy) = bottom; + let map = |v: u32| -> u32 { if v >= b0 && v <= b1 { dst_of.get((v - b0) as usize).map_or(v, |&d| b0 + d) } else { v } }; + let (axis_start, axis_end) = if by_cols { (a.cell.col, tc) } else { (a.cell.row, tr) }; + if axis_start < b0 || axis_start > b1 { + return a; + } + let (nr, nc) = if by_cols { (a.cell.row, map(a.cell.col)) } else { (map(a.cell.row), a.cell.col) }; + if a.mode == AnchorMode::MoveAndSize && axis_end >= b0 && axis_end <= b1 { + let (nbr, nbc) = if by_cols { (tr, map(tc)) } else { (map(tr), tc) }; + let x1 = sh.col_left(nc) + a.dx as f64; + let y1 = sh.row_top(nr) + a.dy as f64; + let x2 = sh.col_left(nbc) + tdx as f64; + let y2 = sh.row_top(nbr) + tdy as f64; + Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: (x2 - x1).max(0.0) as f32, height: (y2 - y1).max(0.0) as f32, mode: a.mode } + } else { + Anchor { cell: CellRef::new(nr, nc), ..a } + } +} + +/// Case-sensitive tie-break for text that is equal ignoring case: as in Excel, lowercase sorts +/// before uppercase at the first letter that differs in case. +fn case_order(a: &str, b: &str) -> Ordering { + a.chars().zip(b.chars()).find(|(x, y)| x != y).map_or(Ordering::Equal, |(x, y)| y.is_lowercase().cmp(&x.is_lowercase())) +} + +pub(super) fn sort_key(v: &Value) -> (u8, i64, String) { + match v { + Value::Number(n) => (0, (n * 1e6) as i64, String::new()), + Value::Text(t) => (1, 0, t.to_lowercase()), + Value::Bool(b) => (2, *b as i64, String::new()), + Value::Empty => (9, 0, String::new()), + _ => (3, 0, v.display()), + } +} diff --git a/crates/engine/src/cmd/data/tools.rs b/crates/engine/src/cmd/data/tools.rs new file mode 100644 index 000000000..1603b2cb4 --- /dev/null +++ b/crates/engine/src/cmd/data/tools.rs @@ -0,0 +1,190 @@ +use super::*; + +// ---------------------------------------------------------------- tools + +pub(super) fn remove_duplicates(s: &mut Session, p: &Json) -> Result { + let (sheet, r) = data_target(s, p, "data.removeDuplicates")?; + let d = s.doc()?; + let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; + // Rows and columns past the data are blank: only the part with data is compared (whole + // columns compared a million blank rows and reported them as removed duplicates). + let r = sh.used_range().and_then(|u| r.intersection(&u)).unwrap_or(RangeRef::cell(r.start)); + let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r)); + let cols: Vec = p + .get("columns") + .and_then(Json::as_array) + .map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect()) + .unwrap_or_else(|| (r.start.col..=r.end.col).collect()); + // With no column to compare, every row key is empty: the first row would survive and every + // other row would be deleted as a duplicate. A column outside the range compares data the + // user didn't select. Excel refuses both. + if cols.is_empty() || cols.iter().any(|c| !(r.start.col..=r.end.col).contains(c)) { + return Err(bad("data.removeDuplicates", "`columns` must name at least one column of the range")); + } + let start = r.start.row + header as u32; + let mut removed = 0; + edit(s, |cx| { + let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) }; + let mut seen = std::collections::HashSet::new(); + let mut keep: Vec = Vec::new(); + // Cells are compared by the text they show, as in Excel: the same date in two formats + // isn't a duplicate, while 1.01 and 1.02 shown as 1.0 are. + let shown = |c: CellRef| crate::display::format(&sh.value(c), cx.wb.styles.get(sh.style_id(c)).num_fmt.as_str(), &cx.wb).text; + for row in start..=r.end.row { + let key: Vec = cols.iter().map(|c| shown(CellRef::new(row, *c)).to_lowercase()).collect(); + if seen.insert(key) { + keep.push(row); + } else { + removed += 1; + } + } + let pictures: Vec<_> = keep + .iter() + .enumerate() + .flat_map(|(i, row)| { + (r.start.col..=r.end.col) + .filter_map(move |col| sh.cell_pictures.get(&CellRef::new(*row, col)).map(|p| (CellRef::new(start + i as u32, col), p.clone()))) + }) + .collect(); + // Rows a filter keeps are compacted to `start + i`, so the notes and hyperlinks that + // describe them move with them; the ones on removed rows are dropped with the row. + let notes: Vec<_> = keep + .iter() + .enumerate() + .flat_map(|(i, row)| { + (r.start.col..=r.end.col) + .filter_map(move |col| sh.comments.get(&CellRef::new(*row, col)).map(|cm| (CellRef::new(start + i as u32, col), cm.clone()))) + }) + .collect(); + let links: Vec<_> = keep + .iter() + .enumerate() + .flat_map(|(i, row)| { + (r.start.col..=r.end.col) + .filter_map(move |col| sh.hyperlinks.get(&CellRef::new(*row, col)).map(|h| (CellRef::new(start + i as u32, col), h.clone()))) + }) + .collect(); + let rows: Vec>> = + keep.iter().map(|row| (r.start.col..=r.end.col).map(|c| sh.cell(CellRef::new(*row, c)).cloned()).collect()).collect(); + let shm = cx.sheet_mut(sheet)?; + for row in start..=r.end.row { + for c in r.start.col..=r.end.col { + shm.remove_cell(CellRef::new(row, c)); + } + } + for row in start..=r.end.row { + for c in r.start.col..=r.end.col { + let at = CellRef::new(row, c); + shm.comments.remove(&at); + shm.hyperlinks.remove(&at); + } + } + for (i, cells) in rows.into_iter().enumerate() { + for (j, cell) in cells.into_iter().enumerate() { + if let Some(cell) = cell { + shm.set_cell(CellRef::new(start + i as u32, r.start.col + j as u32), cell); + } + } + } + shm.cell_pictures.extend(pictures); + for (at, cm) in notes { + shm.comments.insert(at, cm); + } + for (at, h) in links { + shm.hyperlinks.insert(at, h); + } + for c in RangeRef::new(CellRef::new(start, r.start.col), r.end).iter() { + cx.changed.push((sheet, c)); + } + Ok(()) + })?; + let remaining = (r.end.row + 1 - start) as usize - removed; + Ok(json!({"removed": removed, "remaining": remaining})) +} + +pub(super) fn text_to_columns(s: &mut Session, p: &Json) -> Result { + let r = target_range(s, p)?; + let sheet = target_sheet(s, p)?; + let mut delims: Vec = p + .get("delimiters") + .and_then(Json::as_array) + .map(|a| a.iter().filter_map(|d| d.as_str().and_then(|x| x.chars().next())).collect()) + .unwrap_or_else(|| vec!['\t']); + if let Some(o) = str_param(p, "other").and_then(|o| o.chars().next()) { + delims.push(o); + } + let fixed: Option> = + p.get("fixedWidths").and_then(Json::as_array).map(|a| a.iter().filter_map(|x| x.as_u64().map(|v| v as usize)).collect()); + let consecutive = bool_param(p, "treatConsecutive").unwrap_or(false); + let quote = str_param(p, "textQualifier").and_then(|q| q.chars().next()).unwrap_or('"'); + let dest = cell_param_on(s, p, "destination", sheet)?.unwrap_or(r.start); + edit(s, |cx| { + let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) }; + let mut out: Vec<(CellRef, String)> = Vec::new(); + for (i, row) in (r.start.row..=r.end.row).enumerate() { + let text = sh.value(CellRef::new(row, r.start.col)).display(); + let parts: Vec = match &fixed { + Some(w) => { + let chars: Vec = text.chars().collect(); + let mut cuts = vec![0]; + let mut breaks: Vec = w.iter().copied().filter(|x| *x > 0 && *x < chars.len()).collect(); + breaks.sort_unstable(); + breaks.dedup(); + cuts.extend(breaks); + cuts.push(chars.len()); + cuts.windows(2) + .map(|p| chars.get(p[0]..p[1]).map(|s| s.iter().collect::().trim().to_string()).unwrap_or_default()) + .collect() + } + None => split_delimited(&text, &delims, consecutive, quote), + }; + for (j, part) in parts.into_iter().enumerate() { + out.push((CellRef::new(dest.row + i as u32, dest.col + j as u32), part)); + } + } + for (c, t) in out { + let cell = crate::cmd::edit::input_to_cell(&t, sheet, c, &mut cx.wb).unwrap_or(None); + let shm = cx.sheet_mut(sheet)?; + match cell { + Some(x) => shm.set_cell(c, x), + None => { + shm.remove_cell(c); + } + } + cx.changed.push((sheet, c)); + } + Ok(()) + })?; + ok() +} + +pub(super) fn split_delimited(text: &str, delims: &[char], consecutive: bool, quote: char) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut in_q = false; + let mut last_delim = false; + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + if c == quote { + if in_q && chars.peek() == Some("e) { + cur.push(quote); + chars.next(); + } else { + in_q = !in_q; + } + continue; + } + if !in_q && delims.contains(&c) { + if consecutive && last_delim { + continue; + } + out.push(std::mem::take(&mut cur)); + last_delim = true; + continue; + } + last_delim = false; + cur.push(c); + } + out.push(cur); + out +} diff --git a/crates/engine/src/cmd/data/validation.rs b/crates/engine/src/cmd/data/validation.rs new file mode 100644 index 000000000..6d4ae73cf --- /dev/null +++ b/crates/engine/src/cmd/data/validation.rs @@ -0,0 +1,290 @@ +use super::*; + +// ---------------------------------------------------------------- validation + +fn op_param(s: Option<&str>) -> CfOperator { + match s.unwrap_or("between") { + "notBetween" => CfOperator::NotBetween, + "equal" => CfOperator::Equal, + "notEqual" => CfOperator::NotEqual, + "greater" | "greaterThan" => CfOperator::Greater, + "less" | "lessThan" => CfOperator::Less, + "greaterOrEqual" | "greaterThanOrEqual" => CfOperator::GreaterOrEqual, + "lessOrEqual" | "lessThanOrEqual" => CfOperator::LessOrEqual, + _ => CfOperator::Between, + } +} + +/// `r` minus `cut`, as up to four non-overlapping rectangles. +fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec { + let Some(i) = r.intersection(cut) else { return vec![r] }; + let mut out = Vec::new(); + if i.start.row > r.start.row { + out.push(RangeRef::new(r.start, CellRef::new(i.start.row - 1, r.end.col))); + } + if i.end.row < r.end.row { + out.push(RangeRef::new(CellRef::new(i.end.row + 1, r.start.col), r.end)); + } + if i.start.col > r.start.col { + out.push(RangeRef::new(CellRef::new(i.start.row, r.start.col), CellRef::new(i.end.row, i.start.col - 1))); + } + if i.end.col < r.end.col { + out.push(RangeRef::new(CellRef::new(i.start.row, i.end.col + 1), CellRef::new(i.end.row, r.end.col))); + } + out +} + +/// Removes the cells of `cuts` from every validation, keeping the rest of each rule's ranges. +pub(in super::super) fn remove_validation_cells(validations: &mut Vec, cuts: &[RangeRef]) { + for dv in validations.iter_mut() { + for cut in cuts { + dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect(); + } + } + validations.retain(|d| !d.ranges.is_empty()); +} + +pub(super) fn validation(s: &mut Session, p: &Json) -> Result { + let ranges = target_ranges(s, p)?; + let sheet = target_sheet(s, p)?; + if bool_param(p, "clear").unwrap_or(false) { + return edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + remove_validation_cells(&mut sh.validations, &ranges); + Ok(Json::Null) + }); + } + let Some(kind) = str_param(p, "type") else { + s.ui_requests.push(crate::UiRequest::Dialog("dataValidation".into(), json!({}))); + return ok(); + }; + let kind = match kind { + "whole" => ValidationKind::Whole, + "decimal" => ValidationKind::Decimal, + "list" => ValidationKind::List, + "date" => ValidationKind::Date, + "time" => ValidationKind::Time, + "textLength" => ValidationKind::TextLength, + "custom" => ValidationKind::Custom, + "any" => ValidationKind::Any, + // An unknown type used to give an "any value" rule, saved as `type="none"`: no validation. + other => return Err(bad("data.validation", format!("unknown type `{other}` (any, whole, decimal, list, date, time, textLength, custom)"))), + }; + if kind == ValidationKind::List && str_param(p, "formula1").is_none_or(|f| f.trim().trim_start_matches('=').is_empty()) { + return Err(bad("data.validation", "a list validation needs `formula1`")); + } + const OPERATORS: [&str; 12] = [ + "between", + "notBetween", + "equal", + "notEqual", + "greater", + "greaterThan", + "less", + "lessThan", + "greaterOrEqual", + "greaterThanOrEqual", + "lessOrEqual", + "lessThanOrEqual", + ]; + if let Some(op) = str_param(p, "operator").filter(|op| !OPERATORS.contains(op)) { + return Err(bad("data.validation", format!("unknown operator `{op}` ({})", OPERATORS.join(", ")))); + } + let error_style = match str_param(p, "errorStyle") { + None | Some("stop") => ErrorStyle::Stop, + Some("warning") => ErrorStyle::Warning, + Some("information") => ErrorStyle::Information, + Some(other) => return Err(bad("data.validation", format!("unknown errorStyle `{other}` (stop, warning, information)"))), + }; + let f1 = str_param(p, "formula1").unwrap_or(""); + let f1 = if kind == ValidationKind::List { list_source(f1) } else { f1.trim_start_matches('=').to_string() }; + let dv = Validation { + ranges: ranges.clone(), + kind, + op: op_param(str_param(p, "operator")), + f1, + f2: str_param(p, "formula2").map(|f| f.trim_start_matches('=').to_string()), + allow_blank: bool_param(p, "ignoreBlank").unwrap_or(true), + in_cell_dropdown: bool_param(p, "dropdown").unwrap_or(true), + input_title: str_param(p, "inputTitle").unwrap_or("").into(), + input_message: str_param(p, "inputMessage").unwrap_or("").into(), + show_input: true, + error_title: str_param(p, "errorTitle").unwrap_or("").into(), + error_message: str_param(p, "errorMessage").unwrap_or("").into(), + error_style, + show_error: true, + }; + edit(s, |cx| { + let sh = cx.sheet_mut(sheet)?; + remove_validation_cells(&mut sh.validations, &ranges); + sh.validations.push(dv.clone()); + Ok(Json::Null) + }) +} + +/// Whether a list rule's source is a literal list (`"a,b"`, or `a,b` typed without quotes) rather +/// than a reference or a name to evaluate. +fn is_literal_list(f: &str) -> bool { + f.starts_with('"') + || (!f.contains('!') + && gridcraft_formula::parse(f).map(|e| !matches!(e, gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_))).unwrap_or(true)) +} + +/// A list rule's source as a file stores it. A literal list typed without quotes, as in Excel's +/// Source box (`Open,In progress,Closed`), becomes the quoted string Excel saves +/// (`"Open,In progress,Closed"`, inner quotes doubled); unquoted, the file would read it as a +/// formula. A formula (`=…`), a reference, a name or an already quoted list is kept as before. +fn list_source(f: &str) -> String { + let t = f.trim(); + if t.is_empty() || t.starts_with('=') || t.starts_with('"') || !is_literal_list(t) { + return f.trim_start_matches('=').to_string(); + } + format!("\"{}\"", t.replace('"', "\"\"")) +} + +/// List entries of a list validation (literal or from a range). +pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec { + let f = dv.f1.trim(); + if is_literal_list(f) { + // A quoted list is a string literal: `""` inside stands for one `"`. + let items = match f.strip_prefix('"').and_then(|x| x.strip_suffix('"')) { + Some(body) => body.replace("\"\"", "\""), + None => f.trim_matches('"').to_string(), + }; + return items.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(); + } + let at = dv.ranges.first().map(|r| r.start).unwrap_or_default(); + match gridcraft_calc::evaluate(wb, sheet, at, f) { + Value::Array(a) => a.data.iter().filter(|v| !v.is_empty()).map(Value::display).collect(), + v if !v.is_empty() && !v.is_error() => vec![v.display()], + _ => vec![], + } +} + +/// Checks typed input against the validation of a cell. `Ok(None)` = valid. +pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) -> Option<(Validation, String)> { + let sh = wb.sheet(sheet)?; + let dv = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(at)))?; + if dv.kind == ValidationKind::Any || !dv.show_error { + return None; + } + if input.is_empty() && dv.allow_blank { + return None; + } + let v = gridcraft_core::parse::parse_input(input, wb.date_system).value; + // The formulas are written for the first cell of the range and see the value being entered, + // as in Excel (a custom `=A1>10` on A1:A9 checks A5's new value at A5). + let first = dv.ranges.first().map(|r| r.start).unwrap_or(at); + let entered; + let wb = if sh.value(at) == v { + wb + } else { + let mut w = wb.clone(); + if let Some(sh) = w.sheet_mut(sheet) { + let style = sh.style_id(at); + sh.cells.set(at, Cell { value: v.clone(), style, ..Default::default() }); + } + entered = w; + &entered + }; + let eval = |f: &str| gridcraft_calc::evaluate(wb, sheet, at, &formula_at(f, first, at)); + let num = |f: &str| eval(f).to_number().ok(); + let ok = match dv.kind { + ValidationKind::List => list_items(wb, sheet, dv).iter().any(|x| x.eq_ignore_ascii_case(input)), + ValidationKind::Custom => eval(&dv.f1).to_bool().unwrap_or(false), + ValidationKind::TextLength => { + let n = input.chars().count() as f64; + compare_op(dv.op, n, num(&dv.f1), dv.f2.as_deref().and_then(num)) + } + _ => match v.as_f64() { + Some(n) => { + if dv.kind == ValidationKind::Whole && n.fract() != 0.0 { + false + } else { + compare_op(dv.op, n, num(&dv.f1), dv.f2.as_deref().and_then(num)) + } + } + None => false, + }, + }; + if ok { + return None; + } + let msg = if dv.error_message.is_empty() { + "This value doesn't match the data validation restrictions defined for this cell.".to_string() + } else { + dv.error_message.clone() + }; + Some((dv.clone(), msg)) +} + +/// A formula written for `first`, as seen from `at` (relative references shifted). +fn formula_at(f: &str, first: CellRef, at: CellRef) -> String { + if first == at { + return f.to_string(); + } + match gridcraft_formula::parse(f.trim_start_matches('=')) { + Ok(e) => gridcraft_formula::print(&gridcraft_formula::adjust::shift_relative( + e, + at.row as i64 - first.row as i64, + at.col as i64 - first.col as i64, + )), + Err(_) => f.to_string(), + } +} + +/// Moves the relative references of a rule formula written for `first` so it reads the same cells from `to` +/// (pasting a rule); text without relative references (constants, literal lists) stays as typed. +pub(in super::super) fn move_rule_formula(f: &str, first: CellRef, to: CellRef) -> String { + if first == to { + return f.to_string(); + } + let moved = formula_at(f, first, to); + let has_relative = gridcraft_formula::parse(f.trim_start_matches('=')).is_ok_and(|e| gridcraft_formula::print(&e) != moved); + let eq = if f.starts_with('=') { "=" } else { "" }; + if has_relative { format!("{eq}{moved}") } else { f.to_string() } +} + +fn compare_op(op: CfOperator, n: f64, a: Option, b: Option) -> bool { + let Some(a) = a else { return false }; + match op { + CfOperator::Between => b.is_some_and(|b| n >= a.min(b) && n <= a.max(b)), + CfOperator::NotBetween => b.is_some_and(|b| n < a.min(b) || n > a.max(b)), + CfOperator::Equal => n == a, + CfOperator::NotEqual => n != a, + CfOperator::Greater => n > a, + CfOperator::Less => n < a, + CfOperator::GreaterOrEqual => n >= a, + CfOperator::LessOrEqual => n <= a, + } +} + +pub(super) fn validate_cmd(s: &mut Session, p: &Json) -> Result { + let d = s.doc()?; + let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); + let input = str_param(p, "input").unwrap_or(""); + match check_validation(&d.wb, d.wb.active_sheet(), at, input) { + None => Ok(json!({"ok": true})), + Some((dv, msg)) => Ok(json!({"ok": false, "message": msg, "title": dv.error_title, "style": format!("{:?}", dv.error_style)})), + } +} + +pub(super) fn circle_invalid(s: &mut Session, _: &Json) -> Result { + let d = s.doc()?; + let sheet = d.wb.active_sheet(); + let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; + let mut bad_cells = Vec::new(); + for dv in &sh.validations { + for r in &dv.ranges { + let r = sh.used_range().and_then(|u| r.intersection(&u)); + let Some(r) = r else { continue }; + for c in r.iter().take(100_000) { + let text = sh.input_text(c); + if !text.is_empty() && check_validation(&d.wb, sheet, c, &text).is_some() { + bad_cells.push(c.a1()); + } + } + } + } + Ok(json!({"invalid": bad_cells})) +} From bbff147bf1c2fbba18537e84b5c6add16a65ad46 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:47:27 +0000 Subject: [PATCH 53/76] refactor(engine): move cmd/extra.rs items to cmd/extra/ --- crates/engine/src/cmd/extra.rs | 1023 +----------------------- crates/engine/src/cmd/extra/cells.rs | 147 ++++ crates/engine/src/cmd/extra/chart.rs | 259 ++++++ crates/engine/src/cmd/extra/data.rs | 131 +++ crates/engine/src/cmd/extra/objects.rs | 146 ++++ crates/engine/src/cmd/extra/scripts.rs | 146 ++++ crates/engine/src/cmd/extra/window.rs | 184 +++++ 7 files changed, 1030 insertions(+), 1006 deletions(-) create mode 100644 crates/engine/src/cmd/extra/cells.rs create mode 100644 crates/engine/src/cmd/extra/chart.rs create mode 100644 crates/engine/src/cmd/extra/data.rs create mode 100644 crates/engine/src/cmd/extra/objects.rs create mode 100644 crates/engine/src/cmd/extra/scripts.rs create mode 100644 crates/engine/src/cmd/extra/window.rs diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 60ffa102b..bf21433ab 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -7,6 +7,23 @@ use serde_json::{Value as Json, json}; use super::*; +mod cells; +mod chart; +mod data; +mod objects; +mod scripts; +mod window; + +pub(crate) use cells::is_checkbox_cell; +use cells::{background, checkbox, draw_border, new_cell_style, recently_used, theme_colors, theme_fonts, toggle_checkbox, use_in_formula}; +use chart::{add_element, change_colors, format_series, move_chart, quick_layout, select_data}; +use data::{from_csv, from_table_range, queries, table_resize}; +use objects::{align, arrange, rotate, selection_pane, shape_fill, shape_outline}; +pub(crate) use scripts::has_scripts; +use scripts::{all_scripts, delete_script, new_script, record, run_script, stop_action, trust_scripts}; +use window::{arrange_all, custom_views, hide_window, new_window, ruler, set_view, split, switch_windows, unhide_window, watch_window}; +pub(crate) use window::{keep_watch_list, window_param}; + pub fn specs() -> Vec { vec![ // Chart Design / Format @@ -308,430 +325,6 @@ pub fn specs() -> Vec { ] } -fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> { - let d = s.doc()?; - let si = d.wb.active_sheet(); - let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; - let idx = match u32_param(p, "chart") { - Some(id) => sh.charts.iter().position(|c| c.id == id), - None => sh.charts.len().checked_sub(1), - }; - idx.map(|i| (si, i)).ok_or_else(|| bad("chart", "no chart on this sheet")) -} - -fn with_chart(s: &mut Session, p: &Json, f: impl FnOnce(&mut Chart) -> Result<()>) -> Result { - let (si, ci) = find_chart(s, p)?; - edit(s, |cx| { - let c = cx.sheet_mut(si)?.charts.get_mut(ci).ok_or_else(|| bad("chart", "no such chart"))?; - f(c)?; - Ok(json!({"chart": c.id})) - }) -} - -fn add_element(s: &mut Session, p: &Json) -> Result { - let el = str_param(p, "element").unwrap_or("title").to_string(); - let on = bool_param(p, "on").unwrap_or(true); - let pos = str_param(p, "position").map(str::to_string); - with_chart(s, p, |c| { - match el.as_str() { - "title" => c.title = if on { c.title.clone().filter(|t| !t.is_empty()).or(Some("Chart Title".into())) } else { Some(String::new()) }, - "legend" => { - c.legend = if !on { - LegendPos::None - } else { - match pos.as_deref() { - Some("top") => LegendPos::Top, - Some("left") => LegendPos::Left, - Some("right") => LegendPos::Right, - _ => LegendPos::Bottom, - } - } - } - "dataLabels" => c.data_labels = on, - "gridlines" => c.gridlines = on, - "axisTitles" => { - c.x_title = on.then(|| c.x_title.clone().unwrap_or_else(|| "Axis Title".into())); - c.y_title = on.then(|| c.y_title.clone().unwrap_or_else(|| "Axis Title".into())); - } - "smooth" => { - for s in &mut c.series { - s.smooth = on; - } - } - "trendline" => { - let only = u32_param(p, "series").map(|i| i as usize); - if only.is_some_and(|i| i >= c.series.len()) { - return Err(bad("chart.addElement", "no such series")); - } - let targets: Vec = (0..c.series.len()).filter(|i| only.is_none_or(|o| o == *i)).collect(); - if !on { - for i in targets { - if let Some(se) = c.series.get_mut(i) { - se.trendlines.clear(); - } - } - return Ok(()); - } - let t = trendline_param(p)?; - let eligible: Vec = - targets.into_iter().filter(|i| gridcraft_chart::supports_trendline(gridcraft_chart::series_plot_kind(c, *i))).collect(); - if eligible.is_empty() { - return Err(bad("chart.addElement", "trendlines need a 2-D column, bar, line, area, scatter or bubble series")); - } - for i in eligible { - if let Some(se) = c.series.get_mut(i) { - se.trendlines.push(t.clone()); - } - } - } - other => return Err(bad("chart.addElement", format!("unknown element `{other}`"))), - } - Ok(()) - }) -} - -/// The trendline described by `chart.addElement`'s trendline parameters (Excel's Add Chart -/// Element ▸ Trendline choices, plus the Format Trendline options). -fn trendline_param(p: &Json) -> Result { - let num = |k: &str| Trendline::clamp_forecast(f64_param(p, k).unwrap_or(0.0)); - let mut t = Trendline { - forward: num("forward"), - backward: num("backward"), - display_equation: bool_param(p, "displayEquation").unwrap_or(false), - display_r_squared: bool_param(p, "displayRSquared").unwrap_or(false), - ..Trendline::default() - }; - t.kind = match str_param(p, "type").unwrap_or("linear") { - "linear" => TrendlineKind::Linear, - // Excel's Linear Forecast: a linear trendline projected two periods ahead. - "linearForecast" => { - if t.forward == 0.0 { - t.forward = 2.0; - } - TrendlineKind::Linear - } - "exponential" => TrendlineKind::Exponential, - "logarithmic" => TrendlineKind::Logarithmic, - "power" => TrendlineKind::Power, - "polynomial" => TrendlineKind::Polynomial(u32_param(p, "order").unwrap_or(2).clamp(2, 6) as u8), - "movingAverage" => TrendlineKind::MovingAverage(u32_param(p, "period").unwrap_or(2).clamp(2, 255)), - other => return Err(bad("chart.addElement", format!("unknown trendline type `{other}`"))), - }; - Ok(t) -} - -fn quick_layout(s: &mut Session, p: &Json) -> Result { - let n = u32_param(p, "layout").unwrap_or(1); - with_chart(s, p, |c| { - let (legend, labels, grid, axes) = match n { - 2 => (LegendPos::Top, true, false, false), - 3 => (LegendPos::Bottom, false, true, false), - 4 => (LegendPos::Bottom, true, false, false), - 5 => (LegendPos::None, false, true, true), - 6 => (LegendPos::Right, false, true, true), - _ => (LegendPos::Right, false, true, false), - }; - c.legend = legend; - c.data_labels = labels; - c.gridlines = grid; - if axes { - c.x_title.get_or_insert_with(|| "Axis Title".into()); - c.y_title.get_or_insert_with(|| "Axis Title".into()); - } else { - c.x_title = None; - c.y_title = None; - } - Ok(()) - }) -} - -fn change_colors(s: &mut Session, p: &Json) -> Result { - let explicit: Option> = - p.get("colors").and_then(Json::as_array).map(|a| a.iter().filter_map(|c| c.as_str().and_then(Color::from_hex)).collect()); - let palette = str_param(p, "palette").unwrap_or("colorful").to_string(); - with_chart(s, p, |c| { - for (i, se) in c.series.iter_mut().enumerate() { - se.color = match &explicit { - Some(cols) if !cols.is_empty() => cols.get(i % cols.len()).copied(), - _ => match palette.as_str() { - "monochrome" => Some(Color::Theme(4, (-400 + (i as i16 * 200)).clamp(-800, 800))), - p if p.starts_with("accent") => { - let k: u8 = p.trim_start_matches("accent").parse().unwrap_or(1); - Some(Color::Theme(3 + k.clamp(1, 6), (-500 + i as i16 * 250).clamp(-800, 800))) - } - _ => None, - }, - }; - } - Ok(()) - }) -} - -fn select_data(s: &mut Session, p: &Json) -> Result { - let d = s.doc()?; - // A new data range rebuilds the series, as in Excel's Select Data › Chart data range. - let rebuilt = match str_param(p, "range") { - Some(range) => { - let range = range.trim_start_matches('='); - let (sheet_name, body) = split_sheet(range); - let src_si = match sheet_name { - Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?, - None => d.wb.active_sheet(), - }; - let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?; - if r.count() > 1_000_000 { - return Err(bad("chart.selectData", "range too large for a chart")); - } - let src = d.wb.sheet(src_si).ok_or(EngineError::NoDocument)?; - let by_rows = bool_param(p, "byRows").unwrap_or(r.width() > r.height() + 1); - let series = super::insert::series_from_range(src, r, by_rows); - Some((series, format!("{}!{}", gridcraft_formula::quote_sheet(&src.name), r.a1()), by_rows)) - } - None => None, - }; - // Horizontal (Category) Axis Labels › Edit: one labels range for every series ("" clears). - let categories = match str_param(p, "categories") { - None => None, - Some(t) if t.trim().trim_start_matches('=').is_empty() => Some(None), - Some(t) => { - let t = t.trim().trim_start_matches('='); - let (sheet_name, body) = split_sheet(t); - let si = match sheet_name { - Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?, - None => d.wb.active_sheet(), - }; - let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "`categories` is not a range"))?; - let name = d.wb.sheet(si).map(|sh| sh.name.clone()).ok_or(EngineError::NoDocument)?; - let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1); - Some(Some(format!("{}!{}:{}", gridcraft_formula::quote_sheet(&name), abs(r.start), abs(r.end)))) - } - }; - let mut remove: Vec = p - .get("removeSeries") - .and_then(Json::as_array) - .map(|a| a.iter().filter_map(Json::as_u64).filter_map(|i| usize::try_from(i).ok()).collect()) - .unwrap_or_default(); - remove.sort_unstable(); - remove.dedup(); - if rebuilt.is_none() && categories.is_none() && remove.is_empty() { - return Err(bad("chart.selectData", "missing `range`, `categories` or `removeSeries`")); - } - with_chart(s, p, move |c| { - if let Some((series, source, by_rows)) = rebuilt { - c.series = series; - super::insert::scatter_x_from_first(c.kind, &mut c.series); - c.source = Some(source); - c.by_rows = by_rows; - } - for i in remove.iter().rev() { - if *i < c.series.len() && c.series.len() > 1 { - c.series.remove(*i); - } - } - if let Some(cats) = categories { - for se in &mut c.series { - se.categories = cats.clone(); - } - } - Ok(()) - }) -} - -fn move_chart(s: &mut Session, p: &Json) -> Result { - let (si, ci) = find_chart(s, p)?; - let target = match p.get("sheet").and_then(Json::as_str) { - Some(n) => Some(s.doc()?.wb.sheet_index(n).ok_or_else(|| bad("chart.move", "no such sheet"))?), - None => None, - }; - let at = cell_param_on(s, p, "at", target.unwrap_or(si))?; - edit(s, |cx| { - let mut chart = cx.sheet_mut(si)?.charts.remove(ci); - if let Some(a) = at { - chart.anchor.cell = a; - } - let dest = target.unwrap_or(si); - cx.sheet_mut(dest)?.charts.push(chart); - Ok(Json::Null) - }) -} - -fn format_series(s: &mut Session, p: &Json) -> Result { - let idx = u32_param(p, "series").unwrap_or(0) as usize; - let color = str_param(p, "color").and_then(Color::from_hex); - with_chart(s, p, |c| { - if let Some(se) = c.series.get_mut(idx) { - se.color = color; - } - Ok(()) - }) -} - -fn shape_fill(s: &mut Session, p: &Json) -> Result { - if str_param(p, "kind") == Some("shape") { - let color = str_param(p, "color").and_then(Color::from_hex).unwrap_or_default(); - return with_shape(s, p, |sh| sh.fill = color); - } - format_series(s, p) -} - -fn shape_outline(s: &mut Session, p: &Json) -> Result { - let color = str_param(p, "color").and_then(Color::from_hex).unwrap_or_default(); - with_shape(s, p, |sh| sh.line = color) -} - -fn with_shape(s: &mut Session, p: &Json, f: impl FnOnce(&mut Shape)) -> Result { - let si = s.doc()?.wb.active_sheet(); - let id = u32_param(p, "id"); - edit(s, |cx| { - let shapes = &mut cx.sheet_mut(si)?.shapes; - let sh = match id { - Some(id) => shapes.iter_mut().find(|x| x.id == id), - None => shapes.last_mut(), - } - .ok_or_else(|| bad("shape", "no such shape"))?; - f(sh); - Ok(Json::Null) - }) -} - -fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result { - let id = u32_param(p, "id").ok_or_else(|| bad("arrange", "missing `id`"))?; - let kind = str_param(p, "kind").unwrap_or("shape").to_string(); - let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false); - let si = s.doc()?.wb.active_sheet(); - fn reorder(v: &mut Vec, i: usize, forward: bool, extreme: bool) { - if i >= v.len() { - return; - } - let item = v.remove(i); - let j = match (forward, extreme) { - (true, true) => v.len(), - (false, true) => 0, - (true, false) => (i + 1).min(v.len()), - (false, false) => i.saturating_sub(1), - }; - v.insert(j, item); - } - edit(s, |cx| { - let sh = cx.sheet_mut(si)?; - match kind.as_str() { - "chart" => { - if let Some(i) = sh.charts.iter().position(|c| c.id == id) { - reorder(&mut sh.charts, i, forward, extreme); - } - } - "image" => { - if let Some(i) = sh.images.iter().position(|c| c.id == id) { - reorder(&mut sh.images, i, forward, extreme); - } - } - _ => { - if let Some(i) = sh.shapes.iter().position(|c| c.id == id) { - reorder(&mut sh.shapes, i, forward, extreme); - } - } - } - Ok(Json::Null) - }) -} - -fn selection_pane(s: &mut Session, _: &Json) -> Result { - let d = s.doc()?; - let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - let mut v = Vec::new(); - for c in &sh.shapes { - v.push(json!({"kind": "shape", "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); - } - for c in &sh.images { - v.push(json!({"kind": "image", "id": c.id, "name": format!("Picture {}", c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); - } - for c in &sh.charts { - v.push(json!({"kind": "chart", "id": c.id, "name": format!("Chart {}", c.id), "title": c.title, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); - } - Ok(Json::Array(v)) -} - -fn anchors_of<'a>(sh: &'a mut Sheet, kind: &str) -> Vec<(u32, &'a mut Anchor)> { - match kind { - "chart" => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), - "image" => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), - _ => sh.shapes.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), - } -} - -fn align(s: &mut Session, p: &Json) -> Result { - let kind = str_param(p, "kind").unwrap_or("shape").to_string(); - let ids: Vec = - p.get("ids").and_then(Json::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as u32)).collect()).unwrap_or_default(); - let how = str_param(p, "align").unwrap_or("left").to_string(); - let si = s.doc()?.wb.active_sheet(); - edit(s, |cx| { - let sheet = cx.sheet_mut(si)?; - let geo: Vec<(u32, f64, f64, f32, f32)> = { - let shc = sheet.clone(); - anchors_of(sheet, &kind) - .into_iter() - .filter(|(id, _)| ids.contains(id)) - .map(|(id, a)| (id, shc.col_left(a.cell.col) + a.dx as f64, shc.row_top(a.cell.row) + a.dy as f64, a.width, a.height)) - .collect() - }; - if geo.len() < 2 { - return Ok(Json::Null); - } - let left = geo.iter().map(|g| g.1).fold(f64::INFINITY, f64::min); - let right = geo.iter().map(|g| g.1 + g.3 as f64).fold(f64::NEG_INFINITY, f64::max); - let top = geo.iter().map(|g| g.2).fold(f64::INFINITY, f64::min); - let bottom = geo.iter().map(|g| g.2 + g.4 as f64).fold(f64::NEG_INFINITY, f64::max); - let shc = sheet.clone(); - for (id, a) in anchors_of(sheet, &kind) { - let Some(g) = geo.iter().find(|g| g.0 == id) else { continue }; - let (mut x, mut y) = (g.1, g.2); - match how.as_str() { - "left" => x = left, - "right" => x = right - g.3 as f64, - "center" => x = (left + right) / 2.0 - g.3 as f64 / 2.0, - "top" => y = top, - "bottom" => y = bottom - g.4 as f64, - _ => y = (top + bottom) / 2.0 - g.4 as f64 / 2.0, - } - let c = CellRef::new(shc.row_at(y), shc.col_at(x)); - a.cell = c; - a.dx = (x - shc.col_left(c.col)) as f32; - a.dy = (y - shc.row_top(c.row)) as f32; - } - Ok(Json::Null) - }) -} - -fn rotate(s: &mut Session, p: &Json) -> Result { - if p.get("flip").is_some_and(|f| !f.is_null()) { - return Err(bad("arrange.rotate", "`flip` is not supported: shapes have no flip state")); - } - with_shape(s, p, |sh| std::mem::swap(&mut sh.anchor.width, &mut sh.anchor.height)) -} - -/// Switches a sheet's view. Like zoom, the view is saved with the file but is not an edit: it -/// neither records an undo step nor marks a clean workbook as changed. -fn set_view(s: &mut Session, p: &Json, mode: SheetViewMode) -> Result { - let si = target_sheet(s, p)?; - let d = s.doc_mut()?; - let dirty = d.is_dirty(); - let wb = std::sync::Arc::make_mut(&mut d.wb); - if let Some(sh) = wb.sheet_mut(si) { - sh.view = mode; - } - if !dirty { - d.saved = d.wb.clone(); - } - Ok(json!({"view": mode.as_str()})) -} - -fn ruler(s: &mut Session, p: &Json) -> Result { - let on = bool_param(p, "on").unwrap_or(s.hide_ruler); - s.hide_ruler = !on; - Ok(json!({"on": on})) -} - const VIEWS_NAME: &str = "_sc_custom_views"; const SCRIPTS_NAME: &str = "_sc_scripts"; const WATCH_NAME: &str = "_sc_watch"; @@ -759,592 +352,10 @@ fn stash_set(wb: &mut Workbook, key: &str, v: &Json) { wb.names.push(DefinedName { name: key.into(), scope: None, formula, comment: String::new(), hidden: true }); } -/// The Watch Window list isn't an edit (Excel doesn't undo it): undo and redo keep the current -/// list when they swap in another snapshot of the workbook. -pub(crate) fn keep_watch_list(from: &Workbook, to: &mut std::sync::Arc) { - let find = |wb: &Workbook| wb.names.iter().find(|n| n.name == WATCH_NAME).cloned(); - let keep = find(from); - if keep == find(to) { - return; - } - let wb = std::sync::Arc::make_mut(to); - wb.names.retain(|n| n.name != WATCH_NAME); - wb.names.extend(keep); -} - -fn custom_views(s: &mut Session, p: &Json) -> Result { - let d = s.doc()?; - let mut views = stash_map(&d.wb, VIEWS_NAME); - if let Some(name) = str_param(p, "save") { - let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - views[name] = json!({"sheet": d.wb.active_sheet(), "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze}); - let v = views.clone(); - edit(s, |cx| { - stash_set(&mut cx.wb, VIEWS_NAME, &v); - Ok(()) - })?; - } else if let Some(name) = str_param(p, "show") { - let v = views.get(name).cloned().ok_or_else(|| bad("view.customViews", "no such view"))?; - if let Some(si) = v["sheet"].as_u64() { - s.execute("sheet.activate", json!({"sheet": si}))?; - } - if let Some(sel) = v["selection"].as_str() { - s.execute("selection.set", json!({"range": sel}))?; - } - if let Some(z) = v["zoom"].as_u64() { - s.execute("view.zoom", json!({"percent": z}))?; - } - if let Some(f) = v.get("freeze") { - let freeze = match (f[0].as_u64(), f[1].as_u64()) { - (Some(r), Some(c)) => u32::try_from(r).ok().zip(u32::try_from(c).ok()), - _ => None, - }; - edit(s, |cx| { - let si = cx.sheet_index(); - cx.sheet_mut(si)?.freeze = freeze; - Ok(()) - })?; - } - } else if let Some(name) = str_param(p, "delete") { - if let Some(o) = views.as_object_mut() { - o.remove(name); - } - let v = views.clone(); - edit(s, |cx| { - stash_set(&mut cx.wb, VIEWS_NAME, &v); - Ok(()) - })?; - } - Ok(stash_get(&s.doc()?.wb, VIEWS_NAME)) -} - -fn split(s: &mut Session, _: &Json) -> Result { - let has = s.doc()?.wb.active().is_some_and(|sh| sh.freeze.is_some()); - if has { s.execute("view.unfreezePanes", json!({})) } else { s.execute("view.freezePanes", json!({})) } -} - -fn new_window(s: &mut Session, _: &Json) -> Result { - let id = s.new_window()?; - let doc = s.window(id).map(|w| w.doc_uid); - Ok(json!({"window": id, "doc": doc, "title": s.window_title(id)})) -} - -/// The window a window command names: `window` (an id), else `doc` (a workbook id: its most -/// recent window), else the active window. -pub(crate) fn window_param(s: &Session, cmd: &str, p: &Json) -> Result { - let t = crate::Target::from_json(p).map_err(|e| bad(cmd, e.to_string()))?; - s.target_window(t).map_err(|e| bad(cmd, e.to_string())) -} - -/// View › Arrange All. The engine has no window geometry: it picks the windows and the layout, -/// and the interface moves its native windows ([`Session::window_list`] order, active first). -fn arrange_all(s: &mut Session, p: &Json) -> Result { - let arrange = match str_param(p, "arrange").unwrap_or("tiled") { - a @ ("tiled" | "horizontal" | "vertical" | "cascade") => a, - other => return Err(bad("view.arrangeAll", format!("`arrange` must be tiled, horizontal, vertical or cascade, not `{other}`"))), - }; - let active = s.active_window(); - let doc = active.and_then(|w| s.window(w)).map(|w| w.doc_uid); - let only_active = p.get("activeWorkbook").and_then(Json::as_bool).unwrap_or(false); - let mut windows: Vec = s.windows().iter().filter(|w| !w.hidden && (!only_active || Some(w.doc_uid) == doc)).map(|w| w.id).collect(); - if let Some(at) = windows.iter().position(|w| Some(*w) == active) - && let Some(first) = windows.get_mut(..=at) - { - first.rotate_right(1); - } - Ok(json!({"arrange": arrange, "windows": windows})) -} - -fn hide_window(s: &mut Session, p: &Json) -> Result { - let id = window_param(s, "view.hideWindow", p)?; - s.hide_window(id); - ok() -} - -fn unhide_window(s: &mut Session, p: &Json) -> Result { - let id = if p.get("window").is_some_and(|v| !v.is_null()) || p.get("doc").is_some_and(|v| !v.is_null()) { - Some(window_param(s, "view.unhideWindow", p)?) - } else if let Some(i) = u32_param(p, "index") { - // A workbook's position in the document list. - let uid = s.documents().get(i as usize).map(|d| d.uid).ok_or_else(|| bad("view.unhideWindow", "no such window"))?; - s.windows().iter().filter(|w| w.doc_uid == uid && w.hidden).map(|w| w.id).next_back().or_else(|| s.doc_window(uid)) - } else { - s.last_hidden_window() - }; - if let Some(id) = id { - s.unhide_window(id); - } - ok() -} - -fn switch_windows(s: &mut Session, p: &Json) -> Result { - if p.get("window").is_some_and(|v| !v.is_null()) || p.get("doc").is_some_and(|v| !v.is_null()) { - let id = window_param(s, "view.switchWindows", p)?; - s.unhide_window(id); - } - Ok(s.window_list()) -} - -fn table_resize(s: &mut Session, p: &Json) -> Result { - let r = str_param(p, "range").and_then(RangeRef::parse).ok_or_else(|| bad("table.resize", "missing `range`"))?; - let d = s.doc()?; - let si = d.wb.active_sheet(); - let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; - let ti = match str_param(p, "table") { - Some(n) => sh.tables.iter().position(|t| t.name.eq_ignore_ascii_case(n)), - None => sh.tables.iter().position(|t| t.range.contains(d.selection.active)), - } - .ok_or_else(|| bad("table.resize", "select a cell in a table"))?; - edit(s, |cx| { - let sheet = cx.sheet_mut(si)?; - let headers: Vec = (r.start.col..=r.end.col).map(|c| sheet.value(CellRef::new(r.start.row, c)).display()).collect(); - if sheet.tables.iter().enumerate().any(|(i, o)| i != ti && o.range.intersects(&r)) { - return Err(EngineError::Other("A table can't overlap another table.".into())); - } - let t = sheet.tables.get_mut(ti).ok_or_else(|| bad("table", "gone"))?; - if r.start.row != t.range.start.row || !r.intersects(&t.range) { - return Err(EngineError::Other( - "The headers must remain in the same row, and the resulting table range must overlap the original table range.".into(), - )); - } - let mut cols = Vec::new(); - for (i, h) in headers.iter().enumerate() { - let name = if h.is_empty() { format!("Column{}", i + 1) } else { h.clone() }; - let existing = t.columns.iter().find(|c| c.name.eq_ignore_ascii_case(&name)).cloned(); - cols.push(existing.unwrap_or(TableColumn { - name, - totals: TotalsFn::None, - totals_label: None, - formula: None, - unique_name: None, - xml: None, - })); - } - t.columns = cols; - t.range = r; - cx.structural = true; - Ok(Json::Null) - }) -} - -fn from_csv(s: &mut Session, p: &Json) -> Result { - let bytes = if let Some(t) = str_param(p, "text") { - t.as_bytes().to_vec() - } else if let Some(path) = str_param(p, "path") { - crate::io::read_file(path)? - } else { - s.ui_requests.push(crate::UiRequest::Dialog("open".into(), json!({}))); - return ok(); - }; - let text = String::from_utf8_lossy(&bytes); - // JSON array of objects → rows. - let rows: Vec> = - if text.trim_start().starts_with('[') { - let arr: Vec = serde_json::from_str(&text).map_err(|e| bad("data.getData", e.to_string()))?; - let mut keys: Vec = Vec::new(); - for o in &arr { - if let Some(m) = o.as_object() { - for k in m.keys() { - if !keys.contains(k) { - keys.push(k.clone()); - } - } - } - } - let mut out = vec![keys.iter().map(|k| json!(k)).collect::>()]; - for o in &arr { - out.push(keys.iter().map(|k| o.get(k).cloned().unwrap_or(Json::Null)).collect()); - } - out - } else { - let delim = str_param(p, "delimiter") - .and_then(|d| d.chars().next()) - .unwrap_or(if text.lines().next().is_some_and(|l| l.contains('\t')) { '\t' } else { ',' }); - let opts = gridcraft_xlsx::CsvOptions { delimiter: delim as u8, ..Default::default() }; - let wb = gridcraft_xlsx::read_csv(&bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?; - let sh = wb.sheet(0).ok_or(EngineError::NoDocument)?; - let Some(u) = sh.used_range() else { return Err(EngineError::Other("The file is empty.".into())) }; - (u.start.row..=u.end.row) - .map(|r| { - (u.start.col..=u.end.col) - .map(|c| match sh.value(CellRef::new(r, c)) { - Value::Number(n) => json!(n), - Value::Bool(b) => json!(b), - Value::Empty => Json::Null, - v => json!(v.display()), - }) - .collect() - }) - .collect() - }; - if rows.is_empty() || rows.len() > 1_048_576 { - return Err(EngineError::Other("Nothing to import.".into())); - } - let name = str_param(p, "sheet").map(str::to_string).or_else(|| { - str_param(p, "path").and_then(|x| std::path::Path::new(x).file_stem().and_then(std::ffi::OsStr::to_str).map(|f| f.chars().take(31).collect())) - }); - let r = s.execute("home.insertSheet", json!({"name": name})).or_else(|_| s.execute("home.insertSheet", json!({})))?; - let w = rows.iter().map(Vec::len).max().unwrap_or(1).max(1); - s.execute("range.setValues", json!({"range": "A1", "values": rows}))?; - let range = RangeRef::new(CellRef::new(0, 0), CellRef::new(rows.len() as u32 - 1, w as u32 - 1)); - let _ = s.execute("insert.table", json!({"range": range.a1(), "header": true})); - let _ = s.execute("home.autofitColumnWidth", json!({"cols": RangeRef::cols(0, w as u32 - 1).a1()})); - Ok(json!({"sheet": r["name"], "rows": rows.len(), "cols": w})) -} - -fn from_table_range(s: &mut Session, p: &Json) -> Result { - let r = target_range(s, p)?; - let d = s.doc()?; - let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - let r = if r.is_single() { crate::selection::current_region(sh, r.start) } else { r }; - let text = crate::display::range_text(&d.wb, d.wb.active_sheet(), r); - from_csv(s, &json!({"text": text, "delimiter": "\t", "sheet": format!("{} (2)", sh.name).chars().take(31).collect::()})) -} - -fn queries(s: &mut Session, _: &Json) -> Result { - let d = s.doc()?; - Ok(Json::Array( - d.wb.sheets - .iter() - .flat_map(|sh| { - sh.tables - .iter() - .map(move |t| json!({"name": t.name, "sheet": sh.name, "range": t.range.a1(), "rows": t.range.height().saturating_sub(1)})) - }) - .collect(), - )) -} - -fn record(s: &mut Session, p: &Json) -> Result { - if bool_param(p, "stop").unwrap_or(false) || s.recording.is_some() { - let Some(start) = s.recording.take() else { return Ok(json!({"recording": false})) }; - let cmds: Vec = - s.journal.iter().skip(start).filter(|(id, _)| !id.starts_with("automate.")).map(|(id, p)| json!({"command": id, "params": p})).collect(); - let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Script {}", start)); - s.execute("automate.newScript", json!({"name": name, "commands": cmds}))?; - return Ok(json!({"recording": false, "saved": name, "steps": cmds.len()})); - } - s.recording = Some(s.journal.len()); - Ok(json!({"recording": true})) -} - -fn new_script(s: &mut Session, p: &Json) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("automate.newScript", "missing `name`"))?.to_string(); - let cmds = p.get("commands").cloned().unwrap_or(json!([])); - if !cmds.is_array() { - return Err(bad("automate.newScript", "`commands` must be an array")); - } - edit(s, |cx| { - let mut all = stash_map(&cx.wb, SCRIPTS_NAME); - all[name.as_str()] = cmds.clone(); - stash_set(&mut cx.wb, SCRIPTS_NAME, &all); - Ok(json!({"name": name})) - }) -} - -fn all_scripts(s: &mut Session, _: &Json) -> Result { - let d = s.doc()?; - let (wb, trusted) = (&d.wb, d.scripts_trusted); - let all = stash_get(wb, SCRIPTS_NAME); - let mut list: Vec = all - .as_object() - .map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len), "trusted": trusted})).collect()) - .unwrap_or_default(); - // A preserved VBA project is reported alongside GridCraft's own scripts; it is carried on - // save but never executed here, so it has no step count. - if let Some(bytes) = &wb.vba_project { - list.push(json!({"name": "(VBA)", "steps": 0, "kind": "vba", "size": bytes.len(), "executed": false})); - } - Ok(Json::Array(list)) -} - -/// Whether `wb` carries GridCraft scripts (a workbook opened with them starts untrusted). -pub(crate) fn has_scripts(wb: &Workbook) -> bool { - stash_map(wb, SCRIPTS_NAME).as_object().is_some_and(|o| !o.is_empty()) -} - -/// Why a script step is refused, whatever the workbook's trust: scripts stay inside the -/// workbook. Anything that reads or writes files (`file.*`, or any `path` parameter: Get Data, -/// From Text/CSV, Insert Picture) or leaves the app (`link.*` opens URLs) is the caller's to run -/// directly, not a stored script's. (Nested `automate.*` steps are skipped.) This check goes by -/// command id and parameter name, so it is only the up-front refusal: the steps also run inside -/// an [`crate::io::ScriptSandbox`] (file access refused whatever the parameter is called) and -/// [`run_script`] drops any `OpenUrl` request a step raises. -fn denied_script_step(id: &str, params: &Json) -> Option { - if id.starts_with("file.") || id.starts_with("link.") { - return Some(format!("scripts can't run `{id}`: it reaches outside the workbook")); - } - // Scripts are scoped to their workbook: a step that switches the active window would - // point every later step at another open document, so those are refused too. - if id.starts_with("window.") || matches!(id, "view.switchWindows" | "view.newWindow" | "view.hideWindow" | "view.unhideWindow") { - return Some(format!("scripts can't run `{id}`: it reaches outside the workbook")); - } - if params.get("path").is_some() { - return Some(format!("scripts can't pass a file `path` to `{id}`")); - } - None -} - -fn trust_scripts(s: &mut Session, p: &Json) -> Result { - // Only a missing `trusted` means "trust"; `"false"`, `0` or `null` are mistakes, not consent. - let trusted = match p.get("trusted") { - None => true, - Some(Json::Bool(b)) => *b, - Some(_) => return Err(bad("automate.trustScripts", "`trusted` must be true or false")), - }; - s.doc_mut()?.scripts_trusted = trusted; - Ok(json!({"trusted": trusted})) -} - -/// Runs a stored script. Scripts in a workbook opened from a file run only once the workbook is -/// trusted for this session (`automate.trustScripts`, or `trusted: true` on this call); steps that -/// reach outside the workbook ([`denied_script_step`]) are refused before any step runs. -fn run_script(s: &mut Session, p: &Json) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("automate.runScript", "missing `name`"))?; - let d = s.doc()?; - let all = stash_get(&d.wb, SCRIPTS_NAME); - let steps = all.get(name).and_then(Json::as_array).cloned().ok_or_else(|| bad("automate.runScript", "no such script"))?; - if !d.scripts_trusted && !bool_param(p, "trusted").unwrap_or(false) { - return Err(bad( - "automate.runScript", - "scripts in this workbook came from a file and are disabled; trust the workbook first (Trust Scripts in View › Macros, Alt+F8; `automate.trustScripts`; or `trusted: true`)", - )); - } - let steps: Vec = steps.into_iter().take(100_000).collect(); - for st in &steps { - let id = st["command"].as_str().unwrap_or(""); - if let Some(why) = denied_script_step(id, &st["params"]) { - return Err(bad("automate.runScript", why)); - } - } - let _sandbox = crate::io::ScriptSandbox::enter(); - let mut n = 0; - for st in &steps { - let id = st["command"].as_str().unwrap_or(""); - if id.starts_with("automate.") { - continue; - } - let before = s.ui_requests.len(); - let ran = s.execute(id, st["params"].clone()); - // A step that asks the host to open a URL leaves the app: drop the request and stop. - if s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_)))) { - let mut i = 0; - s.ui_requests.retain(|r| { - i += 1; - i <= before || !matches!(r, crate::UiRequest::OpenUrl(_)) - }); - return Err(bad("automate.runScript", format!("scripts can't open links (`{id}`)"))); - } - ran?; - n += 1; - } - Ok(json!({"ran": n})) -} - -/// Automate › Stop Action: rolls back the undo groups open on the active workbook, so Undo and -/// Redo work there again. -fn stop_action(s: &mut Session, _: &Json) -> Result { - let uid = s.doc()?.uid; - Ok(json!({"stopped": s.abort_groups(uid)})) -} - -fn delete_script(s: &mut Session, p: &Json) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string(); - edit(s, |cx| { - let mut all = stash_map(&cx.wb, SCRIPTS_NAME); - if let Some(o) = all.as_object_mut() { - o.remove(&name); - } - stash_set(&mut cx.wb, SCRIPTS_NAME, &all); - Ok(Json::Null) - }) -} - /// Number format that marks checkbox cells (TRUE/FALSE values drawn as boxes). pub const CHECKBOX_FORMAT: &str = "\"☐\";\"☐\";\"☐\";[=1]\"☑\""; -/// Whether `c` has checkbox formatting. -pub(crate) fn is_checkbox_cell(wb: &gridcraft_model::Workbook, sh: &gridcraft_model::Sheet, c: CellRef) -> bool { - wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" -} - -fn checkbox(s: &mut Session, p: &Json) -> Result { - let ranges = target_ranges(s, p)?; - let si = target_sheet(s, p)?; - edit(s, |cx| { - for r in &ranges { - if r.count() > 100_000 { - return Err(bad("insert.checkbox", "range too large")); - } - for c in r.iter() { - let st = cx.wb.sheet(si).map(|sh| sh.style_id(c)).unwrap_or_default(); - let new = cx.wb.styles.derive(st, |s| { - s.num_fmt = NumFmt::new("checkbox"); - s.align.h = HAlign::Center; - }); - let sh = cx.sheet_mut(si)?; - // Values and formulas already there stay (only TRUE/FALSE draw as a box); an empty - // cell starts unchecked. - let mut cell = sh.cell(c).cloned().unwrap_or_default(); - if cell.value.is_empty() && cell.formula.is_none() { - cell.value = Value::Bool(false); - } - cell.style = new; - sh.set_cell(c, cell); - cx.touch(si, c); - } - } - Ok(Json::Null) - }) -} - -fn toggle_checkbox(s: &mut Session, p: &Json) -> Result { - let si = target_sheet(s, p)?; - let d = s.doc()?; - let cells: Vec = match cell_param_on(s, p, "cell", si)? { - Some(c) => vec![c], - None => d.selection.ranges.iter().flat_map(|r| r.iter().take(100_000).collect::>()).collect(), - }; - edit(s, |cx| { - for c in cells { - let checkbox = cx.wb.sheet(si).is_some_and(|sh| is_checkbox_cell(&cx.wb, sh, c)); - let sh = cx.sheet_mut(si)?; - if let Some(cell) = sh.cells.get_mut(c) - && cell.formula.is_none() - { - // An empty checkbox cell counts as FALSE. - let next = match cell.value { - Value::Bool(b) if checkbox => Value::Bool(!b), - Value::Empty if checkbox => Value::Bool(true), - _ => continue, - }; - cell.value = next; - cx.changed.push((si, c)); - } - } - Ok(Json::Null) - }) -} - /// Copy of the params that is always a JSON object, so keys can be assigned; any other JSON value counts as no params (like null). fn object_params(p: &Json) -> Json { Json::Object(p.as_object().cloned().unwrap_or_default()) } - -fn theme_colors(s: &mut Session, p: &Json) -> Result { - let mut params = object_params(p); - if params.get("name").is_none() { - params["name"] = json!(s.doc()?.wb.theme.name.clone()); - } - s.execute("pageLayout.theme", params) -} - -fn theme_fonts(s: &mut Session, p: &Json) -> Result { - let name = s.doc()?.wb.theme.name.clone(); - let colors: Vec = s.doc()?.wb.theme.colors.iter().map(|c| format!("#{c:06X}")).collect(); - s.execute("pageLayout.theme", json!({"name": name, "colors": colors, "majorFont": p.get("major"), "minorFont": p.get("minor")})) -} - -fn background(s: &mut Session, p: &Json) -> Result { - if bool_param(p, "clear").unwrap_or(false) { - let si = s.doc()?.wb.active_sheet(); - return edit(s, |cx| { - cx.sheet_mut(si)?.images.retain(|i| i.alt != "Sheet background"); - Ok(Json::Null) - }); - } - let mut params = object_params(p); - params["alt"] = json!("Sheet background"); - params["at"] = json!("A1"); - s.execute("insert.picture", params) -} - -fn draw_border(s: &mut Session, p: &Json) -> Result { - let edge = str_param(p, "edge").unwrap_or("grid"); - let preset = match edge { - "top" => "top", - "bottom" => "bottom", - "left" => "left", - "right" => "right", - "outline" => "outside", - _ => "all", - }; - let mut params = object_params(p); - params["preset"] = json!(preset); - s.execute("home.borders", params) -} - -fn new_cell_style(s: &mut Session, p: &Json) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("home.newCellStyle", "missing `name`"))?.to_string(); - let d = s.doc()?; - let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - let c = cell_param(s, p, "fromCell")?.unwrap_or(d.selection.active); - let style = d.wb.styles.get(sh.style_id(c)).clone(); - edit(s, |cx| { - cx.wb.cell_styles.retain(|(n, _)| !n.eq_ignore_ascii_case(&name)); - cx.wb.cell_styles.push((name.clone(), style.clone())); - Ok(json!({"name": name})) - }) -} - -fn recently_used(s: &mut Session, _: &Json) -> Result { - let mut seen: Vec = Vec::new(); - for (id, p) in s.journal.iter().rev() { - if id != "cell.set" { - continue; - } - let input = p.get("input").and_then(Json::as_str).unwrap_or(""); - if let Ok(e) = gridcraft_formula::parse(input.trim_start_matches('=')) { - for f in e.functions() { - if !seen.iter().any(|x| x == f) { - seen.push(f.to_string()); - } - } - } - if seen.len() >= 10 { - break; - } - } - Ok(json!(seen)) -} - -fn use_in_formula(s: &mut Session, p: &Json) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("formulas.useInFormula", "missing `name`"))?; - s.ui_requests.push(crate::UiRequest::InsertName(name.to_string())); - ok() -} - -fn watch_window(s: &mut Session, p: &Json) -> Result { - let mut list: Vec = - stash_get(&s.doc()?.wb, WATCH_NAME).as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default(); - let mut changed = false; - if let Some(a) = str_param(p, "add") { - if !list.iter().any(|x| x == a) { - list.push(a.to_string()); - } - changed = true; - } - if let Some(r) = str_param(p, "remove") { - list.retain(|x| x != r); - changed = true; - } - if changed { - let v = json!(list.clone()); - let d = s.doc_mut()?; - // The watch list is view state: don't add an undo step. - let wb = std::sync::Arc::make_mut(&mut d.wb); - stash_set(wb, WATCH_NAME, &v); - } - let d = s.doc()?; - let rows: Vec = list - .iter() - .map(|w| { - let (sheet, body) = split_sheet(w); - let si = sheet.and_then(|n| d.wb.sheet_index(&n)).unwrap_or(d.wb.active_sheet()); - let c = CellRef::parse(body).unwrap_or_default(); - let sh = d.wb.sheet(si); - json!({"cell": w, "value": sh.map(|s| s.value(c).display()), "formula": sh.and_then(|s| s.cell(c)).and_then(|x| x.formula.as_ref()).map(|f| format!("={}", f.text))}) - }) - .collect(); - Ok(Json::Array(rows)) -} diff --git a/crates/engine/src/cmd/extra/cells.rs b/crates/engine/src/cmd/extra/cells.rs new file mode 100644 index 000000000..5cddf0982 --- /dev/null +++ b/crates/engine/src/cmd/extra/cells.rs @@ -0,0 +1,147 @@ +use super::*; + +/// Whether `c` has checkbox formatting. +pub(crate) fn is_checkbox_cell(wb: &gridcraft_model::Workbook, sh: &gridcraft_model::Sheet, c: CellRef) -> bool { + wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" +} + +pub(super) fn checkbox(s: &mut Session, p: &Json) -> Result { + let ranges = target_ranges(s, p)?; + let si = target_sheet(s, p)?; + edit(s, |cx| { + for r in &ranges { + if r.count() > 100_000 { + return Err(bad("insert.checkbox", "range too large")); + } + for c in r.iter() { + let st = cx.wb.sheet(si).map(|sh| sh.style_id(c)).unwrap_or_default(); + let new = cx.wb.styles.derive(st, |s| { + s.num_fmt = NumFmt::new("checkbox"); + s.align.h = HAlign::Center; + }); + let sh = cx.sheet_mut(si)?; + // Values and formulas already there stay (only TRUE/FALSE draw as a box); an empty + // cell starts unchecked. + let mut cell = sh.cell(c).cloned().unwrap_or_default(); + if cell.value.is_empty() && cell.formula.is_none() { + cell.value = Value::Bool(false); + } + cell.style = new; + sh.set_cell(c, cell); + cx.touch(si, c); + } + } + Ok(Json::Null) + }) +} + +pub(super) fn toggle_checkbox(s: &mut Session, p: &Json) -> Result { + let si = target_sheet(s, p)?; + let d = s.doc()?; + let cells: Vec = match cell_param_on(s, p, "cell", si)? { + Some(c) => vec![c], + None => d.selection.ranges.iter().flat_map(|r| r.iter().take(100_000).collect::>()).collect(), + }; + edit(s, |cx| { + for c in cells { + let checkbox = cx.wb.sheet(si).is_some_and(|sh| is_checkbox_cell(&cx.wb, sh, c)); + let sh = cx.sheet_mut(si)?; + if let Some(cell) = sh.cells.get_mut(c) + && cell.formula.is_none() + { + // An empty checkbox cell counts as FALSE. + let next = match cell.value { + Value::Bool(b) if checkbox => Value::Bool(!b), + Value::Empty if checkbox => Value::Bool(true), + _ => continue, + }; + cell.value = next; + cx.changed.push((si, c)); + } + } + Ok(Json::Null) + }) +} + +pub(super) fn theme_colors(s: &mut Session, p: &Json) -> Result { + let mut params = object_params(p); + if params.get("name").is_none() { + params["name"] = json!(s.doc()?.wb.theme.name.clone()); + } + s.execute("pageLayout.theme", params) +} + +pub(super) fn theme_fonts(s: &mut Session, p: &Json) -> Result { + let name = s.doc()?.wb.theme.name.clone(); + let colors: Vec = s.doc()?.wb.theme.colors.iter().map(|c| format!("#{c:06X}")).collect(); + s.execute("pageLayout.theme", json!({"name": name, "colors": colors, "majorFont": p.get("major"), "minorFont": p.get("minor")})) +} + +pub(super) fn background(s: &mut Session, p: &Json) -> Result { + if bool_param(p, "clear").unwrap_or(false) { + let si = s.doc()?.wb.active_sheet(); + return edit(s, |cx| { + cx.sheet_mut(si)?.images.retain(|i| i.alt != "Sheet background"); + Ok(Json::Null) + }); + } + let mut params = object_params(p); + params["alt"] = json!("Sheet background"); + params["at"] = json!("A1"); + s.execute("insert.picture", params) +} + +pub(super) fn draw_border(s: &mut Session, p: &Json) -> Result { + let edge = str_param(p, "edge").unwrap_or("grid"); + let preset = match edge { + "top" => "top", + "bottom" => "bottom", + "left" => "left", + "right" => "right", + "outline" => "outside", + _ => "all", + }; + let mut params = object_params(p); + params["preset"] = json!(preset); + s.execute("home.borders", params) +} + +pub(super) fn new_cell_style(s: &mut Session, p: &Json) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("home.newCellStyle", "missing `name`"))?.to_string(); + let d = s.doc()?; + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let c = cell_param(s, p, "fromCell")?.unwrap_or(d.selection.active); + let style = d.wb.styles.get(sh.style_id(c)).clone(); + edit(s, |cx| { + cx.wb.cell_styles.retain(|(n, _)| !n.eq_ignore_ascii_case(&name)); + cx.wb.cell_styles.push((name.clone(), style.clone())); + Ok(json!({"name": name})) + }) +} + +pub(super) fn recently_used(s: &mut Session, _: &Json) -> Result { + let mut seen: Vec = Vec::new(); + for (id, p) in s.journal.iter().rev() { + if id != "cell.set" { + continue; + } + let input = p.get("input").and_then(Json::as_str).unwrap_or(""); + if let Ok(e) = gridcraft_formula::parse(input.trim_start_matches('=')) { + for f in e.functions() { + if !seen.iter().any(|x| x == f) { + seen.push(f.to_string()); + } + } + } + if seen.len() >= 10 { + break; + } + } + Ok(json!(seen)) +} + +pub(super) fn use_in_formula(s: &mut Session, p: &Json) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("formulas.useInFormula", "missing `name`"))?; + s.ui_requests.push(crate::UiRequest::InsertName(name.to_string())); + ok() +} diff --git a/crates/engine/src/cmd/extra/chart.rs b/crates/engine/src/cmd/extra/chart.rs new file mode 100644 index 000000000..2e16befa6 --- /dev/null +++ b/crates/engine/src/cmd/extra/chart.rs @@ -0,0 +1,259 @@ +use super::*; + +fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> { + let d = s.doc()?; + let si = d.wb.active_sheet(); + let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; + let idx = match u32_param(p, "chart") { + Some(id) => sh.charts.iter().position(|c| c.id == id), + None => sh.charts.len().checked_sub(1), + }; + idx.map(|i| (si, i)).ok_or_else(|| bad("chart", "no chart on this sheet")) +} + +fn with_chart(s: &mut Session, p: &Json, f: impl FnOnce(&mut Chart) -> Result<()>) -> Result { + let (si, ci) = find_chart(s, p)?; + edit(s, |cx| { + let c = cx.sheet_mut(si)?.charts.get_mut(ci).ok_or_else(|| bad("chart", "no such chart"))?; + f(c)?; + Ok(json!({"chart": c.id})) + }) +} + +pub(super) fn add_element(s: &mut Session, p: &Json) -> Result { + let el = str_param(p, "element").unwrap_or("title").to_string(); + let on = bool_param(p, "on").unwrap_or(true); + let pos = str_param(p, "position").map(str::to_string); + with_chart(s, p, |c| { + match el.as_str() { + "title" => c.title = if on { c.title.clone().filter(|t| !t.is_empty()).or(Some("Chart Title".into())) } else { Some(String::new()) }, + "legend" => { + c.legend = if !on { + LegendPos::None + } else { + match pos.as_deref() { + Some("top") => LegendPos::Top, + Some("left") => LegendPos::Left, + Some("right") => LegendPos::Right, + _ => LegendPos::Bottom, + } + } + } + "dataLabels" => c.data_labels = on, + "gridlines" => c.gridlines = on, + "axisTitles" => { + c.x_title = on.then(|| c.x_title.clone().unwrap_or_else(|| "Axis Title".into())); + c.y_title = on.then(|| c.y_title.clone().unwrap_or_else(|| "Axis Title".into())); + } + "smooth" => { + for s in &mut c.series { + s.smooth = on; + } + } + "trendline" => { + let only = u32_param(p, "series").map(|i| i as usize); + if only.is_some_and(|i| i >= c.series.len()) { + return Err(bad("chart.addElement", "no such series")); + } + let targets: Vec = (0..c.series.len()).filter(|i| only.is_none_or(|o| o == *i)).collect(); + if !on { + for i in targets { + if let Some(se) = c.series.get_mut(i) { + se.trendlines.clear(); + } + } + return Ok(()); + } + let t = trendline_param(p)?; + let eligible: Vec = + targets.into_iter().filter(|i| gridcraft_chart::supports_trendline(gridcraft_chart::series_plot_kind(c, *i))).collect(); + if eligible.is_empty() { + return Err(bad("chart.addElement", "trendlines need a 2-D column, bar, line, area, scatter or bubble series")); + } + for i in eligible { + if let Some(se) = c.series.get_mut(i) { + se.trendlines.push(t.clone()); + } + } + } + other => return Err(bad("chart.addElement", format!("unknown element `{other}`"))), + } + Ok(()) + }) +} + +/// The trendline described by `chart.addElement`'s trendline parameters (Excel's Add Chart +/// Element ▸ Trendline choices, plus the Format Trendline options). +fn trendline_param(p: &Json) -> Result { + let num = |k: &str| Trendline::clamp_forecast(f64_param(p, k).unwrap_or(0.0)); + let mut t = Trendline { + forward: num("forward"), + backward: num("backward"), + display_equation: bool_param(p, "displayEquation").unwrap_or(false), + display_r_squared: bool_param(p, "displayRSquared").unwrap_or(false), + ..Trendline::default() + }; + t.kind = match str_param(p, "type").unwrap_or("linear") { + "linear" => TrendlineKind::Linear, + // Excel's Linear Forecast: a linear trendline projected two periods ahead. + "linearForecast" => { + if t.forward == 0.0 { + t.forward = 2.0; + } + TrendlineKind::Linear + } + "exponential" => TrendlineKind::Exponential, + "logarithmic" => TrendlineKind::Logarithmic, + "power" => TrendlineKind::Power, + "polynomial" => TrendlineKind::Polynomial(u32_param(p, "order").unwrap_or(2).clamp(2, 6) as u8), + "movingAverage" => TrendlineKind::MovingAverage(u32_param(p, "period").unwrap_or(2).clamp(2, 255)), + other => return Err(bad("chart.addElement", format!("unknown trendline type `{other}`"))), + }; + Ok(t) +} + +pub(super) fn quick_layout(s: &mut Session, p: &Json) -> Result { + let n = u32_param(p, "layout").unwrap_or(1); + with_chart(s, p, |c| { + let (legend, labels, grid, axes) = match n { + 2 => (LegendPos::Top, true, false, false), + 3 => (LegendPos::Bottom, false, true, false), + 4 => (LegendPos::Bottom, true, false, false), + 5 => (LegendPos::None, false, true, true), + 6 => (LegendPos::Right, false, true, true), + _ => (LegendPos::Right, false, true, false), + }; + c.legend = legend; + c.data_labels = labels; + c.gridlines = grid; + if axes { + c.x_title.get_or_insert_with(|| "Axis Title".into()); + c.y_title.get_or_insert_with(|| "Axis Title".into()); + } else { + c.x_title = None; + c.y_title = None; + } + Ok(()) + }) +} + +pub(super) fn change_colors(s: &mut Session, p: &Json) -> Result { + let explicit: Option> = + p.get("colors").and_then(Json::as_array).map(|a| a.iter().filter_map(|c| c.as_str().and_then(Color::from_hex)).collect()); + let palette = str_param(p, "palette").unwrap_or("colorful").to_string(); + with_chart(s, p, |c| { + for (i, se) in c.series.iter_mut().enumerate() { + se.color = match &explicit { + Some(cols) if !cols.is_empty() => cols.get(i % cols.len()).copied(), + _ => match palette.as_str() { + "monochrome" => Some(Color::Theme(4, (-400 + (i as i16 * 200)).clamp(-800, 800))), + p if p.starts_with("accent") => { + let k: u8 = p.trim_start_matches("accent").parse().unwrap_or(1); + Some(Color::Theme(3 + k.clamp(1, 6), (-500 + i as i16 * 250).clamp(-800, 800))) + } + _ => None, + }, + }; + } + Ok(()) + }) +} + +pub(super) fn select_data(s: &mut Session, p: &Json) -> Result { + let d = s.doc()?; + // A new data range rebuilds the series, as in Excel's Select Data › Chart data range. + let rebuilt = match str_param(p, "range") { + Some(range) => { + let range = range.trim_start_matches('='); + let (sheet_name, body) = split_sheet(range); + let src_si = match sheet_name { + Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?, + None => d.wb.active_sheet(), + }; + let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?; + if r.count() > 1_000_000 { + return Err(bad("chart.selectData", "range too large for a chart")); + } + let src = d.wb.sheet(src_si).ok_or(EngineError::NoDocument)?; + let by_rows = bool_param(p, "byRows").unwrap_or(r.width() > r.height() + 1); + let series = crate::cmd::insert::series_from_range(src, r, by_rows); + Some((series, format!("{}!{}", gridcraft_formula::quote_sheet(&src.name), r.a1()), by_rows)) + } + None => None, + }; + // Horizontal (Category) Axis Labels › Edit: one labels range for every series ("" clears). + let categories = match str_param(p, "categories") { + None => None, + Some(t) if t.trim().trim_start_matches('=').is_empty() => Some(None), + Some(t) => { + let t = t.trim().trim_start_matches('='); + let (sheet_name, body) = split_sheet(t); + let si = match sheet_name { + Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?, + None => d.wb.active_sheet(), + }; + let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "`categories` is not a range"))?; + let name = d.wb.sheet(si).map(|sh| sh.name.clone()).ok_or(EngineError::NoDocument)?; + let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1); + Some(Some(format!("{}!{}:{}", gridcraft_formula::quote_sheet(&name), abs(r.start), abs(r.end)))) + } + }; + let mut remove: Vec = p + .get("removeSeries") + .and_then(Json::as_array) + .map(|a| a.iter().filter_map(Json::as_u64).filter_map(|i| usize::try_from(i).ok()).collect()) + .unwrap_or_default(); + remove.sort_unstable(); + remove.dedup(); + if rebuilt.is_none() && categories.is_none() && remove.is_empty() { + return Err(bad("chart.selectData", "missing `range`, `categories` or `removeSeries`")); + } + with_chart(s, p, move |c| { + if let Some((series, source, by_rows)) = rebuilt { + c.series = series; + crate::cmd::insert::scatter_x_from_first(c.kind, &mut c.series); + c.source = Some(source); + c.by_rows = by_rows; + } + for i in remove.iter().rev() { + if *i < c.series.len() && c.series.len() > 1 { + c.series.remove(*i); + } + } + if let Some(cats) = categories { + for se in &mut c.series { + se.categories = cats.clone(); + } + } + Ok(()) + }) +} + +pub(super) fn move_chart(s: &mut Session, p: &Json) -> Result { + let (si, ci) = find_chart(s, p)?; + let target = match p.get("sheet").and_then(Json::as_str) { + Some(n) => Some(s.doc()?.wb.sheet_index(n).ok_or_else(|| bad("chart.move", "no such sheet"))?), + None => None, + }; + let at = cell_param_on(s, p, "at", target.unwrap_or(si))?; + edit(s, |cx| { + let mut chart = cx.sheet_mut(si)?.charts.remove(ci); + if let Some(a) = at { + chart.anchor.cell = a; + } + let dest = target.unwrap_or(si); + cx.sheet_mut(dest)?.charts.push(chart); + Ok(Json::Null) + }) +} + +pub(super) fn format_series(s: &mut Session, p: &Json) -> Result { + let idx = u32_param(p, "series").unwrap_or(0) as usize; + let color = str_param(p, "color").and_then(Color::from_hex); + with_chart(s, p, |c| { + if let Some(se) = c.series.get_mut(idx) { + se.color = color; + } + Ok(()) + }) +} diff --git a/crates/engine/src/cmd/extra/data.rs b/crates/engine/src/cmd/extra/data.rs new file mode 100644 index 000000000..cef840f21 --- /dev/null +++ b/crates/engine/src/cmd/extra/data.rs @@ -0,0 +1,131 @@ +use super::*; + +pub(super) fn table_resize(s: &mut Session, p: &Json) -> Result { + let r = str_param(p, "range").and_then(RangeRef::parse).ok_or_else(|| bad("table.resize", "missing `range`"))?; + let d = s.doc()?; + let si = d.wb.active_sheet(); + let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; + let ti = match str_param(p, "table") { + Some(n) => sh.tables.iter().position(|t| t.name.eq_ignore_ascii_case(n)), + None => sh.tables.iter().position(|t| t.range.contains(d.selection.active)), + } + .ok_or_else(|| bad("table.resize", "select a cell in a table"))?; + edit(s, |cx| { + let sheet = cx.sheet_mut(si)?; + let headers: Vec = (r.start.col..=r.end.col).map(|c| sheet.value(CellRef::new(r.start.row, c)).display()).collect(); + if sheet.tables.iter().enumerate().any(|(i, o)| i != ti && o.range.intersects(&r)) { + return Err(EngineError::Other("A table can't overlap another table.".into())); + } + let t = sheet.tables.get_mut(ti).ok_or_else(|| bad("table", "gone"))?; + if r.start.row != t.range.start.row || !r.intersects(&t.range) { + return Err(EngineError::Other( + "The headers must remain in the same row, and the resulting table range must overlap the original table range.".into(), + )); + } + let mut cols = Vec::new(); + for (i, h) in headers.iter().enumerate() { + let name = if h.is_empty() { format!("Column{}", i + 1) } else { h.clone() }; + let existing = t.columns.iter().find(|c| c.name.eq_ignore_ascii_case(&name)).cloned(); + cols.push(existing.unwrap_or(TableColumn { + name, + totals: TotalsFn::None, + totals_label: None, + formula: None, + unique_name: None, + xml: None, + })); + } + t.columns = cols; + t.range = r; + cx.structural = true; + Ok(Json::Null) + }) +} + +pub(super) fn from_csv(s: &mut Session, p: &Json) -> Result { + let bytes = if let Some(t) = str_param(p, "text") { + t.as_bytes().to_vec() + } else if let Some(path) = str_param(p, "path") { + crate::io::read_file(path)? + } else { + s.ui_requests.push(crate::UiRequest::Dialog("open".into(), json!({}))); + return ok(); + }; + let text = String::from_utf8_lossy(&bytes); + // JSON array of objects → rows. + let rows: Vec> = + if text.trim_start().starts_with('[') { + let arr: Vec = serde_json::from_str(&text).map_err(|e| bad("data.getData", e.to_string()))?; + let mut keys: Vec = Vec::new(); + for o in &arr { + if let Some(m) = o.as_object() { + for k in m.keys() { + if !keys.contains(k) { + keys.push(k.clone()); + } + } + } + } + let mut out = vec![keys.iter().map(|k| json!(k)).collect::>()]; + for o in &arr { + out.push(keys.iter().map(|k| o.get(k).cloned().unwrap_or(Json::Null)).collect()); + } + out + } else { + let delim = str_param(p, "delimiter") + .and_then(|d| d.chars().next()) + .unwrap_or(if text.lines().next().is_some_and(|l| l.contains('\t')) { '\t' } else { ',' }); + let opts = gridcraft_xlsx::CsvOptions { delimiter: delim as u8, ..Default::default() }; + let wb = gridcraft_xlsx::read_csv(&bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?; + let sh = wb.sheet(0).ok_or(EngineError::NoDocument)?; + let Some(u) = sh.used_range() else { return Err(EngineError::Other("The file is empty.".into())) }; + (u.start.row..=u.end.row) + .map(|r| { + (u.start.col..=u.end.col) + .map(|c| match sh.value(CellRef::new(r, c)) { + Value::Number(n) => json!(n), + Value::Bool(b) => json!(b), + Value::Empty => Json::Null, + v => json!(v.display()), + }) + .collect() + }) + .collect() + }; + if rows.is_empty() || rows.len() > 1_048_576 { + return Err(EngineError::Other("Nothing to import.".into())); + } + let name = str_param(p, "sheet").map(str::to_string).or_else(|| { + str_param(p, "path").and_then(|x| std::path::Path::new(x).file_stem().and_then(std::ffi::OsStr::to_str).map(|f| f.chars().take(31).collect())) + }); + let r = s.execute("home.insertSheet", json!({"name": name})).or_else(|_| s.execute("home.insertSheet", json!({})))?; + let w = rows.iter().map(Vec::len).max().unwrap_or(1).max(1); + s.execute("range.setValues", json!({"range": "A1", "values": rows}))?; + let range = RangeRef::new(CellRef::new(0, 0), CellRef::new(rows.len() as u32 - 1, w as u32 - 1)); + let _ = s.execute("insert.table", json!({"range": range.a1(), "header": true})); + let _ = s.execute("home.autofitColumnWidth", json!({"cols": RangeRef::cols(0, w as u32 - 1).a1()})); + Ok(json!({"sheet": r["name"], "rows": rows.len(), "cols": w})) +} + +pub(super) fn from_table_range(s: &mut Session, p: &Json) -> Result { + let r = target_range(s, p)?; + let d = s.doc()?; + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let r = if r.is_single() { crate::selection::current_region(sh, r.start) } else { r }; + let text = crate::display::range_text(&d.wb, d.wb.active_sheet(), r); + from_csv(s, &json!({"text": text, "delimiter": "\t", "sheet": format!("{} (2)", sh.name).chars().take(31).collect::()})) +} + +pub(super) fn queries(s: &mut Session, _: &Json) -> Result { + let d = s.doc()?; + Ok(Json::Array( + d.wb.sheets + .iter() + .flat_map(|sh| { + sh.tables + .iter() + .map(move |t| json!({"name": t.name, "sheet": sh.name, "range": t.range.a1(), "rows": t.range.height().saturating_sub(1)})) + }) + .collect(), + )) +} diff --git a/crates/engine/src/cmd/extra/objects.rs b/crates/engine/src/cmd/extra/objects.rs new file mode 100644 index 000000000..59ab8447a --- /dev/null +++ b/crates/engine/src/cmd/extra/objects.rs @@ -0,0 +1,146 @@ +use super::chart::format_series; +use super::*; + +pub(super) fn shape_fill(s: &mut Session, p: &Json) -> Result { + if str_param(p, "kind") == Some("shape") { + let color = str_param(p, "color").and_then(Color::from_hex).unwrap_or_default(); + return with_shape(s, p, |sh| sh.fill = color); + } + format_series(s, p) +} + +pub(super) fn shape_outline(s: &mut Session, p: &Json) -> Result { + let color = str_param(p, "color").and_then(Color::from_hex).unwrap_or_default(); + with_shape(s, p, |sh| sh.line = color) +} + +fn with_shape(s: &mut Session, p: &Json, f: impl FnOnce(&mut Shape)) -> Result { + let si = s.doc()?.wb.active_sheet(); + let id = u32_param(p, "id"); + edit(s, |cx| { + let shapes = &mut cx.sheet_mut(si)?.shapes; + let sh = match id { + Some(id) => shapes.iter_mut().find(|x| x.id == id), + None => shapes.last_mut(), + } + .ok_or_else(|| bad("shape", "no such shape"))?; + f(sh); + Ok(Json::Null) + }) +} + +pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result { + let id = u32_param(p, "id").ok_or_else(|| bad("arrange", "missing `id`"))?; + let kind = str_param(p, "kind").unwrap_or("shape").to_string(); + let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false); + let si = s.doc()?.wb.active_sheet(); + fn reorder(v: &mut Vec, i: usize, forward: bool, extreme: bool) { + if i >= v.len() { + return; + } + let item = v.remove(i); + let j = match (forward, extreme) { + (true, true) => v.len(), + (false, true) => 0, + (true, false) => (i + 1).min(v.len()), + (false, false) => i.saturating_sub(1), + }; + v.insert(j, item); + } + edit(s, |cx| { + let sh = cx.sheet_mut(si)?; + match kind.as_str() { + "chart" => { + if let Some(i) = sh.charts.iter().position(|c| c.id == id) { + reorder(&mut sh.charts, i, forward, extreme); + } + } + "image" => { + if let Some(i) = sh.images.iter().position(|c| c.id == id) { + reorder(&mut sh.images, i, forward, extreme); + } + } + _ => { + if let Some(i) = sh.shapes.iter().position(|c| c.id == id) { + reorder(&mut sh.shapes, i, forward, extreme); + } + } + } + Ok(Json::Null) + }) +} + +pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result { + let d = s.doc()?; + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let mut v = Vec::new(); + for c in &sh.shapes { + v.push(json!({"kind": "shape", "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); + } + for c in &sh.images { + v.push(json!({"kind": "image", "id": c.id, "name": format!("Picture {}", c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); + } + for c in &sh.charts { + v.push(json!({"kind": "chart", "id": c.id, "name": format!("Chart {}", c.id), "title": c.title, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); + } + Ok(Json::Array(v)) +} + +fn anchors_of<'a>(sh: &'a mut Sheet, kind: &str) -> Vec<(u32, &'a mut Anchor)> { + match kind { + "chart" => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), + "image" => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), + _ => sh.shapes.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), + } +} + +pub(super) fn align(s: &mut Session, p: &Json) -> Result { + let kind = str_param(p, "kind").unwrap_or("shape").to_string(); + let ids: Vec = + p.get("ids").and_then(Json::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as u32)).collect()).unwrap_or_default(); + let how = str_param(p, "align").unwrap_or("left").to_string(); + let si = s.doc()?.wb.active_sheet(); + edit(s, |cx| { + let sheet = cx.sheet_mut(si)?; + let geo: Vec<(u32, f64, f64, f32, f32)> = { + let shc = sheet.clone(); + anchors_of(sheet, &kind) + .into_iter() + .filter(|(id, _)| ids.contains(id)) + .map(|(id, a)| (id, shc.col_left(a.cell.col) + a.dx as f64, shc.row_top(a.cell.row) + a.dy as f64, a.width, a.height)) + .collect() + }; + if geo.len() < 2 { + return Ok(Json::Null); + } + let left = geo.iter().map(|g| g.1).fold(f64::INFINITY, f64::min); + let right = geo.iter().map(|g| g.1 + g.3 as f64).fold(f64::NEG_INFINITY, f64::max); + let top = geo.iter().map(|g| g.2).fold(f64::INFINITY, f64::min); + let bottom = geo.iter().map(|g| g.2 + g.4 as f64).fold(f64::NEG_INFINITY, f64::max); + let shc = sheet.clone(); + for (id, a) in anchors_of(sheet, &kind) { + let Some(g) = geo.iter().find(|g| g.0 == id) else { continue }; + let (mut x, mut y) = (g.1, g.2); + match how.as_str() { + "left" => x = left, + "right" => x = right - g.3 as f64, + "center" => x = (left + right) / 2.0 - g.3 as f64 / 2.0, + "top" => y = top, + "bottom" => y = bottom - g.4 as f64, + _ => y = (top + bottom) / 2.0 - g.4 as f64 / 2.0, + } + let c = CellRef::new(shc.row_at(y), shc.col_at(x)); + a.cell = c; + a.dx = (x - shc.col_left(c.col)) as f32; + a.dy = (y - shc.row_top(c.row)) as f32; + } + Ok(Json::Null) + }) +} + +pub(super) fn rotate(s: &mut Session, p: &Json) -> Result { + if p.get("flip").is_some_and(|f| !f.is_null()) { + return Err(bad("arrange.rotate", "`flip` is not supported: shapes have no flip state")); + } + with_shape(s, p, |sh| std::mem::swap(&mut sh.anchor.width, &mut sh.anchor.height)) +} diff --git a/crates/engine/src/cmd/extra/scripts.rs b/crates/engine/src/cmd/extra/scripts.rs new file mode 100644 index 000000000..9f86837ab --- /dev/null +++ b/crates/engine/src/cmd/extra/scripts.rs @@ -0,0 +1,146 @@ +use super::*; + +pub(super) fn record(s: &mut Session, p: &Json) -> Result { + if bool_param(p, "stop").unwrap_or(false) || s.recording.is_some() { + let Some(start) = s.recording.take() else { return Ok(json!({"recording": false})) }; + let cmds: Vec = + s.journal.iter().skip(start).filter(|(id, _)| !id.starts_with("automate.")).map(|(id, p)| json!({"command": id, "params": p})).collect(); + let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Script {}", start)); + s.execute("automate.newScript", json!({"name": name, "commands": cmds}))?; + return Ok(json!({"recording": false, "saved": name, "steps": cmds.len()})); + } + s.recording = Some(s.journal.len()); + Ok(json!({"recording": true})) +} + +pub(super) fn new_script(s: &mut Session, p: &Json) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("automate.newScript", "missing `name`"))?.to_string(); + let cmds = p.get("commands").cloned().unwrap_or(json!([])); + if !cmds.is_array() { + return Err(bad("automate.newScript", "`commands` must be an array")); + } + edit(s, |cx| { + let mut all = stash_map(&cx.wb, SCRIPTS_NAME); + all[name.as_str()] = cmds.clone(); + stash_set(&mut cx.wb, SCRIPTS_NAME, &all); + Ok(json!({"name": name})) + }) +} + +pub(super) fn all_scripts(s: &mut Session, _: &Json) -> Result { + let d = s.doc()?; + let (wb, trusted) = (&d.wb, d.scripts_trusted); + let all = stash_get(wb, SCRIPTS_NAME); + let mut list: Vec = all + .as_object() + .map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len), "trusted": trusted})).collect()) + .unwrap_or_default(); + // A preserved VBA project is reported alongside GridCraft's own scripts; it is carried on + // save but never executed here, so it has no step count. + if let Some(bytes) = &wb.vba_project { + list.push(json!({"name": "(VBA)", "steps": 0, "kind": "vba", "size": bytes.len(), "executed": false})); + } + Ok(Json::Array(list)) +} + +/// Whether `wb` carries GridCraft scripts (a workbook opened with them starts untrusted). +pub(crate) fn has_scripts(wb: &Workbook) -> bool { + stash_map(wb, SCRIPTS_NAME).as_object().is_some_and(|o| !o.is_empty()) +} + +/// Why a script step is refused, whatever the workbook's trust: scripts stay inside the +/// workbook. Anything that reads or writes files (`file.*`, or any `path` parameter: Get Data, +/// From Text/CSV, Insert Picture) or leaves the app (`link.*` opens URLs) is the caller's to run +/// directly, not a stored script's. (Nested `automate.*` steps are skipped.) This check goes by +/// command id and parameter name, so it is only the up-front refusal: the steps also run inside +/// an [`crate::io::ScriptSandbox`] (file access refused whatever the parameter is called) and +/// [`run_script`] drops any `OpenUrl` request a step raises. +fn denied_script_step(id: &str, params: &Json) -> Option { + if id.starts_with("file.") || id.starts_with("link.") { + return Some(format!("scripts can't run `{id}`: it reaches outside the workbook")); + } + // Scripts are scoped to their workbook: a step that switches the active window would + // point every later step at another open document, so those are refused too. + if id.starts_with("window.") || matches!(id, "view.switchWindows" | "view.newWindow" | "view.hideWindow" | "view.unhideWindow") { + return Some(format!("scripts can't run `{id}`: it reaches outside the workbook")); + } + if params.get("path").is_some() { + return Some(format!("scripts can't pass a file `path` to `{id}`")); + } + None +} + +pub(super) fn trust_scripts(s: &mut Session, p: &Json) -> Result { + // Only a missing `trusted` means "trust"; `"false"`, `0` or `null` are mistakes, not consent. + let trusted = match p.get("trusted") { + None => true, + Some(Json::Bool(b)) => *b, + Some(_) => return Err(bad("automate.trustScripts", "`trusted` must be true or false")), + }; + s.doc_mut()?.scripts_trusted = trusted; + Ok(json!({"trusted": trusted})) +} + +/// Runs a stored script. Scripts in a workbook opened from a file run only once the workbook is +/// trusted for this session (`automate.trustScripts`, or `trusted: true` on this call); steps that +/// reach outside the workbook ([`denied_script_step`]) are refused before any step runs. +pub(super) fn run_script(s: &mut Session, p: &Json) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("automate.runScript", "missing `name`"))?; + let d = s.doc()?; + let all = stash_get(&d.wb, SCRIPTS_NAME); + let steps = all.get(name).and_then(Json::as_array).cloned().ok_or_else(|| bad("automate.runScript", "no such script"))?; + if !d.scripts_trusted && !bool_param(p, "trusted").unwrap_or(false) { + return Err(bad( + "automate.runScript", + "scripts in this workbook came from a file and are disabled; trust the workbook first (Trust Scripts in View › Macros, Alt+F8; `automate.trustScripts`; or `trusted: true`)", + )); + } + let steps: Vec = steps.into_iter().take(100_000).collect(); + for st in &steps { + let id = st["command"].as_str().unwrap_or(""); + if let Some(why) = denied_script_step(id, &st["params"]) { + return Err(bad("automate.runScript", why)); + } + } + let _sandbox = crate::io::ScriptSandbox::enter(); + let mut n = 0; + for st in &steps { + let id = st["command"].as_str().unwrap_or(""); + if id.starts_with("automate.") { + continue; + } + let before = s.ui_requests.len(); + let ran = s.execute(id, st["params"].clone()); + // A step that asks the host to open a URL leaves the app: drop the request and stop. + if s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_)))) { + let mut i = 0; + s.ui_requests.retain(|r| { + i += 1; + i <= before || !matches!(r, crate::UiRequest::OpenUrl(_)) + }); + return Err(bad("automate.runScript", format!("scripts can't open links (`{id}`)"))); + } + ran?; + n += 1; + } + Ok(json!({"ran": n})) +} + +/// Automate › Stop Action: rolls back the undo groups open on the active workbook, so Undo and +/// Redo work there again. +pub(super) fn stop_action(s: &mut Session, _: &Json) -> Result { + let uid = s.doc()?.uid; + Ok(json!({"stopped": s.abort_groups(uid)})) +} + +pub(super) fn delete_script(s: &mut Session, p: &Json) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string(); + edit(s, |cx| { + let mut all = stash_map(&cx.wb, SCRIPTS_NAME); + if let Some(o) = all.as_object_mut() { + o.remove(&name); + } + stash_set(&mut cx.wb, SCRIPTS_NAME, &all); + Ok(Json::Null) + }) +} diff --git a/crates/engine/src/cmd/extra/window.rs b/crates/engine/src/cmd/extra/window.rs new file mode 100644 index 000000000..19e92743f --- /dev/null +++ b/crates/engine/src/cmd/extra/window.rs @@ -0,0 +1,184 @@ +use super::*; + +/// Switches a sheet's view. Like zoom, the view is saved with the file but is not an edit: it +/// neither records an undo step nor marks a clean workbook as changed. +pub(super) fn set_view(s: &mut Session, p: &Json, mode: SheetViewMode) -> Result { + let si = target_sheet(s, p)?; + let d = s.doc_mut()?; + let dirty = d.is_dirty(); + let wb = std::sync::Arc::make_mut(&mut d.wb); + if let Some(sh) = wb.sheet_mut(si) { + sh.view = mode; + } + if !dirty { + d.saved = d.wb.clone(); + } + Ok(json!({"view": mode.as_str()})) +} + +pub(super) fn ruler(s: &mut Session, p: &Json) -> Result { + let on = bool_param(p, "on").unwrap_or(s.hide_ruler); + s.hide_ruler = !on; + Ok(json!({"on": on})) +} + +/// The Watch Window list isn't an edit (Excel doesn't undo it): undo and redo keep the current +/// list when they swap in another snapshot of the workbook. +pub(crate) fn keep_watch_list(from: &Workbook, to: &mut std::sync::Arc) { + let find = |wb: &Workbook| wb.names.iter().find(|n| n.name == WATCH_NAME).cloned(); + let keep = find(from); + if keep == find(to) { + return; + } + let wb = std::sync::Arc::make_mut(to); + wb.names.retain(|n| n.name != WATCH_NAME); + wb.names.extend(keep); +} + +pub(super) fn custom_views(s: &mut Session, p: &Json) -> Result { + let d = s.doc()?; + let mut views = stash_map(&d.wb, VIEWS_NAME); + if let Some(name) = str_param(p, "save") { + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + views[name] = json!({"sheet": d.wb.active_sheet(), "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze}); + let v = views.clone(); + edit(s, |cx| { + stash_set(&mut cx.wb, VIEWS_NAME, &v); + Ok(()) + })?; + } else if let Some(name) = str_param(p, "show") { + let v = views.get(name).cloned().ok_or_else(|| bad("view.customViews", "no such view"))?; + if let Some(si) = v["sheet"].as_u64() { + s.execute("sheet.activate", json!({"sheet": si}))?; + } + if let Some(sel) = v["selection"].as_str() { + s.execute("selection.set", json!({"range": sel}))?; + } + if let Some(z) = v["zoom"].as_u64() { + s.execute("view.zoom", json!({"percent": z}))?; + } + if let Some(f) = v.get("freeze") { + let freeze = match (f[0].as_u64(), f[1].as_u64()) { + (Some(r), Some(c)) => u32::try_from(r).ok().zip(u32::try_from(c).ok()), + _ => None, + }; + edit(s, |cx| { + let si = cx.sheet_index(); + cx.sheet_mut(si)?.freeze = freeze; + Ok(()) + })?; + } + } else if let Some(name) = str_param(p, "delete") { + if let Some(o) = views.as_object_mut() { + o.remove(name); + } + let v = views.clone(); + edit(s, |cx| { + stash_set(&mut cx.wb, VIEWS_NAME, &v); + Ok(()) + })?; + } + Ok(stash_get(&s.doc()?.wb, VIEWS_NAME)) +} + +pub(super) fn split(s: &mut Session, _: &Json) -> Result { + let has = s.doc()?.wb.active().is_some_and(|sh| sh.freeze.is_some()); + if has { s.execute("view.unfreezePanes", json!({})) } else { s.execute("view.freezePanes", json!({})) } +} + +pub(super) fn new_window(s: &mut Session, _: &Json) -> Result { + let id = s.new_window()?; + let doc = s.window(id).map(|w| w.doc_uid); + Ok(json!({"window": id, "doc": doc, "title": s.window_title(id)})) +} + +/// The window a window command names: `window` (an id), else `doc` (a workbook id: its most +/// recent window), else the active window. +pub(crate) fn window_param(s: &Session, cmd: &str, p: &Json) -> Result { + let t = crate::Target::from_json(p).map_err(|e| bad(cmd, e.to_string()))?; + s.target_window(t).map_err(|e| bad(cmd, e.to_string())) +} + +/// View › Arrange All. The engine has no window geometry: it picks the windows and the layout, +/// and the interface moves its native windows ([`Session::window_list`] order, active first). +pub(super) fn arrange_all(s: &mut Session, p: &Json) -> Result { + let arrange = match str_param(p, "arrange").unwrap_or("tiled") { + a @ ("tiled" | "horizontal" | "vertical" | "cascade") => a, + other => return Err(bad("view.arrangeAll", format!("`arrange` must be tiled, horizontal, vertical or cascade, not `{other}`"))), + }; + let active = s.active_window(); + let doc = active.and_then(|w| s.window(w)).map(|w| w.doc_uid); + let only_active = p.get("activeWorkbook").and_then(Json::as_bool).unwrap_or(false); + let mut windows: Vec = s.windows().iter().filter(|w| !w.hidden && (!only_active || Some(w.doc_uid) == doc)).map(|w| w.id).collect(); + if let Some(at) = windows.iter().position(|w| Some(*w) == active) + && let Some(first) = windows.get_mut(..=at) + { + first.rotate_right(1); + } + Ok(json!({"arrange": arrange, "windows": windows})) +} + +pub(super) fn hide_window(s: &mut Session, p: &Json) -> Result { + let id = window_param(s, "view.hideWindow", p)?; + s.hide_window(id); + ok() +} + +pub(super) fn unhide_window(s: &mut Session, p: &Json) -> Result { + let id = if p.get("window").is_some_and(|v| !v.is_null()) || p.get("doc").is_some_and(|v| !v.is_null()) { + Some(window_param(s, "view.unhideWindow", p)?) + } else if let Some(i) = u32_param(p, "index") { + // A workbook's position in the document list. + let uid = s.documents().get(i as usize).map(|d| d.uid).ok_or_else(|| bad("view.unhideWindow", "no such window"))?; + s.windows().iter().filter(|w| w.doc_uid == uid && w.hidden).map(|w| w.id).next_back().or_else(|| s.doc_window(uid)) + } else { + s.last_hidden_window() + }; + if let Some(id) = id { + s.unhide_window(id); + } + ok() +} + +pub(super) fn switch_windows(s: &mut Session, p: &Json) -> Result { + if p.get("window").is_some_and(|v| !v.is_null()) || p.get("doc").is_some_and(|v| !v.is_null()) { + let id = window_param(s, "view.switchWindows", p)?; + s.unhide_window(id); + } + Ok(s.window_list()) +} + +pub(super) fn watch_window(s: &mut Session, p: &Json) -> Result { + let mut list: Vec = + stash_get(&s.doc()?.wb, WATCH_NAME).as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default(); + let mut changed = false; + if let Some(a) = str_param(p, "add") { + if !list.iter().any(|x| x == a) { + list.push(a.to_string()); + } + changed = true; + } + if let Some(r) = str_param(p, "remove") { + list.retain(|x| x != r); + changed = true; + } + if changed { + let v = json!(list.clone()); + let d = s.doc_mut()?; + // The watch list is view state: don't add an undo step. + let wb = std::sync::Arc::make_mut(&mut d.wb); + stash_set(wb, WATCH_NAME, &v); + } + let d = s.doc()?; + let rows: Vec = list + .iter() + .map(|w| { + let (sheet, body) = split_sheet(w); + let si = sheet.and_then(|n| d.wb.sheet_index(&n)).unwrap_or(d.wb.active_sheet()); + let c = CellRef::parse(body).unwrap_or_default(); + let sh = d.wb.sheet(si); + json!({"cell": w, "value": sh.map(|s| s.value(c).display()), "formula": sh.and_then(|s| s.cell(c)).and_then(|x| x.formula.as_ref()).map(|f| format!("={}", f.text))}) + }) + .collect(); + Ok(Json::Array(rows)) +} From 1a1726bb9d3f67a39a16a9578d8ccdd8b12184b5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:53:29 +0000 Subject: [PATCH 54/76] refactor(engine): move cmd/format.rs items to cmd/format/ --- crates/engine/src/cmd/format.rs | 442 +------------------- crates/engine/src/cmd/format/cell_styles.rs | 197 +++++++++ crates/engine/src/cmd/format/sizes.rs | 243 +++++++++++ 3 files changed, 446 insertions(+), 436 deletions(-) create mode 100644 crates/engine/src/cmd/format/cell_styles.rs create mode 100644 crates/engine/src/cmd/format/sizes.rs diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 10dd2989d..744b6d136 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -802,445 +802,15 @@ fn decimals(s: &mut Session, p: &Json, delta: i32) -> Result { set_fmt(s, p, &new) } -/// Built-in cell styles (our own palette). -pub fn builtin_cell_style(name: &str, theme: &Theme) -> Option