Files
gridcraft/crates/engine/src/lib.rs
T

327 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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;
pub mod cmd;
pub mod display;
pub mod fill;
pub mod io;
pub mod pivot;
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};
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,
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,
}
/// 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>,
}
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![],
}
}
pub fn is_dirty(&self) -> bool {
!Arc::ptr_eq(&self.wb, &self.saved)
}
pub fn sheet_index(&self) -> usize {
self.wb.active_sheet
}
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>,
}
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![],
}
}
}
/// 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,
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>,
/// Workbook view: normal, pageLayout or pageBreakPreview.
pub view_mode: String,
/// 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,
}
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, &params)));
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(), params.clone()));
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)
}
}
/// 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;