Type: Undo and Redo put the caret where the undone or redone edit was

While the Type tool edits type, Edit > Undo and Redo (the menu, the
keyboard, the History panel and MCP all run edit.undo and edit.redo)
left the caret at its byte offset, clamped to the text. Typing "abc"
before "XYZ" and pressing Undo left the caret after "XYZ".

The engine now tells the active tool about edit.undo and edit.redo
before it finishes the command. The Type tool keeps the edited text and
a list of the steps that changed it, and puts the caret, with nothing
selected, at the end of the span the step changed: where typed text
began after its Undo, after restored text after the Undo of a deletion,
and after retyped text after Redo. For the steps the tool made, it keeps
where the span ended, so text that repeats the characters next to it
puts the caret at the right end. A step that changes no characters,
such as a formatting change, leaves the caret and the selection as they
are. Typed lists keep their caret positions.
This commit is contained in:
dotCooCoo
2026-10-29 10:10:58 +07:00
committed by bflatastic
parent 77b2391f7a
commit c3af6605ef
8 changed files with 414 additions and 9 deletions
+1 -1
View File
@@ -121,7 +121,7 @@ impl Session {
}
if self.depth == 0 {
self.journal_in_undo_groups(doc, &grouped, journal);
self.after_command();
self.after_command(Some(id));
}
Ok(r)
}
+1
View File
@@ -165,6 +165,7 @@ mod transparencygrid;
mod trap;
mod typearea;
mod typescale;
mod typeundo;
mod units;
mod variables;
mod webexport;
+54
View File
@@ -0,0 +1,54 @@
//! Edit › Undo and Redo while the Type tool edits: the caret goes to where the step changed the text.
use serde_json::json;
use vectorcraft_doc::{NodeId, NodeKind};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use crate::*;
fn session() -> Session {
let mut s = Session::new();
s.prefs.placeholder_text = false;
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn plain(s: &Session, id: NodeId) -> String {
match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Text(t) => t.plain_text(),
_ => panic!("not text"),
}
}
fn caret(s: &Session) -> (u64, u64) {
let o = s.tool_options();
(o["caret"].as_u64().unwrap(), o["anchor"].as_u64().unwrap())
}
/// "abc" typed before "XYZ": Undo puts the caret before "XYZ" and Redo after "abc", from the
/// menu command and from the History panel's steps alike (both run `edit.undo` and `edit.redo`).
#[test]
fn undo_and_redo_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();
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)));
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!((plain(&s, id).as_str(), caret(&s)), ("abcXYZ", (3, 3)));
// A deleted character comes back with the caret after it, wherever the caret went since.
s.tool_key(ToolKey::End, Mods::default(), v).unwrap();
s.tool_key(ToolKey::Backspace, Mods::default(), v).unwrap();
assert_eq!(plain(&s, id), "abcXY");
s.tool_key(ToolKey::Home, Mods::default(), v).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!((plain(&s, id).as_str(), caret(&s)), ("abcXYZ", (6, 6)));
}
+7 -2
View File
@@ -181,7 +181,7 @@ impl Session {
let r = self.apply_actions(acts);
self.keep_tool_settings();
self.tool = lender;
self.after_command();
self.after_command(None);
r.map(drop)
}
@@ -678,10 +678,15 @@ impl Session {
/// Let the active tool finish work a command from outside it ended ([`vectorcraft_tools::Tool::after_command`]):
/// the Type tool and the Eyedropper clear a character range whose text is no longer selected.
pub(crate) fn after_command(&mut self) {
/// After `edit.undo` or `edit.redo` (`cmd`), the tool is told first, so the Type tool puts the
/// caret where the step changed the text.
pub(crate) fn after_command(&mut self, cmd: Option<&str>) {
if self.in_tool_actions || (!self.tool.wants_text() && self.tool.id() != "eyedropper") {
return;
}
if let Some(cmd @ ("edit.undo" | "edit.redo")) = cmd {
self.with_tool_cx(self.last_view, |t, cx| t.notify(cx, 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.
if let Err(e) = self.apply_actions(acts) {