Type: Change Case, Smart Punctuation and other commands that change nothing record no undo step

These commands edited the document even when nothing changed, so the
call recorded an undo step, emptied Redo and marked the document
modified:

- Change Case to the case the text has, Smart Punctuation on type with
  nothing left to change or on a selection with no type in it, and
  Find and Replace when every match is replaced with the text it is.
  All three rewrite the text runs through edit_runs, which now edits
  only when a run changes.
- Convert To Area Type with no point type among the objects, and
  Convert To Point Type with no area type among them.
- Type on a Path Options given the values the type has, without Flip:
  OK in the dialog with nothing changed, or a Type on a Path effect
  the type has.
- Fill with Placeholder Text on type that holds its placeholder text
  already.
- type.insert with an empty text on type that ends with a run of
  text.

These commands now return without editing the document. The replies
have the same form as before. A call that changes something still
records one undo step.

One line in the evidence list of docs/target-app-parity.md names every
command above except type.insert.
This commit is contained in:
dotCooCoo authored and bflatastic committed 2026-10-12 08:45:22 +07:00
1 parent 256cd1f85a
commit a24269eb15
4 files changed
+178 -4

No files matched your search

+46 -4
View File
@@ -132,9 +132,10 @@ fn ids_json(ids: &[NodeId]) -> Value {
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
}
/// Rewrite every run of the given text objects with `f(run text, state)`.
/// Rewrite every run of the given text objects with `f(run text, state)`. When no run changes, the
/// document and its undo history stay as they are.
fn edit_runs<S: Default>(s: &mut Session, label: &str, ids: &[NodeId], f: impl Fn(&str, &mut S) -> String) -> Result<usize> {
s.edit(label, |d, _| {
super::overprint::edit_counted(s, label, |d| {
let mut changed = 0;
for id in ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
@@ -260,6 +261,11 @@ fn smart_punctuation(s: &mut Session, p: &Value) -> Result<Value> {
fn to_area(s: &mut Session, p: &Value) -> Result<Value> {
let ids = texts(s, p, "type.convertToAreaType")?;
// With no point type among the objects, the document and its undo history stay as they are.
let d = &s.doc()?.doc;
if !ids.iter().any(|id| matches!(d.node(*id).map(|n| &n.kind), Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::Point))) {
return Ok(ids_json(&ids));
}
s.edit("Convert To Area Type", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
@@ -302,6 +308,11 @@ fn locate(runs: &[TextRun], byte: usize) -> Option<(usize, usize)> {
fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
let ids = texts(s, p, "type.convertToPointType")?;
// With no area type among the objects, the document and its undo history stay as they are.
let d = &s.doc()?.doc;
if !ids.iter().any(|id| matches!(d.node(*id).map(|n| &n.kind), Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::Area { .. }))) {
return Ok(ids_json(&ids));
}
s.edit("Convert To Point Type", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
@@ -418,6 +429,22 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
None => None,
};
let spacing = p.get("spacing").and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(-1000.0, 1000.0));
// Without a flip, when every object already has every value given, the document and its undo
// history stay as they are.
let has_values = |t: &TextObject| match &t.kind {
TextKind::OnPath { start: st, end: en, .. } => {
start.is_none_or(|v| v == *st)
&& end.is_none_or(|v| v == *en)
&& effect.is_none_or(|e| t.path_effect == e)
&& align.is_none_or(|a| t.path_align == a)
&& spacing.is_none_or(|v| t.path_spacing == v)
}
_ => true,
};
let d = &s.doc()?.doc;
if !flip && ids.iter().all(|id| path_text(d.node(*id)).is_none_or(has_values)) {
return options(s);
}
s.edit("Type on a Path Options", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
@@ -457,13 +484,18 @@ start as rough drafts like this one. Keep going until the frame is full and the
fn fill_placeholder(s: &mut Session, p: &Value) -> Result<Value> {
let ids = texts(s, p, "type.fillPlaceholder")?;
s.edit("Fill with Placeholder Text", |d, _| {
// When filling leaves every object as it was, the document and its undo history stay as they
// are.
super::overprint::edit_counted(s, "Fill with Placeholder Text", |d| {
let mut changed = 0;
for id in &ids {
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
let before = t.clone();
fill_with_placeholder(t);
changed += usize::from(*t != before);
}
}
Ok(())
Ok(changed)
})?;
Ok(ids_json(&ids))
}
@@ -539,6 +571,16 @@ fn insert_char(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(C, "give `char` or `text`")),
};
let ids = texts(s, p, C)?;
// When `text` is empty and every object ends with a run of text, the document and its undo
// history stay as they are.
let d = &s.doc()?.doc;
let ends_with_text = |id: &NodeId| match d.node(*id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => t.runs.last().is_some_and(|r| r.inline.is_none()),
_ => false,
};
if text.is_empty() && ids.iter().all(ends_with_text) {
return Ok(ids_json(&ids));
}
s.edit("Typing", |d, _| {
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
+96
View File
@@ -620,6 +620,22 @@ fn find_and_replace_options() {
assert!(s.execute("edit.findReplace", &json!({"find": ""})).is_err());
}
/// Find and Replace that replaces each match with the text it is (`CAT` found in `cat`, ignoring
/// case, and replaced by `cat`) records no undo step: the step to redo stays and the document stays
/// unmodified. It still replies with the count. A replacement that changes the text is one undo step.
#[test]
fn find_and_replace_that_changes_nothing_records_no_undo_step() {
let mut s = session();
let a = text(&mut s, "cat cat");
select_with_a_step_to_redo(&mut s, &[a]);
let before = history(&s);
assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved");
assert_eq!(s.execute("edit.findReplace", &json!({"find": "CAT", "replace": "cat"})).unwrap(), json!({"count": 2}));
assert_eq!((history(&s), plain(&s, a)), (before, "cat cat".to_string()));
assert_eq!(s.execute("edit.findReplace", &json!({"find": "cat", "replace": "dog"})).unwrap(), json!({"count": 2}));
assert_eq!((history(&s), plain(&s, a)), ((before.0 + 1, 0, true), "dog dog".to_string()));
}
#[test]
fn find_next_cycles_through_text_objects() {
let mut s = session();
@@ -795,6 +811,25 @@ fn change_case_variants() {
assert!(s.execute("type.changeCase", &json!({"case": "weird"})).is_err());
}
/// Change Case is one undo step, and Change Case to the case the text has already records no undo
/// step: the step to redo stays and the document stays unmodified. It still replies with the
/// objects.
#[test]
fn change_case_to_the_case_the_text_has_records_no_undo_step() {
let mut s = session();
let t = text(&mut s, "hello wORLD. again here");
for case in ["upper", "lower", "title", "sentence"] {
sel(&mut s, &[t]);
let undo = history(&s).0;
s.execute("type.changeCase", &json!({"case": case})).unwrap();
assert_eq!(history(&s), (undo + 1, 0, true), "{case}");
select_with_a_step_to_redo(&mut s, &[t]);
let before = (history(&s), plain(&s, t));
assert_eq!(s.execute("type.changeCase", &json!({"case": case})).unwrap(), json!({"ids": [t.0]}), "{case} again");
assert_eq!((history(&s), plain(&s, t)), before, "{case} again");
}
}
#[test]
fn smart_punctuation_quotes_dashes_ellipsis() {
let mut s = session();
@@ -804,6 +839,24 @@ fn smart_punctuation_quotes_dashes_ellipsis() {
assert_eq!(plain(&s, t), "“Hi” it’s 1–2 and 3—4…");
}
/// Smart Punctuation on type with nothing left to change, or on a selection with no type in it,
/// records no undo step and replies `changed: 0`.
#[test]
fn smart_punctuation_with_nothing_to_change_records_no_undo_step() {
let mut s = session();
let t = text(&mut s, "\"Hi\" it's 1--2 and 3---4...");
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[t]);
assert_eq!(s.execute("type.smartPunctuation", &json!({})).unwrap()["changed"], 1);
for id in [t, r] {
select_with_a_step_to_redo(&mut s, &[id]);
let before = history(&s);
assert_eq!(s.execute("type.smartPunctuation", &json!({})).unwrap(), json!({"changed": 0}), "{id:?}");
assert_eq!(history(&s), before, "{id:?}");
}
assert_eq!(plain(&s, t), "“Hi” it’s 1–2 and 3—4…");
}
#[test]
fn convert_point_area_roundtrip() {
let mut s = session();
@@ -819,6 +872,29 @@ fn convert_point_area_roundtrip() {
assert_eq!(tt.plain_text(), "Some words");
}
/// Convert To Area Type with no point type selected, and Convert To Point Type with no area type
/// selected, record no undo step and reply with the objects. With point type and area type
/// selected, Convert To Area Type converts the point type in one undo step, and Convert To Point
/// Type then converts both in another.
#[test]
fn converting_type_to_the_kind_it_is_records_no_undo_step() {
let mut s = session();
let p = text(&mut s, "Point");
let a = id_of(&s.execute("text.create", &json!({"x": 0, "y": 100, "text": "Area", "area": {"width": 100, "height": 50}})).unwrap());
for (c, id) in [("type.convertToAreaType", a), ("type.convertToPointType", p)] {
select_with_a_step_to_redo(&mut s, &[id]);
let before = history(&s);
assert_eq!(s.execute(c, &json!({})).unwrap(), json!({"ids": [id.0]}), "{c}");
assert_eq!(history(&s), before, "{c}");
}
for c in ["type.convertToAreaType", "type.convertToPointType"] {
select_with_a_step_to_redo(&mut s, &[p, a]);
let undo = history(&s).0;
s.execute(c, &json!({})).unwrap();
assert_eq!(history(&s), (undo + 1, 0, true), "{c}");
}
}
#[test]
fn area_to_point_turns_wraps_into_breaks() {
let mut s = session();
@@ -856,6 +932,26 @@ fn area_placeholder_fills_frame() {
assert!(plain(&s, a).len() > 400);
}
/// Fill with Placeholder Text on type that holds its placeholder text already (point type, and area
/// type filled to its frame), and `type.insert` with an empty `text`, record no undo step and reply
/// with the objects. Inserting text is one undo step.
#[test]
fn placeholder_and_insert_that_change_nothing_record_no_undo_step() {
let mut s = session();
let t = text(&mut s, "x");
let a = id_of(&s.execute("text.create", &json!({"x": 0, "y": 100, "text": "", "area": {"width": 300, "height": 300}})).unwrap());
sel(&mut s, &[t, a]);
s.execute("type.fillPlaceholder", &json!({})).unwrap();
select_with_a_step_to_redo(&mut s, &[t, a]);
let before = (history(&s), plain(&s, t), plain(&s, a));
for (c, p) in [("type.fillPlaceholder", json!({})), ("type.insert", json!({"text": ""}))] {
assert_eq!(s.execute(c, &p).unwrap(), json!({"ids": [t.0, a.0]}), "{c}");
assert_eq!((history(&s), plain(&s, t), plain(&s, a)), before, "{c}");
}
s.execute("type.insert", &json!({"text": "z"})).unwrap();
assert_eq!(history(&s), (before.0.0 + 1, 0, true));
}
#[test]
fn font_and_size_menu_items_use_set_style() {
let mut s = session();
+35
View File
@@ -69,6 +69,20 @@ fn last_step(s: &Session) -> String {
s.doc().unwrap().history.undo.last().unwrap().label.clone()
}
/// How many steps there are to undo and to redo, and whether the document has unsaved changes.
fn history(s: &Session) -> (usize, usize, bool) {
let st = s.doc().unwrap();
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
}
/// Leave a step to redo (a rectangle drawn and undone, which keeps the selection) and mark the
/// document saved.
fn with_a_step_to_redo(s: &mut Session) {
s.execute("shape.rectangle", &json!({"x": 700, "y": 500, "width": 10, "height": 10})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
s.doc_mut().unwrap().mark_saved();
}
/// Press, drag and release `tool` from `from` to `to`.
fn drag(s: &mut Session, tool: &str, from: Point, to: Point, mods: Mods) {
let v = ViewInfo::default();
@@ -113,6 +127,27 @@ fn options_keep_the_brackets() {
}
}
/// Type on a Path Options given the values the type has (OK in the dialog with nothing changed, the
/// effect it has from the Type on a Path menu, the end bracket at the end of the path, or the
/// query's own reply) records no undo step: the step to redo stays and the document stays
/// unmodified. It still replies with the options. A flip is one undo step.
#[test]
fn options_the_type_has_record_no_undo_step() {
let mut s = session();
on_line(&mut s);
with_a_step_to_redo(&mut s);
let before = history(&s);
assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved");
let q = s.execute("type.pathOptions", &json!({})).unwrap();
let ok = json!({"effect": "rainbow", "flip": false, "alignToPath": "baseline", "spacing": 0});
for p in [ok, json!({"effect": "rainbow"}), json!({"end": null}), q.clone()] {
assert_eq!(s.execute("type.pathOptions", &p).unwrap(), q, "{p}");
assert_eq!(history(&s), before, "{p}");
}
s.execute("type.pathOptions", &json!({"flip": true})).unwrap();
assert_eq!(history(&s), (before.0 + 1, 0, true));
}
#[test]
fn flip_turns_the_type_to_the_other_side_of_its_stretch() {
let mut s = session();
+1
View File
@@ -482,6 +482,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included.
All Artwork Above and Other Layers (Object › Lock and Object › Hide), Transform Each, Rearrange All Artboards, the width point and width profile commands, Liquify, Puppet Warp, Define Perspective Grid, Attach to Active Plane, Release with Perspective, moves and transforms in perspective, Move Plane and Move Plane to Match Object record no undo step when they change nothing.
Change Case, Smart Punctuation, Find and Replace, Convert To Area Type, Convert To Point Type, Type on a Path Options, the Type on a Path effects and Fill with Placeholder Text record no undo step when they change nothing.
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`).
- **Files (M4):**
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465).