401 lines
14 KiB
Rust
401 lines
14 KiB
Rust
//! 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 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<T> = std::result::Result<T, EngineError>;
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub struct HistoryEntry {
|
||
pub label: String,
|
||
pub wb: Arc<Workbook>,
|
||
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<Workbook>,
|
||
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<RangeRef>,
|
||
}
|
||
|
||
/// 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<Workbook>,
|
||
pub calc: Calc,
|
||
pub selection: Selection,
|
||
pub undo: Vec<HistoryEntry>,
|
||
pub redo: Vec<HistoryEntry>,
|
||
pub path: Option<String>,
|
||
pub saved: Arc<Workbook>,
|
||
pub revision: u64,
|
||
pub uid: u64,
|
||
pub title: String,
|
||
/// Per-sheet selection memory.
|
||
pub sheet_selections: Vec<Selection>,
|
||
/// 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<io::FileKind>,
|
||
}
|
||
|
||
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<String>, 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<String>,
|
||
/// 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<String>),
|
||
OpenUrl(String),
|
||
}
|
||
|
||
#[derive(Default)]
|
||
pub struct Session {
|
||
docs: Vec<DocState>,
|
||
active: usize,
|
||
pub clipboard: Option<Clipboard>,
|
||
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<UiRequest>,
|
||
/// Format painter source style (sticky when double-clicked).
|
||
pub format_painter: Option<(Arc<Workbook>, usize, RangeRef, bool)>,
|
||
/// Find state (last search).
|
||
pub last_find: Option<Json>,
|
||
/// 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<usize>,
|
||
/// Windows hidden with View › Hide.
|
||
pub hidden_windows: Vec<usize>,
|
||
/// 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<CommandInfo> {
|
||
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<Json> {
|
||
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::<String>().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<Json, String> {
|
||
self.execute(id, params).map_err(|e| e.to_string())
|
||
}
|
||
|
||
pub fn take_ui_requests(&mut self) -> Vec<UiRequest> {
|
||
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<Json> {
|
||
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;
|