mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Undo groups: an open undo group also groups the edits in a document opened or created before its end
A document opened or created between edit.undoGroup.begin and edit.undoGroup.end opens an undo group of its own with the begun group's name, so an atomic command_batch that starts with file.new or document.open is one undo step in the new document. The group opens after the command that opens the document: when that command also edits it (a command.batch), those edits stay the command's own undo step, and a canceled group keeps them, so the journal still replays. Both commands also run with no document open, and a command.batch that fails rolls back a begun group. Switching to a document that was already open does not open a group in it.
This commit is contained in:
@@ -99,8 +99,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Begin Undo Group",
|
||||
[],
|
||||
None,
|
||||
"{label?: string} until edit.undoGroup.end, the edits made in the active document are one undo step, named `label` (else after its first edit): for automation, such as MCP command_batch with `atomic` (#1163)",
|
||||
has_doc,
|
||||
"{label?: string} until edit.undoGroup.end, the edits made in the document active at the begin, and in each document opened or created before then (also when no document is open; the edits of a command that opens a document and edits it stay that command's undo step), are one undo step in that document, named `label` (else after its first edit); in a document that was open before the begin and is switched to (document.activate), each edit stays its own undo step: for automation, such as MCP command_batch with `atomic` (#1163)",
|
||||
always,
|
||||
undo_group_begin
|
||||
),
|
||||
cmd!(
|
||||
@@ -109,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
[],
|
||||
None,
|
||||
"{cancel?: bool} close the undo group: its edits stay one undo step or, with `cancel`, are undone",
|
||||
has_doc,
|
||||
always,
|
||||
undo_group_end
|
||||
),
|
||||
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{} (the Artboard tool chosen: artboard.cut)", has_selection_or_artboard_tool, cut),
|
||||
@@ -427,6 +427,7 @@ fn undo_group_begin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(Value::String(l)) if !l.trim().is_empty() && l.chars().count() <= 200 => Some(l.trim().to_string()),
|
||||
Some(_) => return Err(bad(C, "`label` must be a non-empty name of at most 200 characters")),
|
||||
};
|
||||
s.explicit_undo_group = Some(label.clone());
|
||||
s.begin_named_undo_group(label);
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// The active artboard the app passes, for the steps that act on it.
|
||||
let artboard = p.get("artboard").and_then(Value::as_u64);
|
||||
// What an error rolls back besides the documents: session-level state a step may change
|
||||
// (paint defaults, drawing mode, clipboard, the Untitled-N count).
|
||||
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone(), s.untitled_counter);
|
||||
// (paint defaults, drawing mode, clipboard, the Untitled-N count, a begun undo group).
|
||||
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone(), s.untitled_counter, s.explicit_undo_group.clone());
|
||||
// A drag or typing still in progress is a step of its own, as before Undo: the batch's steps
|
||||
// would otherwise run inside it and take its baseline as theirs (#1697).
|
||||
if s.active().is_some() {
|
||||
@@ -30,7 +30,7 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Err(e) => {
|
||||
roll_back(s, start, &stash);
|
||||
s.active = active;
|
||||
(s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard, s.untitled_counter) = saved;
|
||||
(s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard, s.untitled_counter, s.explicit_undo_group) = saved;
|
||||
s.pending_paint = None;
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
@@ -1431,6 +1431,10 @@ pub struct Session {
|
||||
/// The running top-level query command changed the selection and is journaled like any other
|
||||
/// command ([`Session::journal_query`]).
|
||||
journal_query: bool,
|
||||
/// From `edit.undoGroup.begin` to `edit.undoGroup.end`: the name of the undo step (`None`: after
|
||||
/// its first edit). Each document opened or created meanwhile opens an undo group of its own
|
||||
/// ([`Session::add_document`]). `None` otherwise.
|
||||
pub(crate) explicit_undo_group: Option<Option<String>>,
|
||||
/// While `command.batch` runs: the documents its steps closed or replaced (Revert), which an
|
||||
/// error brings back; `None` otherwise.
|
||||
pub(crate) batch_stash: Option<Vec<DocState>>,
|
||||
@@ -1563,6 +1567,7 @@ impl Session {
|
||||
journal_note: Default::default(),
|
||||
note_depth: 1,
|
||||
journal_query: false,
|
||||
explicit_undo_group: None,
|
||||
batch_stash: None,
|
||||
place_hand_off: None,
|
||||
queue_hand_off: None,
|
||||
@@ -1665,7 +1670,9 @@ impl Session {
|
||||
refresh_tree_text_bounds(tree);
|
||||
}
|
||||
}
|
||||
/// Add a document and make it active.
|
||||
/// Add a document and make it active. Between `edit.undoGroup.begin` and `edit.undoGroup.end`,
|
||||
/// an undo group with the begun group's name opens in it: here outside a command, and after the
|
||||
/// command that adds it otherwise ([`Session::execute`]).
|
||||
pub fn add_document(&mut self, mut doc: Document, path: Option<String>) -> usize {
|
||||
// Every edit is checked against the document before it, so the first one is within the cap
|
||||
// (importers and the native reader have seen to theirs already, with a warning).
|
||||
@@ -1680,6 +1687,11 @@ impl Session {
|
||||
self.docs.push(st);
|
||||
let i = self.docs.len() - 1;
|
||||
self.active = Some(i);
|
||||
if self.depth == 0
|
||||
&& let Some(label) = self.explicit_undo_group.clone()
|
||||
{
|
||||
self.begin_named_undo_group(label);
|
||||
}
|
||||
i
|
||||
}
|
||||
/// Replace the document in tab `index` (File → Revert): new content, cleared history and
|
||||
@@ -1843,11 +1855,15 @@ impl Session {
|
||||
// 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) };
|
||||
let r = r?;
|
||||
if self.depth == 0 {
|
||||
self.inherit_new_art();
|
||||
}
|
||||
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);
|
||||
@@ -2279,6 +2295,20 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Between `edit.undoGroup.begin` and `edit.undoGroup.end`, open an undo group with the begun
|
||||
/// group's name in each document that the top-level command just run opened or created (its
|
||||
/// [`DocState::uid`] is not in `open`). The group takes the edits after that command: the
|
||||
/// command's own edits there stay its undo step, and a canceled group keeps them, as the journal
|
||||
/// entry that made the document stays.
|
||||
fn open_undo_groups_in_new_documents(&mut self, open: &[u64]) {
|
||||
let Some(label) = &self.explicit_undo_group else { return };
|
||||
for st in &mut self.docs {
|
||||
if !open.contains(&st.uid) && st.undo_group.is_none() {
|
||||
st.undo_group = Some(UndoGroup { first: None, journal: vec![], label: label.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The documents with an undo group open ([`DocState::uid`]), each with the document it holds.
|
||||
fn undo_group_docs(&self) -> Vec<(u64, *const Document)> {
|
||||
self.docs.iter().filter(|st| st.undo_group.is_some()).map(|st| (st.uid, Arc::as_ptr(&st.doc))).collect()
|
||||
@@ -2311,8 +2341,15 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Close the open undo groups: their edits stay one undo step or, `cancel`led (Escape), are
|
||||
/// undone and dropped from the journal.
|
||||
/// undone and dropped from the journal. A group begun with `edit.undoGroup.begin` ends too, so
|
||||
/// documents opened after this open no undo group.
|
||||
pub fn end_undo_group(&mut self, cancel: bool) {
|
||||
self.explicit_undo_group = None;
|
||||
self.close_undo_groups(cancel);
|
||||
}
|
||||
|
||||
/// [`Session::end_undo_group`] without ending a group begun with `edit.undoGroup.begin`.
|
||||
fn close_undo_groups(&mut self, cancel: bool) {
|
||||
let mut journal: Vec<(usize, Option<usize>)> = vec![];
|
||||
for st in &mut self.docs {
|
||||
let Some(g) = st.undo_group.take() else { continue };
|
||||
@@ -2353,7 +2390,7 @@ impl Session {
|
||||
// The other documents' groups are set aside while `end_undo_group` closes this one's.
|
||||
let others: Vec<(u64, UndoGroup)> =
|
||||
self.docs.iter_mut().filter(|st| st.uid != uid).filter_map(|st| Some((st.uid, st.undo_group.take()?))).collect();
|
||||
self.end_undo_group(cancel);
|
||||
self.close_undo_groups(cancel);
|
||||
for (other, g) in others {
|
||||
if let Some(st) = self.document_mut(other) {
|
||||
st.undo_group = Some(g);
|
||||
|
||||
@@ -520,6 +520,144 @@ fn an_undo_group_is_one_undo_step() {
|
||||
assert_eq!(undo_len(&s), undo + 2);
|
||||
}
|
||||
|
||||
/// Between `edit.undoGroup.begin` and `edit.undoGroup.end`, each document created meanwhile opens an
|
||||
/// undo group with the begun group's name, also when no document was open at the begin: its edits
|
||||
/// are one undo step there, and a cancel undoes them and leaves the document open. A group opened
|
||||
/// without the command (a scrubbed field) and a begin that a failed batch rolled back open no group
|
||||
/// in a new document.
|
||||
#[test]
|
||||
fn an_undo_group_begun_by_command_opens_in_each_document_created_before_its_end() {
|
||||
let mut s = Session::new();
|
||||
let rect = json!({"x": 10, "y": 10, "width": 100, "height": 50});
|
||||
let new = json!({"width": 400, "height": 300});
|
||||
let labels = |s: &Session| s.doc().unwrap().history.undo.iter().map(|e| e.label.clone()).collect::<Vec<_>>();
|
||||
let objects = |s: &Session| s.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len());
|
||||
s.execute("edit.undoGroup.begin", &json!({"label": "Job"})).unwrap();
|
||||
for _ in 0..2 {
|
||||
s.execute("file.new", &new).unwrap();
|
||||
s.execute("shape.rectangle", &rect).unwrap();
|
||||
s.execute("shape.ellipse", &rect).unwrap();
|
||||
}
|
||||
s.execute("edit.undoGroup.end", &json!({})).unwrap();
|
||||
assert_eq!(labels(&s), ["Job"]);
|
||||
s.execute("document.activate", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(labels(&s), ["Job"]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(objects(&s), 0, "one undo takes back both edits");
|
||||
|
||||
// Closed: the next document's edits are steps of their own.
|
||||
s.execute("file.new", &new).unwrap();
|
||||
s.execute("shape.rectangle", &rect).unwrap();
|
||||
s.execute("shape.rectangle", &rect).unwrap();
|
||||
assert_eq!(labels(&s).len(), 2);
|
||||
|
||||
// Canceled: the new document stays open without the group's edits, and the journal replays to it.
|
||||
let count = s.documents().len();
|
||||
s.execute("edit.undoGroup.begin", &json!({})).unwrap();
|
||||
s.execute("file.new", &new).unwrap();
|
||||
s.execute("shape.rectangle", &rect).unwrap();
|
||||
s.execute("shape.rectangle", &rect).unwrap();
|
||||
s.execute("edit.undoGroup.end", &json!({"cancel": true})).unwrap();
|
||||
assert_eq!(s.documents().len(), count + 1);
|
||||
assert_eq!((objects(&s), labels(&s).len()), (0, 0));
|
||||
assert_eq!(documents(&replayed(&s)), documents(&s));
|
||||
|
||||
// A scrubbed field's group stays in its own document.
|
||||
s.begin_undo_group();
|
||||
s.execute("file.new", &new).unwrap();
|
||||
assert!(s.doc().unwrap().undo_group.is_none());
|
||||
s.end_undo_group(false);
|
||||
|
||||
// A group begun by command and closed with `end_undo_group` opens no group in a later document.
|
||||
s.execute("edit.undoGroup.begin", &json!({})).unwrap();
|
||||
s.end_undo_group(false);
|
||||
s.execute("file.new", &new).unwrap();
|
||||
assert!(s.doc().unwrap().undo_group.is_none());
|
||||
|
||||
// A batch that begins a group and then fails leaves no group begun.
|
||||
let steps = json!([{"command": "edit.undoGroup.begin"}, {"command": "no.such.command"}]);
|
||||
assert!(s.execute("command.batch", &json!({ "commands": steps })).is_err());
|
||||
s.execute("file.new", &new).unwrap();
|
||||
assert!(s.doc().unwrap().undo_group.is_none());
|
||||
}
|
||||
|
||||
/// Between `edit.undoGroup.begin` and `edit.undoGroup.end`, a command that creates a document and
|
||||
/// edits it (a `command.batch`) keeps its edits as an undo step of its own in that document, and the
|
||||
/// edits after it are the group's step. A cancel undoes only those, keeps the document and the
|
||||
/// command's edits, and the journal replays to the same documents.
|
||||
#[test]
|
||||
fn an_undo_group_leaves_out_the_edits_of_the_command_that_creates_its_document() {
|
||||
let mut s = Session::new();
|
||||
let rect = json!({"x": 10, "y": 10, "width": 100, "height": 50});
|
||||
let batch = json!({"commands": [
|
||||
{"command": "file.new", "params": {"width": 400, "height": 300}},
|
||||
{"command": "shape.rectangle", "params": rect},
|
||||
]});
|
||||
let labels = |s: &Session| s.doc().unwrap().history.undo.iter().map(|e| e.label.clone()).collect::<Vec<_>>();
|
||||
let objects = |s: &Session| s.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len());
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
for cancel in [false, true] {
|
||||
s.execute("edit.undoGroup.begin", &json!({"label": "Job"})).unwrap();
|
||||
s.execute("command.batch", &batch).unwrap();
|
||||
s.execute("shape.ellipse", &rect).unwrap();
|
||||
s.execute("shape.ellipse", &rect).unwrap();
|
||||
s.execute("edit.undoGroup.end", &json!({ "cancel": cancel })).unwrap();
|
||||
let (objects, steps) = (objects(&s), labels(&s).len());
|
||||
assert_eq!((objects, steps), if cancel { (1, 1) } else { (3, 2) }, "cancel: {cancel}");
|
||||
assert_eq!(documents(&replayed(&s)), documents(&s), "cancel: {cancel}");
|
||||
}
|
||||
assert_eq!(s.documents().len(), 3);
|
||||
}
|
||||
|
||||
/// Between `edit.undoGroup.begin` and `edit.undoGroup.end`, switching to a document that was open
|
||||
/// before the begin opens no group in it: each edit made there is an undo step of its own. The edits
|
||||
/// made in the document active at the begin, and in a document created meanwhile, stay one undo step
|
||||
/// each after a switch away and back, and the journal replays to the same documents.
|
||||
#[test]
|
||||
fn an_undo_group_begun_by_command_opens_no_group_in_a_document_switched_to() {
|
||||
let mut s = Session::new();
|
||||
let rect = json!({"x": 10, "y": 10, "width": 100, "height": 50});
|
||||
let new = json!({"width": 400, "height": 300});
|
||||
let labels = |s: &Session, i: usize| s.documents()[i].history.undo.iter().map(|e| e.label.clone()).collect::<Vec<_>>();
|
||||
let objects = |s: &Session| s.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len());
|
||||
s.execute("file.new", &new).unwrap();
|
||||
s.execute("file.new", &new).unwrap();
|
||||
s.execute("document.activate", &json!({"index": 0})).unwrap();
|
||||
s.execute("edit.undoGroup.begin", &json!({"label": "Job"})).unwrap();
|
||||
s.execute("shape.rectangle", &rect).unwrap();
|
||||
s.execute("document.activate", &json!({"index": 1})).unwrap();
|
||||
assert!(s.doc().unwrap().undo_group.is_none());
|
||||
s.execute("shape.rectangle", &rect).unwrap();
|
||||
s.execute("shape.ellipse", &rect).unwrap();
|
||||
s.execute("file.new", &new).unwrap();
|
||||
s.execute("shape.rectangle", &rect).unwrap();
|
||||
s.execute("document.activate", &json!({"index": 1})).unwrap();
|
||||
s.execute("shape.rectangle", &rect).unwrap();
|
||||
s.execute("document.activate", &json!({"index": 2})).unwrap();
|
||||
s.execute("shape.ellipse", &rect).unwrap();
|
||||
s.execute("document.activate", &json!({"index": 0})).unwrap();
|
||||
s.execute("shape.ellipse", &rect).unwrap();
|
||||
s.execute("edit.undoGroup.end", &json!({})).unwrap();
|
||||
assert_eq!((labels(&s, 0), labels(&s, 2)), (vec!["Job".to_string()], vec!["Job".to_string()]));
|
||||
assert_eq!(labels(&s, 1).len(), 3, "{:?}", labels(&s, 1));
|
||||
assert!(s.documents().iter().all(|d| d.undo_group.is_none()));
|
||||
assert_eq!(documents(&replayed(&s)), documents(&s));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(objects(&s), 0, "one undo takes back both edits in the document active at the begin");
|
||||
s.execute("document.activate", &json!({"index": 1})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(objects(&s), 2, "one undo takes back one edit in the document switched to");
|
||||
}
|
||||
|
||||
/// `edit.undoGroup.begin`'s params text gives the undo step's default name next to `label`, and the
|
||||
/// clause on a document switched to says only that its edits stay steps of their own.
|
||||
#[test]
|
||||
fn the_undo_group_params_text_names_the_default_label_next_to_label() {
|
||||
let params = cmd::find_command("edit.undoGroup.begin").unwrap().params;
|
||||
assert!(params.contains("named `label` (else after its first edit); in a document"), "{params}");
|
||||
assert!(params.contains("each edit stays its own undo step: for automation"), "{params}");
|
||||
}
|
||||
|
||||
/// An undo scope ([`Session::begin_undo_scope`]) in a document without an open undo group opens
|
||||
/// one, and [`Session::end_undo_scope`] closes that group only, whichever document is active, and
|
||||
/// leaves another document's group open; canceled, it undoes only that group's edits and cuts the
|
||||
|
||||
@@ -153,9 +153,12 @@ fn group_open_in_another_document() -> (Session, String, String, String) {
|
||||
run(&mut s, "select.set", json!({"ids": [t]}));
|
||||
let char_style = run(&mut s, "library.add", json!({"library": lib, "kind": "charStyle"}))["name"].as_str().unwrap().to_string();
|
||||
let para_style = run(&mut s, "library.add", json!({"library": lib, "kind": "paraStyle"}))["name"].as_str().unwrap().to_string();
|
||||
// The second document exists before the group begins, so no group opens in it.
|
||||
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
||||
run(&mut s, "document.activate", json!({"index": 0}));
|
||||
run(&mut s, "edit.undoGroup.begin", json!({}));
|
||||
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}));
|
||||
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
||||
run(&mut s, "document.activate", json!({"index": 1}));
|
||||
let u = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Body", "size": 9}))["id"].clone();
|
||||
run(&mut s, "select.set", json!({"ids": [u]}));
|
||||
(s, lib, char_style, para_style)
|
||||
|
||||
@@ -149,6 +149,55 @@ fn render_preview_and_screenshot_match_a_png_export_on_white() {
|
||||
}
|
||||
}
|
||||
|
||||
/// An atomic `command_batch` that starts with `file.new` makes the edits after it one undo step in
|
||||
/// the new document, with or without a document open before it, and also when a later step fails.
|
||||
#[test]
|
||||
fn an_atomic_batch_is_one_undo_step_in_a_document_it_creates() {
|
||||
let mut steps = vec![
|
||||
json!({"id":"file.new","params":{"width":200,"height":100}}),
|
||||
json!({"id":"shape.rectangle","params":{"x":0,"y":0,"width":5,"height":5}}),
|
||||
json!({"id":"shape.ellipse","params":{"x":9,"y":0,"width":5,"height":5}}),
|
||||
];
|
||||
for fail in [false, true] {
|
||||
if fail {
|
||||
steps.push(json!({"id":"no.such.command"}));
|
||||
}
|
||||
for backend in [Headless::new(), Headless::with_document()] {
|
||||
let mut s = Server::new(Box::new(backend));
|
||||
let r = call(&mut s, "command_batch", json!({"steps":steps,"atomic":true,"label":"Job"}));
|
||||
assert_eq!(r["isError"], fail, "{r}");
|
||||
let p = payload(&r);
|
||||
assert_eq!((p["completed"].clone(), p["failed"].clone()), (json!(3), json!(u8::from(fail))), "{r}");
|
||||
assert_eq!(payload(&call(&mut s, "doc_inspect", json!({"depth":0})))["history"], json!(["Job"]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// In an atomic `command_batch`, the edits made in a document that was open before the batch and
|
||||
/// that a step switches to are undo steps of their own, and the edits in the document active when
|
||||
/// the batch began are one undo step, also when they come before and after the switch.
|
||||
#[test]
|
||||
fn an_atomic_batch_groups_no_edits_in_a_document_it_switches_to() {
|
||||
let mut s = Server::new(Box::new(Headless::with_document()));
|
||||
let r = call(&mut s, "command_run", json!({"id":"file.new","params":{"width":200,"height":100}}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let rect = json!({"x":0,"y":0,"width":5,"height":5});
|
||||
let steps = json!([
|
||||
{"id":"shape.rectangle","params":rect},
|
||||
{"id":"document.activate","params":{"index":0}},
|
||||
{"id":"shape.rectangle","params":rect},
|
||||
{"id":"shape.ellipse","params":rect},
|
||||
{"id":"document.activate","params":{"index":1}},
|
||||
{"id":"shape.ellipse","params":rect},
|
||||
]);
|
||||
let r = call(&mut s, "command_batch", json!({"steps":steps,"atomic":true,"label":"Job"}));
|
||||
assert_eq!((r["isError"].clone(), payload(&r)["completed"].clone()), (json!(false), json!(6)), "{r}");
|
||||
let history = |s: &mut Server| payload(&call(s, "doc_inspect", json!({"depth":0})))["history"].clone();
|
||||
assert_eq!(history(&mut s), json!(["Job"]));
|
||||
call(&mut s, "command_run", json!({"id":"document.activate","params":{"index":0}}));
|
||||
assert_eq!(history(&mut s).as_array().map(Vec::len), Some(2), "{}", history(&mut s));
|
||||
}
|
||||
|
||||
struct Panics;
|
||||
impl Backend for Panics {
|
||||
fn call(&mut self, _: &str, _: Value) -> Result<Value, String> {
|
||||
|
||||
@@ -133,9 +133,9 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"command_batch",
|
||||
"Run several commands",
|
||||
"Run steps in order; each edit is its own undo step, or with atomic:true the whole batch is one (named `label`, else after its first edit). A step's id is a command id, or draw_path / draw_shape with that tool's arguments as params (its result is the tool's: the new object's `id`). A param string that is exactly \"$N.path\" (N: 0-based step index) or \"$last.path\" takes that value from an earlier step's result, e.g. {\"id\":\"$1.id\"}; \"$$\" starts a literal \"$\". Returns completed, failed, results and created (the ids draw_path / draw_shape steps made, in order). Stops on the first error by default. At most 256 steps; kept step results total at most 8 MiB, past which the batch stops with the reason.",
|
||||
"Run steps in order; each edit is its own undo step, or with atomic:true the whole batch is one (named `label`, else after its first edit; `atomic` says which documents). A step's id is a command id, or draw_path / draw_shape with that tool's arguments as params (its result is the tool's: the new object's `id`). A param string that is exactly \"$N.path\" (N: 0-based step index) or \"$last.path\" takes that value from an earlier step's result, e.g. {\"id\":\"$1.id\"}; \"$$\" starts a literal \"$\". Returns completed, failed, results and created (the ids draw_path / draw_shape steps made, in order). Stops on the first error by default. At most 256 steps; kept step results total at most 8 MiB, past which the batch stops with the reason.",
|
||||
obj(
|
||||
json!({"steps": {"type":"array", "maxItems": 256, "items":obj(json!({"id":string("Command id, or draw_path / draw_shape"),"params":{"type":"object"}}), &["id"])}, "stop_on_error":{"type":"boolean", "default":true}, "atomic":{"type":"boolean", "default":false, "description":"Make the batch's edits one undo step (needs an open document)"}, "label":string("The undo step's name with atomic")}),
|
||||
json!({"steps": {"type":"array", "maxItems": 256, "items":obj(json!({"id":string("Command id, or draw_path / draw_shape"),"params":{"type":"object"}}), &["id"])}, "stop_on_error":{"type":"boolean", "default":true}, "atomic":{"type":"boolean", "default":false, "description":"Make the batch's edits one undo step in the active document and in each document a step opens or creates; in a document open before the batch that a step switches to, each edit stays its own undo step"}, "label":string("The undo step's name with atomic")}),
|
||||
&["steps"],
|
||||
),
|
||||
false,
|
||||
|
||||
@@ -290,10 +290,13 @@ fn scrubbing_w_in_the_app_is_one_undo_step() {
|
||||
#[test]
|
||||
fn a_scrub_leaves_another_documents_undo_group_open() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
// The second document exists before the group begins, so no group opens in it.
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("document.activate", &json!({"index": 0})).unwrap();
|
||||
app.session.execute("edit.undoGroup.begin", &json!({})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("document.activate", &json!({"index": 1})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let other = |app: &VectorcraftApp| (app.session.documents()[0].undo_group.is_some(), objects(&app.session.documents()[0]));
|
||||
@@ -334,9 +337,12 @@ fn a_scrub_inside_an_open_undo_group_joins_its_step() {
|
||||
#[test]
|
||||
fn a_scrub_closes_its_group_in_its_own_document_after_a_switch() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
// The second document exists before the group begins, so no group opens in it.
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("document.activate", &json!({"index": 0})).unwrap();
|
||||
app.session.execute("edit.undoGroup.begin", &json!({})).unwrap();
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("document.activate", &json!({"index": 1})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let undo = undo_len(&app);
|
||||
|
||||
+1
-1
@@ -349,7 +349,7 @@ The older names below remain listed because existing workflows use them.
|
||||
|---|---|
|
||||
| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text); returns the command array |
|
||||
| `command_run` | `id`, optional `params`; runs the command through the existing backend. An unknown key in `params` is an error that names it and the nearest key the command takes (`invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`?`), so a misspelt key no longer runs the command with its default (see [Parameter keys](#parameter-keys)) |
|
||||
| `command_batch` | `steps: [{id, params?}]` (at most 256), optional `stop_on_error` (default true), `atomic` and `label`; returns `completed`, `failed`, `results: [{ok, result\|error}]` and `created` (the ids the `draw_path` / `draw_shape` steps made, in order). A step's `id` is a command id, or `draw_path` / `draw_shape` with that tool's arguments as `params` (checked as `tools/call` checks them; the step's result is the tool's, so `"$N.id"` is the new object). Each edit has its own undo step, or with `atomic: true` the batch's edits are one undo step named `label` (else after its first edit; `edit.undoGroup.begin`/`end` underneath); failures set `isError`. A param string `"$N.path"` or `"$last.path"` takes a value from an earlier step's result (see `run` below). Kept step results total at most 8 MiB; past that the batch stops with the reason. This is the MCP tool, not the engine command `command.batch` (that one takes `commands: [{command, params}]` and is what the Actions panel plays); use `command_run` with `command.batch` when replaying a recorded action |
|
||||
| `command_batch` | `steps: [{id, params?}]` (at most 256), optional `stop_on_error` (default true), `atomic` and `label`; returns `completed`, `failed`, `results: [{ok, result\|error}]` and `created` (the ids the `draw_path` / `draw_shape` steps made, in order). A step's `id` is a command id, or `draw_path` / `draw_shape` with that tool's arguments as `params` (checked as `tools/call` checks them; the step's result is the tool's, so `"$N.id"` is the new object). Each edit has its own undo step, or with `atomic: true` the batch's edits are one undo step named `label` (else after its first edit; `edit.undoGroup.begin`/`end` underneath), and the edits made in a document that a step opens or creates (`document.open`, `file.new`) are one undo step there, named the same, also when no document was open before the batch. In a document that was open before the batch and that a step switches to (`document.activate`), each edit stays its own undo step; failures set `isError`. A param string `"$N.path"` or `"$last.path"` takes a value from an earlier step's result (see `run` below). Kept step results total at most 8 MiB; past that the batch stops with the reason. This is the MCP tool, not the engine command `command.batch` (that one takes `commands: [{command, params}]` and is what the Actions panel plays); use `command_run` with `command.batch` when replaying a recorded action |
|
||||
| `doc_inspect` | Optional `depth`, `childLimit`, `ids`, `kind`, `limit`, `offset`; same summary as `inspect_document` |
|
||||
| `render_preview` | Optional `max_side` (1–4096, default 1024) and `artboard` (0-based, default 0); inline PNG of that artboard without editing it, drawn as `export` draws a PNG with `background: "white"` at the scale that fits `max_side` (at most 1). Artboards too large to render within the allocation bound return a tool error |
|
||||
| `ui_inspect`, `ui_screenshot` | Connected desktop state/window capture; tool errors in headless mode |
|
||||
|
||||
Reference in New Issue
Block a user