//! The GridCraft engine façade. //! //! Every user-visible action is a command with a stable id (`cell.set`, `home.bold`, //! `data.sortAscending`…) and JSON parameters. The egui UI, the CLI, the control channel and MCP //! all go through [`Session::execute`], so every action is scriptable and journaled. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] pub mod catalog; pub mod cf; mod clip_html; pub mod cmd; pub mod display; pub mod fill; pub mod io; pub mod links; pub mod outline; pub mod paper; pub mod pivot; pub mod pivot_calc; pub mod sample; pub mod selection; pub mod tables; use std::sync::Arc; use gridcraft_calc::Calc; use gridcraft_core::{CellRef, RangeRef}; use gridcraft_model::Workbook; use serde::Serialize; use serde_json::Value as Json; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command, register_test_command}; pub use gridcraft_calc as calc; pub use gridcraft_core as core; pub use gridcraft_formula as formula; pub use gridcraft_model as model; pub use gridcraft_numfmt as numfmt; pub use selection::Selection; #[derive(Debug, thiserror::Error)] pub enum EngineError { #[error("unknown command `{0}`")] UnknownCommand(String), #[error("command `{0}` is not available right now: {1}")] Disabled(String, String), #[error("invalid parameters for `{cmd}`: {msg}")] BadParams { cmd: String, msg: String }, #[error("no workbook open")] NoDocument, #[error("{0}")] Other(String), #[error("internal error in `{0}` (the workbook was kept as it was): {1}")] Internal(String, String), } pub type Result = std::result::Result; #[derive(Clone, Debug)] pub struct HistoryEntry { pub label: String, pub wb: Arc, pub selection: Selection, } /// The clipboard: a block of cells (with formulas and styles) or marching-ants reference. #[derive(Clone, Debug)] pub struct Clipboard { /// Source workbook snapshot, sheet and range. pub wb: Arc, pub sheet: usize, pub range: RangeRef, /// The range as copied, before whole rows/columns were trimmed to the used range /// (Insert Copied Cells inserts whole lines for a row or column copy). pub selected: RangeRef, pub cut: bool, /// Document uid of the source (a cut only moves within the same workbook). pub doc_uid: u64, /// Plain text (tab-separated) for the system clipboard. pub text: String, /// A multi-area copy: the areas as selected (for the marquee). `wb` then holds them slid /// together into one block at `range`. Empty for a single-range copy. pub areas: Vec, } /// In-cell edit mode as shown in the status bar. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] pub enum Mode { #[default] Ready, Enter, Edit, Point, } #[derive(Clone, Debug)] pub struct DocState { pub wb: Arc, pub calc: Calc, pub selection: Selection, pub undo: Vec, pub redo: Vec, pub path: Option, pub saved: Arc, pub revision: u64, pub uid: u64, pub title: String, /// Per-sheet selection memory. pub sheet_selections: Vec, /// Allow Edit Ranges unlocked with their password in this session: (sheet name, title). pub unlocked_ranges: Vec<(String, String)>, /// The format `path`'s content was recognised as when its extension doesn't name one (an /// XLSX called `budget.dat`): Save writes it back in that format. pub content_kind: Option, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); const UNDO_LIMIT: usize = 100; impl DocState { pub fn new(mut wb: Workbook, path: Option, title: String) -> DocState { let mut calc = Calc::new(); calc.recalc_all(&mut wb); let wb = Arc::new(wb); let sel = Selection::at(wb.active().map(|s| s.view_active).unwrap_or_default()); DocState { saved: wb.clone(), wb, calc, selection: sel, undo: vec![], redo: vec![], path, revision: 1, uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), title, sheet_selections: vec![], unlocked_ranges: vec![], content_kind: None, } } pub fn is_dirty(&self) -> bool { !Arc::ptr_eq(&self.wb, &self.saved) } pub fn sheet_index(&self) -> usize { 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 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 { return; } let dirty = self.is_dirty(); let wb = Arc::make_mut(&mut self.wb); for (i, c) in tops { if let Some(sh) = wb.sheets.get_mut(*i) && sh.view_top_left != *c { Arc::make_mut(sh).view_top_left = *c; } } if let Some(sh) = wb.sheets.get_mut(si) && sh.view_active != active { Arc::make_mut(sh).view_active = active; } if !dirty { self.saved = self.wb.clone(); } } pub fn display_title(&self) -> String { self.path .as_deref() .and_then(|p| std::path::Path::new(p).file_name()) .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| self.title.clone()) } } /// Engine-side preferences. #[derive(Clone, Debug, Serialize, serde::Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Prefs { /// After pressing Enter, move the selection: "down", "right", "up", "left", "none". pub enter_direction: String, pub autocomplete: bool, pub user_name: String, pub default_font: String, pub default_font_size: f32, pub sheets_in_new_workbook: usize, pub r1c1: bool, /// Words added with Add to Dictionary. pub user_dictionary: Vec, /// View › Show › Formula Bar (an application option in Excel, not saved in the file). pub formula_bar: bool, } impl Default for Prefs { fn default() -> Self { Prefs { enter_direction: "down".into(), autocomplete: true, user_name: "GridCraft User".into(), default_font: gridcraft_model::DEFAULT_FONT.into(), default_font_size: gridcraft_model::DEFAULT_FONT_SIZE, sheets_in_new_workbook: 1, r1c1: false, user_dictionary: vec![], formula_bar: true, } } } /// Requests from commands to the UI (open a dialog, show a message…). #[derive(Clone, Debug, PartialEq, Serialize)] pub enum UiRequest { Dialog(String, Json), Message(String), /// Start editing the active cell (F2) with optional initial text. EditCell(Option), OpenUrl(String), } #[derive(Default)] pub struct Session { docs: Vec, active: usize, pub clipboard: Option, pub prefs: Prefs, pub mode: Mode, /// Commands run so far, without their passwords ([`cmd::journal_params`]). pub journal: Vec<(String, Json)>, pub ui_requests: Vec, /// Format painter source style (sticky when double-clicked). pub format_painter: Option<(Arc, usize, RangeRef, bool)>, /// Find state (last search). pub last_find: Option, /// View › Show › Ruler is off (rulers in Page Layout view; an application option). 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, pub draw_width: f32, /// Review › Show Comments: the Comments pane is open. pub comments_pane: bool, /// Review › Notes › Show All Notes: every note is displayed. An application display setting /// (like Excel's comment indicator option), so it's not saved in the file. pub show_all_notes: bool, } impl Session { pub fn new() -> Session { Session::default() } pub fn documents(&self) -> &[DocState] { &self.docs } pub fn active_index(&self) -> usize { self.active } pub fn set_active(&mut self, i: usize) { if i < self.docs.len() { self.active = i; } } pub fn active(&self) -> Option<&DocState> { self.docs.get(self.active) } pub fn active_mut(&mut self) -> Option<&mut DocState> { self.docs.get_mut(self.active) } pub fn doc(&self) -> Result<&DocState> { self.active().ok_or(EngineError::NoDocument) } pub fn doc_mut(&mut self) -> Result<&mut DocState> { self.active_mut().ok_or(EngineError::NoDocument) } pub fn add_document(&mut self, d: DocState) -> usize { self.docs.push(d); self.active = self.docs.len() - 1; self.active } 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); } } } /// A new blank workbook titled `BookN`. pub fn new_workbook(&mut self) -> usize { let n = (1..).find(|n| !self.docs.iter().any(|d| d.title == format!("Book{n}") && d.path.is_none())).unwrap_or(1); let mut wb = Workbook::new(); for _ in 1..self.prefs.sheets_in_new_workbook.clamp(1, 255) { let name = wb.next_sheet_name(); wb.sheets.push(Arc::new(gridcraft_model::Sheet::new(name))); } self.add_document(DocState::new(wb, None, format!("Book{n}"))) } pub fn commands(&self) -> Vec { command_specs().iter().map(|c| c.info(self)).collect() } /// Runs a command by id. Undoable commands record a history step when the workbook changed. /// Panics inside commands are caught and reported; the workbook is restored. pub fn execute(&mut self, id: &str, params: Json) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; if let Err(why) = (spec.enabled)(self) { return Err(EngineError::Disabled(id.to_string(), why)); } let before = self.active().map(|d| (d.uid, d.wb.clone(), d.selection.clone())); let params = if params.is_null() { Json::Object(Default::default()) } else { params }; let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, ¶ms))); let result = match result { Ok(r) => r, Err(p) => { let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()); // Restore the workbook as it was. if let Some((uid, wb, sel)) = &before && let Some(d) = self.docs.iter_mut().find(|d| d.uid == *uid) { d.wb = wb.clone(); d.selection = sel.clone(); d.calc.rebuild(&d.wb); } return Err(EngineError::Internal(id.to_string(), msg)); } }; if spec.journal && result.is_ok() { self.journal.push((id.to_string(), cmd::journal_params(¶ms))); if self.journal.len() > 10_000 { self.journal.drain(..5_000); } } if spec.undoable && let Some((uid, wb, sel)) = before && let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid) && !Arc::ptr_eq(&d.wb, &wb) { d.undo.push(HistoryEntry { label: spec.label.trim_end_matches('…').to_string(), wb, selection: sel }); if d.undo.len() > UNDO_LIMIT { d.undo.remove(0); } d.redo.clear(); d.revision += 1; } result } pub fn run(&mut self, id: &str, params: Json) -> std::result::Result { self.execute(id, params).map_err(|e| e.to_string()) } pub fn take_ui_requests(&mut self) -> Vec { std::mem::take(&mut self.ui_requests) } /// Runs a command for a caller with no UI (MCP, CLI): queued UI requests are dropped, and a command that only /// queued a file dialog because it got no file (`file.open`, `file.saveAs`, `file.save` on a workbook with no /// file yet, `data.getData`) is an error instead of a silent success. pub fn execute_headless(&mut self, id: &str, params: Json) -> Result { let r = self.execute(id, params); let requests = self.take_ui_requests(); let asked_for_file = requests.iter().any(|q| matches!(q, UiRequest::Dialog(name, _) if name == "open" || name == "saveAs")); if r.is_ok() && asked_for_file { let what = match id { "file.open" => "`path` or `base64`", "data.getData" | "data.fromTextCsv" => "`path` or `text`", _ => "`path`", }; let msg = format!("missing {what}: programmatic calls never open dialogs"); return Err(EngineError::BadParams { cmd: id.to_string(), msg }); } r } } /// Convenience: the active workbook's active sheet. pub fn active_sheet(s: &Session) -> Option<&gridcraft_model::Sheet> { s.active().and_then(|d| d.wb.active()) } pub fn cell(r: u32, c: u32) -> CellRef { CellRef::new(r, c) } #[cfg(test)] mod tests;