diff --git a/crates/engine/src/cmd/addons/arrange.rs b/crates/engine/src/cmd/addons/arrange.rs index d7cae868ed..3dd2e0e43b 100644 --- a/crates/engine/src/cmd/addons/arrange.rs +++ b/crates/engine/src/cmd/addons/arrange.rs @@ -21,6 +21,7 @@ use vectorcraft_geom::{Affine, Point, Rect}; use vectorcraft_render::effects; use super::super::appearance::series_children_in_paint_order; +use super::super::colorcmds::edit_if_changed; use super::super::edit::selected_roots; use super::super::graph::capture_series_object_opacity; use super::super::menucmds::whole_or; @@ -92,7 +93,7 @@ pub fn specs() -> Vec { "Randomize Opacity", ["Object", "Plug-ins", "Arrange Tools"], None, - "{min?: % 0..100 (30), max?: % 0..100 (100; not less than min), seed?: whole number (0; the same seed, objects and range give the same opacities), ids?: [id…] (default: the selection)} give each selected object (the top-level ones) its own opacity, picked at random and evenly from min to max, as one undo step; it is the object's opacity, as the Transparency panel sets it. On paths directly in a graph series whose opacity changes, the graph stores the opacity and blend mode of the frontmost of them for its next redraw (docs/mcp.md) → {count}", + "{min?: % 0..100 (30), max?: % 0..100 (100; not less than min), seed?: whole number (0; the same seed, objects and range give the same opacities), ids?: [id…] (default: the selection)} give each selected object (the top-level ones) its own opacity, picked at random and evenly from min to max, as one undo step (none when no opacity changes); it is the object's opacity, as the Transparency panel sets it. On paths directly in a graph series whose opacity changes, the graph stores the opacity and blend mode of the frontmost of them for its next redraw (docs/mcp.md) → {count}", has_selection, randomize_opacity ), @@ -397,7 +398,8 @@ fn randomize_opacity(s: &mut Session, p: &Value) -> Result { return Ok(json!({ "count": 0 })); } let (lo, hi) = ((min / 100.0) as f32, (max / 100.0) as f32); - s.edit("Randomize Opacity", |d, _| { + // A run that gives every object the opacity it has records no undo step. + edit_if_changed(s, "Randomize Opacity", |_, d| { let mut changed = vec![]; for id in &ids { let o = rng.range(lo, hi); @@ -409,7 +411,7 @@ fn randomize_opacity(s: &mut Session, p: &Value) -> Result { } } keep_series_opacity(d, &changed); - Ok(()) + Ok(((), !changed.is_empty())) })?; Ok(json!({ "count": ids.len() })) } diff --git a/crates/engine/src/cmd/addons/arrange/tests.rs b/crates/engine/src/cmd/addons/arrange/tests.rs index 85191935b2..759f6f321e 100644 --- a/crates/engine/src/cmd/addons/arrange/tests.rs +++ b/crates/engine/src/cmd/addons/arrange/tests.rs @@ -393,6 +393,25 @@ fn randomize_opacity_takes_ids_and_leaves_the_rest() { assert_eq!((opacity(&s, g), opacity(&s, a), opacity(&s, b)), (0.2, 1.0, 1.0)); } +#[test] +fn randomize_opacity_records_no_undo_step_when_no_opacity_changes() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 5.0, 5.0); + let b = rect(&mut s, 10.0, 0.0, 5.0, 5.0); + select(&mut s, &[a, b]); + let n = steps(&s); + // Every object already has the one opacity the range allows. + let r = s.execute("addon.arrange.randomizeOpacity", &json!({"min": 100, "max": 100})).unwrap(); + assert_eq!(r["count"], json!(2)); + assert_eq!(steps(&s), n); + assert_eq!((opacity(&s, a), opacity(&s, b)), (1.0, 1.0)); + s.execute("addon.arrange.randomizeOpacity", &json!({"min": 40, "max": 40})).unwrap(); + assert_eq!(steps(&s), n + 1); + s.execute("addon.arrange.randomizeOpacity", &json!({"min": 40, "max": 40})).unwrap(); + assert_eq!(steps(&s), n + 1, "the same opacities again"); + assert_eq!((opacity(&s, a), opacity(&s, b)), (0.4, 0.4)); +} + #[test] fn randomize_opacity_refuses_bad_params() { let mut s = session(); diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index b3fab4a2bc..73e2a78b31 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Vertical", ["Type", "Orientation"], None, - "{ids?|id?} set vertical writing for the selected type and every frame of its threads → {vertical, ids}", + "{ids?|id?} set vertical writing for the selected type and every frame of its threads; no undo step when every object is already vertical → {vertical, ids}", has_selection, |s, p| orientation(s, p, true) ), @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Horizontal", ["Type", "Orientation"], None, - "{ids?|id?} set horizontal writing for the selected type and every frame of its threads → {vertical, ids}", + "{ids?|id?} set horizontal writing for the selected type and every frame of its threads; no undo step when every object is already horizontal → {vertical, ids}", has_selection, |s, p| orientation(s, p, false) ), @@ -59,7 +59,7 @@ pub fn specs() -> Vec { "Optical Margin Alignment", ["Type"], None, - "{ids?|id?, on?: bool} turn Optical Margin Alignment on or off for the selected type and every frame of its threads (on omitted: the opposite of the first object's setting): in every paragraph of point and area type, quotation marks, hyphens, full stops and commas at a line's start or end hang wholly outside the margin there; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their advance; a left-aligned line hangs at its left margin, a right-aligned line hangs at its right margin, and centered and justified lines hang at both; Auto aligns a right-to-left line at its right; type on a path hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered → {on, ids}", + "{ids?|id?, on?: bool} turn Optical Margin Alignment on or off for the selected type and every frame of its threads (on omitted: the opposite of the first object's setting): in every paragraph of point and area type, quotation marks, hyphens, full stops and commas at a line's start or end hang wholly outside the margin there; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their advance; a left-aligned line hangs at its left margin, a right-aligned line hangs at its right margin, and centered and justified lines hang at both; Auto aligns a right-to-left line at its right; type on a path hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered; no undo step when every object already has the setting → {on, ids}", has_selection, optical_margin_alignment ), @@ -68,7 +68,7 @@ pub fn specs() -> Vec { "System Layout", [], None, - "{on?: bool} turn System Layout on or off for the document (on omitted: the opposite of the document's setting) and every text object in it. On, each character that takes any space advances by a whole number of points, at least one, so small type does not run together; a character with no width stays at none. Off (the default, and what a document from before it reads as), spacing uses fractions of a point. New point, area and path type inherits the document's setting → {on}", + "{on?: bool} turn System Layout on or off for the document (on omitted: the opposite of the document's setting) and every text object in it. On, each character that takes any space advances by a whole number of points, at least one, so small type does not run together; a character with no width stays at none. Off (the default, and what a document from before it reads as), spacing uses fractions of a point. New point, area and path type inherits the document's setting. No undo step when the document and every text object already have the setting → {on}", has_doc, system_layout ), @@ -77,7 +77,7 @@ pub fn specs() -> Vec { "Fractional Widths", [], None, - "{ids?|id?, on?: bool} turn Fractional Widths on or off for the selected type and every frame of its threads (on omitted: the opposite of the first object's setting). On (the default, and what a document from before it reads as), spacing between characters uses fractions of a point. Off, each character that takes any space advances by a whole number of points, at least one, so small type does not run together; a character with no width stays at none. The setting is the whole type object, not a range of characters → {on, ids}", + "{ids?|id?, on?: bool} turn Fractional Widths on or off for the selected type and every frame of its threads (on omitted: the opposite of the first object's setting). On (the default, and what a document from before it reads as), spacing between characters uses fractions of a point. Off, each character that takes any space advances by a whole number of points, at least one, so small type does not run together; a character with no width stays at none. The setting is the whole type object, not a range of characters. No undo step when every object already has the setting → {on, ids}", has_selection, fractional_widths ), @@ -157,24 +157,30 @@ pub fn specs() -> Vec { } /// Type › Type Orientation: set vertical or horizontal writing on every selected text object and -/// every frame of their threads, in one undo step. +/// every frame of their threads, in one undo step (none when nothing changes). fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result { let ids = super::threads::with_threads(&s.doc()?.doc, &text_targets(s, p, "Text Orientation")?); - s.edit("Text Orientation", |d, _| { + // When every object already has the orientation, the document and its undo history stay as they are. + super::colorcmds::edit_if_changed(s, "Text Orientation", |_, d| { + let mut changed = false; for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(*id)); }; - t.vertical = vertical; - refresh_bounds(t); + if t.vertical != vertical { + t.vertical = vertical; + refresh_bounds(t); + changed = true; + } } - Ok(()) + Ok(((), changed)) })?; Ok(json!({"vertical": vertical, "ids": ids.iter().map(|id| id.0).collect::>()})) } /// Type › Optical Margin Alignment: set `on` (without it, the opposite of the first object's -/// setting) on every selected text object and every frame of their threads, in one undo step. +/// setting) on every selected text object and every frame of their threads, in one undo step +/// (none when nothing changes). fn optical_margin_alignment(s: &mut Session, p: &Value) -> Result { let ids = super::threads::with_threads(&s.doc()?.doc, &text_targets(s, p, "Optical Margin Alignment")?); let first = ids.first().and_then(|id| match s.doc().ok()?.doc.node(*id).map(|n| &n.kind) { @@ -182,29 +188,38 @@ fn optical_margin_alignment(s: &mut Session, p: &Value) -> Result { _ => None, }); let on = switch_or(p, "on", !first.unwrap_or(false), "type.opticalMarginAlignment")?; - s.edit("Optical Margin Alignment", |d, _| { + // When every object already has the setting, the document and its undo history stay as they are. + super::colorcmds::edit_if_changed(s, "Optical Margin Alignment", |_, d| { + let mut changed = false; for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(*id)); }; - t.optical_margin = on; - refresh_bounds(t); + if t.optical_margin != on { + t.optical_margin = on; + refresh_bounds(t); + changed = true; + } } - Ok(()) + Ok(((), changed)) })?; Ok(json!({"on": on, "ids": ids.iter().map(|id| id.0).collect::>()})) } /// Character panel menu › System Layout: set `on` (without it, the opposite of the document's -/// setting) on the document and every text object, in one undo step. Off is the default. +/// setting) on the document and every text object, in one undo step (none when nothing changes). +/// Off is the default. fn system_layout(s: &mut Session, p: &Value) -> Result { let current = s.doc()?.doc.setup.system_layout; let on = switch_or(p, "on", !current, "type.systemLayout")?; - s.edit("System Layout", |d, _| { + // When the document and every text object already have the setting, the document and its undo + // history stay as they are. + super::colorcmds::edit_if_changed(s, "System Layout", |_, d| { + let mut changed = d.setup.system_layout != on; d.setup.system_layout = on; let mut ids = vec![]; d.walk(|n| { - if matches!(n.kind, NodeKind::Text(_)) { + if matches!(&n.kind, NodeKind::Text(t) if t.system_layout != on) { ids.push(n.id); } }); @@ -214,15 +229,16 @@ fn system_layout(s: &mut Session, p: &Value) -> Result { }; t.system_layout = on; refresh_bounds(t); + changed = true; } - Ok(()) + Ok(((), changed)) })?; Ok(json!({ "on": on })) } /// Character panel menu › Fractional Widths: set `on` (without it, the opposite of the first /// object's setting) on every selected text object and every frame of their threads, in one undo -/// step. On is the default. +/// step (none when nothing changes). On is the default. fn fractional_widths(s: &mut Session, p: &Value) -> Result { let ids = super::threads::with_threads(&s.doc()?.doc, &text_targets(s, p, "Fractional Widths")?); let first = ids.first().and_then(|id| match s.doc().ok()?.doc.node(*id).map(|n| &n.kind) { @@ -230,15 +246,20 @@ fn fractional_widths(s: &mut Session, p: &Value) -> Result { _ => None, }); let on = switch_or(p, "on", !first.unwrap_or(true), "type.fractionalWidths")?; - s.edit("Fractional Widths", |d, _| { + // When every object already has the setting, the document and its undo history stay as they are. + super::colorcmds::edit_if_changed(s, "Fractional Widths", |_, d| { + let mut changed = false; for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(*id)); }; - t.fractional_widths = on; - refresh_bounds(t); + if t.fractional_widths != on { + t.fractional_widths = on; + refresh_bounds(t); + changed = true; + } } - Ok(()) + Ok(((), changed)) })?; Ok(json!({"on": on, "ids": ids.iter().map(|id| id.0).collect::>()})) } diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 35365cd713..36a0f965b7 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -199,8 +199,11 @@ fn optical_margin_alignment_is_set_per_object_and_saved_only_when_on() { assert!(left(&s) < inside - 5.0, "{} {inside}", left(&s)); assert_eq!(serde_json::to_value(text(&s)).unwrap()["opticalMarginAlignment"], true); // `on` sets it; without `on` it toggles. + // Setting the value the type already has: no undo step. + let n = s.doc().unwrap().history.undo.len(); assert_eq!(s.execute("type.opticalMarginAlignment", &json!({"on": true})).unwrap()["on"], true); assert!(text(&s).optical_margin); + assert_eq!(s.doc().unwrap().history.undo.len(), n); assert_eq!(s.execute("type.opticalMarginAlignment", &json!({})).unwrap()["on"], false); assert!(!text(&s).optical_margin); assert_eq!(left(&s), inside); @@ -247,6 +250,10 @@ fn system_layout_sets_the_document_and_every_text_object() { assert!(fractional.iter().zip(&whole).any(|(a, b)| (a - b).abs() > 0.05), "{fractional:?} {whole:?}"); assert_eq!(serde_json::to_value(setup(&s)).unwrap()["systemLayout"], true); assert_eq!(serde_json::to_value(text(&s, id)).unwrap()["systemLayout"], true); + // Setting the value the document and its type already have: no undo step. + let n = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("type.systemLayout", &json!({"on": true})).unwrap()["on"], true); + assert_eq!(s.doc().unwrap().history.undo.len(), n); // New type inherits the document's setting. let made = s.execute("text.create", &json!({"x": 10, "y": 120, "text": "Z"})).unwrap()["id"].as_u64().unwrap(); assert!(text(&s, made).system_layout); @@ -305,6 +312,10 @@ fn fractional_widths_is_set_per_object_and_saved_only_when_off() { assert!(text(&s).fractional_widths); assert_eq!(s.execute("type.fractionalWidths", &json!({"on": false})).unwrap()["on"], false); assert!(!text(&s).fractional_widths); + // Setting the value the type already has: no undo step. + let n = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("type.fractionalWidths", &json!({"on": false})).unwrap()["on"], false); + assert_eq!(s.doc().unwrap().history.undo.len(), n); // One undo step. s.execute("edit.undo", &json!({})).unwrap(); assert!(text(&s).fractional_widths); @@ -2331,8 +2342,17 @@ fn vertical_point_type_keeps_its_anchor_on_the_column_centre_line() { let tall = bounds(&s); assert!(tall.height() > tall.width(), "{tall:?}"); assert!((tall.center().x - 100.0).abs() < 6.0, "the anchor stays on the column's centre line: {tall:?}"); + // Vertical type set vertical again: no undo step. + let n = s.doc().unwrap().history.undo.len(); + s.execute("type.orientation.vertical", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + assert_eq!(bounds(&s), tall); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(bounds(&s), wide); + // Horizontal type set horizontal: no undo step either. + let n = s.doc().unwrap().history.undo.len(); + s.execute("type.orientation.horizontal", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n); } #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 9199105658..dba7851340 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -684,7 +684,8 @@ CJK composition. (the selection, or `ids`) and reply `{vertical, ids}`. The writing direction is a setting of the whole thread: the commands set every frame of a selected frame's thread, and `ids` lists them all. Threading frames gives the thread its first frame's direction, frames taken out of a thread keep theirs, and a file whose frames of one thread differ opens -with the first frame's direction in all of them. +with the first frame's direction in all of them. When every frame already has the direction, the command records no +undo step. For SVG/SVGZ, `export`'s top-level `outlineText: true` writes text as paths; DXF takes it too (its type is outlined anyway unless `preserve: editability`). PDF instead uses `options: {"advanced": {"outlineText": true}}`; its default is false (live text). @@ -8969,6 +8970,7 @@ for the selected type objects (or `ids`); without `on` it sets the opposite of t on. Optical Margin Alignment is a setting of the whole thread: the command sets every frame of a selected frame's thread, and `ids` lists them all. Threading frames gives the thread its first frame's setting, frames taken out of a thread keep it, and a file whose frames of one thread differ opens with the first frame's setting in all of them. +When every frame already has the setting, the command records no undo step. In every paragraph of point and area type, these characters hang outside the margin when they start or end a line: quotation marks, hyphens (a hyphenated line's hyphen too), full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half their width; and A, V, W, Y, v, w @@ -11550,7 +11552,7 @@ as `object.setBounds` measures them (Use Preview Bounds), except Arrange in Grid | `addon.arrange.replace` | `fit`: `keep`, `bounds` (the replaced object's width and height), `proportional` (fits inside them) (`keep`); `scaleStrokes`; `keep` (false) | Puts a copy of the topmost selected object centred on each of the other selected objects, in that object's place in the stacking order; the replaced objects are deleted unless `keep` (the copy then goes right above each). Needs two objects or more; the copies are selected. → `{ids}` | | `addon.arrange.size` | `width`, `height` pt, 0.001…1000000 (at least one); `proportional` (false: a side not given stays; true: it follows the given one in proportion); `reference` 0…8 (4, the centre); `scaleStrokes` | Gives every selected object that width and height, keeping its reference point in place (the 9-point grid row by row from the top-left). | | `addon.arrange.groupOverlapping` | none | Groups the selected objects (at most 20000) wherever their visual bounds overlap or touch, chains included: when A overlaps B and B overlaps C, all three go in one group. Only objects in the same layer or group are grouped together; objects that overlap nothing stay as they are. Each new group goes where its frontmost member was in the stacking order, and the groups and the objects left alone stay selected; with nothing overlapping it makes no step. Needs two objects or more. → `{groups: [id…], grouped}` (how many objects went into groups) | -| `addon.arrange.randomizeOpacity` | `min`, `max` % 0…100 (30, 100; `min` not more than `max`); `seed` whole number (0); `ids` (the selection) | Gives each selected object its own opacity (the object opacity the Transparency panel sets), picked at random and evenly from `min` to `max`; the same seed, objects and range give the same opacities. Groups take one opacity of their own; the objects inside keep theirs. → `{count}` | +| `addon.arrange.randomizeOpacity` | `min`, `max` % 0…100 (30, 100; `min` not more than `max`); `seed` whole number (0); `ids` (the selection) | Gives each selected object its own opacity (the object opacity the Transparency panel sets), picked at random and evenly from `min` to `max`; the same seed, objects and range give the same opacities. Groups take one opacity of their own; the objects inside keep theirs. A run that changes no opacity, such as `min` and `max` both at the opacity every object has, records no undo step. → `{count}` | | `addon.arrange.sort` | `by`, `apply`: `width`, `height`, `area` (width × height), `x` (left edge), `y` (top edge), `opacity` (%), `stacking` (stacking order from the back) (`area`, `stacking`); `order`: `ascending` (small to large), `descending` (large to small), `random` (`ascending`); `distribute` (false); `seed` whole number (0, for `random`); `scaleStrokes`; `ids` (the selection) | Sorts the selected objects (at most 20000) by `by` and gives them the values of `apply` in that order: the first gets the smallest value. Without `distribute` the objects trade the values they have; with it the values step evenly from the smallest to the largest. A width, height or area changes about each object's centre (an area in proportion), a place moves the top-left corner. Stacking order always trades places, and only among objects in the same layer or group. Needs two objects or more. → `{count}` | Zoom to Selection is the UI command `addon.arrange.zoomSelection {}` (remote mode only): it zooms and centres the