diff --git a/crates/engine/src/cmd/fileio/batch.rs b/crates/engine/src/cmd/fileio/batch.rs index d5aa6d144..3dc3a51d9 100644 --- a/crates/engine/src/cmd/fileio/batch.rs +++ b/crates/engine/src/cmd/fileio/batch.rs @@ -27,15 +27,19 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { let active = s.active; s.batch_stash = Some(vec![]); let mut grouped = Vec::with_capacity(cmds.len()); + // The Type tool keeps what the steps' Undo and Redo change in it, to put back on a rollback. + s.notify_text_tool("batch.begin"); let r = run_steps(s, &label, artboard, &mut cmds, &mut grouped); let stash = s.batch_stash.take().unwrap_or_default(); let results = match r { Ok(results) => { + s.notify_text_tool("batch.end"); s.batch_steps = Some(grouped); results } Err(e) => { roll_back(s, start, &stash); + s.notify_text_tool("batch.rollBack"); s.active = active; (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; diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 13e140d97..24cd7096f 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -122,6 +122,9 @@ impl Session { 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) } diff --git a/crates/engine/src/tests/typeundo.rs b/crates/engine/src/tests/typeundo.rs index 2c7bf46d0..66befa724 100644 --- a/crates/engine/src/tests/typeundo.rs +++ b/crates/engine/src/tests/typeundo.rs @@ -52,3 +52,29 @@ fn undo_and_redo_put_the_caret_where_the_step_changed_the_text() { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!((plain(&s, id).as_str(), caret(&s)), ("abcXYZ", (6, 6))); } + +/// Undo and Redo run as steps of `command.batch` put the caret where they do run alone. +#[test] +fn undo_and_redo_in_a_batch_put_the_caret_where_the_step_changed_the_text() { + let v = ViewInfo::default(); + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "XYZ", "size": 20})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.select_tool("type", v).unwrap(); + for k in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(k, 101.0, 95.0), v).unwrap(); + } + s.set_tool_option("select", &json!({"start": 0, "end": 0})); + s.tool_text("abc", v).unwrap(); + s.execute("command.batch", &json!({"commands": [{"command": "edit.undo"}]})).unwrap(); + assert_eq!((plain(&s, id).as_str(), caret(&s)), ("XYZ", (0, 0))); + s.execute("command.batch", &json!({"commands": [{"command": "edit.redo"}]})).unwrap(); + assert_eq!((plain(&s, id).as_str(), caret(&s)), ("abcXYZ", (3, 3))); + // A batch whose later step fails is rolled back: the text and the caret are as they were, and + // Undo still takes back the typing. + let failed = json!({"commands": [{"command": "edit.undo"}, {"command": "no.suchCommand"}]}); + assert!(s.execute("command.batch", &failed).is_err()); + assert_eq!((plain(&s, id).as_str(), caret(&s)), ("abcXYZ", (3, 3))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((plain(&s, id).as_str(), caret(&s)), ("XYZ", (0, 0))); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 62a559a67..82a538067 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -685,7 +685,7 @@ impl Session { return; } if let Some(cmd @ ("edit.undo" | "edit.redo")) = cmd { - self.with_tool_cx(self.last_view, |t, cx| t.notify(cx, cmd)); + self.notify_text_tool(cmd); } let acts = self.with_tool_cx(self.last_view, |t, cx| t.after_command(cx)); // The command itself succeeded: a failure here only leaves the edit as it was. @@ -694,6 +694,17 @@ impl Session { } } + /// Tell the active tool that `edit.undo` or `edit.redo` ran, on its own or as a step of + /// `command.batch`, so the Type tool puts the caret where the step changed the text; or that a + /// `command.batch` began, ended or was rolled back (`batch.begin`, `batch.end`, + /// `batch.rollBack`), so the Type tool puts back what its Undo and Redo steps changed when it fails. + pub(crate) fn notify_text_tool(&mut self, what: &str) { + if self.in_tool_actions || (!self.tool.wants_text() && self.tool.id() != "eyedropper") { + return; + } + self.with_tool_cx(self.last_view, |t, cx| t.notify(cx, what)); + } + /// Apply tool actions. Errors from previews are reported but keep the interaction alive. pub fn apply_actions(&mut self, acts: Vec) -> Result> { let outer = std::mem::replace(&mut self.in_tool_actions, true); diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 85253daf0..2daa5a768 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -92,6 +92,7 @@ struct TypedList { /// typing session or a typed list), `ends` holds the byte where the changed span ends before and /// after it, so that Undo and Redo of the step put the caret there even where the change repeats /// the characters next to it; a step a command from outside the tool made has none. +#[derive(Clone)] struct OwnEdit { id: NodeId, before: u64, @@ -101,6 +102,18 @@ struct OwnEdit { done: bool, } +/// What Undo and Redo change in the tool, kept when a `command.batch` begins so that a batch that +/// fails and is rolled back puts it back ([`TypeTool::batch_notice`]). +struct HistoryState { + editing: Option, + caret: usize, + anchor: usize, + goal_x: Option, + known: Option<(NodeId, String)>, + edits: Vec, + stepped: bool, +} + /// How many undo steps [`TypeTool::edits`] keeps. const OWN_EDITS_MAX: usize = 256; @@ -183,6 +196,8 @@ pub struct TypeTool { edits: Vec, /// The command that ran was Undo or Redo ([`TypeTool::after_history_step`]). stepped: bool, + /// The state at the start of each `command.batch` still running, innermost last. + batches: Vec, cache: Mutex>, } @@ -515,6 +530,35 @@ impl TypeTool { self.known = Some((id, text)); } + /// A `command.batch` began (`batch.begin`), ended (`batch.end`), or failed and was rolled back + /// (`batch.rollBack`). After a rollback, what the batch's Undo and Redo steps changed in the tool + /// is as it was at the batch's start, when the tool still edits the same object. + fn batch_notice(&mut self, what: &str) { + match what { + "batch.begin" => self.batches.push(HistoryState { + editing: self.editing, + caret: self.caret, + anchor: self.anchor, + goal_x: self.goal_x, + known: self.known.clone(), + edits: self.edits.clone(), + stepped: self.stepped, + }), + "batch.end" => { + self.batches.pop(); + } + "batch.rollBack" => { + if let Some(s) = self.batches.pop() + && s.editing == self.editing + { + (self.caret, self.anchor, self.goal_x) = (s.caret, s.anchor, s.goal_x); + (self.known, self.edits, self.stepped) = (s.known, s.edits, s.stepped); + } + } + _ => {} + } + } + /// After Undo of a list that typed characters started (and of what was typed after it), the /// text is as it was typed: the caret goes on after the characters. After Redo the text is as /// the list left it: the caret goes back to the paragraph's start. diff --git a/crates/tools/src/text/tool.rs b/crates/tools/src/text/tool.rs index 455a44b65..f2bfff400 100644 --- a/crates/tools/src/text/tool.rs +++ b/crates/tools/src/text/tool.rs @@ -468,10 +468,13 @@ impl Tool for TypeTool { self.finish(cx) } /// After `text.create` / `text.createInPath` the engine selects the new object; edit it. After - /// `edit.undo` / `edit.redo` the caret goes to where the step changed the edited text. + /// `edit.undo` / `edit.redo` the caret goes to where the step changed the edited text. The + /// `batch.*` notices keep what a failed `command.batch` changed from staying ([`TypeTool::batch_notice`]). fn notify(&mut self, cx: &ToolContext, what: &str) { if matches!(what, "edit.undo" | "edit.redo") { self.after_history_step(cx, what == "edit.undo"); + } else if what.starts_with("batch.") { + self.batch_notice(what); } else if what == "text.editNew" && let Some(id) = cx.selection.objects.first().copied() {