Skip to main content

ravenrs_sheet/engine/
mod.rs

1//! The GridCraft engine façade.
2//!
3//! Every user-visible action is a command with a stable id (`cell.set`, `home.bold`,
4//! `data.sortAscending`…) and JSON parameters. The egui UI, the CLI, the control channel and MCP
5//! all go through [`Session::execute`], so every action is scriptable and journaled.
6#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
7#![forbid(unsafe_code)]
8
9pub mod catalog;
10pub mod cf;
11mod chart_svg;
12mod clip_html;
13pub mod cmd;
14pub mod display;
15pub mod fill;
16pub mod io;
17pub mod links;
18pub mod macros;
19pub mod outline;
20pub mod paper;
21pub mod pivot;
22pub mod pivot_calc;
23pub mod recent;
24pub mod record;
25pub mod sample;
26pub mod selection;
27pub mod tables;
28pub mod windows;
29
30use std::sync::Arc;
31
32use crate::base::{CellRef, RangeRef};
33use crate::calc::Calc;
34use crate::model::Workbook;
35use serde::Serialize;
36use serde_json::Value as Json;
37
38pub use crate::base as core;
39pub use crate::calc;
40pub use crate::formula;
41pub use crate::math;
42pub use crate::model;
43pub use crate::numfmt;
44pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command, register_test_command};
45pub use selection::Selection;
46pub use windows::{Target, TargetGuard, WindowState, WindowView};
47
48#[derive(Debug, thiserror::Error)]
49pub enum EngineError {
50    #[error("unknown command `{0}`")]
51    UnknownCommand(String),
52    #[error("command `{0}` is not available right now: {1}")]
53    Disabled(String, String),
54    #[error("invalid parameters for `{cmd}`: {msg}")]
55    BadParams { cmd: String, msg: String },
56    #[error("no workbook open")]
57    NoDocument,
58    #[error("{0}")]
59    Other(String),
60    /// The command would replace something; run it again with `overwrite: true` to go ahead.
61    #[error("confirm: {0} (run again with `overwrite: true`)")]
62    Confirm(String),
63    /// Active content (a macro) asks to run; run it again with `approve: true` to allow it.
64    #[error("approve: {0} (run again with `approve: true`)")]
65    Approve(String),
66    #[error("internal error in `{0}` (the workbook was kept as it was): {1}")]
67    Internal(String, String),
68}
69
70pub type Result<T> = std::result::Result<T, EngineError>;
71
72#[derive(Clone, Debug)]
73pub struct HistoryEntry {
74    pub label: String,
75    pub wb: Arc<Workbook>,
76    pub selection: Selection,
77    /// Estimate of storage this snapshot retains beyond the next workbook version.
78    pub retained_bytes: usize,
79}
80
81/// An undo group opened with [`Session::group_begin`]. Hand it back to [`Session::group_end`]
82/// (keep the changes as one undo step) or [`Session::group_rollback`] (undo them all).
83#[derive(Debug, PartialEq, Eq)]
84#[must_use = "an undo group stays open until it is ended or rolled back"]
85pub struct GroupToken {
86    id: u64,
87    doc: u64,
88}
89
90impl GroupToken {
91    /// The uid of the document the group belongs to.
92    pub fn doc(&self) -> u64 {
93        self.doc
94    }
95}
96
97/// An open undo group: what runs on `doc` until it ends becomes one undo step.
98#[derive(Clone, Debug)]
99struct OpenGroup {
100    id: u64,
101    doc: u64,
102    label: String,
103    /// Undo depth when the group began; the group's own steps are cut back to it.
104    start: usize,
105    wb: Arc<Workbook>,
106    selection: Selection,
107    /// The redo stack when the group began (a rollback puts it back).
108    redo: Vec<HistoryEntry>,
109    /// Where the recording was when the group began (`None` when nothing was recording): a
110    /// rollback takes back the steps recorded since, as it takes back their edits.
111    recorded: Option<(u64, u64)>,
112}
113
114/// Where the last [`Session::group_end`] or [`Session::group_rollback`] left its document: the
115/// workbook and selection it ended at, the undo length then, and whether it made a step. A
116/// command that ended the group tells its own later changes from the group's step by it, and
117/// keeps the group's name only when the group's step is exactly its own (the entry holds the
118/// workbook the step started from). Dropped when the outermost command finishes, so it keeps no
119/// workbook alive between commands.
120#[derive(Debug)]
121struct GroupStep {
122    doc: u64,
123    after: Arc<Workbook>,
124    selection: Selection,
125    len: usize,
126    stepped: bool,
127}
128
129/// The clipboard: a block of cells (with formulas and styles) or marching-ants reference.
130#[derive(Clone, Debug)]
131pub struct Clipboard {
132    /// Source workbook snapshot, sheet and range.
133    pub wb: Arc<Workbook>,
134    pub sheet: usize,
135    pub range: RangeRef,
136    /// The range as copied, before whole rows/columns were trimmed to the used range
137    /// (Insert Copied Cells inserts whole lines for a row or column copy).
138    pub selected: RangeRef,
139    pub cut: bool,
140    /// Document uid of the source (a cut only moves within the same workbook).
141    pub doc_uid: u64,
142    /// Plain text (tab-separated) for the system clipboard.
143    pub text: String,
144    /// A multi-area copy: the areas as selected (for the marquee). `wb` then holds them slid
145    /// together into one block at `range`. Empty for a single-range copy.
146    pub areas: Vec<RangeRef>,
147    /// Trust of the source document: pasting content from a file someone else made carries it.
148    pub trust: Trust,
149}
150
151/// Whether a document's active content (macros) may run without asking the person first. There
152/// is no default: whoever makes a document says where it came from ([`DocState::new`]), so a new
153/// way of building one from bytes can't come out trusted by omission.
154#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
155#[serde(rename_all = "camelCase")]
156pub enum Trust {
157    /// Made in this session: a new workbook or a sample.
158    Local,
159    /// Opened from a file, or holding content pasted from one: active content asks before it
160    /// runs.
161    FromFile,
162}
163
164impl Trust {
165    /// Whether active content may run without asking.
166    pub fn allows_code(self) -> bool {
167        self == Trust::Local
168    }
169
170    /// The trust of a document after it receives content from a document with trust `source`:
171    /// content from an untrusted file leaves the receiver untrusted.
172    pub fn receive(self, source: Trust) -> Trust {
173        if source == Trust::FromFile { Trust::FromFile } else { self }
174    }
175}
176
177/// In-cell edit mode as shown in the status bar.
178#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
179pub enum Mode {
180    #[default]
181    Ready,
182    Enter,
183    Edit,
184    Point,
185}
186
187#[derive(Clone, Debug)]
188pub struct DocState {
189    pub wb: Arc<Workbook>,
190    pub calc: Calc,
191    pub selection: Selection,
192    pub undo: Vec<HistoryEntry>,
193    pub redo: Vec<HistoryEntry>,
194    pub path: Option<String>,
195    pub saved: Arc<Workbook>,
196    pub revision: u64,
197    /// Identifies this open workbook for the rest of the session: it never changes while the
198    /// workbook is open (saving, renaming, reordering) and is never reused for another one.
199    pub uid: u64,
200    pub title: String,
201    /// Per-sheet selection memory.
202    pub sheet_selections: Vec<Selection>,
203    /// Allow Edit Ranges unlocked with their password in this session: (sheet name, title).
204    pub unlocked_ranges: Vec<(String, String)>,
205    /// The format `path`'s content was recognised as when its extension doesn't name one (an
206    /// XLSX called `budget.dat`): Save writes it back in that format.
207    pub content_kind: Option<io::FileKind>,
208    /// Whether the scripts stored in this workbook may run (`automate.runScript`). A workbook
209    /// that arrives already carrying scripts (opened from a file) starts untrusted until the
210    /// user trusts it in this session (`automate.trustScripts`); one with no scripts starts
211    /// trusted, since any script it gains later was written here.
212    pub scripts_trusted: bool,
213    /// Where the workbook's content came from: whether its active content may run without
214    /// asking. Unlike `scripts_trusted`, this is provenance, not consent: it is set when the
215    /// workbook is opened from a file, carried by content pasted from such a workbook, and never
216    /// changed by a trust decision.
217    pub trust: Trust,
218    /// While a macro's footprint is measured: every cell a command wrote, by sheet name, even
219    /// where it wrote what was already there.
220    pub touched: Option<Vec<(String, CellRef)>>,
221    /// While a macro step runs: the structural edits its command made (the sheet's name, the
222    /// edit), so the macro's inputs, output and Repeat ranges follow their cells as formulas do.
223    pub edits: Option<Vec<(String, crate::formula::adjust::Edit)>>,
224    /// The references `edit.goTo` with `record: true` went to in this document, newest first,
225    /// without names, each area written with its sheet (`Sheet1!B14:C22,Sheet1!F19:G30`).
226    pub go_to_recent: Vec<String>,
227}
228
229static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
230const UNDO_LIMIT: usize = 100;
231/// Past this many entries the journal drops its oldest half.
232const JOURNAL_MAX_ENTRIES: usize = 10_000;
233/// Past this many bytes (entries serialized as JSON) the journal drops its oldest entries until
234/// it is under half. Entries keep their full params so they replay (recorded scripts,
235/// `engine.journal`), and a `file.open` holds the whole file as base64: without a size cap a
236/// few opens of a large workbook keep tens of MB alive.
237pub(crate) const JOURNAL_MAX_BYTES: usize = 64 * 1024 * 1024;
238const UNDO_BYTES_LIMIT: usize = 128 * 1024 * 1024;
239
240fn history_retained_bytes(older: &Workbook, newer: &Workbook) -> usize {
241    let mut bytes = std::mem::size_of::<Workbook>();
242    for (index, sheet) in older.sheets.iter().enumerate() {
243        // Moving or inserting sheets must not charge unchanged sheets at a new index.
244        if newer.sheets.iter().any(|next| Arc::ptr_eq(sheet, next)) {
245            continue;
246        }
247        bytes = bytes.saturating_add(std::mem::size_of::<crate::model::Sheet>());
248        let empty = crate::model::CellStore::new();
249        let next = newer.sheets.get(index).map_or(&empty, |next| &next.cells);
250        bytes = bytes.saturating_add(sheet.cells.retained_bytes_against(next));
251    }
252    // Snapshots share the macro list until a change to it; a snapshot with its own list keeps it.
253    if !older.macros.shares(&newer.macros) {
254        bytes = bytes.saturating_add(older.macros.estimated_bytes());
255    }
256    bytes
257}
258
259fn push_history(history: &mut Vec<HistoryEntry>, label: String, wb: Arc<Workbook>, next: &Workbook, selection: Selection) {
260    push_history_with_budget(history, label, wb, next, selection, UNDO_BYTES_LIMIT);
261}
262
263fn push_history_with_budget(
264    history: &mut Vec<HistoryEntry>,
265    label: String,
266    wb: Arc<Workbook>,
267    next: &Workbook,
268    selection: Selection,
269    byte_budget: usize,
270) {
271    push_entry(history, label, wb, next, selection);
272    trim_to_budget(history, byte_budget);
273}
274
275/// Adds a step without applying the limits: inside a command they wait for the outermost
276/// command to collapse its steps (see [`Session::trim_history`]).
277fn push_entry(history: &mut Vec<HistoryEntry>, label: String, wb: Arc<Workbook>, next: &Workbook, selection: Selection) {
278    let retained_bytes = history_retained_bytes(&wb, next);
279    history.push(HistoryEntry { label, wb, selection, retained_bytes });
280}
281
282/// Drops the oldest steps past [`UNDO_LIMIT`] or `byte_budget`, always keeping the latest one
283/// (even if that one edit exceeds the budget).
284fn trim_to_budget(history: &mut Vec<HistoryEntry>, byte_budget: usize) {
285    let mut total = history.iter().fold(0usize, |sum, entry| sum.saturating_add(entry.retained_bytes));
286    while history.len() > 1 && (history.len() > UNDO_LIMIT || total > byte_budget) {
287        total = total.saturating_sub(history.remove(0).retained_bytes);
288    }
289}
290
291impl DocState {
292    /// A document for `wb`, which came from where `trust` says: [`Trust::FromFile`] for anything
293    /// read from bytes (a file, a download, a recovery copy), [`Trust::Local`] only for a workbook
294    /// made in this session.
295    pub fn new(mut wb: Workbook, path: Option<String>, title: String, trust: Trust) -> DocState {
296        let mut calc = Calc::new();
297        calc.recalc_all(&mut wb);
298        Self::with_calc(wb, calc, path, title, trust)
299    }
300    /// A workbook as its file saved it: the dependency graph is built but nothing is
301    /// evaluated, so every value is the one the file holds until an edit reaches it. `trust`
302    /// is as for [`DocState::new`].
303    pub fn from_saved(wb: Workbook, path: Option<String>, title: String, trust: Trust) -> DocState {
304        let mut calc = Calc::new();
305        calc.rebuild(&wb);
306        Self::with_calc(wb, calc, path, title, trust)
307    }
308    fn with_calc(mut wb: Workbook, calc: Calc, path: Option<String>, title: String, trust: Trust) -> DocState {
309        // Equations arrive without a layout (files never store one); painting reads only the cache.
310        wb.refresh_equations();
311        let scripts_trusted = !cmd::extra::has_scripts(&wb);
312        let wb = Arc::new(wb);
313        let sel = Selection::at(wb.active().map(|s| s.view_active).unwrap_or_default());
314        DocState {
315            saved: wb.clone(),
316            wb,
317            calc,
318            selection: sel,
319            undo: vec![],
320            redo: vec![],
321            path,
322            revision: 1,
323            uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
324            title,
325            sheet_selections: vec![],
326            unlocked_ranges: vec![],
327            content_kind: None,
328            scripts_trusted,
329            trust,
330            touched: None,
331            edits: None,
332            go_to_recent: vec![],
333        }
334    }
335    pub fn is_dirty(&self) -> bool {
336        !Arc::ptr_eq(&self.wb, &self.saved)
337    }
338    pub fn sheet_index(&self) -> usize {
339        self.wb.active_sheet()
340    }
341    /// Records the view state that is saved with the file (`sheetView topLeftCell`, the active
342    /// cell): the first visible cell of the scrolling area per sheet index in `tops`, and the
343    /// active sheet's active cell. View state never makes the workbook dirty.
344    pub fn store_view(&mut self, tops: &[(usize, CellRef)]) {
345        let active = self.selection.active;
346        let si = self.wb.active_sheet();
347        let changed = tops.iter().any(|(i, c)| self.wb.sheets.get(*i).is_some_and(|sh| sh.view_top_left != *c))
348            || self.wb.sheets.get(si).is_some_and(|sh| sh.view_active != active);
349        if !changed {
350            return;
351        }
352        let dirty = self.is_dirty();
353        let wb = Arc::make_mut(&mut self.wb);
354        for (i, c) in tops {
355            if let Some(sh) = wb.sheets.get_mut(*i)
356                && sh.view_top_left != *c
357            {
358                Arc::make_mut(sh).view_top_left = *c;
359            }
360        }
361        if let Some(sh) = wb.sheets.get_mut(si)
362            && sh.view_active != active
363        {
364            Arc::make_mut(sh).view_active = active;
365        }
366        if !dirty {
367            self.saved = self.wb.clone();
368        }
369    }
370    pub fn display_title(&self) -> String {
371        self.path
372            .as_deref()
373            .and_then(|p| std::path::Path::new(p).file_name())
374            .map(|n| n.to_string_lossy().to_string())
375            .unwrap_or_else(|| self.title.clone())
376    }
377}
378
379/// Engine-side preferences.
380#[derive(Clone, Debug, Serialize, serde::Deserialize)]
381#[serde(default, rename_all = "camelCase")]
382pub struct Prefs {
383    /// After pressing Enter, move the selection: "down", "right", "up", "left", "none".
384    pub enter_direction: String,
385    pub autocomplete: bool,
386    pub user_name: String,
387    pub default_font: String,
388    pub default_font_size: f32,
389    pub sheets_in_new_workbook: usize,
390    pub r1c1: bool,
391    /// Words added with Add to Dictionary.
392    pub user_dictionary: Vec<String>,
393    /// View › Show › Formula Bar (an application option in Excel, not saved in the file).
394    pub formula_bar: bool,
395    /// The statistics the status bar shows for a selection (right-click the status bar).
396    pub status_bar: StatusBarStats,
397    /// Formulas › Recently Used: the functions most recently entered in a formula, newest first
398    /// (at most [`recent::MAX`]; see [`recent`]). An application list, as in Excel.
399    #[serde(deserialize_with = "recent::lenient")]
400    pub recent_functions: Vec<String>,
401}
402
403/// The selection statistics shown on the status bar, set with `view.statusBar`. Excel turns on
404/// Average, Count and Sum by default.
405#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
406#[serde(default, rename_all = "camelCase")]
407pub struct StatusBarStats {
408    pub average: bool,
409    pub count: bool,
410    pub numerical_count: bool,
411    pub minimum: bool,
412    pub maximum: bool,
413    pub sum: bool,
414}
415
416impl Default for StatusBarStats {
417    fn default() -> Self {
418        StatusBarStats { average: true, count: true, numerical_count: false, minimum: false, maximum: false, sum: true }
419    }
420}
421
422impl Default for Prefs {
423    fn default() -> Self {
424        Prefs {
425            enter_direction: "down".into(),
426            autocomplete: true,
427            user_name: "GridCraft User".into(),
428            default_font: crate::model::DEFAULT_FONT.into(),
429            default_font_size: crate::model::DEFAULT_FONT_SIZE,
430            sheets_in_new_workbook: 1,
431            r1c1: false,
432            user_dictionary: vec![],
433            formula_bar: true,
434            status_bar: StatusBarStats::default(),
435            recent_functions: recent::defaults(),
436        }
437    }
438}
439
440/// Requests from commands to the UI (open a dialog, show a message…).
441#[derive(Clone, Debug, PartialEq, Serialize)]
442pub enum UiRequest {
443    Dialog(String, Json),
444    Message(String),
445    /// Start editing the active cell (F2) with optional initial text.
446    EditCell(Option<String>),
447    /// Put a defined name at the caret of the cell being edited, in place of selected text; with
448    /// no edit under way, start editing the active cell with `=` and the name.
449    InsertName(String),
450    OpenUrl(String),
451}
452
453/// Width in points of one line of text drawn in a cell font, measured by a front end that has
454/// the font the grid draws with. With `exact` false the front end may answer with a cheaper
455/// measure that is never narrower (AutoFit asks for it on columns with many distinct values).
456/// Commands call it while they run, so a front end must not run commands while holding a lock
457/// the measure takes (egui's: see `theme::cell_text_width` in the UI crate).
458pub type TextWidth = Arc<dyn Fn(&str, &model::Font, bool) -> f32 + Send + Sync>;
459
460#[derive(Default)]
461pub struct Session {
462    docs: Vec<DocState>,
463    /// Index in `docs` of the active window's workbook.
464    active: usize,
465    /// Windows on the open workbooks ([`windows`]); every workbook has at least one.
466    windows: Vec<WindowState>,
467    active_window: Option<u64>,
468    /// Last window id handed out.
469    next_window: u64,
470    /// Window activation counter (most recently used order).
471    activation: u64,
472    pub clipboard: Option<Clipboard>,
473    pub prefs: Prefs,
474    pub mode: Mode,
475    /// Commands run so far, without their passwords ([`cmd::journal_params`]).
476    pub journal: Vec<(String, Json)>,
477    /// Serialized size of each journal entry, measured once when it is pushed, and their sum.
478    journal_sizes: Vec<usize>,
479    journal_bytes: usize,
480    /// While a dry run runs (the footprint, `macro.check`), its commands are not journaled: the
481    /// dry run is put back, and journaling it would evict the person's own oldest entries.
482    pub(crate) journal_paused: u32,
483    pub ui_requests: Vec<UiRequest>,
484    /// Format painter source style (sticky when double-clicked).
485    pub format_painter: Option<(Arc<Workbook>, usize, RangeRef, bool)>,
486    /// Find state (last search).
487    pub last_find: Option<Json>,
488    /// View › Show › Ruler is off (rulers in Page Layout view; an application option).
489    pub hide_ruler: bool,
490    /// Journal position where Automate › Record Actions started.
491    pub recording: Option<usize>,
492    /// The recording buffer while a recorder runs ([`Session::start_recording`]): top-level
493    /// commands with the place they ran in, kept apart from the journal.
494    pub recorder: Option<record::Recorder>,
495    /// How many `execute` calls are running (0 between top-level commands).
496    depth: u32,
497    /// The steps the last successful `automate.runScript` or `macro.run` ran while a recorder was
498    /// on, for the recorder to keep with that step (see [`record::Recorder::ran`]); taken by the
499    /// recording hook.
500    pub(crate) script_ran: Option<Vec<(String, Json)>>,
501    /// Macros the person allowed to run in this session: (document uid, the macro's content as
502    /// canonical JSON, from [`macros::fingerprint`]).
503    pub macro_approvals: std::collections::HashSet<(u64, String)>,
504    /// The macro being debugged, between debugger actions.
505    pub macro_debug: Option<macros::debug::Debugger>,
506    /// Where the macro recorder started (beside `recorder`, its buffer).
507    pub macro_recording: Option<macros::recorder::RecordingStart>,
508    /// Draw tab tool: "" (select), "pen" or "eraser"; pen colour and width.
509    pub draw_tool: String,
510    pub draw_color: String,
511    pub draw_width: f32,
512    /// Review › Show Comments: the Comments pane is open.
513    pub comments_pane: bool,
514    /// Review › Notes › Show All Notes: every note is displayed. An application display setting
515    /// (like Excel's comment indicator option), so it's not saved in the file.
516    pub show_all_notes: bool,
517    /// Whether `link.open` may open web and e-mail addresses without the user's confirmation
518    /// (#2181). Host code sets it; no command does.
519    pub link_policy: links::LinkPolicy,
520    /// Open undo groups, innermost last.
521    groups: Vec<OpenGroup>,
522    next_group: u64,
523    /// Counts group begins and ends, so `execute` can tell that a command opened or closed one.
524    group_epoch: u64,
525    /// Where the last group ended, so `execute` can tell a command's own changes from its group's.
526    group_step: Option<GroupStep>,
527    /// For each running command, innermost last: its document and that document's undo length
528    /// when it began. Steps before that length are not touched while the command runs (see
529    /// [`Session::bound_tail`]).
530    marks: Vec<(u64, usize)>,
531    /// Measures cell text for AutoFit Column Width. Without it (CLI, MCP) AutoFit uses
532    /// [`cmd::format::approx_text_width`].
533    pub text_width: Option<TextWidth>,
534}
535
536impl Session {
537    pub fn new() -> Session {
538        Session::default()
539    }
540    pub fn documents(&self) -> &[DocState] {
541        &self.docs
542    }
543    pub fn active_index(&self) -> usize {
544        self.active
545    }
546    /// Makes the workbook at `i` active, in its most recently active window (unless the active
547    /// window already shows it).
548    pub fn set_active(&mut self, i: usize) {
549        let Some(uid) = self.docs.get(i).map(|d| d.uid) else { return };
550        if self.active == i && self.active_window.and_then(|w| self.window(w)).is_some_and(|w| w.doc_uid == uid) {
551            return;
552        }
553        if let Some(w) = self.doc_window(uid) {
554            self.activate_window(w);
555        }
556    }
557    pub fn active(&self) -> Option<&DocState> {
558        self.docs.get(self.active)
559    }
560    /// Where the open workbook with this [`DocState::uid`] is in [`Session::documents`].
561    pub fn document_index(&self, uid: u64) -> Option<usize> {
562        self.docs.iter().position(|d| d.uid == uid)
563    }
564    pub fn active_mut(&mut self) -> Option<&mut DocState> {
565        self.docs.get_mut(self.active)
566    }
567    pub fn doc(&self) -> Result<&DocState> {
568        self.active().ok_or(EngineError::NoDocument)
569    }
570    pub fn doc_mut(&mut self) -> Result<&mut DocState> {
571        self.active_mut().ok_or(EngineError::NoDocument)
572    }
573    /// The warning an edit of `at` on `sheet` gets from sheet protection, or `None` when the edit
574    /// is allowed. Front ends ask before opening an editor, so typing into a locked cell never
575    /// leaves a draft that can't be entered (and so blocks saving).
576    pub fn locked_cell_message(&self, sheet: usize, at: CellRef) -> Option<String> {
577        let d = self.active()?;
578        let sh = d.wb.sheet(sheet)?;
579        if !sh.is_protected() || !d.wb.styles.get(sh.style_id(at)).protection.locked {
580            return None;
581        }
582        cmd::locked_cell_error(sh, at, &d.unlocked_ranges)
583    }
584    /// Opens a workbook in a new window, which becomes the active one.
585    pub fn add_document(&mut self, d: DocState) -> usize {
586        let uid = d.uid;
587        self.docs.push(d);
588        let w = self.push_window(uid);
589        self.activate_window(w);
590        self.docs.len() - 1
591    }
592    /// Closes a workbook and its windows; the most recently active remaining window comes forward.
593    pub fn close_document(&mut self, i: usize) {
594        if i < self.docs.len() {
595            let d = self.docs.remove(i);
596            self.groups.retain(|g| g.doc != d.uid);
597            // A macro being debugged in this workbook ends with it; its group just went.
598            if self.macro_debug.as_ref().is_some_and(|m| m.doc == d.uid) {
599                self.macro_debug = None;
600            }
601            self.windows.retain(|w| w.doc_uid != d.uid);
602            if self.active_window.is_some_and(|w| self.window(w).is_none()) {
603                self.activate_recent();
604            } else if let Some(di) = self.active_window.and_then(|w| self.window(w)).and_then(|w| self.document_index(w.doc_uid)) {
605                self.active = di;
606            }
607        }
608    }
609    /// A new blank workbook titled `BookN`.
610    pub fn new_workbook(&mut self) -> usize {
611        let n = (1..).find(|n| !self.docs.iter().any(|d| d.title == format!("Book{n}") && d.path.is_none())).unwrap_or(1);
612        let mut wb = Workbook::new();
613        wb.props.author = self.prefs.user_name.clone();
614        for _ in 1..self.prefs.sheets_in_new_workbook.clamp(1, 255) {
615            let name = wb.next_sheet_name();
616            wb.sheets.push(Arc::new(crate::model::Sheet::new(name)));
617        }
618        self.add_document(DocState::new(wb, None, format!("Book{n}"), Trust::Local))
619    }
620
621    /// Journals a command, then trims the oldest entries past either cap. A recording in progress
622    /// keeps pointing at the same entry (the steps the trim dropped are lost).
623    fn push_journal(&mut self, id: &str, params: Json) {
624        let size = |e: &(String, Json)| serde_json::to_vec(e).map_or(0, |v| v.len());
625        // `journal` is public: if it was changed from outside, measure it again.
626        if self.journal_sizes.len() != self.journal.len() {
627            self.journal_sizes = self.journal.iter().map(size).collect();
628            self.journal_bytes = self.journal_sizes.iter().sum();
629        }
630        let entry = (id.to_string(), params);
631        let entry_size = size(&entry);
632        self.journal.push(entry);
633        self.journal_sizes.push(entry_size);
634        self.journal_bytes = self.journal_bytes.saturating_add(entry_size);
635        let len = self.journal.len();
636        let mut n = if len > JOURNAL_MAX_ENTRIES { len - JOURNAL_MAX_ENTRIES / 2 } else { 0 };
637        let mut kept = self.journal_bytes.saturating_sub(self.journal_sizes.iter().take(n).sum());
638        if self.journal_bytes > JOURNAL_MAX_BYTES {
639            // The newest entry stays even when it is larger than the cap on its own.
640            while kept > JOURNAL_MAX_BYTES / 2 && n + 1 < len {
641                kept = kept.saturating_sub(self.journal_sizes.get(n).copied().unwrap_or(0));
642                n += 1;
643            }
644        }
645        if n > 0 {
646            self.journal.drain(..n);
647            self.journal_sizes.drain(..n);
648            self.journal_bytes = kept;
649            self.recording = self.recording.map(|r| r.saturating_sub(n));
650        }
651    }
652
653    /// Cuts the journal back to its first `keep` entries, with its size bookkeeping.
654    pub(crate) fn truncate_journal(&mut self, keep: usize) {
655        if self.journal_sizes.len() == self.journal.len() {
656            self.journal_sizes.truncate(keep);
657            self.journal_bytes = self.journal_sizes.iter().sum();
658        }
659        self.journal.truncate(keep);
660        self.recording = self.recording.map(|r| r.min(self.journal.len()));
661    }
662
663    pub fn commands(&self) -> Vec<CommandInfo> {
664        command_specs().iter().map(|c| c.info(self)).collect()
665    }
666
667    /// Runs a command by id. Undoable commands record a history step when the workbook changed,
668    /// unless an undo group is open on the document (the group then makes the step).
669    /// Panics inside commands are caught and reported; the workbook is restored.
670    pub fn execute(&mut self, id: &str, params: Json) -> Result<Json> {
671        let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
672        if let Err(why) = (spec.enabled)(self) {
673            return Err(EngineError::Disabled(id.to_string(), why));
674        }
675        // A debugger whose undo group something else ended locks nothing any more (`macro.debug`
676        // itself says that the run had ended).
677        if id != "macro.debug" {
678            macros::debug::drop_if_ended(self);
679        }
680        let query = !spec.journal && !spec.undoable;
681        // While a macro is being debugged its undo group is open, and its workbook is locked:
682        // between steps only the debugger, queries and commands that look around run there
683        // (`macros::debug::LOOKS_AROUND`). Anything else waits, undoable or not (saving, view
684        // modes, stopping Record Actions, which saves a script), also when another command runs
685        // it. Only the run's own steps go ahead. Other workbooks stay editable.
686        let locked = self.active().is_some_and(|d| self.macro_debug.as_ref().is_some_and(|m| m.locks(d.uid) && !m.stepping()));
687        if locked && !macros::debug::runs_while_locked(id, query) {
688            return Err(EngineError::Disabled(id.to_string(), macros::debug::BUSY.into()));
689        }
690        let before = self.active().map(|d| (d.uid, d.wb.clone(), d.selection.clone()));
691        // A dry run inside this command that a panic cuts short leaves journaling paused and its
692        // touch log open; the panic branch puts both back.
693        let paused_before = self.journal_paused;
694        let params = if params.is_null() { Json::Object(Default::default()) } else { params };
695        let (first_group, group_epoch) = (self.next_group, self.group_epoch);
696        // Whether a group was open on the document before the command, and how long its history was.
697        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())));
698        // A command that panics leaves the history as it found it: the steps commands nested in it
699        // made or undid, and the redo history they cleared, come back. A length is not enough,
700        // because a nested `edit.undo` removes entries. A query runs nothing that changes the
701        // history (`every_query_leaves_the_history_alone` runs each one), so it needs no
702        // copy; front ends run queries all the time.
703        let history_before = before.as_ref().filter(|_| !query).and_then(|(uid, ..)| {
704            #[cfg(test)]
705            tests::HISTORY_COPIES.with(|n| n.set(n.get().saturating_add(1)));
706            self.docs.iter().find(|d| d.uid == *uid).map(|d| (d.undo.clone(), d.redo.clone()))
707        });
708        // The groups open on the document before the command (usually none), for the same reason.
709        let groups_before: Vec<(u64, String, Arc<Workbook>, Selection)> = match &before {
710            Some((uid, ..)) => {
711                self.groups.iter().filter(|g| g.doc == *uid).map(|g| (g.id, g.label.clone(), g.wb.clone(), g.selection.clone())).collect()
712            }
713            None => Vec::new(),
714        };
715        // The recorder keeps top-level calls with the place they ran in.
716        let place = (self.depth == 0 && self.recorder.is_some()).then(|| match self.active() {
717            Some(d) => (d.uid, d.wb.active_sheet(), d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), d.selection.clone()),
718            None => (0, 0, String::new(), Selection::default()),
719        });
720        let doc_uid = before.as_ref().map(|(uid, ..)| *uid);
721        let requests_before = self.ui_requests.len();
722        let marked = doc_uid.zip(start).map(|(uid, (_, len))| (uid, len));
723        self.marks.extend(marked);
724        self.depth = self.depth.saturating_add(1);
725        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, &params)));
726        self.depth = self.depth.saturating_sub(1);
727        if marked.is_some() {
728            self.marks.pop();
729        }
730        let result = match result {
731            Ok(r) => r,
732            Err(p) => {
733                let msg = p
734                    .downcast_ref::<&str>()
735                    .map(ToString::to_string)
736                    .or_else(|| p.downcast_ref::<String>().cloned())
737                    .unwrap_or_else(|| "panic".into());
738                // Restore the workbook and its history as they were, and drop the undo groups the
739                // command opened.
740                self.groups.retain(|g| g.id < first_group);
741                let group_open = before.as_ref().is_some_and(|(uid, ..)| self.groups.iter().any(|g| g.doc == *uid));
742                if let Some((uid, wb, sel)) = &before
743                    && let Some(d) = self.docs.iter_mut().find(|d| d.uid == *uid)
744                {
745                    d.wb = wb.clone();
746                    d.selection = sel.clone();
747                    if let Some((undo, redo)) = history_before {
748                        d.undo = undo;
749                        d.redo = redo;
750                    }
751                    // A group open before the command and ended or rolled back inside it stays
752                    // ended (its token went with the command), and keeps a step from its start to
753                    // the restored workbook, so Undo still returns to the state before the group.
754                    if !group_open
755                        && let Some((_, label, wb, selection)) = groups_before.into_iter().min_by_key(|g| g.0)
756                        && !Arc::ptr_eq(&wb, &d.wb)
757                    {
758                        push_entry(&mut d.undo, label, wb, &d.wb, selection);
759                        d.redo.clear();
760                    }
761                    d.calc.rebuild(&d.wb);
762                }
763                if self.journal_paused != paused_before {
764                    self.journal_paused = paused_before;
765                    for d in self.docs.iter_mut() {
766                        d.touched = None;
767                    }
768                }
769                if self.depth == 0 {
770                    // Only the `macro.debug` call itself can lose the run's place: a command nested
771                    // in a step that panics is an error of that step, which the run handles.
772                    macros::debug::recover(self);
773                    self.trim_history();
774                    self.group_step = None;
775                }
776                return Err(EngineError::Internal(id.to_string(), msg));
777            }
778        };
779        // A command that fails leaves none of its own undo groups open (a `?` between
780        // `group_begin` and `group_end`): each is rolled back as `group_rollback` does. Only a
781        // command that succeeds keeps a group it opened (a debugger's start).
782        if result.is_err() {
783            self.rollback_groups_from(first_group);
784        }
785        if spec.journal && result.is_ok() {
786            // A command that started recording is not part of the recording; one that stopped it
787            // finds no recorder. A menu-style call that only opened a dialog (`file.open` with no
788            // file, `home.formatCells` with no style) is not recorded either: the dialog's own
789            // call is, so playing the recording back runs the command once, with its parameters.
790            // A call that opened a dialog and changed the workbook too (a script with an edit before
791            // a menu-style step) is recorded: the edit happened, and the dialog's confirming call
792            // is a step of its own.
793            let opened_dialog = self.ui_requests.get(requests_before..).is_some_and(|new| new.iter().any(|q| matches!(q, UiRequest::Dialog(..))));
794            let changed = before.as_ref().is_some_and(|(uid, wb, _)| self.docs.iter().any(|d| d.uid == *uid && !Arc::ptr_eq(&d.wb, wb)));
795            if let Some((doc, sheet, sheet_name, selection)) = place.filter(|_| (!opened_dialog || changed) && !record::NOT_RECORDED.contains(&id))
796                && let Some(rec) = self.recorder.as_mut()
797            {
798                // A toggle (Bold) is recorded with the state it set, as Excel records
799                // `Bold = True`: playing it back sets that state rather than flipping it again.
800                let params = match &result {
801                    Ok(r) if params.is_object() => record::with_toggle_state(id, cmd::journal_params(&params), r),
802                    _ => cmd::journal_params(&params),
803                };
804                let kept = rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params });
805                // A script or macro run is kept with the steps it ran, so editing or deleting the
806                // script or macro before the recording stops does not change what was recorded.
807                if let Some(ran) = self.script_ran.take()
808                    && kept
809                    && matches!(id, "automate.runScript" | "macro.run")
810                {
811                    rec.attach_ran(ran);
812                }
813            }
814            if self.journal_paused == 0 {
815                self.push_journal(id, cmd::journal_params(&params));
816                recent::note(&mut self.prefs.recent_functions, id, &params);
817            }
818        }
819        // Inside an undo group the group owns the step. A command that ended a group open before
820        // it leaves the group its step and makes one of its own for what it changed after the end.
821        // A command that opened and closed its own groups makes one step of its own, which
822        // replaces the steps those groups made.
823        let epoch_changed = self.group_epoch != group_epoch;
824        let in_group_now = before.as_ref().is_some_and(|(uid, ..)| self.in_group(*uid));
825        let was_open = start.is_some_and(|(was_open, _)| was_open);
826        if spec.undoable
827            && let Some((uid, wb, sel)) = before
828            && let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid)
829            && !Arc::ptr_eq(&d.wb, &wb)
830        {
831            let label = spec.label.trim_end_matches('…').to_string();
832            if in_group_now {
833                // A group this command opened and left open (a debugger's start) takes the
834                // command's start as its own, so what the command changed before opening it is
835                // part of the group's step and not a step of its own.
836                if !was_open
837                    && let Some((_, len)) = start
838                    && let Some(g) = self.groups.iter_mut().filter(|g| g.doc == uid && g.id >= first_group).min_by_key(|g| g.id)
839                {
840                    g.start = len;
841                    g.wb = wb;
842                    g.selection = sel;
843                }
844            } else if epoch_changed && was_open {
845                // The group it ended made its step; changes after the end, whether direct or by
846                // nested commands, are the command's own step from where the group ended.
847                if let Some(step) = self.group_step.as_ref().filter(|g| g.doc == uid)
848                    && (d.undo.len() > step.len || !Arc::ptr_eq(&step.after, &d.wb))
849                {
850                    d.undo.truncate(step.len);
851                    push_entry(&mut d.undo, label, step.after.clone(), &d.wb, step.selection.clone());
852                    d.redo.clear();
853                }
854            } else {
855                let mut label = label;
856                if epoch_changed && let Some((_, len)) = start {
857                    // A command whose only step is its group's keeps that name ("Run Macro: Totals").
858                    // That is so only when the one entry the command added is a group's step from
859                    // the command's own start to its own result: an entry a nested command made,
860                    // a group step of an earlier command (undone since), or a group's step that
861                    // the command edited past all name the step after the command instead.
862                    if d.undo.len() == len + 1
863                        && let Some(last) = d.undo.last()
864                        && Arc::ptr_eq(&last.wb, &wb)
865                        && self.group_step.as_ref().is_some_and(|g| g.doc == uid && g.stepped && Arc::ptr_eq(&g.after, &d.wb))
866                    {
867                        label = last.label.clone();
868                    }
869                    d.undo.truncate(len);
870                }
871                push_entry(&mut d.undo, label, wb, &d.wb, sel);
872                d.redo.clear();
873            }
874            // Inside a group the redo history waits for the group: `group_end` clears it when the
875            // group becomes a step, and `group_rollback` puts it back as it was.
876            d.revision += 1;
877        }
878        // The history limit applies once the outermost command has collapsed its steps, so the
879        // lengths it compared stay true. Meanwhile the steps made inside it are bounded on their own.
880        if self.depth == 0 {
881            self.trim_history();
882            self.group_step = None;
883        } else if let Some(uid) = doc_uid {
884            self.bound_tail(uid);
885        }
886        result
887    }
888
889    /// Runs `f` as one undo step labelled `label` on the active document: whatever commands `f`
890    /// runs, one Undo brings back the workbook as it was before. If `f` fails, every change it made
891    /// is rolled back and its error is returned. Groups nest; the outermost one makes the step.
892    pub fn grouped<R>(&mut self, label: &str, f: impl FnOnce(&mut Session) -> Result<R>) -> Result<R> {
893        let token = self.group_begin(label)?;
894        let doc = token.doc();
895        // A panic in `f` rolls the group back before it goes on, so a caller that catches it
896        // (outside `execute`, which drops a command's groups itself) finds no group left open.
897        match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self))) {
898            Ok(Ok(r)) => match self.group_end(token) {
899                Ok(_) => Ok(r),
900                // `f` closed the group's document (`file.close`, or `file.open` taking the place of
901                // an untouched new workbook), and the group went with it: nothing is left to undo.
902                Err(_) if !self.docs.iter().any(|d| d.uid == doc) => Ok(r),
903                Err(e) => Err(e),
904            },
905            Ok(Err(e)) => {
906                // The error from `f` is the one to report. A rollback fails only when the group
907                // has already ended, and then there is nothing left to undo.
908                let _ = self.group_rollback(token);
909                Err(e)
910            }
911            Err(panic) => {
912                let _ = self.group_rollback(token);
913                std::panic::resume_unwind(panic)
914            }
915        }
916    }
917
918    /// Opens an undo group on the active document that can span several calls (for example a
919    /// debugger that runs one step per call). Until [`Session::group_end`] or
920    /// [`Session::group_rollback`], undoable commands on that document add no undo steps of their
921    /// own, and Undo and Redo are unavailable there.
922    pub fn group_begin(&mut self, label: &str) -> Result<GroupToken> {
923        let d = self.doc()?;
924        let g = OpenGroup {
925            id: self.next_group,
926            doc: d.uid,
927            label: label.trim_end_matches('…').to_string(),
928            start: d.undo.len(),
929            wb: d.wb.clone(),
930            selection: d.selection.clone(),
931            redo: d.redo.clone(),
932            recorded: self.recorder.as_ref().map(record::Recorder::mark),
933        };
934        let token = GroupToken { id: g.id, doc: g.doc };
935        self.next_group = self.next_group.saturating_add(1);
936        self.group_epoch = self.group_epoch.wrapping_add(1);
937        self.groups.push(g);
938        Ok(token)
939    }
940
941    /// Ends an undo group. If the workbook changed, the group becomes one undo step, unless it is
942    /// nested in another open group on the same document, which then owns the step. Groups opened
943    /// inside it and still open end with it. Returns whether the workbook changed.
944    pub fn group_end(&mut self, token: GroupToken) -> Result<bool> {
945        let g = self.take_group(&token)?;
946        let outer = self.in_group(g.doc);
947        let Some(d) = self.docs.iter_mut().find(|d| d.uid == g.doc) else { return Ok(false) };
948        // Steps pushed directly while the group was open fold into the group's step.
949        d.undo.truncate(g.start);
950        let changed = !Arc::ptr_eq(&d.wb, &g.wb);
951        if changed && !outer {
952            push_entry(&mut d.undo, g.label, g.wb, &d.wb, g.selection);
953            d.redo.clear();
954            d.revision += 1;
955        }
956        if !outer {
957            self.group_step =
958                Some(GroupStep { doc: g.doc, after: d.wb.clone(), selection: d.selection.clone(), len: d.undo.len(), stepped: changed });
959        }
960        // Inside a command, the outermost command applies the history limit when it finishes.
961        if self.depth == 0 {
962            self.trim_history();
963        }
964        Ok(changed)
965    }
966
967    /// Stops the running action on the document with uid `doc`: every undo group open on it is
968    /// rolled back as [`Session::group_rollback`] does, putting back the workbook, selection, undo
969    /// and redo history as they were when the outermost one began. Their tokens are then spent
970    /// (ending or rolling one back reports that it has already ended). Returns whether a group
971    /// was open. `automate.stopAction` runs it on the active document.
972    pub fn abort_groups(&mut self, doc: u64) -> bool {
973        let Some(id) = self.groups.iter().filter(|g| g.doc == doc).map(|g| g.id).min() else { return false };
974        // Rolling back the outermost group ends the ones opened inside it.
975        self.group_rollback(GroupToken { id, doc }).is_ok()
976    }
977
978    /// Rolls back every open undo group numbered `first` or later (the groups a failed command
979    /// opened), outermost first on each document.
980    fn rollback_groups_from(&mut self, first: u64) {
981        let opened: Vec<(u64, u64)> = self.groups.iter().filter(|g| g.id >= first).map(|g| (g.id, g.doc)).collect();
982        for (id, doc) in opened {
983            // A group inside one rolled back before it is already gone.
984            if self.groups.iter().any(|g| g.id == id) {
985                let _ = self.group_rollback(GroupToken { id, doc });
986            }
987        }
988    }
989
990    /// Bounds the undo steps pushed while a command runs (a script of many steps, a subtotal per
991    /// group): at most [`UNDO_LIMIT`] of them within [`UNDO_BYTES_LIMIT`], dropping the oldest.
992    /// Only steps after the latest mark go: a running command's start, an open group's start and
993    /// the end of the last group are positions the command or group collapses its steps by when
994    /// it finishes, so what is before them stays as it is until [`Session::trim_history`].
995    fn bound_tail(&mut self, uid: u64) {
996        let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid) else { return };
997        let marks = self.marks.iter().filter(|(doc, _)| *doc == uid).map(|(_, len)| *len);
998        let groups = self.groups.iter().filter(|g| g.doc == uid).map(|g| g.start);
999        let step = self.group_step.as_ref().filter(|g| g.doc == uid).map(|g| g.len);
1000        let mark = marks.chain(groups).chain(step).max().unwrap_or(0).min(d.undo.len());
1001        let mut bytes = d.undo.iter().skip(mark).fold(0usize, |sum, e| sum.saturating_add(e.retained_bytes));
1002        while d.undo.len() > mark + 1 && (d.undo.len() - mark > UNDO_LIMIT || bytes > UNDO_BYTES_LIMIT) {
1003            bytes = bytes.saturating_sub(d.undo.remove(mark).retained_bytes);
1004        }
1005    }
1006
1007    /// Drops the oldest undo steps past [`UNDO_LIMIT`] or [`UNDO_BYTES_LIMIT`]. A document with
1008    /// an open group waits for the group to end, because the group remembers where its steps start.
1009    fn trim_history(&mut self) {
1010        let groups = &self.groups;
1011        for d in self.docs.iter_mut().filter(|d| !groups.iter().any(|g| g.doc == d.uid)) {
1012            trim_to_budget(&mut d.undo, UNDO_BYTES_LIMIT);
1013        }
1014    }
1015
1016    /// Ends an undo group by putting back the workbook, selection, undo and redo history as they
1017    /// were when it began.
1018    pub fn group_rollback(&mut self, token: GroupToken) -> Result<()> {
1019        let g = self.take_group(&token)?;
1020        let Some(d) = self.docs.iter_mut().find(|d| d.uid == g.doc) else { return Ok(()) };
1021        if !Arc::ptr_eq(&d.wb, &g.wb) {
1022            d.wb = g.wb;
1023            d.calc.rebuild(&d.wb);
1024        }
1025        d.selection = g.selection;
1026        d.undo.truncate(g.start);
1027        d.redo = g.redo;
1028        d.revision += 1;
1029        // The steps recorded while the group was open go with their edits (all of them when the
1030        // recording started inside the group).
1031        if let Some(rec) = self.recorder.as_mut() {
1032            rec.cut_back(g.recorded.unwrap_or((0, 0)), g.doc);
1033        }
1034        // The record of the last group step stays when the document is back exactly where that
1035        // step left it (this group began there); otherwise the rollback is where the group ended.
1036        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));
1037        if !kept && !self.groups.iter().any(|o| o.doc == g.doc) {
1038            self.group_step = Some(GroupStep { doc: g.doc, after: d.wb.clone(), selection: d.selection.clone(), len: g.start, stepped: false });
1039        }
1040        Ok(())
1041    }
1042
1043    /// Starts recording top-level commands into an empty buffer, replacing any recording in
1044    /// progress. Queries, failed commands and commands run by other commands are not recorded.
1045    pub fn start_recording(&mut self) {
1046        self.recorder = Some(record::Recorder::default());
1047    }
1048
1049    /// Stops recording and returns what was recorded (`None` when nothing was recording).
1050    pub fn stop_recording(&mut self) -> Option<record::Recorder> {
1051        self.recorder.take()
1052    }
1053
1054    /// Whether the recording buffer is collecting commands.
1055    pub fn is_recording(&self) -> bool {
1056        self.recorder.is_some()
1057    }
1058
1059    /// How many commands are running: 0 between top-level commands, 1 inside one, 2 inside a
1060    /// command run by a command, and so on.
1061    pub fn depth(&self) -> u32 {
1062        self.depth
1063    }
1064
1065    /// Runs a command as a command in progress would (one level deeper), for tests of what a
1066    /// nested call may do.
1067    #[cfg(test)]
1068    pub(crate) fn execute_nested(&mut self, id: &str, params: Json) -> Result<Json> {
1069        self.depth = self.depth.saturating_add(1);
1070        let r = self.execute(id, params);
1071        self.depth = self.depth.saturating_sub(1);
1072        r
1073    }
1074
1075    /// Whether the token's undo group is still open (nothing has ended or rolled it back).
1076    pub fn group_is_open(&self, token: &GroupToken) -> bool {
1077        self.groups.iter().any(|g| g.id == token.id)
1078    }
1079
1080    /// Whether an undo group is open on the document with this uid.
1081    pub fn in_group(&self, doc: u64) -> bool {
1082        self.groups.iter().any(|g| g.doc == doc)
1083    }
1084
1085    /// Removes the token's group and any group on the same document opened after it.
1086    fn take_group(&mut self, token: &GroupToken) -> Result<OpenGroup> {
1087        let ended = || EngineError::Other("That undo group has already ended.".into());
1088        let at = self.groups.iter().position(|g| g.id == token.id).ok_or_else(ended)?;
1089        let mut later = self.groups.split_off(at).into_iter();
1090        let g = later.next().ok_or_else(ended)?;
1091        self.groups.extend(later.filter(|o| o.doc != g.doc));
1092        self.group_epoch = self.group_epoch.wrapping_add(1);
1093        Ok(g)
1094    }
1095
1096    pub fn run(&mut self, id: &str, params: Json) -> std::result::Result<Json, String> {
1097        self.execute(id, params).map_err(|e| e.to_string())
1098    }
1099
1100    pub fn take_ui_requests(&mut self) -> Vec<UiRequest> {
1101        std::mem::take(&mut self.ui_requests)
1102    }
1103
1104    /// Runs a command for a caller with no UI (MCP, CLI): queued UI requests are dropped, and a command that only
1105    /// queued a file dialog because it got no file (`file.open`, `file.saveAs`, `file.save` on a workbook with no
1106    /// file yet, `data.getData`) is an error instead of a silent success.
1107    pub fn execute_headless(&mut self, id: &str, params: Json) -> Result<Json> {
1108        let r = self.execute(id, params);
1109        let requests = self.take_ui_requests();
1110        let asked_for_file = requests.iter().any(|q| matches!(q, UiRequest::Dialog(name, _) if name == "open" || name == "saveAs"));
1111        // A call that only opened a dialog is not recorded (see `execute`), so the recording
1112        // has no step to take back here.
1113        if r.is_ok() && asked_for_file {
1114            let what = match id {
1115                "file.open" => "`path` or `base64`",
1116                "data.getData" | "data.fromTextCsv" => "`path` or `text`",
1117                _ => "`path`",
1118            };
1119            let msg = format!("missing {what}: programmatic calls never open dialogs");
1120            return Err(EngineError::BadParams { cmd: id.to_string(), msg });
1121        }
1122        r
1123    }
1124}
1125
1126/// Convenience: the active workbook's active sheet.
1127pub fn active_sheet(s: &Session) -> Option<&crate::model::Sheet> {
1128    s.active().and_then(|d| d.wb.active())
1129}
1130
1131pub fn cell(r: u32, c: u32) -> CellRef {
1132    CellRef::new(r, c)
1133}
1134
1135#[cfg(test)]
1136mod tests;