Files
gridcraft/crates/engine/src/cmd/mod.rs
T
Pranav 6ead1fb519 Engine: building blocks for multi-command actions (undo groups, recording buffer, document trust, chunked hidden names) (#462)
* Engine: undo groups make several commands one undo step

Session::grouped(label, f) runs f as one undo step on the active
document. On success the group leaves exactly one undo entry holding the
workbook from before the group; on error it restores the workbook,
selection, undo and redo stacks as they were and returns the error.
group_begin, group_end and group_rollback do the same across separate
calls, for an action that runs one step per call.

While a group is open on a document, undoable commands on it add no
undo steps of their own, so a long group neither grows the history nor
pushes older entries past the undo limit, and Undo and Redo are
disabled there. Groups nest: the outermost one makes the step, and a
failing inner group rolls back only its own changes. Closing a document
drops its groups, and a command that panics drops the groups it opened.
history.list reports whether a group is open.

Insert Copied Cells folds its nested steps into one undo step by
counting revisions; inside a group the nested steps push no entries, so
it now leaves the history alone there instead of removing entries from
before the group.

* Engine: a recording buffer of top-level commands, apart from the journal

A macro recorder needs more than a journal index: the journal is
trimmed past 10,000 entries, it also holds the commands that other
commands run (so playback would run them twice), and it keeps no
selection, which relative references need.

Session now tracks how deeply execute calls are nested. While recording,
a buffer (record.rs) keeps each successful, journaled top-level call with
the document uid, the active sheet (index and name) and the selection it
ran in, then its id and parameters without secrets, as the journal
stores them. Queries, failed commands and the command that starts
recording are not kept. The buffer holds up to 100,000 steps and counts
any it drops. Session::start_recording, stop_recording, is_recording and
depth are the interface.

Automate › Record Actions keeps recording through the journal as before.

* Engine: document trust for workbooks opened from files

Active content such as macros must not run only because a workbook was
opened. DocState gets a trust state: Local for workbooks made in the
session (new workbooks and samples) and FromFile for anything opened
with file.open. Active content in a FromFile workbook asks before it
runs. A second window on a workbook shares its trust.

The clipboard carries the trust of its source document. Pasting
formulas, formats (conditional formats hold formulas) or validation
from a FromFile workbook into another workbook makes that workbook
FromFile too; pasting values, column widths or notes carries nothing
active and changes nothing. Sheet moves and copies stay inside one
workbook, so its trust is unchanged. document.inspect reports the trust.

* Engine: workbook data in chunked hidden names (cmd/stash.rs)

GridCraft keeps workbook data in hidden defined names so it travels
inside the xlsx file. The existing items write one name holding one
string literal, which past Excel's 255-character literal and
8,192-character formula limits gives a file Excel does not accept.

cmd/stash.rs stores an item `key` as hidden workbook-scope names
_gc_<key>_0, _gc_<key>_1, ..., each a formula of string literals of at
most 255 characters (UTF-16 units, as Excel counts them) joined with &,
at most 8,192 characters per name. Reading concatenates the chunks in
order and stops at a gap; it also accepts the older single _sc_<key>
name. Names are matched case-insensitively and only at workbook scope.
Keys are limited to letters, digits, _ and ., items to 4,194,304
characters, and junk around or inside the literals, duplicate or
out-of-order chunks, non-JSON and over-deep JSON all read safely.
keys(prefix) lists the stored items that start with a prefix.

Custom views, scripts, the watch list and scenarios keep their current
storage; nothing reads or writes the new names yet.

docs/architecture.md describes the engine building blocks added in this
series: undo groups, the recording buffer, document trust and this
storage.

* Engine: a linked paste of values from a file carries the file's trust

A linked paste writes formulas even when it pastes values, but the values exception skipped the trust update, so a Local workbook stayed Local after linking to a FromFile one. The exception now applies only to unlinked pastes.

* Engine: a stash item with only later chunks reads as missing and falls back to the legacy name

With chunk _gc_k_2 but no _gc_k_0, get returned Some("") and never looked at _sc_k. It now takes the legacy path unless chunk 0 exists.

* Engine: a command that runs its own undo groups makes one undo step for itself

execute treated any change in the group epoch as 'the group owns the step'. A command that edited outside its own group, or whose group rolled back before it edited, left that edit with no undo step or only part of it. Now only a group that was open before the command (or is still open after) owns the step; a command that opened and closed its own groups replaces their steps with one entry for the command.

* Engine: a full undo history keeps one step per command that runs its own undo groups

When the history held 100 steps, group_end pushed its step and dropped the oldest one at once,
so execute's collapse compared against a length that no longer matched. A command that was one
group kept the group's step and added its own, losing two old steps. After test.editThenGroup,
the second Undo brought back the intermediate state after the outside edit.

The history limit now applies after the outermost command has collapsed its steps, and when a
group ends outside any command. A document with an open group is trimmed when the group ends.

* Engine: pasting only formats from a file leaves the destination's trust alone

Paste Special › Formats copies cell styles and merged areas. It copies no conditional formats,
formulas or validation, so it carries nothing that runs. It still marked the destination
FromFile: copying a formula cell from an opened file and pasting its formats into a new workbook
made that workbook ask before running its own active content.

Formats pastes now count as passive, like values, column widths and notes. The comment and
docs/architecture.md no longer say that pasted formats carry trust.

* Engine: a command keeps its group's name only for a group it ended itself

The record of the last group step (document, history length, epoch) outlived the Undo of that
step. A later command that opened and closed its own groups at the same history length, after
rolling one back, passed the epoch check (the record's epoch merely differed from the one the
command started with) and took the name of the nested edit it had made instead of its own.

The step must now come from a group ended during the command (an epoch past the command's
starting one), and a rollback drops a record of a step it truncated away.

* Engine: a group rolled back inside a command keeps the redo history

A command that changed the workbook cleared the redo history even when an open group owned
its step. When the group was then rolled back inside the same command (the debugger's Stop
with Undo Changes), the rollback put the redo history back and the command's bookkeeping cleared
it again. Now only a command that makes a step of its own clears the redo history; a group
clears it when it ends as a step, and a rollback restores it.

* Engine: a visible name of the item shape is not workbook data

The item store read, listed and removed every workbook-scope name of the `_gc_<key>_<n>` shape,
visible or not, so saving an item removed a person's own visible name that happened to look like
one. Items are the hidden names the store writes; a legacy `_sc_<key>` name still reads. A visible
`_gc_` name is left alone by every operation, and writing an item that would need that name is
refused before anything changes: a hidden name beside it with the same name is the one an xlsx
file drops, so the item would be lost on save.

* Engine: a command keeps its group's name only for a step that is exactly the group's

The record of the last group step held the document, the history length and a group epoch, and a command reused the group's label when the lengths matched and the epoch was newer. A direct edit after the group (no nested command, so the length still matches) then left the step named after the group alone.

The record now holds the workbook the group's step led to (a weak reference, so it keeps nothing alive). A command keeps the group's name only when the one entry it added starts from the command's own starting workbook and ends at its result, which is provenance by identity rather than by position: an entry a nested command made, a step of an earlier command undone since, and a group step the command edited past all name the step after the command. A later group that rolled back changes nothing, and the rollback no longer needs to clear the record.

* Engine: the recording buffer is bounded in bytes and keeps the start of a recording

The buffer kept at most 100,000 steps but each step keeps its full parameters, and a file.open step holds the whole file as base64, so a recording of a few opens of a large workbook kept tens of MB alive. Like the journal, the buffer now also stops at 64 MB of steps serialized as JSON. A step that does not fit is counted as dropped, and once a step is dropped every later one is too, so what the buffer holds is always how the recording began, never a sequence with gaps.

* Docs: the architecture revision row carries the date the change lands; document trust beside script trust

The header and the revision row said 2026-10-11, older than the row above them. The document trust paragraph now says how DocState::trust (provenance) differs from DocState::scripts_trusted (per-session consent for stored scripts, #2178).

* Engine: writing a stash item checks for a clashing visible name in one pass over the names

The check ran once per chunk, each a scan of every defined name: an item of many chunks in a workbook with thousands of names compared every pair. One pass now parses each visible workbook-scope name and refuses when it is a chunk of this key below the chunk count. The behaviour and its test are unchanged.

* Engine: a command's changes after ending a group, or before opening one it leaves open, are not lost or a step of their own

Two boundaries of the group rules were wrong. A command that ended a group open before it was treated as grouped whatever it did next: with an unchanged group (no step) its later edit had no undo entry at all, and with nested edits after the end the entry was the nested command's. The group's step now stays the group's, and the command makes one step of its own, under its name, for what changed after the end, from the workbook the group ended at.

A command that edited and then opened a group it leaves open (a debugger's start) left its nested entries in the history, so one Undo after the group ended brought back a state from inside the command. Such a group now takes the command's start as its own, and its step covers the command's earlier edits.

The record of where the last group ended now holds the workbook and selection it ended at, the history length then and whether it made a step; it lives until the outermost command finishes. A rollback records where it ended too, unless the document is back exactly where the last recorded step left it.

* Engine: the undo steps made inside a command are bounded while it runs

The history limit is applied once the outermost command has finished, so the lengths its bookkeeping compares stay true (round 3). That left the steps nested commands make unbounded until then: a script of up to 100,000 steps, or a subtotal with a step per group, kept every one of them alive, each with the cells it retains, and before this branch every push trimmed the history at once.

Each nested command now bounds the steps made since the latest mark on its document to the undo limit and byte budget, dropping the oldest of them. The marks are the starts of the running commands, the starts of the open groups and the end of the last group: the positions a command or group collapses its steps by when it finishes, so nothing before them moves. What the bound drops is only what the final trim would drop.

* Engine: a grouped action that closes its document succeeds

Session::grouped ended its group after the action, and when the action had closed the group's document (file.close, or file.open taking the place of an untouched new workbook) the group had gone with it, so the action was reported as failed although it had done what it was asked. A group that is gone because its document is gone now counts as ended; a group ended by someone else while the document is open is still an error.

* Docs: undo groups at a command's boundaries, and the bound on steps made inside a command

* Engine: a linked paste carries the file's trust whatever it pastes

Paste Link writes a formula into every cell whatever `what` says, so a linked
paste of formats from a file now makes the destination FromFile as a paste of
formulas does. Column widths and notes write no cells, linked or not, and
still carry nothing. Regression test for the three kinds.

* Engine: a stash chunk number with leading zeros names someone else's name

`_gc_watch_00` parsed as chunk 0, so a hidden one was read as the item's first
chunk and a visible one stopped `_gc_watch_0` from being written. Only the
chunk number as `set` writes it counts now. Regression test.

* Engine: a command that panics leaves the undo history as it found it

The panic guard restored the workbook and selection but not the history, so
a nested edit made before the panic left a no-op undo step and the redo
history it had cleared stayed lost. The guard now truncates the steps made
during the command and puts the redo history back. Regression test, and the
architecture note says what the guard restores.

* Engine: a headless call that fails for want of a file is not recorded

file.open with no file succeeds by queuing a dialog, which execute_headless
turns into an error for callers with no UI; the recording kept the step.
Recorder::truncate forgets it and keeps the byte count right. Regression test.

* Engine: a later stash chunk on its own is not a listed item

stash::keys reported a key that get read as missing, because only a later
chunk of it existed. keys now counts chunk 0 and legacy names only, as get
does. Regression assertions in the hostile-names test.

* Tests: a linked paste of column widths or notes copies its metadata and no cell

The source now carries a note and a column width, and the test checks that
the destination received them while its trust stays Local and no cell is
written.

* Engine: the panic guard restores entries a nested Undo removed

Truncating the undo history to its length at the command's start cannot
bring back an entry that a nested edit.undo popped. The guard now keeps the
undo and redo history as they were and puts both back. The regression test
gains the undo-then-panic case.

* Engine: a group a panicking command ended keeps its step

A group open before a command and ended or rolled back inside it lost its
undo step when the command then panicked: the restored history held no entry
for the group's changes, so Undo skipped the state before the group. The
panic path now pushes the outermost such group's step from its start to the
restored workbook. The group stays ended, because its token went with the
command. Regression test for a group ended and one rolled back.

* Docs: list every paste that carries a file's trust

The architecture note named formulas and validation only; it now lists the
Paste Special kinds and linked pastes that write formulas, and the kinds that
carry nothing.

* Engine: a grouped action that panics is rolled back before the panic goes on

Session::grouped ran its action without a panic guard, so a caller outside
a command that caught the panic found the group still open: Undo and Redo
disabled, the partial edits kept, and the next grouped action nested in the
abandoned group. The group is now rolled back and the panic resumed.
Regression test.

* Engine: the recorder counts a dropped step without serializing it

Once a step has been dropped, or the step count is full, push serialized
each later step only to count it; a file.open step holds a whole file. The
checks that need no size come first.

* Engine: a failed command rolls back the undo groups it opened

A command that opened an undo group and then returned an error (a `?`
between `group_begin` and `group_end`) left the group open for good:
Undo and Redo refused with "finish or stop the running action first",
later edits made no undo steps, and nothing could stop the group.

`execute` now rolls back every group a failed command opened, as
`group_rollback` does, restoring the workbook, selection, undo and
redo history to where each group began. Only a command that succeeds
keeps a group it opened, which is what a debugger's start needs.

`Session::abort_groups(doc)` and the menu-less command
`automate.stopAction` roll back the groups left open on a document,
for a debugger the person abandons or a caller that dropped its
token. The command is not undoable and works while a group is open,
and the Undo refusal now names it.

* Engine: the recorder skips a call that only opened a dialog

A menu-style call with no parameters, such as `file.open`,
`data.fromTextCsv` or `home.formatCells` with `{}`, only queues its
dialog and succeeds. The recorder kept it as a step with empty
parameters, and then kept the dialog's confirming call as well, so
playing the recording back ran the command twice, or failed on the
empty step when played back headless. Only `execute_headless` took
the step back, and only for the open and save-as dialogs.

`execute` now compares the queued UI requests before and after a
top-level command and does not record one that queued a dialog. The
special case in `execute_headless` goes (it keeps turning a missing
file into an error), and with it `Recorder::truncate`, which had no
other caller.

* Engine: stash items keep characters XML can't carry

The xlsx writer drops characters XML 1.0 can't carry: controls other
than tab, line feed and carriage return, and U+FFFE and U+FFFF. A
stash item holding one lost it on save without a warning, and so did
a JSON item, since serde_json leaves U+FFFE and U+FFFF unescaped: a
stored macro parameter changed silently.

`chunk_formulas` now writes each such character, and the marker
itself, as the noncharacter U+FDD0 followed by four hex digits, and
`get` turns them back for `_gc_` items. A marker that isn't followed
by four hex digits naming a character is read as it is. An escape
counts as five units against the 255-unit literal limit and is never
split across literals, and the guard on the stored size allows the
longest item even when every character is escaped. Legacy `_sc_`
items are read unchanged.

* Engine: list stash items in one pass over the names

Each `stash::get` looks at every defined name, so listing the items
with `keys` and then reading each with `get` took time quadratic in
the number of names. A file with 20,000 hidden `_gc_macro_<i>_0`
names took about ten seconds in a debug build, and the macro list
reads its items this way from data that comes from the file.

`stash::items(wb, prefix)` now groups the names by item in one pass
and returns each item's text by key, and `keys` lists from the same
pass. Both return at most `MAX_ITEMS` (10,000) items, the first keys
in sorted order, so a file with any number of hidden names can't make
a listing unbounded. A test counts the names each listing looks at.

* Engine: the name commands leave GridCraft's data names alone

`formulas.defineName {"name": "_gc_macro_1_0"}` replaced the hidden
chunk of a stored item with a visible name: the item vanished from
`stash::get` and `stash::keys`, and its other chunks stayed behind as
orphan hidden names. `formulas.deleteName` could remove chunks the
same way, and `formulas.createFromSelection` with `replace` could
replace one from a cell label. `stash::set` refused a clash with a
visible name, but nothing guarded the other direction.

Defining, renaming or deleting a name that `stash::item_key`
recognises (`_gc_<key>_<n>` and legacy `_sc_<key>`) is now refused
with an error that names `force: true`, which lets the call go ahead.
Create from Selection lists such labels in `skipped` unless `force`
is true. The params docs of the three commands say so.

* Engine: queries take no copy of the undo history

`execute` copied the document's undo and redo history on every call,
so a panic could put it back: up to 100 entries per stack, each with
a label and a selection. Queries paid for it too, although no query
runs a command or changes the history, and front ends and agents run
queries all the time.

The copy is now skipped for queries (commands that neither journal
nor make undo steps). A test runs every query, with empty and with
typical parameters, and checks the history is unchanged, so a query
that ever changes it fails there. Commands that can change the
history, nested ones included, still take the copy: a nested command
that panics is restored at its own level, and the outer command can
carry on, so relying on the outermost copy would weaken that restore.

* Engine: a document's maker says where its content came from

`Trust` defaulted to `Local`, and `DocState::new` set it, so a
document was trusted unless its maker remembered to mark it. Every
current path that reads a file goes through `file.open`, which marks
it `FromFile`, but a later way of building a document from bytes
(recovery, templates, a new import) would have come out trusted by
omission.

`DocState::new` now takes the `Trust`, and `Trust` has no default.
New workbooks and samples pass `Local`, `file.open` passes
`FromFile`, a new window passes the trust of its workbook, and the
tests that build documents from bytes pass `FromFile`.

* Engine: the name commands refuse only names that hold GridCraft's data

The guard refused any name of the stash's shape, so a visible name a person
had defined, such as _gc_rate_0, could not be edited or deleted from Name
Manager, which cannot pass force. It now refuses a name only when the
workbook holds a hidden workbook-scope item under it; a visible name of the
shape is the person's own, as the stash itself treats it. The test covers
such a name. The query comment says what the copy skip relies on: a query
runs nothing that changes the history.

* UI: the toolbar's Undo and Redo follow the commands' own availability

The ribbon enabled them from the history lengths alone, so while an undo
group was open on the document (an action spanning several calls) a click
ran edit.undo and got its refusal. The buttons now ask cmd::can_undo and
cmd::can_redo, the undo list is empty while a group is open, and the two
buttons carry a name and their enabled state for assistive technology. A UI
test opens a group, clicks both and checks the history and their state.

* Engine: the name commands use the stash's own rule for what holds its data

The guard asked for a hidden name, but the stash reads a legacy _sc_<key>
name whether it is hidden or not, so a visible one could be removed or
replaced without force. stash::holds_item is the one rule (item_of), shared
by the guard; the test covers a visible legacy name.

* Docs: the panic guard's note says what happens to a group the command ended

It keeps a step from the group's start to the restored workbook, so Undo
still returns to the state before the group; the note said the history came
back exactly.
2026-10-24 02:06:44 -04:00

599 lines
24 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 command registry. Ids follow Excel's ribbon (`home.bold`, `insert.chart`,
//! `data.sortAscending`) plus primitive editing commands (`cell.set`, `selection.set`).
pub mod analysis;
pub mod data;
pub mod draw;
pub mod edit;
pub mod extra;
pub mod file;
pub mod forecast;
pub mod format;
pub mod formulas;
pub mod insert;
pub mod inspect;
pub mod pivot;
pub mod print;
pub mod review;
pub mod sheet;
pub mod slicer;
pub mod solver;
pub mod spelling;
pub mod stash;
pub mod view;
pub mod whatif;
use gridcraft_calc::Key;
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_model::Workbook;
use serde::Serialize;
use serde_json::Value as Json;
use crate::{DocState, EngineError, Result, Selection, Session};
pub type Run = fn(&mut Session, &Json) -> Result<Json>;
pub type Enabled = fn(&Session) -> std::result::Result<(), String>;
pub struct CommandSpec {
pub id: &'static str,
pub label: &'static str,
/// Ribbon/menu placement, e.g. `["Home", "Font"]`. Empty = not in menus.
pub menu: &'static [&'static str],
pub shortcut: Option<&'static str>,
pub params: &'static str,
pub enabled: Enabled,
pub run: Run,
pub journal: bool,
pub undoable: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct CommandInfo {
pub id: &'static str,
pub label: &'static str,
pub menu: Vec<&'static str>,
pub shortcut: Option<&'static str>,
pub params: &'static str,
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub disabled_reason: Option<String>,
}
impl CommandSpec {
pub fn info(&self, s: &Session) -> CommandInfo {
let e = (self.enabled)(s);
CommandInfo {
id: self.id,
label: self.label,
menu: self.menu.to_vec(),
shortcut: self.shortcut,
params: self.params,
enabled: e.is_ok(),
disabled_reason: e.err(),
}
}
}
/// Params that hold a secret: `password`, or a name ending in `Password`.
pub fn is_secret_param(key: &str) -> bool {
key == "password" || key.ends_with("Password")
}
/// The params as the journal records them: secret params are left out, so a password never
/// reaches the journal dialog, `engine.journal` or a recorded script (saved in the workbook).
/// Replaying such an entry runs the command without that param.
pub fn journal_params(params: &Json) -> Json {
match params {
Json::Object(o) => Json::Object(o.iter().filter(|(k, _)| !is_secret_param(k)).map(|(k, v)| (k.clone(), v.clone())).collect()),
other => other.clone(),
}
}
pub fn always(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
pub fn has_doc(s: &Session) -> std::result::Result<(), String> {
s.active().map(|_| ()).ok_or_else(|| "no workbook open".into())
}
pub fn can_undo(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
not_grouping(s)?;
if s.active().is_some_and(|d| !d.undo.is_empty()) { Ok(()) } else { Err("nothing to undo".into()) }
}
pub fn can_redo(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
not_grouping(s)?;
if s.active().is_some_and(|d| !d.redo.is_empty()) { Ok(()) } else { Err("nothing to redo".into()) }
}
/// Undo and Redo wait while an undo group is open on the active document (an action that spans
/// several calls is still running).
fn not_grouping(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|d| s.in_group(d.uid)) { Err("finish or stop the running action first (automate.stopAction)".into()) } else { Ok(()) }
}
pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.clipboard.is_some() { Ok(()) } else { Err("the clipboard is empty".into()) }
}
macro_rules! cmd {
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: true }
};
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, undoable: false }
};
(noundo $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: false }
};
}
pub(crate) use cmd;
pub fn command_specs() -> &'static [CommandSpec] {
static SPECS: std::sync::OnceLock<Vec<CommandSpec>> = std::sync::OnceLock::new();
SPECS.get_or_init(|| {
let mut v = Vec::new();
v.extend(file::specs());
v.extend(edit::specs());
v.extend(format::specs());
v.extend(sheet::specs());
v.extend(insert::specs());
v.extend(data::specs());
v.extend(formulas::specs());
v.extend(review::specs());
v.extend(view::specs());
v.extend(inspect::specs());
v.extend(extra::specs());
v.extend(draw::specs());
v.extend(spelling::specs());
v.extend(solver::specs());
v.extend(pivot::specs());
v.extend(slicer::specs());
v.extend(whatif::specs());
v.extend(forecast::specs());
v.extend(analysis::specs());
v.extend(print::specs());
v
})
}
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
if let Some(spec) = command_specs().iter().find(|c| c.id == id) {
return Some(spec);
}
// Commands registered by UI integration tests (pending keytips go live
// the moment their command lands, with no table edit).
test_commands().iter().find(|c| c.id == id).copied()
}
static TEST_COMMANDS: std::sync::Mutex<Vec<&'static CommandSpec>> = std::sync::Mutex::new(Vec::new());
/// Test-only: register an extra command so UI integration tests can prove a pending
/// keytip becomes live the moment its command lands (see crates/ui-egui/tests/keytips.rs).
/// Not part of the public API; harmless no-op for production callers who never call it.
#[doc(hidden)]
pub fn register_test_command(id: &'static str, run: Run) {
// Leaked intentionally: test commands live for the whole process.
let spec: &'static CommandSpec = Box::leak(Box::new(CommandSpec {
id,
label: id,
menu: &[],
shortcut: None,
params: "",
enabled: |_| Ok(()),
run,
journal: true,
undoable: false,
}));
test_commands().push(spec);
}
/// Test-only: an undoable command, to exercise how `execute` records its step.
#[cfg(test)]
pub(crate) fn register_undoable_test_command(id: &'static str, run: Run) {
let spec: &'static CommandSpec = Box::leak(Box::new(CommandSpec {
id,
label: id,
menu: &[],
shortcut: None,
params: "",
enabled: |_| Ok(()),
run,
journal: false,
undoable: true,
}));
test_commands().push(spec);
}
fn test_commands() -> std::sync::MutexGuard<'static, Vec<&'static CommandSpec>> {
#[allow(clippy::unwrap_used)] // a poisoned test registry has no meaningful recovery
match TEST_COMMANDS.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
// ---------------------------------------------------------------- param helpers
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
pub(crate) fn str_param<'a>(p: &'a Json, key: &str) -> Option<&'a str> {
p.get(key).and_then(Json::as_str)
}
pub(crate) fn f64_param(p: &Json, key: &str) -> Option<f64> {
p.get(key).and_then(Json::as_f64)
}
pub(crate) fn bool_param(p: &Json, key: &str) -> Option<bool> {
p.get(key).and_then(Json::as_bool)
}
pub(crate) fn u32_param(p: &Json, key: &str) -> Option<u32> {
p.get(key).and_then(Json::as_u64).map(|v| v.min(u32::MAX as u64) as u32)
}
/// An object anchor mode from Excel's wording or the model's: `"moveAndSize"`/`"twoCell"`,
/// `"moveOnly"`/`"oneCell"`, `"absolute"`/`"don't move or size"` (case-insensitive).
pub(crate) fn anchor_mode_param(p: &Json) -> Option<gridcraft_model::AnchorMode> {
parse_anchor_mode(str_param(p, "mode").or_else(|| str_param(p, "anchorMode"))?)
}
pub(crate) fn parse_anchor_mode(s: &str) -> Option<gridcraft_model::AnchorMode> {
match s.to_ascii_lowercase().replace([' ', '-', '_', '\''], "").as_str() {
"moveandsize" | "moveandsizeandcells" | "twocell" => Some(gridcraft_model::AnchorMode::MoveAndSize),
"moveonly" | "movebutdontsize" | "movebutdontsizewithcells" | "onecell" => Some(gridcraft_model::AnchorMode::MoveOnly),
"absolute" | "dontmoveorsize" | "dontmoveorsizewithcells" | "none" => Some(gridcraft_model::AnchorMode::Absolute),
_ => None,
}
}
/// The wire name of an anchor mode (matches the `mode` param of [`anchor_mode_param`]).
pub(crate) fn anchor_mode_name(m: gridcraft_model::AnchorMode) -> &'static str {
match m {
gridcraft_model::AnchorMode::MoveAndSize => "moveAndSize",
gridcraft_model::AnchorMode::MoveOnly => "moveOnly",
gridcraft_model::AnchorMode::Absolute => "absolute",
}
}
pub(crate) fn ok() -> Result<Json> {
Ok(Json::Null)
}
/// A range parameter (`"A1:B2"`, `"Sheet2!A1"`, `"B:B"`), defaulting to the current selection's
/// areas.
pub(crate) fn target_ranges(s: &Session, p: &Json) -> Result<Vec<RangeRef>> {
if let Some(r) = str_param(p, "range").or_else(|| str_param(p, "cell")) {
let (_, body) = split_sheet(r);
let mut out = Vec::new();
for part in body.split(',') {
out.push(RangeRef::parse(part).ok_or_else(|| bad("range", format!("not a range: `{part}`")))?);
}
return Ok(out);
}
Ok(s.doc()?.selection.ranges.clone())
}
/// A `rows`/`cols` parameter: `3:5` and `B:C`, or a single `3` or `B`.
pub(crate) fn parse_lines(t: &str) -> Option<RangeRef> {
RangeRef::parse(t).or_else(|| if t.contains(':') { None } else { RangeRef::parse(&format!("{t}:{t}")) })
}
pub(crate) fn target_range(s: &Session, p: &Json) -> Result<RangeRef> {
Ok(target_ranges(s, p)?.first().copied().unwrap_or_default())
}
/// The sheet a call names (`sheet` param or a `Sheet!` prefix on `range`), else the active one.
pub(crate) fn target_sheet(s: &Session, p: &Json) -> Result<usize> {
let d = s.doc()?;
if let Some(v) = p.get("sheet") {
if let Some(i) = v.as_u64() {
return if (i as usize) < d.wb.sheets.len() { Ok(i as usize) } else { Err(bad("sheet", "no such sheet")) };
}
if let Some(n) = v.as_str() {
return d.wb.sheet_index(n).ok_or_else(|| bad("sheet", format!("no sheet named `{n}`")));
}
}
if let Some(r) = str_param(p, "range").or_else(|| str_param(p, "cell"))
&& let (Some(sh), _) = split_sheet(r)
{
return d.wb.sheet_index(&sh).ok_or_else(|| bad("range", format!("no sheet named `{sh}`")));
}
Ok(d.wb.active_sheet)
}
pub(crate) use gridcraft_core::split_sheet;
/// A single-cell parameter. Absent or null is `Ok(None)` (callers default it); a value that is not
/// a cell reference, or that names a sheet other than the active one, is an error rather than a
/// silent fallback to some other cell.
pub(crate) fn cell_param(s: &Session, p: &Json, key: &str) -> Result<Option<CellRef>> {
cell_param_on(s, p, key, s.doc()?.wb.active_sheet)
}
/// Like [`cell_param`], for a command that operates on `sheet` (a `Sheet!` prefix must name it).
pub(crate) fn cell_param_on(s: &Session, p: &Json, key: &str, sheet: usize) -> Result<Option<CellRef>> {
let t = match p.get(key) {
None | Some(Json::Null) => return Ok(None),
Some(Json::String(t)) => t,
Some(v) => return Err(bad("cell", format!("invalid cell reference `{v}` for `{key}`"))),
};
let (sh, body) = split_sheet(t);
let c = CellRef::parse(body).ok_or_else(|| bad("cell", format!("invalid cell reference `{t}` for `{key}`")))?;
if let Some(name) = sh
&& s.doc()?.wb.sheet_index(&name) != Some(sheet)
{
return Err(bad("cell", format!("`{t}` for `{key}` names a sheet other than the one this command works on")));
}
Ok(Some(c))
}
/// Applies an edit to a copy of the active workbook, then recalculates and commits it.
/// `changed` collects cells whose content changed; set `structural` for row/column/sheet edits
/// (full recalc).
pub struct Ctx<'a> {
pub wb: Workbook,
pub changed: Vec<Key>,
pub structural: bool,
pub sel: &'a mut Selection,
/// Rows whose automatic height should be recomputed (sheet, row).
pub fit_rows: Vec<(usize, u32)>,
/// The command applied sheet protection itself (row/column inserts and deletes the
/// protection allows), so cells it shifts aren't refused as edits of locked cells.
pub protection_checked: bool,
}
pub(crate) fn edit<R>(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result<R>) -> Result<R> {
let d = s.doc_mut()?;
commit(d, |cx| {
let r = f(cx)?;
crate::tables::sync_headers(cx);
Ok(r)
})
}
pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>) -> Result<R> {
// `GRIDCRAFT_PROFILE` timing. Read the clock only when profiling, and never on wasm: there
// `Instant::now` traps (`RuntimeError: unreachable`), which broke every edit in the web app.
#[allow(clippy::disallowed_methods)]
let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| std::time::Instant::now()) };
let mut sel = d.selection.clone();
let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new(), protection_checked: false };
let r = f(&mut ctx)?;
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, .. } = ctx;
if !protection_checked {
check_protection(&d.wb, &wb, &d.unlocked_ranges)?;
}
if !structural {
check_arrays(&d.wb, &wb)?;
}
if changed.len() <= 200_000 {
fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));
}
if let Some(t0) = t0 {
eprintln!("commit: edit {:?}, {} changed", t0.elapsed(), changed.len());
}
if structural {
d.calc.recalc_all(&mut wb);
} else if !changed.is_empty() {
d.calc.cells_changed(&mut wb, &changed);
}
if let Some(t0) = t0 {
eprintln!("commit: + recalc {:?}", t0.elapsed());
}
fit_rows.sort_unstable();
fit_rows.dedup();
for (si, row) in fit_rows {
auto_row_height(&mut wb, si, row);
}
if let Some(t0) = t0 {
eprintln!("commit: + rows {:?}", t0.elapsed());
}
d.wb = std::sync::Arc::new(wb);
d.selection = sel;
Ok(r)
}
/// Excel's message for an edit of a locked cell on a protected sheet.
pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet.";
/// Refuses an edit that changed what a locked cell on a protected sheet holds (its value or
/// formula), whichever command made it. Sheets are matched by name, so moving, renaming or
/// deleting a sheet isn't an edit of its cells.
fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)]) -> Result<()> {
for sh in &old.sheets {
if !sh.is_protected() {
continue;
}
let Some(after) = new.sheet_index(&sh.name).and_then(|i| new.sheets.get(i)) else { continue };
if std::sync::Arc::ptr_eq(sh, after) || !after.is_protected() {
continue;
}
for c in sh.cells.diff(&after.cells) {
if !same_content(sh.cell(c), after.cell(c))
&& old.styles.get(sh.style_id(c)).protection.locked
&& let Some(m) = locked_cell_error(sh, c, unlocked)
{
return Err(EngineError::Other(m));
}
}
}
Ok(())
}
/// Refuses an action a protected sheet does not allow, by the tick box for it in Review ▸ Protect
/// Sheet. `what` names the action in the message. An unprotected sheet allows everything.
///
/// Cell edits are guarded separately, by whether the cell is locked (`locked_cell_error`): these
/// permissions are the ones that are not about one cell's contents.
pub(crate) fn refuse_unless_allowed(s: &Session, sheet: usize, what: &str, pick: fn(&gridcraft_model::SheetProtection) -> bool) -> Result<()> {
if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pick(pr))) {
return Err(EngineError::Other(format!("{what} isn't allowed on this protected sheet (Review › Unprotect Sheet).")));
}
Ok(())
}
/// Whether a locked cell on a protected sheet stays locked: `None` when it's in an Allow Edit
/// Range without a password or one unlocked in this session, else the message to show.
pub(crate) fn locked_cell_error(sh: &gridcraft_model::Sheet, c: CellRef, unlocked: &[(String, String)]) -> Option<String> {
let mut needs_password = None;
for r in sh.edit_ranges.iter().filter(|r| r.contains(c)) {
if r.password.is_none() || unlocked.iter().any(|(s, t)| *s == sh.name && *t == r.title) {
return None;
}
needs_password.get_or_insert(r.title.as_str());
}
Some(match needs_password {
Some(t) => format!("The range \"{t}\" needs its password before it can be changed (Review › Allow Edit Ranges)."),
None => PROTECTED.into(),
})
}
/// Refuses an edit that put something into a cell of a multi-cell legacy array other than its
/// formula cell, while the array stays (Excel: "You can't change part of an array."). The array
/// would show its value there while the file kept the new content. Cells shifted by row and
/// column edits aren't edits of their content, so structural commits aren't checked.
fn check_arrays(old: &Workbook, new: &Workbook) -> Result<()> {
for sh in &old.sheets {
if sh.spill.is_empty() {
continue;
}
let Some(after) = new.sheet_index(&sh.name).and_then(|i| new.sheets.get(i)) else { continue };
if std::sync::Arc::ptr_eq(sh, after) {
continue;
}
for c in sh.cells.diff(&after.cells) {
let filled = after.cell(c).is_some_and(|x| x.formula.is_some() || !x.value.is_empty());
if !filled || !sh.spill.contains_key(&c) || same_content(sh.cell(c), after.cell(c)) {
continue;
}
let in_array = sh.spill_ranges.keys().any(|&anchor| {
anchor != c
&& after.cell(anchor).and_then(|x| x.formula.as_ref()).and_then(|f| f.array).is_some_and(|a| a.start == anchor && a.contains(c))
});
if in_array {
return Err(EngineError::Other("You can't change part of an array.".into()));
}
}
}
Ok(())
}
/// Two cells hold the same value or formula (formats aside).
fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::Cell>) -> bool {
let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| c.formula.is_none() && c.value.is_empty());
match (a, b) {
(Some(a), Some(b)) => match (&a.formula, &b.formula) {
(Some(x), Some(y)) => x.text == y.text && x.array == y.array,
(None, None) => a.value == b.value,
_ => false,
},
_ => empty(a) && empty(b),
}
}
/// Lines a single text line needs when word-wrapped into `width` points. The grid wraps whole
/// words (egui's LayoutJob), so dividing the total width by the column width undercounts: each
/// drawn line ends early at the last word that fits. A word wider than the column breaks
/// mid-word, the way egui does. (#1969)
fn wrapped_line_count(line: &str, width: f32, size: f32, bold: bool) -> f32 {
let word_w = |w: &str| crate::cmd::format::approx_text_width(w, size, bold);
let space_w = word_w(" ");
let mut lines = 1.0f32;
let mut used = 0.0f32;
for word in line.split_whitespace() {
let w = word_w(word);
if w > width {
// wider than the column: egui fills the current line, then breaks it across lines
if used > 0.0 {
lines += 1.0;
}
let chunks = (w / width).ceil();
lines += chunks - 1.0;
used = w - (chunks - 1.0) * width;
continue;
}
let gap = if used > 0.0 { space_w } else { 0.0 };
if used + gap + w > width {
lines += 1.0;
used = w;
} else {
used += gap + w;
}
}
lines
}
/// Recomputes an automatic row height from its content: the largest font, and wrapped text.
/// Rows with a user-set height keep it.
///
/// Line breaks in a cell's text count only when the cell wraps, as in Excel: with Wrap Text off
/// a multi-line value shows on one line and AutoFit gives a one-line row.
pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
let Some(sh) = wb.sheet(si) else { return };
if sh.rows.get(&row).is_some_and(|i| i.custom) {
return;
}
let default = sh.default_row_height;
// Fast path: rows of default-size, unwrapped content keep the default height.
let plain = sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1).all(|(_, cell)| {
let st = wb.styles.get(cell.style);
st.font.size <= gridcraft_model::DEFAULT_FONT_SIZE && !st.align.wrap
});
if plain {
if sh.rows.get(&row).is_some_and(|i| i.size.is_some())
&& let Some(shm) = wb.sheet_mut(si)
&& let Some(e) = shm.rows.get_mut(&row)
{
{
e.size = None;
if *e == gridcraft_model::LineInfo::default() {
shm.rows.remove(&row);
}
}
}
return;
}
let mut need: f32 = default;
for (c, cell) in sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1) {
// Merged cells never auto-fit their rows, like Excel (column autofit skips them too).
if sh.merge_at(CellRef::new(row, c)).is_some_and(|m| !m.is_single()) {
continue;
}
let st = wb.styles.get(cell.style);
let size = st.font.size;
// Scaled from an 11pt row's height, not the sheet's default: a sheet whose default
// already fits a bigger font (21pt rows for 16pt text) would otherwise scale it twice.
let line = (size * 1.8).round().max(gridcraft_model::DEFAULT_ROW_HEIGHT * size / gridcraft_model::DEFAULT_FONT_SIZE);
let mut lines = 1.0f32;
if !cell.value.is_empty() || cell.formula.is_some() {
// Only a wrapping cell is laid out over several lines: each explicit line break
// starts one, and each line folds at the column width.
if st.align.wrap {
let text = crate::display::cell_text(wb, sh, CellRef::new(row, c));
let w = sh.col_width(c).max(1.0) - 6.0;
lines = text.lines().map(|l| wrapped_line_count(l, w.max(1.0), size, st.font.bold)).sum::<f32>().max(1.0);
}
} else if st.font.size <= gridcraft_model::DEFAULT_FONT_SIZE {
continue;
}
let h = if lines > 1.0 { (size * 96.0 / 72.0 * 1.22 * lines + 5.0).ceil() } else { line };
need = need.max(h.min(546.0));
}
let Some(shm) = wb.sheet_mut(si) else { return };
let e = shm.rows.entry(row).or_default();
e.size = if (need - default).abs() < 0.5 { None } else { Some(need) };
if *e == gridcraft_model::LineInfo::default() {
shm.rows.remove(&row);
}
}
impl Ctx<'_> {
pub fn sheet_index(&self) -> usize {
self.wb.active_sheet
}
pub fn sheet_mut(&mut self, i: usize) -> Result<&mut gridcraft_model::Sheet> {
self.wb.sheet_mut(i).ok_or_else(|| EngineError::Other("no such sheet".into()))
}
pub fn touch(&mut self, sheet: usize, c: CellRef) {
self.changed.push((sheet, c));
}
}