Files
gridcraft/crates/engine/src/lib.rs
T
2026-10-25 02:25:39 +00:00

954 lines
42 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;
mod chart_svg;
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 record;
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,
/// Estimate of storage this snapshot retains beyond the next workbook version.
pub retained_bytes: usize,
}
/// An undo group opened with [`Session::group_begin`]. Hand it back to [`Session::group_end`]
/// (keep the changes as one undo step) or [`Session::group_rollback`] (undo them all).
#[derive(Debug, PartialEq, Eq)]
#[must_use = "an undo group stays open until it is ended or rolled back"]
pub struct GroupToken {
id: u64,
doc: u64,
}
impl GroupToken {
/// The uid of the document the group belongs to.
pub fn doc(&self) -> u64 {
self.doc
}
}
/// An open undo group: what runs on `doc` until it ends becomes one undo step.
#[derive(Clone, Debug)]
struct OpenGroup {
id: u64,
doc: u64,
label: String,
/// Undo depth when the group began; the group's own steps are cut back to it.
start: usize,
wb: Arc<Workbook>,
selection: Selection,
/// The redo stack when the group began (a rollback puts it back).
redo: Vec<HistoryEntry>,
}
/// Where the last [`Session::group_end`] or [`Session::group_rollback`] left its document: the
/// workbook and selection it ended at, the undo length then, and whether it made a step. A
/// command that ended the group tells its own later changes from the group's step by it, and
/// keeps the group's name only when the group's step is exactly its own (the entry holds the
/// workbook the step started from). Dropped when the outermost command finishes, so it keeps no
/// workbook alive between commands.
#[derive(Debug)]
struct GroupStep {
doc: u64,
after: Arc<Workbook>,
selection: Selection,
len: usize,
stepped: bool,
}
/// 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>,
/// Trust of the source document: pasting content from a file someone else made carries it.
pub trust: Trust,
}
/// Whether a document's active content (macros) may run without asking the person first. There
/// is no default: whoever makes a document says where it came from ([`DocState::new`]), so a new
/// way of building one from bytes can't come out trusted by omission.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum Trust {
/// Made in this session: a new workbook or a sample.
Local,
/// Opened from a file, or holding content pasted from one: active content asks before it
/// runs.
FromFile,
}
impl Trust {
/// Whether active content may run without asking.
pub fn allows_code(self) -> bool {
self == Trust::Local
}
/// The trust of a document after it receives content from a document with trust `source`:
/// content from an untrusted file leaves the receiver untrusted.
pub fn receive(self, source: Trust) -> Trust {
if source == Trust::FromFile { Trust::FromFile } else { self }
}
}
/// 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>,
/// Whether the scripts stored in this workbook may run (`automate.runScript`). A workbook
/// that arrives already carrying scripts (opened from a file) starts untrusted until the
/// user trusts it in this session (`automate.trustScripts`); one with no scripts starts
/// trusted, since any script it gains later was written here.
pub scripts_trusted: bool,
/// Where the workbook's content came from: whether its active content may run without
/// asking. Unlike `scripts_trusted`, this is provenance, not consent: it is set when the
/// workbook is opened from a file, carried by content pasted from such a workbook, and never
/// changed by a trust decision.
pub trust: Trust,
/// The references `edit.goTo` with `record: true` went to in this document, newest first,
/// without names, each area written with its sheet (`Sheet1!B14:C22,Sheet1!F19:G30`).
pub go_to_recent: Vec<String>,
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
const UNDO_LIMIT: usize = 100;
/// Past this many entries the journal drops its oldest half.
const JOURNAL_MAX_ENTRIES: usize = 10_000;
/// Past this many bytes (entries serialized as JSON) the journal drops its oldest entries until
/// it is under half. Entries keep their full params so they replay (recorded scripts,
/// `engine.journal`), and a `file.open` holds the whole file as base64: without a size cap a
/// few opens of a large workbook keep tens of MB alive.
pub(crate) const JOURNAL_MAX_BYTES: usize = 64 * 1024 * 1024;
const UNDO_BYTES_LIMIT: usize = 128 * 1024 * 1024;
fn history_retained_bytes(older: &Workbook, newer: &Workbook) -> usize {
let mut bytes = std::mem::size_of::<Workbook>();
for (index, sheet) in older.sheets.iter().enumerate() {
// Moving or inserting sheets must not charge unchanged sheets at a new index.
if newer.sheets.iter().any(|next| Arc::ptr_eq(sheet, next)) {
continue;
}
bytes = bytes.saturating_add(std::mem::size_of::<gridcraft_model::Sheet>());
let empty = gridcraft_model::CellStore::new();
let next = newer.sheets.get(index).map_or(&empty, |next| &next.cells);
bytes = bytes.saturating_add(sheet.cells.retained_bytes_against(next));
}
bytes
}
fn push_history(history: &mut Vec<HistoryEntry>, label: String, wb: Arc<Workbook>, next: &Workbook, selection: Selection) {
push_history_with_budget(history, label, wb, next, selection, UNDO_BYTES_LIMIT);
}
fn push_history_with_budget(
history: &mut Vec<HistoryEntry>,
label: String,
wb: Arc<Workbook>,
next: &Workbook,
selection: Selection,
byte_budget: usize,
) {
push_entry(history, label, wb, next, selection);
trim_to_budget(history, byte_budget);
}
/// Adds a step without applying the limits: inside a command they wait for the outermost
/// command to collapse its steps (see [`Session::trim_history`]).
fn push_entry(history: &mut Vec<HistoryEntry>, label: String, wb: Arc<Workbook>, next: &Workbook, selection: Selection) {
let retained_bytes = history_retained_bytes(&wb, next);
history.push(HistoryEntry { label, wb, selection, retained_bytes });
}
/// Drops the oldest steps past [`UNDO_LIMIT`] or `byte_budget`, always keeping the latest one
/// (even if that one edit exceeds the budget).
fn trim_to_budget(history: &mut Vec<HistoryEntry>, byte_budget: usize) {
let mut total = history.iter().fold(0usize, |sum, entry| sum.saturating_add(entry.retained_bytes));
while history.len() > 1 && (history.len() > UNDO_LIMIT || total > byte_budget) {
total = total.saturating_sub(history.remove(0).retained_bytes);
}
}
impl DocState {
/// A document for `wb`, which came from where `trust` says: [`Trust::FromFile`] for anything
/// read from bytes (a file, a download, a recovery copy), [`Trust::Local`] only for a workbook
/// made in this session.
pub fn new(mut wb: Workbook, path: Option<String>, title: String, trust: Trust) -> DocState {
let mut calc = Calc::new();
calc.recalc_all(&mut wb);
let scripts_trusted = !cmd::extra::has_scripts(&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,
scripts_trusted,
trust,
go_to_recent: vec![],
}
}
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,
/// The statistics the status bar shows for a selection (right-click the status bar).
pub status_bar: StatusBarStats,
}
/// The selection statistics shown on the status bar, set with `view.statusBar`. Excel turns on
/// Average, Count and Sum by default.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct StatusBarStats {
pub average: bool,
pub count: bool,
pub numerical_count: bool,
pub minimum: bool,
pub maximum: bool,
pub sum: bool,
}
impl Default for StatusBarStats {
fn default() -> Self {
StatusBarStats { average: true, count: true, numerical_count: false, minimum: false, maximum: false, sum: true }
}
}
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,
status_bar: StatusBarStats::default(),
}
}
}
/// 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>),
/// Put a defined name at the caret of the cell being edited, in place of selected text; with
/// no edit under way, start editing the active cell with `=` and the name.
InsertName(String),
OpenUrl(String),
}
/// Width in points of one line of text drawn in a cell font, measured by a front end that has
/// the font the grid draws with. With `exact` false the front end may answer with a cheaper
/// measure that is never narrower (AutoFit asks for it on columns with many distinct values).
/// Commands call it while they run, so a front end must not run commands while holding a lock
/// the measure takes (egui's: see `theme::cell_text_width` in the UI crate).
pub type TextWidth = Arc<dyn Fn(&str, &model::Font, bool) -> f32 + Send + Sync>;
#[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)>,
/// Serialized size of each journal entry, measured once when it is pushed, and their sum.
journal_sizes: Vec<usize>,
journal_bytes: usize,
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 Automate › Record Actions started.
pub recording: Option<usize>,
/// The recording buffer while a recorder runs ([`Session::start_recording`]): top-level
/// commands with the place they ran in, kept apart from the journal.
pub recorder: Option<record::Recorder>,
/// How many `execute` calls are running (0 between top-level commands).
depth: u32,
/// 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,
/// Open undo groups, innermost last.
groups: Vec<OpenGroup>,
next_group: u64,
/// Counts group begins and ends, so `execute` can tell that a command opened or closed one.
group_epoch: u64,
/// Where the last group ended, so `execute` can tell a command's own changes from its group's.
group_step: Option<GroupStep>,
/// For each running command, innermost last: its document and that document's undo length
/// when it began. Steps before that length are not touched while the command runs (see
/// [`Session::bound_tail`]).
marks: Vec<(u64, usize)>,
/// Measures cell text for AutoFit Column Width. Without it (CLI, MCP) AutoFit uses
/// [`cmd::format::approx_text_width`].
pub text_width: Option<TextWidth>,
}
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() {
let d = self.docs.remove(i);
self.groups.retain(|g| g.doc != d.uid);
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}"), Trust::Local))
}
/// Journals a command, then trims the oldest entries past either cap. A recording in progress
/// keeps pointing at the same entry (the steps the trim dropped are lost).
fn push_journal(&mut self, id: &str, params: Json) {
let size = |e: &(String, Json)| serde_json::to_vec(e).map_or(0, |v| v.len());
// `journal` is public: if it was changed from outside, measure it again.
if self.journal_sizes.len() != self.journal.len() {
self.journal_sizes = self.journal.iter().map(size).collect();
self.journal_bytes = self.journal_sizes.iter().sum();
}
let entry = (id.to_string(), params);
let entry_size = size(&entry);
self.journal.push(entry);
self.journal_sizes.push(entry_size);
self.journal_bytes = self.journal_bytes.saturating_add(entry_size);
let len = self.journal.len();
let mut n = if len > JOURNAL_MAX_ENTRIES { len - JOURNAL_MAX_ENTRIES / 2 } else { 0 };
let mut kept = self.journal_bytes.saturating_sub(self.journal_sizes.iter().take(n).sum());
if self.journal_bytes > JOURNAL_MAX_BYTES {
// The newest entry stays even when it is larger than the cap on its own.
while kept > JOURNAL_MAX_BYTES / 2 && n + 1 < len {
kept = kept.saturating_sub(self.journal_sizes.get(n).copied().unwrap_or(0));
n += 1;
}
}
if n > 0 {
self.journal.drain(..n);
self.journal_sizes.drain(..n);
self.journal_bytes = kept;
self.recording = self.recording.map(|r| r.saturating_sub(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,
/// unless an undo group is open on the document (the group then makes the step).
/// 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 (first_group, group_epoch) = (self.next_group, self.group_epoch);
// Whether a group was open on the document before the command, and how long its history was.
let start = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| (self.in_group(*uid), d.undo.len())));
// A command that panics leaves the history as it found it: the steps commands nested in it
// made or undid, and the redo history they cleared, come back. A length is not enough,
// because a nested `edit.undo` removes entries. A query runs nothing that changes the
// history (`every_query_leaves_the_history_alone` runs each one), so it needs no
// copy; front ends run queries all the time.
let query = !spec.journal && !spec.undoable;
let history_before = before.as_ref().filter(|_| !query).and_then(|(uid, ..)| {
#[cfg(test)]
tests::HISTORY_COPIES.with(|n| n.set(n.get().saturating_add(1)));
self.docs.iter().find(|d| d.uid == *uid).map(|d| (d.undo.clone(), d.redo.clone()))
});
// The groups open on the document before the command (usually none), for the same reason.
let groups_before: Vec<(u64, String, Arc<Workbook>, Selection)> = match &before {
Some((uid, ..)) => {
self.groups.iter().filter(|g| g.doc == *uid).map(|g| (g.id, g.label.clone(), g.wb.clone(), g.selection.clone())).collect()
}
None => Vec::new(),
};
// The recorder keeps top-level calls with the place they ran in.
let place = (self.depth == 0 && self.recorder.is_some()).then(|| match self.active() {
Some(d) => (d.uid, d.wb.active_sheet, d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), d.selection.clone()),
None => (0, 0, String::new(), Selection::default()),
});
let doc_uid = before.as_ref().map(|(uid, ..)| *uid);
let requests_before = self.ui_requests.len();
let marked = doc_uid.zip(start).map(|(uid, (_, len))| (uid, len));
self.marks.extend(marked);
self.depth = self.depth.saturating_add(1);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, &params)));
self.depth = self.depth.saturating_sub(1);
if marked.is_some() {
self.marks.pop();
}
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 and its history as they were, and drop the undo groups the
// command opened.
self.groups.retain(|g| g.id < first_group);
let group_open = before.as_ref().is_some_and(|(uid, ..)| self.groups.iter().any(|g| g.doc == *uid));
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();
if let Some((undo, redo)) = history_before {
d.undo = undo;
d.redo = redo;
}
// A group open before the command and ended or rolled back inside it stays
// ended (its token went with the command), and keeps a step from its start to
// the restored workbook, so Undo still returns to the state before the group.
if !group_open
&& let Some((_, label, wb, selection)) = groups_before.into_iter().min_by_key(|g| g.0)
&& !Arc::ptr_eq(&wb, &d.wb)
{
push_entry(&mut d.undo, label, wb, &d.wb, selection);
d.redo.clear();
}
d.calc.rebuild(&d.wb);
}
if self.depth == 0 {
self.trim_history();
self.group_step = None;
}
return Err(EngineError::Internal(id.to_string(), msg));
}
};
// A command that fails leaves none of its own undo groups open (a `?` between
// `group_begin` and `group_end`): each is rolled back as `group_rollback` does. Only a
// command that succeeds keeps a group it opened (a debugger's start).
if result.is_err() {
self.rollback_groups_from(first_group);
}
if spec.journal && result.is_ok() {
// A command that started recording is not part of the recording; one that stopped it
// finds no recorder. A menu-style call that only opened a dialog (`file.open` with no
// file, `home.formatCells` with no style) is not recorded either: the dialog's own
// call is, so playing the recording back runs the command once, with its parameters.
let opened_dialog = self.ui_requests.get(requests_before..).is_some_and(|new| new.iter().any(|q| matches!(q, UiRequest::Dialog(..))));
if let Some((doc, sheet, sheet_name, selection)) = place.filter(|_| !opened_dialog)
&& let Some(rec) = self.recorder.as_mut()
{
rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params: cmd::journal_params(&params) });
}
self.push_journal(id, cmd::journal_params(&params));
}
// Inside an undo group the group owns the step. A command that ended a group open before
// it leaves the group its step and makes one of its own for what it changed after the end.
// A command that opened and closed its own groups makes one step of its own, which
// replaces the steps those groups made.
let epoch_changed = self.group_epoch != group_epoch;
let in_group_now = before.as_ref().is_some_and(|(uid, ..)| self.in_group(*uid));
let was_open = start.is_some_and(|(was_open, _)| was_open);
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)
{
let label = spec.label.trim_end_matches('…').to_string();
if in_group_now {
// A group this command opened and left open (a debugger's start) takes the
// command's start as its own, so what the command changed before opening it is
// part of the group's step and not a step of its own.
if !was_open
&& let Some((_, len)) = start
&& let Some(g) = self.groups.iter_mut().filter(|g| g.doc == uid && g.id >= first_group).min_by_key(|g| g.id)
{
g.start = len;
g.wb = wb;
g.selection = sel;
}
} else if epoch_changed && was_open {
// The group it ended made its step; changes after the end, whether direct or by
// nested commands, are the command's own step from where the group ended.
if let Some(step) = self.group_step.as_ref().filter(|g| g.doc == uid)
&& (d.undo.len() > step.len || !Arc::ptr_eq(&step.after, &d.wb))
{
d.undo.truncate(step.len);
push_entry(&mut d.undo, label, step.after.clone(), &d.wb, step.selection.clone());
d.redo.clear();
}
} else {
let mut label = label;
if epoch_changed && let Some((_, len)) = start {
// A command whose only step is its group's keeps that name ("Run Macro: Totals").
// That is so only when the one entry the command added is a group's step from
// the command's own start to its own result: an entry a nested command made,
// a group step of an earlier command (undone since), or a group's step that
// the command edited past all name the step after the command instead.
if d.undo.len() == len + 1
&& let Some(last) = d.undo.last()
&& Arc::ptr_eq(&last.wb, &wb)
&& self.group_step.as_ref().is_some_and(|g| g.doc == uid && g.stepped && Arc::ptr_eq(&g.after, &d.wb))
{
label = last.label.clone();
}
d.undo.truncate(len);
}
push_entry(&mut d.undo, label, wb, &d.wb, sel);
d.redo.clear();
}
// Inside a group the redo history waits for the group: `group_end` clears it when the
// group becomes a step, and `group_rollback` puts it back as it was.
d.revision += 1;
}
// The history limit applies once the outermost command has collapsed its steps, so the
// lengths it compared stay true. Meanwhile the steps made inside it are bounded on their own.
if self.depth == 0 {
self.trim_history();
self.group_step = None;
} else if let Some(uid) = doc_uid {
self.bound_tail(uid);
}
result
}
/// Runs `f` as one undo step labelled `label` on the active document: whatever commands `f`
/// runs, one Undo brings back the workbook as it was before. If `f` fails, every change it made
/// is rolled back and its error is returned. Groups nest; the outermost one makes the step.
pub fn grouped<R>(&mut self, label: &str, f: impl FnOnce(&mut Session) -> Result<R>) -> Result<R> {
let token = self.group_begin(label)?;
let doc = token.doc();
// A panic in `f` rolls the group back before it goes on, so a caller that catches it
// (outside `execute`, which drops a command's groups itself) finds no group left open.
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self))) {
Ok(Ok(r)) => match self.group_end(token) {
Ok(_) => Ok(r),
// `f` closed the group's document (`file.close`, or `file.open` taking the place of
// an untouched new workbook), and the group went with it: nothing is left to undo.
Err(_) if !self.docs.iter().any(|d| d.uid == doc) => Ok(r),
Err(e) => Err(e),
},
Ok(Err(e)) => {
// The error from `f` is the one to report. A rollback fails only when the group
// has already ended, and then there is nothing left to undo.
let _ = self.group_rollback(token);
Err(e)
}
Err(panic) => {
let _ = self.group_rollback(token);
std::panic::resume_unwind(panic)
}
}
}
/// Opens an undo group on the active document that can span several calls (for example a
/// debugger that runs one step per call). Until [`Session::group_end`] or
/// [`Session::group_rollback`], undoable commands on that document add no undo steps of their
/// own, and Undo and Redo are unavailable there.
pub fn group_begin(&mut self, label: &str) -> Result<GroupToken> {
let d = self.doc()?;
let g = OpenGroup {
id: self.next_group,
doc: d.uid,
label: label.trim_end_matches('…').to_string(),
start: d.undo.len(),
wb: d.wb.clone(),
selection: d.selection.clone(),
redo: d.redo.clone(),
};
let token = GroupToken { id: g.id, doc: g.doc };
self.next_group = self.next_group.saturating_add(1);
self.group_epoch = self.group_epoch.wrapping_add(1);
self.groups.push(g);
Ok(token)
}
/// Ends an undo group. If the workbook changed, the group becomes one undo step, unless it is
/// nested in another open group on the same document, which then owns the step. Groups opened
/// inside it and still open end with it. Returns whether the workbook changed.
pub fn group_end(&mut self, token: GroupToken) -> Result<bool> {
let g = self.take_group(&token)?;
let outer = self.in_group(g.doc);
let Some(d) = self.docs.iter_mut().find(|d| d.uid == g.doc) else { return Ok(false) };
// Steps pushed directly while the group was open fold into the group's step.
d.undo.truncate(g.start);
let changed = !Arc::ptr_eq(&d.wb, &g.wb);
if changed && !outer {
push_entry(&mut d.undo, g.label, g.wb, &d.wb, g.selection);
d.redo.clear();
d.revision += 1;
}
if !outer {
self.group_step =
Some(GroupStep { doc: g.doc, after: d.wb.clone(), selection: d.selection.clone(), len: d.undo.len(), stepped: changed });
}
// Inside a command, the outermost command applies the history limit when it finishes.
if self.depth == 0 {
self.trim_history();
}
Ok(changed)
}
/// Stops the running action on the document with uid `doc`: every undo group open on it is
/// rolled back as [`Session::group_rollback`] does, putting back the workbook, selection, undo
/// and redo history as they were when the outermost one began. Their tokens are then spent
/// (ending or rolling one back reports that it has already ended). Returns whether a group
/// was open. `automate.stopAction` runs it on the active document.
pub fn abort_groups(&mut self, doc: u64) -> bool {
let Some(id) = self.groups.iter().filter(|g| g.doc == doc).map(|g| g.id).min() else { return false };
// Rolling back the outermost group ends the ones opened inside it.
self.group_rollback(GroupToken { id, doc }).is_ok()
}
/// Rolls back every open undo group numbered `first` or later (the groups a failed command
/// opened), outermost first on each document.
fn rollback_groups_from(&mut self, first: u64) {
let opened: Vec<(u64, u64)> = self.groups.iter().filter(|g| g.id >= first).map(|g| (g.id, g.doc)).collect();
for (id, doc) in opened {
// A group inside one rolled back before it is already gone.
if self.groups.iter().any(|g| g.id == id) {
let _ = self.group_rollback(GroupToken { id, doc });
}
}
}
/// Bounds the undo steps pushed while a command runs (a script of many steps, a subtotal per
/// group): at most [`UNDO_LIMIT`] of them within [`UNDO_BYTES_LIMIT`], dropping the oldest.
/// Only steps after the latest mark go: a running command's start, an open group's start and
/// the end of the last group are positions the command or group collapses its steps by when
/// it finishes, so what is before them stays as it is until [`Session::trim_history`].
fn bound_tail(&mut self, uid: u64) {
let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid) else { return };
let marks = self.marks.iter().filter(|(doc, _)| *doc == uid).map(|(_, len)| *len);
let groups = self.groups.iter().filter(|g| g.doc == uid).map(|g| g.start);
let step = self.group_step.as_ref().filter(|g| g.doc == uid).map(|g| g.len);
let mark = marks.chain(groups).chain(step).max().unwrap_or(0).min(d.undo.len());
let mut bytes = d.undo.iter().skip(mark).fold(0usize, |sum, e| sum.saturating_add(e.retained_bytes));
while d.undo.len() > mark + 1 && (d.undo.len() - mark > UNDO_LIMIT || bytes > UNDO_BYTES_LIMIT) {
bytes = bytes.saturating_sub(d.undo.remove(mark).retained_bytes);
}
}
/// Drops the oldest undo steps past [`UNDO_LIMIT`] or [`UNDO_BYTES_LIMIT`]. A document with
/// an open group waits for the group to end, because the group remembers where its steps start.
fn trim_history(&mut self) {
let groups = &self.groups;
for d in self.docs.iter_mut().filter(|d| !groups.iter().any(|g| g.doc == d.uid)) {
trim_to_budget(&mut d.undo, UNDO_BYTES_LIMIT);
}
}
/// Ends an undo group by putting back the workbook, selection, undo and redo history as they
/// were when it began.
pub fn group_rollback(&mut self, token: GroupToken) -> Result<()> {
let g = self.take_group(&token)?;
let Some(d) = self.docs.iter_mut().find(|d| d.uid == g.doc) else { return Ok(()) };
if !Arc::ptr_eq(&d.wb, &g.wb) {
d.wb = g.wb;
d.calc.rebuild(&d.wb);
}
d.selection = g.selection;
d.undo.truncate(g.start);
d.redo = g.redo;
d.revision += 1;
// The record of the last group step stays when the document is back exactly where that
// step left it (this group began there); otherwise the rollback is where the group ended.
let kept = self.group_step.as_ref().is_some_and(|st| st.doc == g.doc && st.len == g.start && Arc::ptr_eq(&st.after, &d.wb));
if !kept && !self.groups.iter().any(|o| o.doc == g.doc) {
self.group_step = Some(GroupStep { doc: g.doc, after: d.wb.clone(), selection: d.selection.clone(), len: g.start, stepped: false });
}
Ok(())
}
/// Starts recording top-level commands into an empty buffer, replacing any recording in
/// progress. Queries, failed commands and commands run by other commands are not recorded.
pub fn start_recording(&mut self) {
self.recorder = Some(record::Recorder::default());
}
/// Stops recording and returns what was recorded (`None` when nothing was recording).
pub fn stop_recording(&mut self) -> Option<record::Recorder> {
self.recorder.take()
}
/// Whether the recording buffer is collecting commands.
pub fn is_recording(&self) -> bool {
self.recorder.is_some()
}
/// How many commands are running: 0 between top-level commands, 1 inside one, 2 inside a
/// command run by a command, and so on.
pub fn depth(&self) -> u32 {
self.depth
}
/// Whether an undo group is open on the document with this uid.
pub fn in_group(&self, doc: u64) -> bool {
self.groups.iter().any(|g| g.doc == doc)
}
/// Removes the token's group and any group on the same document opened after it.
fn take_group(&mut self, token: &GroupToken) -> Result<OpenGroup> {
let ended = || EngineError::Other("That undo group has already ended.".into());
let at = self.groups.iter().position(|g| g.id == token.id).ok_or_else(ended)?;
let mut later = self.groups.split_off(at).into_iter();
let g = later.next().ok_or_else(ended)?;
self.groups.extend(later.filter(|o| o.doc != g.doc));
self.group_epoch = self.group_epoch.wrapping_add(1);
Ok(g)
}
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"));
// A call that only opened a dialog is not recorded (see `execute`), so the recording
// has no step to take back here.
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;