mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 07:10:13 +00:00
Merge pull request #7560 from dotCooCoo/fix/type-no-op-undo-more
Undo: no step when Ruby, Character or another type command changes nothing
This commit is contained in:
@@ -86,7 +86,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Text",
|
||||
[],
|
||||
None,
|
||||
"{id?|ids?, text} replace the contents of text objects (keeps the first run's style) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}",
|
||||
"{id?|ids?, text} replace the contents of text objects (keeps the first run's style); no undo step when no object changes (each already has the text in one style, every paragraph with the first one's attributes, and no ruby) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}",
|
||||
has_doc,
|
||||
set_text
|
||||
),
|
||||
@@ -104,7 +104,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reshape Type Area",
|
||||
[],
|
||||
None,
|
||||
"{id, anchors: [[subpath, anchor]…], dx, dy} or {items: [{id, anchors}…], dx, dy} move anchors of area type's frame (the type area) by dx, dy points, with their handles; the text reflows at its size, through its thread too (what Direct Selection does dragging a frame corner or edge, or the frame anchors its marquee selected, and the arrow keys nudging those); with items, every listed frame moves in one step",
|
||||
"{id, anchors: [[subpath, anchor]…], dx, dy} or {items: [{id, anchors}…], dx, dy} move anchors of area type's frame (the type area) by dx, dy points, with their handles; the text reflows at its size, through its thread too (what Direct Selection does dragging a frame corner or edge, or the frame anchors its marquee selected, and the arrow keys nudging those); with items, every listed frame moves in one step; no undo step when no frame changes (dx and dy both 0)",
|
||||
has_doc,
|
||||
reshape_area
|
||||
),
|
||||
@@ -113,7 +113,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reshape Type Path",
|
||||
[],
|
||||
None,
|
||||
"{id, anchors: [[subpath, anchor]…], dx, dy} move anchors of type on a path's path by dx, dy points, with their handles; the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging an anchor of the path)",
|
||||
"{id, anchors: [[subpath, anchor]…], dx, dy} move anchors of type on a path's path by dx, dy points, with their handles; the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging an anchor of the path); no undo step when the path does not change (dx and dy both 0)",
|
||||
has_doc,
|
||||
reshape_path
|
||||
),
|
||||
@@ -122,7 +122,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Type Path Handle",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath?, anchor, which: \"in\"|\"out\", x, y, independent?: bool} move a direction handle of an anchor of type on a path's path to (x, y), as path.setHandle moves a path's (independent: that side only, the anchor becomes a corner); the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging a handle of the path)",
|
||||
"{id, subpath?, anchor, which: \"in\"|\"out\", x, y, independent?: bool} move a direction handle of an anchor of type on a path's path to (x, y), as path.setHandle moves a path's (independent: that side only, the anchor becomes a corner); the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging a handle of the path); no undo step when the handle is already there and the path does not change",
|
||||
has_doc,
|
||||
set_path_handle
|
||||
),
|
||||
@@ -131,7 +131,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fit Headline",
|
||||
["Type"],
|
||||
None,
|
||||
"{ids?|id?} track the first line of area type so it fills the frame width → {tracking}",
|
||||
"{ids?|id?} track the first line of area type so it fills the frame width; no undo step when the first line already has that tracking → {tracking}",
|
||||
has_selection,
|
||||
fit_headline
|
||||
),
|
||||
@@ -140,7 +140,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
|
||||
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text; no undo step when no character, paragraph or graph label style changes) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
|
||||
has_doc,
|
||||
set_style
|
||||
),
|
||||
@@ -149,7 +149,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Ruby…",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, start?: byte, end?: byte, text?: reading, position?: above|below (vertical type: right|left), align?: start|center|end, size?: 1–100 (percent of the base text's size, default 50), overhang?: bool (default true), remove?: bool} set the ruby (furigana) over the selected text's bytes in `start..end` (without them: all its text): the reading over the base characters, above them in horizontal type and right of them in vertical type; `remove` takes the ruby off the range instead → {ids, ruby: [{start, end, text, position, align, sizePercent, overhang}…] (the first object's ruby)}",
|
||||
"{ids?|id?, start?: byte, end?: byte, text?: reading, position?: above|below (vertical type: right|left), align?: start|center|end, size?: 1–100 (percent of the base text's size, default 50), overhang?: bool (default true), remove?: bool} set the ruby (furigana) over the selected text's bytes in `start..end` (without them: all its text): the reading over the base characters, above them in horizontal type and right of them in vertical type; `remove` takes the ruby off the range instead; no undo step when the ruby does not change (the same reading and options again, or `remove` where there is none) → {ids, ruby: [{start, end, text, position, align, sizePercent, overhang}…] (the first object's ruby)}",
|
||||
has_selection,
|
||||
ruby
|
||||
),
|
||||
@@ -265,8 +265,8 @@ fn fractional_widths(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Type › Ruby: set or remove the ruby (furigana) over a byte range of the selected type in one
|
||||
/// undo step. Without `start`/`end` the range is all the text; a new reading replaces the ruby
|
||||
/// over the range. The reply lists the first object's ruby (start/end in bytes of its plain text).
|
||||
/// undo step (none when nothing changes). Without `start`/`end` the range is all the text; a new
|
||||
/// reading replaces the ruby over the range. The reply lists the first object's ruby (start/end in bytes of its plain text).
|
||||
fn ruby(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "type.ruby";
|
||||
let ids = text_targets(s, p, C)?;
|
||||
@@ -293,7 +293,8 @@ fn ruby(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let overhang = switch_or(p, "overhang", true, C)?;
|
||||
let mut first: Option<Value> = None;
|
||||
s.edit("Ruby", |d, _| {
|
||||
// The same reading again, or a removal where there is no ruby, is no undo step.
|
||||
edit_if_nodes_changed(s, "Ruby", &ids, |d| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else {
|
||||
return Err(EngineError::NoNode(*id));
|
||||
@@ -320,7 +321,7 @@ fn ruby(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
first = Some(serde_json::to_value(&t.ruby).unwrap_or(Value::Null));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(((), false))
|
||||
})?;
|
||||
Ok(json!({"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>(), "ruby": first.unwrap_or(Value::Null)}))
|
||||
}
|
||||
@@ -755,6 +756,19 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// [`Session::edit`] for a type command that may set what the type already has: `f` runs on a
|
||||
/// copy of the document, and it becomes an undo step only when one of the objects `ids` differs
|
||||
/// afterwards or `f` returns true for a change of its own elsewhere (a graph's kept label styles).
|
||||
/// When nothing changed, the document, its undo and redo steps and its saved state stay as they
|
||||
/// were. Only the objects are compared, not the whole document, whose embedded images can be large.
|
||||
fn edit_if_nodes_changed<T>(s: &mut Session, label: &str, ids: &[NodeId], f: impl FnOnce(&mut Document) -> Result<(T, bool)>) -> Result<T> {
|
||||
super::colorcmds::edit_if_changed(s, label, |before, d| {
|
||||
let (v, other) = f(d)?;
|
||||
let changed = other || ids.iter().any(|id| before.node(*id) != d.node(*id));
|
||||
Ok((v, changed))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
let ids = targets(s, p)?;
|
||||
let t = text_ids(&s.doc()?.doc, &ids);
|
||||
@@ -849,7 +863,8 @@ pub(crate) fn style_chars(t: &mut TextObject, range: Option<TextRange>, mut f: i
|
||||
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = str_param(p, "text").ok_or_else(|| bad("text.setText", "missing `text`"))?.to_string();
|
||||
let ids = text_targets(s, p, "text.setText")?;
|
||||
s.edit("Typing", |d, _| {
|
||||
// The text the type already has is no undo step.
|
||||
edit_if_nodes_changed(s, "Typing", &ids, |d| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let style = t.first_style();
|
||||
@@ -860,7 +875,7 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
t.ruby.clear();
|
||||
refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
Ok(((), false))
|
||||
})?;
|
||||
Ok(with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })))
|
||||
}
|
||||
@@ -933,7 +948,8 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let protect = s.prefs.missing_glyph_protection && (font.is_some() || style.is_some());
|
||||
// The fonts the styled characters are set in, for the warning.
|
||||
let mut set = std::collections::BTreeSet::new();
|
||||
s.edit("Character", |d, _| {
|
||||
// Attributes the characters already have are no undo step.
|
||||
edit_if_nodes_changed(s, "Character", &ids, |d| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let size = size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, C)).transpose()?;
|
||||
@@ -1008,8 +1024,9 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// Graph labels keep a change made to all of their text through the graph being drawn again (sizes as given,
|
||||
// not in a scaled label's own units).
|
||||
let mut labels_changed = false;
|
||||
if range.is_none() {
|
||||
super::graph::capture_label_styles(d, &ids, &|st| {
|
||||
labels_changed = super::graph::capture_label_styles(d, &ids, &|st| {
|
||||
if let Some(f) = &font {
|
||||
st.font_family = f.clone();
|
||||
st.font_version = None;
|
||||
@@ -1052,7 +1069,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
Ok(((), labels_changed))
|
||||
})?;
|
||||
let mut out = with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }));
|
||||
if let Some(w) = super::fonts::set_font_warning(set) {
|
||||
@@ -1350,14 +1367,16 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
items.push((id, refs));
|
||||
}
|
||||
let d = Vec2::new(f64_req(p, "dx", C)?, f64_req(p, "dy", C)?);
|
||||
s.edit("Reshape Type Area", |doc, _| {
|
||||
// Anchors moved by no distance are no undo step.
|
||||
let ids: Vec<NodeId> = items.iter().map(|(id, _)| *id).collect();
|
||||
edit_if_nodes_changed(s, "Reshape Type Area", &ids, |doc| {
|
||||
for (id, refs) in &items {
|
||||
let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(*id)) };
|
||||
if !reshape_area_with(t, |t| t.move_area_anchors(refs, d)) {
|
||||
return Err(bad(C, "no such frame anchor, or the frame would reach past the canvas"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(((), false))
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
@@ -1377,7 +1396,8 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !on_path || node.is_some_and(|n| n.extras().perspective.is_some()) {
|
||||
return Err(bad(C, "not type on a path outside perspective"));
|
||||
}
|
||||
s.edit("Reshape Type Path", |doc, _| {
|
||||
// Anchors moved by no distance are no undo step.
|
||||
edit_if_nodes_changed(s, "Reshape Type Path", &[id], |doc| {
|
||||
let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
let before = t.kind.clone();
|
||||
let within = |t: &TextObject| {
|
||||
@@ -1388,7 +1408,7 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "no such path anchor, or the path would reach past the canvas"));
|
||||
}
|
||||
refresh_bounds(t);
|
||||
Ok(())
|
||||
Ok(((), false))
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
@@ -1406,7 +1426,8 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !on_path || node.is_some_and(|n| n.extras().perspective.is_some()) {
|
||||
return Err(bad(C, "not type on a path outside perspective"));
|
||||
}
|
||||
s.edit("Reshape Type Path", |doc, _| {
|
||||
// A handle moved to where it is is no undo step.
|
||||
edit_if_nodes_changed(s, "Reshape Type Path", &[id], |doc| {
|
||||
let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
let before = t.kind.clone();
|
||||
let within = |t: &TextObject| {
|
||||
@@ -1417,7 +1438,7 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "no such path anchor, or the path would reach past the canvas"));
|
||||
}
|
||||
refresh_bounds(t);
|
||||
Ok(())
|
||||
Ok(((), false))
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
@@ -1428,6 +1449,8 @@ mod block;
|
||||
pub(crate) use block::needed_room;
|
||||
#[cfg(test)]
|
||||
mod block_tests;
|
||||
#[cfg(test)]
|
||||
mod noop_tests;
|
||||
|
||||
/// Width of the first line and of the space it can fill, with `tracking` on the first paragraph.
|
||||
/// The third value is where the first line ends in the plain text.
|
||||
@@ -1469,7 +1492,9 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
plans.push((*id, (lo * 10.0).floor() / 10.0));
|
||||
}
|
||||
let first = plans.first().map(|p| p.1).ok_or_else(|| bad(C, "select area type whose first line can fit its frame"))?;
|
||||
s.edit("Fit Headline", |d, _| {
|
||||
// A headline that already has its fitted tracking is no undo step.
|
||||
let fitted: Vec<NodeId> = plans.iter().map(|(id, _)| *id).collect();
|
||||
edit_if_nodes_changed(s, "Fit Headline", &fitted, |d| {
|
||||
for (id, tr) in &plans {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
let end = t.plain_text().find('\n').unwrap_or(usize::MAX).min(vectorcraft_text::edit::runs_len(&t.runs));
|
||||
@@ -1477,7 +1502,7 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(((), false))
|
||||
})?;
|
||||
Ok(json!({ "tracking": first }))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
use super::*;
|
||||
|
||||
/// The document and the number of undo steps, to compare before and after a command that sets
|
||||
/// what the type already has.
|
||||
fn state(s: &Session) -> (std::sync::Arc<Document>, usize) {
|
||||
let st = s.doc().unwrap();
|
||||
(st.doc.clone(), st.history.undo.len())
|
||||
}
|
||||
|
||||
/// Assert that `cmd` with `p` leaves the document and its undo history as they were.
|
||||
fn records_nothing(s: &mut Session, cmd: &str, p: Value) {
|
||||
let before = state(s);
|
||||
s.execute(cmd, &p).unwrap();
|
||||
let after = state(s);
|
||||
assert!(*after.0 == *before.0, "{cmd} {p} changed the document");
|
||||
assert_eq!(after.1, before.1, "{cmd} {p} recorded an undo step");
|
||||
}
|
||||
|
||||
/// Assert that `cmd` with `p` changes the document in exactly one undo step.
|
||||
fn records_one_step(s: &mut Session, cmd: &str, p: Value) {
|
||||
let before = state(s);
|
||||
s.execute(cmd, &p).unwrap();
|
||||
let after = state(s);
|
||||
assert!(*after.0 != *before.0, "{cmd} {p} left the document as it was");
|
||||
assert_eq!(after.1, before.1 + 1, "{cmd} {p} did not record one undo step");
|
||||
}
|
||||
|
||||
fn session_with(text: &str, area: Option<Value>) -> (Session, u64) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap();
|
||||
let mut p = json!({"x": 10, "y": 20, "text": text, "size": 24});
|
||||
if let Some(a) = area {
|
||||
p["area"] = a;
|
||||
}
|
||||
let id = s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
/// Ruby set again with the same reading and options, or removed where there is none, records no
|
||||
/// undo step; a step that does change the ruby still undoes in one.
|
||||
#[test]
|
||||
fn ruby_that_changes_nothing_records_no_undo_step() {
|
||||
let (mut s, id) = session_with("漢字です", None);
|
||||
records_nothing(&mut s, "type.ruby", json!({"remove": true}));
|
||||
let p = json!({"text": "かんじ", "start": 0, "end": 6, "position": "below", "size": 60});
|
||||
records_one_step(&mut s, "type.ruby", p.clone());
|
||||
records_nothing(&mut s, "type.ruby", p);
|
||||
s.execute("type.ruby", &json!({"remove": true})).unwrap();
|
||||
records_nothing(&mut s, "type.ruby", json!({"remove": true, "start": 0, "end": 6}));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("text") };
|
||||
assert_eq!(t.ruby.len(), 1, "undo brings the ruby back");
|
||||
}
|
||||
|
||||
/// `text.setStyle` (the Character panel) given the attributes the characters already have
|
||||
/// records no undo step, with a range too.
|
||||
#[test]
|
||||
fn character_attributes_already_set_record_no_undo_step() {
|
||||
let (mut s, _) = session_with("Headline", None);
|
||||
records_nothing(&mut s, "text.setStyle", json!({"size": 24}));
|
||||
s.execute("text.setStyle", &json!({"tracking": 50, "fill": "#ff0000"})).unwrap();
|
||||
records_nothing(&mut s, "text.setStyle", json!({"tracking": 50, "fill": "#ff0000"}));
|
||||
records_nothing(&mut s, "text.setStyle", json!({"tracking": 50, "start": 0, "end": 4}));
|
||||
s.execute("text.setStyle", &json!({"justify": "center"})).unwrap();
|
||||
records_nothing(&mut s, "text.setStyle", json!({"justify": "center"}));
|
||||
records_one_step(&mut s, "text.setStyle", json!({"tracking": 80, "start": 0, "end": 4}));
|
||||
}
|
||||
|
||||
/// `text.setStyle` on a graph label that leaves the label's own text as it is but changes the
|
||||
/// style its kind of label keeps still records one undo step; given again, it records none.
|
||||
#[test]
|
||||
fn a_kept_graph_label_style_change_alone_records_one_undo_step() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let csv = ",2024,2025\nQ1,3,2\nQ2,5,4";
|
||||
let graph = NodeId(
|
||||
s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let legend = doc.node(graph).unwrap().children().unwrap().iter().find(|c| c.name.as_deref() == Some("Legend")).unwrap();
|
||||
let (label, size) = legend
|
||||
.children()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find_map(|c| match &c.kind {
|
||||
NodeKind::Text(t) if t.plain_text() == "2025" => Some((c.id, t.runs[0].style.size)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let kept = |s: &Session| s.doc().unwrap().doc.node(graph).unwrap().extras().graph.as_deref().unwrap().label_styles.legend.clone();
|
||||
assert!(kept(&s).is_none());
|
||||
let p = json!({"ids": [label.0], "size": size});
|
||||
let before = state(&s);
|
||||
s.execute("text.setStyle", &p).unwrap();
|
||||
let after = state(&s);
|
||||
assert!(after.0.node(label) == before.0.node(label), "the label's own text changed");
|
||||
assert!(kept(&s).is_some(), "the legend keeps the style");
|
||||
assert_eq!(after.1, before.1 + 1, "the kept style change recorded no undo step");
|
||||
records_nothing(&mut s, "text.setStyle", p);
|
||||
}
|
||||
|
||||
/// `text.setText` with the text the type already has records no undo step; new text records one.
|
||||
#[test]
|
||||
fn the_same_text_records_no_undo_step() {
|
||||
let (mut s, _) = session_with("Same words", None);
|
||||
records_nothing(&mut s, "text.setText", json!({"text": "Same words"}));
|
||||
records_one_step(&mut s, "text.setText", json!({"text": "Other words"}));
|
||||
}
|
||||
|
||||
/// Fit Headline run again on a headline it already fitted records no undo step; the first run
|
||||
/// records one.
|
||||
#[test]
|
||||
fn fit_headline_again_records_no_undo_step() {
|
||||
let (mut s, _) = session_with("HEADLINE\nbody text", Some(json!({"width": 300, "height": 120})));
|
||||
let start = state(&s);
|
||||
let tracking = s.execute("text.fitHeadline", &json!({})).unwrap()["tracking"].clone();
|
||||
let fitted = state(&s);
|
||||
assert!(*fitted.0 != *start.0 && fitted.1 == start.1 + 1, "the first Fit Headline did not record one undo step");
|
||||
let before = state(&s);
|
||||
assert_eq!(s.execute("text.fitHeadline", &json!({})).unwrap()["tracking"], tracking);
|
||||
let after = state(&s);
|
||||
assert!(*after.0 == *before.0 && after.1 == before.1, "a second Fit Headline recorded an undo step");
|
||||
}
|
||||
|
||||
/// Reshaping area type or type on a path by no distance, or moving a path handle to where it is,
|
||||
/// records no undo step; a reshape that moves something records one.
|
||||
#[test]
|
||||
fn reshaping_type_by_nothing_records_no_undo_step() {
|
||||
let (mut s, id) = session_with("Boxed words", Some(json!({"width": 200, "height": 100})));
|
||||
records_nothing(&mut s, "text.reshapeArea", json!({"id": id, "anchors": [[0, 2]], "dx": 0, "dy": 0}));
|
||||
records_one_step(&mut s, "text.reshapeArea", json!({"id": id, "anchors": [[0, 2]], "dx": 20, "dy": 10}));
|
||||
let path =
|
||||
s.execute("path.create", &json!({"anchors": [{"x": 100, "y": 300, "in": [50, 300], "out": [150, 300]}, {"x": 400, "y": 300}]})).unwrap();
|
||||
let on_path = s.execute("text.createInPath", &json!({"path": path["id"], "mode": "onPath", "text": "Along the curve"})).unwrap()["id"].clone();
|
||||
records_nothing(&mut s, "text.reshapePath", json!({"id": on_path, "anchors": [[0, 1]], "dx": 0, "dy": 0}));
|
||||
records_one_step(&mut s, "text.reshapePath", json!({"id": on_path, "anchors": [[0, 1]], "dx": 0, "dy": 20}));
|
||||
let handle = json!({"id": on_path, "anchor": 0, "which": "out", "x": 100, "y": 250});
|
||||
records_one_step(&mut s, "text.setPathHandle", handle.clone());
|
||||
records_nothing(&mut s, "text.setPathHandle", handle);
|
||||
}
|
||||
@@ -651,6 +651,8 @@ fn reset_panel_closes_only_its_own_undo_group() {
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
assert_eq!(app.session.active_index(), Some(1));
|
||||
let b = app.run("text.create", json!({"x": 20, "y": 50, "text": "B"})).unwrap()["id"].clone();
|
||||
// A reset that changes nothing records no step, so B starts away from the panel's defaults.
|
||||
app.run("text.setFormat", json!({"id": b, "antiAlias": "crisp"})).unwrap();
|
||||
app.session.set_active(0);
|
||||
app.run("edit.undoGroup.begin", json!({"label": "Batch"})).unwrap();
|
||||
app.run("text.setFormat", json!({"id": a, "antiAlias": "none"})).unwrap();
|
||||
|
||||
@@ -1060,7 +1060,9 @@ fn reset_panel_leaves_an_undo_group_open_in_another_document() {
|
||||
// The second document is open before the group begins, so switching to it opens no group in it.
|
||||
app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
|
||||
assert_eq!(app.session.active_index(), Some(1));
|
||||
app.run("text.create", json!({"x": 20, "y": 50, "text": "B"})).unwrap();
|
||||
let b = app.run("text.create", json!({"x": 20, "y": 50, "text": "B"})).unwrap()["id"].clone();
|
||||
// A reset that changes nothing records no step, so B starts away from the panel's defaults.
|
||||
app.run("text.setFormat", json!({"id": b, "mojikumi": "none"})).unwrap();
|
||||
app.session.set_active(0);
|
||||
app.run("edit.undoGroup.begin", json!({"label": "Batch"})).unwrap();
|
||||
app.run("text.setFormat", json!({"id": a, "mojikumi": "none"})).unwrap();
|
||||
|
||||
+6
-2
@@ -8433,7 +8433,8 @@ undo step. Object › Transform › Scale, the Scale tool and the Transform pane
|
||||
text reflows along it.
|
||||
`text.setPathHandle {id, subpath?, anchor, which: "in"|"out", x, y, independent?}` moves a direction handle of one
|
||||
of its anchors, as `path.setHandle` moves a path's (Direct Selection dragging a handle of the path, #3565), in one
|
||||
undo step.
|
||||
undo step. A call of `text.reshapeArea`, `text.reshapePath` or `text.setPathHandle` that changes nothing (`dx` and
|
||||
`dy` both 0, or a handle moved to where it already is) adds no undo step and keeps redo.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"text.reshapeArea","params":{"id":42,"anchors":[[0,2]],"dx":40,"dy":60}}}
|
||||
@@ -8498,7 +8499,10 @@ the end of the path), and `fit`, `fitScale` and `dynamicText` (Block is on) for
|
||||
Given for several area types (`ids`, or a selection of several), `text.areaOptions` changes only the options given on
|
||||
each one: each frame keeps its other options (it used to take the first frame's), and `fitMinPercent` alone changes only
|
||||
the frames that already shrink text to fit. Options that change nothing (the vertical alignment already set, say) add
|
||||
no undo step and keep redo.
|
||||
no undo step and keep redo. The same holds for `text.setText` when no object changes (the text it already has, in one
|
||||
style, every paragraph with the first one's attributes and no ruby), `text.setStyle` when no character, paragraph or
|
||||
graph label style changes, `type.ruby` with the same reading and options again or `remove` where there is no ruby, and
|
||||
`text.fitHeadline` on a first line that already has the tracking it would set.
|
||||
|
||||
Text that doesn't fit is drawn up to the last line that does, so a one-line headline in a frame lower than its line
|
||||
draws nothing. The commands that make type or change what it holds say so in their own reply: `text.create`,
|
||||
|
||||
Reference in New Issue
Block a user