Files
vectorcraft/crates/engine/src/session/execute.rs
T
dotCooCoo 74863fa778 Engine: execute refuses keys a command doesn't name, in every build
Session::execute ran check_params only in the engine's own tests, so a
direct execute in a release build ran a misspelled key with the
command's default. It now checks every call, the commands a command runs
included, as MCP and the control port already did. A command.batch or
an action.batch is checked before its first step runs, and each step is
checked again as it runs, so an effect.setParams step is checked
against the effects the steps before it added.

Saved steps play through Session::replay, which takes keys a command no
longer reads, so an action recorded by an older version still plays and
Session::replay still plays an older journal. The Actions panel's Play
(Accelerated, Step by Step and Pause For, its function keys and
ui.action.play) and the Batch dialog use it.

Docs: the cmd/params.rs module doc, the Parameter keys section of
docs/mcp.md and a line in docs/target-app-parity.md describe both
paths.
2026-11-08 11:22:28 +07:00

288 lines
14 KiB
Rust

use super::{
CommandSpec, EngineError, NodeId, Result, SELECTS_GIVEN_IDS, Selection, Session, Value, cmd, find_command, given_ids, guard, strip_replay_only,
};
impl Session {
/// `spec`'s `enabled` with `ids` selected instead of the selection, which is then put back.
fn enabled_with_selection(&mut self, spec: &CommandSpec, ids: Vec<NodeId>) -> std::result::Result<(), String> {
let Some(d) = self.active_mut() else { return (spec.enabled)(self) };
let old = std::mem::replace(&mut d.selection, Selection { objects: ids, ..Selection::default() });
let r = (spec.enabled)(self);
if let Some(d) = self.active_mut() {
d.selection = old;
}
r
}
/// Error, under the command's own id, at the first of `ids` that is malformed, names no node, or
/// names a layer or sublayer: a command in [`SELECTS_GIVEN_IDS`] acts on objects.
fn check_given_ids(&self, id: &str, params: &Value) -> Result<()> {
let (Some(st), Some(ids)) = (self.active(), params.get("ids")) else { return Ok(()) };
let Some(given) = ids.as_array() else { return cmd::checked_ids_param(self, params, "ids", id).map(drop) };
for value in given {
let node = cmd::checked_id(self, value, id)?;
if st.doc.node(node).is_some_and(|n| n.is_layer()) {
return Err(if id.starts_with("object.arrange.") {
cmd::bad(id, format!("id {value} is a layer, not an object; move layers with layer.move"))
} else {
cmd::bad(id, format!("id {value} is a layer, not an object"))
});
}
}
Ok(())
}
/// Execute a command by id. This is THE entry point for every frontend.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| cmd::params::unknown_command(id))?;
// A key the command's params description doesn't name is a parameter error (#1442), before
// anything runs, for the commands other commands run too. A `command.batch` or an
// `action.batch` checks its steps before the first one runs, and each step is checked again
// as it runs, against the document the steps before it left (the effects `effect.setParams`
// edits). Saved steps played with `replay` aren't checked.
if !self.replaying {
self.check_params(id, params)?;
}
// Objects given as `id` (where it stands for `ids` of one) or as `ids` beside it are checked
// before anything else: a malformed one, or one naming no object, is an error under this
// command's id, never the selection in its place. `effect.apply {id: "stylize.dropShadow"}`
// without `effect` names the effect (its older key), not an object.
let effect_named = id == "effect.apply"
&& params.get("effect").is_none()
&& params.get("ids").is_none_or(Value::is_null)
&& params.get("id").and_then(Value::as_str).is_some_and(|e| !e.starts_with('$'));
if cmd::params::id_stands_for_ids(spec.params) && !effect_named && self.active().is_some() {
cmd::given_objects(self, params, id)?;
}
if SELECTS_GIVEN_IDS.contains(&id) {
self.check_given_ids(id, params)?;
}
// Clear's menu needs a selection; an agent supplying explicit targets only needs a
// document. The command validates those targets before making any edit.
let given = given_ids(spec.params, params);
let enabled = if id == "edit.clear" && params.get("ids").is_some() {
cmd::has_doc(self)
} else if let Some(ids) = given.clone() {
// Objects given as `ids` (or `id`) stand in for the selection: the command is enabled
// as it is with them selected (#1575).
self.enabled_with_selection(spec, ids)
} else {
(spec.enabled)(self)
};
if let Err(why) = enabled {
return Err(EngineError::Disabled(id.to_string(), why));
}
// Only top-level commands are journaled (commands that call other commands would otherwise
// be recorded twice and replay differently).
if self.depth == 0 {
self.journal_note.clear();
self.note_depth = 1;
self.journal_query = false;
self.batch_stash = None;
self.batch_steps = None;
}
// A selection-only command given `ids` (or `id`) selects them first, in the same undo step
// (#2130); when the command then fails, the selection (and the current layer selecting
// them made current) goes back to how it was, as when the ids are refused.
let select_first = SELECTS_GIVEN_IDS
.contains(&id)
.then(|| {
params.get("ids").filter(|v| !v.is_null()).cloned().or_else(|| given.map(|ids| ids.iter().map(|i| i.0).collect::<Vec<_>>().into()))
})
.flatten();
let selected_before = select_first.as_ref().and(self.active()).map(|d| (d.uid, d.selection.clone(), d.active_layer));
let run = |s: &mut Session| {
if let Some(ids) = &select_first {
s.execute("select.set", &serde_json::json!({ "ids": ids }))?;
}
let r = (spec.run)(s, params);
if r.is_err()
&& let Some((uid, selection, layer)) = selected_before
&& let Some(st) = s.docs.iter_mut().find(|d| d.uid == uid)
{
st.selection = selection;
st.active_layer = layer;
st.revision += 1;
}
r
};
// The entries the command journals belong to the undo groups of the documents it edits, and to
// the group of the document it starts in when it ends there too.
let (doc, journal, grouped) = (self.active().map(|d| d.uid), self.journal.len(), self.undo_group_docs());
let open = (self.depth == 0).then(|| self.docs.iter().map(|d| d.uid).collect::<Vec<_>>());
let r = if self.depth == 0 { self.run_guarded(id, run) } else { self.run_nested(run) };
if r.is_err() && self.depth == 0 {
self.batch_paint_docs.clear();
}
let r = r?;
if self.depth == 0 {
// A color-mode history step restores exact new-art colors; selection inheritance
// must not immediately replace that restoration.
let restored = self.active().is_some_and(|d| match id {
"edit.undo" => d.history.redo.last().is_some_and(|e| e.color_mode_paints.is_some()),
"edit.redo" => d.history.undo.last().is_some_and(|e| e.color_mode_paints.is_some()),
_ => false,
});
if !restored {
self.inherit_new_art();
}
for uid in std::mem::take(&mut self.batch_paint_docs) {
if let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid)
&& let Some(e) = st.history.undo.last_mut()
&& let Some((_, after)) = &mut e.color_mode_paints
{
after.clone_from(&self.paint);
e.recost_paints();
st.history.trim();
}
}
}
if let Some(open) = open {
self.open_undo_groups_in_new_documents(&open);
}
let journaled = spec.journal || (self.depth == 0 && std::mem::take(&mut self.journal_query));
if journaled && self.depth == 0 && self.active().is_none_or(|d| d.interaction.is_none()) {
let p = self.noted(params);
self.journal.push((id.to_string(), p));
}
if self.depth == 0 {
self.journal_in_undo_groups(doc, &grouped, journal);
self.after_command(Some(id));
} else if matches!(id, "edit.undo" | "edit.redo") {
// A step of `command.batch`: the tool hears of it as it does of the command alone.
self.notify_text_tool(id);
}
Ok(r)
}
/// Is the interface in Japanese? Then new type starts with em box top-to-top leading and em
/// box centre character alignment (#432).
pub fn japanese_interface(&self) -> bool {
self.ui_language.as_deref().unwrap_or(&self.prefs.interface_language).eq_ignore_ascii_case("ja")
}
/// Record `key: value` in the running top-level command's journal entry (or its interaction's
/// preview, or its step in a batch) unless its params give `key`: a value it resolved from the
/// preferences or the clock.
pub fn note_journal(&mut self, key: &str, value: Value) {
if self.depth == self.note_depth {
self.journal_note.insert(key.to_string(), value);
}
}
/// Journal the running query command when it is the top-level command: it changed the
/// selection (`document.find {select: true}`), so an action recorded around it repeats that.
pub(crate) fn journal_query(&mut self) {
if self.depth == 1 {
self.journal_query = true;
}
}
/// Run `id` as a step of the running command, which journals its steps (`command.batch`):
/// → the step's result and its params with what it noted ([`Session::note_journal`]), for the
/// step in the running command's journal entry.
pub(crate) fn execute_step(&mut self, id: &str, params: &Value) -> Result<(Value, Value)> {
let outer = (std::mem::take(&mut self.journal_note), self.note_depth);
self.note_depth = self.depth + 1;
let r = self.execute(id, params);
let noted = self.noted(params);
(self.journal_note, self.note_depth) = outer;
Ok((r?, noted))
}
/// [`Session::execute`] for saved steps: a journal entry or an action's steps, recorded by this
/// version or an older one. Keys a command's params description doesn't name (a key it no longer
/// reads, an effect parameter it no longer takes) are taken, by the commands it runs too, so what
/// was recorded still plays (#1442). Everything else is checked as `execute` checks it.
pub fn replay(&mut self, id: &str, params: &Value) -> Result<Value> {
let outer = self.set_replaying(true);
let r = self.execute(id, params);
self.set_replaying(outer);
r
}
/// Whether [`Session::execute`] plays saved steps as [`Session::replay`] does. A frontend that
/// runs saved steps through its own path to `execute` sets it around that call, and puts back the
/// value this returns, which it had before.
pub fn set_replaying(&mut self, on: bool) -> bool {
std::mem::replace(&mut self.replaying, on)
}
/// Whether saved steps are playing ([`Session::set_replaying`]).
pub fn replaying(&self) -> bool {
self.replaying
}
/// The journal from entry `start` on, as an action records it: without the params that only
/// pin a replay to the original run ([`REPLAY_ONLY`]), so playing the action later acts now.
pub fn journal_for_action(&self, start: usize) -> Vec<(String, Value)> {
self.journal
.since(start)
.iter()
.cloned()
.map(|(id, mut p)| {
strip_replay_only(&id, &mut p);
(id, p)
})
.collect()
}
/// `params` with the noted values added (see [`Session::note_journal`]).
pub(super) fn noted(&mut self, params: &Value) -> Value {
let mut note = std::mem::take(&mut self.journal_note);
match params {
// The params' own values win.
Value::Object(m) if !note.is_empty() => {
note.extend(m.clone());
Value::Object(note)
}
Value::Null if !note.is_empty() => Value::Object(note),
_ => params.clone(),
}
}
pub(crate) fn run_nested(&mut self, f: impl FnOnce(&mut Self) -> Result<Value>) -> Result<Value> {
self.depth += 1;
let r = f(self);
self.depth -= 1;
r
}
/// A top-level command: a panic (a bug) becomes [`EngineError::Internal`] and every open
/// document goes back to how it was before the command (its art, selection, interaction and
/// undo history), and the same document stays active, instead of crashing the frontend.
pub(crate) fn run_guarded(&mut self, id: &str, f: impl FnOnce(&mut Self) -> Result<Value>) -> Result<Value> {
let snapshot: Vec<_> =
self.docs.iter().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.interaction.clone(), d.history.clone())).collect();
let active = self.active().map(|d| d.uid);
let r = match guard::catch_panic(|| self.run_nested(f)) {
Ok(r) => r,
Err(msg) => {
self.depth = 0;
self.journal_note.clear();
for (uid, doc, selection, interaction, history) in snapshot {
// A document the command closed stays closed.
let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid) else { continue };
st.doc = doc;
st.selection = selection;
st.interaction = interaction;
st.history = history;
cmd::globaledit::follow_history(st);
st.drop_stale_variables_highlight();
st.revision += 1;
}
if let Some(i) = active.and_then(|uid| self.docs.iter().position(|d| d.uid == uid)) {
self.active = Some(i);
}
Err(EngineError::Internal { cmd: id.to_string(), msg })
}
};
// Undo, Redo and a failed batch bring back documents with their selections: each document
// gets its selection's new art on a locked artboard.
for st in &mut self.docs {
st.sync_new_art();
}
r
}
}