diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 09b03f2d63..439661b209 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -422,6 +422,15 @@ pub(crate) fn design_reference(s: &Session, p: &Value, cmd: &str) -> Result Result> { + if matches!(item, ItemTarget::Item { explicit: false, .. }) { + return Ok(None); + } + design_reference(s, p, cmd) +} + /// `ids` after `of` (see [`design_reference`]), when there is one. pub(crate) fn with_reference(of: Option, ids: &[NodeId]) -> Vec { of.into_iter().chain(ids.iter().copied()).collect() @@ -606,16 +615,22 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result> { /// What the edit changes on the topmost visible fill and stroke of graph series marks is stored on /// their graphs in the same step ([`capture_series_changes`]). Nothing targeted is not an edit, and /// when `f` leaves every target as it was, the document and its undo history stay as they are. +/// With `of` (see [`reference_of`]), an explicit aligned `item` is a row of the stack of `of`, which +/// is not among `ids`: it must be there and of the edited kind, each target edits the row equal to +/// it, a target without one is left as it is, and no target with one is an error. Returns the +/// targets `f` was aimed at, in the order of `ids`. +#[allow(clippy::too_many_arguments)] pub(crate) fn edit_items( s: &mut Session, ids: &[NodeId], item: ItemTarget, + of: Option, cmd: &str, label: &str, fill: bool, f: impl FnMut(&mut Node, Option) -> Result<()>, -) -> Result<()> { - edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| Ok(capture_series_changes(before, d, edited))) +) -> Result> { + edit_items_then(s, ids, item, of, cmd, label, fill, f, |before, d, edited| Ok(capture_series_changes(before, d, edited))) } /// [`edit_items`], then `after` on the document as it was before the edit, the edited document and @@ -627,17 +642,29 @@ pub(crate) fn edit_items_then( s: &mut Session, ids: &[NodeId], item: ItemTarget, + of: Option, cmd: &str, label: &str, fill: bool, mut f: impl FnMut(&mut Node, Option) -> Result<()>, mut after: impl FnMut(&vectorcraft_doc::Document, &mut vectorcraft_doc::Document, &[NodeId]) -> Result, -) -> Result<()> { - if ids.is_empty() { - return Ok(()); - } +) -> Result> { + let art = of.and_then(|of| s.doc().ok()?.doc.node(of).map(|n| n.appearance.clone())); + let per = match (item, art) { + // Checked even when the art is the only object given, which leaves `ids` empty. + (ItemTarget::Item { index, explicit: true, aligned: true }, Some(art)) => { + item.resolve(&art, fill, cmd)?; + let per = item.per_target_of(s, of, ids); + if per.iter().all(Option::is_none) { + return Err(bad(cmd, format!("no appearance item {index}"))); + } + per + } + _ if ids.is_empty() => return Ok(Vec::new()), + _ => item.per_target(s, ids), + }; + let aimed: Vec = ids.iter().zip(&per).filter(|(_, item)| item.is_some()).map(|(id, _)| *id).collect(); let before = s.doc()?.doc.clone(); - let per = item.per_target(s, ids); super::overprint::edit_counted(s, label, |d| { let mut edited = Vec::new(); // The objects whose rows `f` edited: on type, no row means its characters. @@ -656,7 +683,13 @@ pub(crate) fn edit_items_then( let labels = super::graph::capture_label_rows(d, &rows); Ok(count_changed(d, &before, &edited) + usize::from(more || labels)) })?; - Ok(()) + Ok(aimed) +} + +/// The first of `ids` that [`edit_items`] aimed at `item` with `of` changes, and the item it changes there. Read it +/// before the edit: once edited, a row that was equal to a row of the art of `of` no longer is. +pub(crate) fn first_edited(s: &Session, item: ItemTarget, of: Option, ids: &[NodeId]) -> Option<(NodeId, ItemTarget)> { + ids.iter().zip(item.per_target_of(s, of, ids)).find_map(|(id, it)| Some((*id, it?))) } /// How many of `ids` differ in `d` from `before`. @@ -665,8 +698,8 @@ pub(crate) fn count_changed(d: &vectorcraft_doc::Document, before: &vectorcraft_ } /// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the -/// targeted item (on the object whose row it is, see [`ItemTarget::anchor`]), else `default`. A `stroke` other than true, false or -/// null is an error of command `cmd`. +/// targeted item (on the object whose row it is, see [`ItemTarget::anchor`] and [`reference_of`]), else `default`. A `stroke` other than +/// true, false or null is an error of command `cmd`. pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool, cmd: &str) -> Result { check_switches(p, &["stroke"], cmd)?; if let Some(b) = p.get("stroke").and_then(Value::as_bool) { @@ -674,8 +707,14 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo } let ItemTarget::Item { index, .. } = item else { return Ok(default) }; let d = &s.doc()?.doc; - let ids = drop_design_art(d, appearance_targets(s, p)?); - Ok(ids.get(item.anchor(s, &ids)).and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill())) + let holder = match reference_of(s, p, item, cmd)? { + Some(of) => Some(of), + None => { + let ids = drop_design_art(d, appearance_targets(s, p)?); + ids.get(item.anchor(s, &ids)).copied() + } + }; + Ok(holder.and_then(|id| d.node(id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill())) } /// The whole number of 0 or more that `key` holds, read with [`u64_req`] and [`index`]. diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 7619444c74..798b635e65 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -14,7 +14,8 @@ use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_render::effects; use super::appearance::{ - ItemTarget, aligned, appearance_targets, design_reference, edit_targets, index_param, item_indices, item_target, item_target_at, with_reference, + ItemTarget, aligned, appearance_targets, design_reference, edit_targets, index_param, item_indices, item_target, item_target_at, reference_of, + with_reference, }; use super::expand::has_seeded; use super::rasterfx; @@ -336,15 +337,6 @@ pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: us /// have. const APPENDED: usize = usize::MAX; -/// The [`design_reference`] of an effect edit aimed at `item`: none for the Appearance panel's active row, which is a -/// row of the first selected object's stack. -fn reference_of(s: &Session, p: &Value, item: ItemTarget, cmd: &str) -> Result> { - if matches!(item, ItemTarget::Item { explicit: false, .. }) { - return Ok(None); - } - design_reference(s, p, cmd) -} - #[allow(clippy::too_many_arguments)] fn edit_effects( s: &mut Session, diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index a37cce38d0..c0cb1b5292 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -201,7 +201,9 @@ fn edit(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freefor first.get_or_insert(r); Ok(()) }; - edit_items(s, &ids, item, cmd, "Gradient", !stroke, |n, index| { + let of = super::appearance::reference_of(s, p, item, cmd)?; + let shown_at = super::appearance::first_edited(s, item, of, &ids); + edit_items(s, &ids, item, of, cmd, "Gradient", !stroke, |n, index| { if index.is_none() && let NodeKind::Text(t) = &mut n.kind { @@ -222,7 +224,9 @@ fn edit(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freefor })?; let r = first.ok_or_else(|| bad(cmd, NOT_FREEFORM))?; // The edited gradient is the last one used (`paint.lastGradient`). - let shown = s.doc()?.doc.node(ids[0]).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, cmd).ok().flatten())); + let shown = shown_at.and_then(|(id, item)| { + s.doc().ok()?.doc.node(id).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, cmd).ok().flatten())) + }); if let Some(p) = shown { s.remember_paint(&p); } diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 7c7724f0f9..a3ebacf42f 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -580,7 +580,10 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { let new_default = apply_gradient_edit_in_model(&default_paint, &with_stored_stops(&for_default, &default_paint, &stored), None, &|_| true, model) .map_err(|e| bad(C, e))?; let freeform = str_param(p, "kind").and_then(GradientKind::parse) == Some(GradientKind::Freeform); - edit_items(s, &ids, item, C, "Gradient", !stroke, |n, index| { + let of = super::appearance::reference_of(s, p, item, C)?; + let shown_at = super::appearance::first_edited(s, item, of, &ids); + // The objects returned: with a design's art, those with a row equal to the art's. + let edited = edit_items(s, &ids, item, of, C, "Gradient", !stroke, |n, index| { if index.is_none() && let NodeKind::Text(t) = &mut n.kind { @@ -616,9 +619,12 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { st.gradient_mode = m; } // The last gradient is the one the first object now shows (the defaults' without objects). - let shown = match ids.first() { - Some(id) => s.doc()?.doc.node(*id).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, C).ok().flatten())), - None => Some(new_default.clone()), + let shown = match shown_at { + Some((id, item)) => { + s.doc()?.doc.node(id).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, C).ok().flatten())) + } + None if ids.is_empty() => Some(new_default.clone()), + None => None, }; if let Some(shown) = shown { s.remember_paint(&shown); @@ -628,7 +634,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { } else { s.paint.fill = new_default; } - Ok(json!({"ids": ids.iter().map(|i| i.0).collect::>()})) + Ok(json!({"ids": edited.iter().map(|i| i.0).collect::>()})) } /// A painted stroke on a type run without a weight gets 1 pt. @@ -766,7 +772,9 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result { } let targets = super::appearance::drop_design_art(&s.doc()?.doc, targets); let model = super::paint::new_color_model(s, &Value::Null); - edit_items(s, &targets, item, C, "Gradient", !stroke, |n, index| { + let of = super::appearance::reference_of(s, p, item, C)?; + // The objects returned: with a design's art, those with a row equal to the art's. + let targets = edit_items(s, &targets, item, of, C, "Gradient", !stroke, |n, index| { if index.is_none() && let NodeKind::Text(t) = &mut n.kind { diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index df002ae526..ba059aac4a 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -859,6 +859,173 @@ fn aligned_rows_of_a_graph_designs_art_and_a_different_stack_are_not_moved() { assert_eq!(paints, [art_was.items[1].paint().clone(), art_was.items[0].paint().clone(), art_was.items[1].paint().clone()]); } +/// An `item` given with `aligned: true` and the art of a graph design in a series first is a row of the art's stack in the +/// paint and stroke commands: each other object edits the row equal to it, and a row the art does not have as a fill or +/// stroke as edited, or that no other object has, is an error that leaves the document and its undo history as they are. +#[test] +fn aligned_paint_rows_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objects() { + let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone(); + let green = Paint::solid(vectorcraft_color::Color::rgb(0.0, 1.0, 0.0)); + // The art's row 1 is the blue fill the mark has as row 0, and its row 2 the black stroke the mark has as row 1. + let (mut s, _, art, mark) = aligned_design_art(); + let black = rows(&s, mark).items[1].clone(); + s.edit("Stroke", |d, _| { + d.node_mut(art).unwrap().appearance.items.push(black.clone()); + Ok(()) + }) + .unwrap(); + let (art_was, mark_was) = (rows(&s, art), rows(&s, mark)); + let fill = |s: &mut Session, cmd: &str, p: serde_json::Value| { + s.execute(cmd, &p).unwrap(); + let now = rows(s, mark); + assert_eq!(rows(s, art), art_was, "{cmd}"); + assert_ne!(now.items[0], mark_was.items[0], "{cmd} edits the mark's blue fill"); + assert_eq!(now.items[1], mark_was.items[1], "{cmd} leaves the mark's stroke"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(rows(s, mark), mark_was, "{cmd} is one undo step"); + now + }; + let ids = [art.0, mark.0]; + let now = fill(&mut s, "paint.setFill", json!({"ids": ids, "item": 1, "aligned": true, "color": "#00ff00"})); + assert_eq!(now.items[0].paint(), &green); + let now = fill(&mut s, "paint.invert", json!({"ids": ids, "item": 1, "aligned": true})); + assert_eq!(Some(s.last_solid), now.items[0].paint().color(), "the inverted color is the last color, not the art's"); + let now = fill(&mut s, "paint.editGradient", json!({"ids": ids, "item": 1, "aligned": true, "kind": "linear"})); + let Paint::Gradient(g) = now.items[0].paint() else { panic!("{:?}", now.items[0].paint()) }; + assert_eq!(&s.last_gradient, &**g, "the mark's new gradient is the last gradient"); + + let stroke = |s: &mut Session, cmd: &str, p: serde_json::Value| { + s.execute(cmd, &p).unwrap(); + let now = rows(s, mark); + assert_eq!(rows(s, art), art_was, "{cmd}"); + assert_eq!(now.items[0], mark_was.items[0], "{cmd} leaves the mark's fill"); + assert_ne!(now.items[1], mark_was.items[1], "{cmd} edits the mark's stroke"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(rows(s, mark), mark_was, "{cmd} is one undo step"); + now + }; + let now = stroke(&mut s, "paint.setStroke", json!({"ids": ids, "item": 2, "aligned": true, "color": "#00ff00"})); + assert_eq!(now.items[1].paint(), &green); + let now = stroke(&mut s, "stroke.set", json!({"ids": ids, "item": 2, "aligned": true, "weight": 5})); + assert_eq!(now.stroke_at(Some(1)).unwrap().width, 5.0); + let now = stroke(&mut s, "stroke.setAdvanced", json!({"ids": ids, "item": 2, "aligned": true, "arrowScale": [200, 200]})); + assert_eq!(now.stroke_at(Some(1)).unwrap().arrow_scale, (200.0, 200.0)); + // Without `stroke`, the kind of the art's row is edited. + let now = stroke(&mut s, "paint.editGradient", json!({"ids": ids, "item": 2, "aligned": true, "kind": "linear"})); + assert!(matches!(now.items[1].paint(), Paint::Gradient(_))); + fill(&mut s, "paint.complement", json!({"ids": ids, "item": 1, "aligned": true, "stroke": false})); + + let steps = s.doc().unwrap().history.undo.len(); + for (cmd, p, said) in [ + ("stroke.set", json!({"ids": ids, "item": 1, "aligned": true, "weight": 5}), "appearance item 1 is not a stroke"), + ("paint.setFill", json!({"ids": ids, "item": 2, "aligned": true, "color": "#00ff00"}), "appearance item 2 is not a fill"), + ("paint.setFill", json!({"ids": ids, "item": 0, "aligned": true, "color": "#00ff00"}), "no appearance item 0"), + ("paint.setFill", json!({"ids": ids, "item": 9, "aligned": true, "color": "#00ff00"}), "no appearance item 9"), + // The art alone: no other object has the row. + ("paint.setFill", json!({"ids": [art.0], "item": 9, "aligned": true, "color": "#00ff00"}), "no appearance item 9"), + ("paint.setFill", json!({"ids": [art.0], "item": 1, "aligned": true, "color": "#00ff00"}), "no appearance item 1"), + ("stroke.set", json!({"ids": [art.0], "item": 1, "aligned": true, "weight": 5}), "appearance item 1 is not a stroke"), + ("paint.editGradient", json!({"ids": [art.0], "item": 1, "aligned": true, "kind": "linear"}), "no appearance item 1"), + ] { + let defaults = format!("{:?}", (&s.paint.fill, &s.paint.stroke, &s.last_gradient)); + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains(said), "{cmd}: {e}"); + assert_eq!((rows(&s, art), rows(&s, mark)), (art_was.clone(), mark_was.clone()), "{cmd}"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{cmd}"); + assert_eq!(format!("{:?}", (&s.paint.fill, &s.paint.stroke, &s.last_gradient)), defaults, "{cmd}"); + } + + // An object with no row equal to the art's is left out of what the commands report as edited. + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let rect = *s.doc().unwrap().selection.subjects().first().unwrap(); + s.execute("paint.setFill", &json!({"ids": [rect.0], "color": "#ff00ff"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [rect.0], "color": "#ff00ff"})).unwrap(); + let rect_was = rows(&s, rect); + let ids = [art.0, mark.0, rect.0]; + for (cmd, p, key, said) in [ + ("paint.setFill", json!({"ids": ids, "item": 1, "aligned": true, "color": "#00ff00"}), "objects", json!(1)), + ("stroke.set", json!({"ids": ids, "item": 2, "aligned": true, "weight": 5}), "objects", json!(1)), + ("paint.editGradient", json!({"ids": ids, "item": 1, "aligned": true, "kind": "linear"}), "ids", json!([mark.0])), + ("paint.setGradientGeom", json!({"ids": ids, "item": 1, "aligned": true, "start": [0, 0], "end": [10, 40]}), "ids", json!([mark.0])), + ] { + if cmd == "paint.setGradientGeom" { + s.execute("paint.editGradient", &json!({"ids": [mark.0], "item": 0, "kind": "linear"})).unwrap(); + let gradient = rows(&s, mark).items[0].clone(); + s.edit("Gradient", |d, _| { + d.node_mut(art).unwrap().appearance.items[1] = gradient.clone(); + Ok(()) + }) + .unwrap(); + } + let r = s.execute(cmd, &p).unwrap(); + assert_eq!(r[key], said, "{cmd}: {r}"); + assert_eq!(rows(&s, rect), rect_was, "{cmd}"); + s.execute("edit.undo", &json!({})).unwrap(); + } + + // A gradient spanning the selection spans only the objects that take it: a distant object without an equal row does + // not move it. + s.execute("shape.rectangle", &json!({"x": 5000, "y": 5000, "width": 10, "height": 10})).unwrap(); + let far = *s.doc().unwrap().selection.subjects().first().unwrap(); + s.execute("paint.setFill", &json!({"ids": [far.0], "color": "#ff00ff"})).unwrap(); + let far_was = rows(&s, far); + let gradient = json!({"stops": [{"offset": 0, "color": "#000000"}, {"offset": 1, "color": "#ffffff"}]}); + let mut placed = Vec::new(); + for ids in [vec![art.0, mark.0], vec![art.0, mark.0, far.0]] { + s.execute("paint.setFill", &json!({"ids": ids, "item": 1, "aligned": true, "gradient": gradient, "span": "selection"})).unwrap(); + placed.push(rows(&s, mark).items[0].clone()); + assert_eq!(rows(&s, far), far_was); + s.execute("edit.undo", &json!({})).unwrap(); + } + assert!(matches!(placed[0].paint(), Paint::Gradient(_))); + assert_eq!(placed[1], placed[0]); +} + +/// The gradient and freeform gradient commands read an `item` given with `aligned: true` and the art of a graph design in +/// a series first as a row of the art's stack, as the paint commands do. +#[test] +fn aligned_gradient_rows_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objects() { + let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone(); + for kind in ["linear", "freeform"] { + let (mut s, _, art, mark) = aligned_design_art(); + // The mark's fill and the art's equal row become the same gradient. + s.execute("paint.editGradient", &json!({"ids": [mark.0], "item": 0, "kind": kind})).unwrap(); + let gradient = rows(&s, mark).items[0].clone(); + // A yellow fill above the mark's gradient is the mark's row 1, as the gradient is the art's row 1. + let mut yellow = gradient.clone(); + *yellow.paint_mut() = Paint::solid(vectorcraft_color::Color::rgb(1.0, 1.0, 0.0)); + s.edit("Gradient", |d, _| { + d.node_mut(art).unwrap().appearance.items[1] = gradient.clone(); + d.node_mut(mark).unwrap().appearance.items.insert(1, yellow.clone()); + Ok(()) + }) + .unwrap(); + let ids = [art.0, mark.0]; + let edits = if kind == "linear" { + vec![ + ("paint.setGradientGeom", json!({"ids": ids, "item": 1, "aligned": true, "start": [0, 0], "end": [10, 40]})), + ("paint.editGradient", json!({"ids": ids, "item": 1, "aligned": true, "kind": "radial"})), + ] + } else { + vec![("paint.freeform.addPoint", json!({"ids": ids, "item": 1, "aligned": true, "at": [5, 5]}))] + }; + for (cmd, p) in edits { + let (art_was, mark_was) = (rows(&s, art), rows(&s, mark)); + s.execute(cmd, &p).unwrap(); + let now = rows(&s, mark); + assert_eq!(rows(&s, art), art_was, "{cmd}"); + assert_ne!(now.items[0], mark_was.items[0], "{cmd} edits the mark's gradient fill"); + assert_eq!(now.items[1..], mark_was.items[1..], "{cmd}"); + s.execute("edit.undo", &json!({})).unwrap(); + // The edited gradient is the last gradient (paint.setGradientGeom does not set it). + if cmd != "paint.setGradientGeom" { + let Paint::Gradient(g) = now.items[0].paint() else { panic!("{cmd}") }; + assert_eq!(&s.last_gradient, &**g, "{cmd}: the mark's edited gradient is the last gradient"); + } + } + } +} + /// The paint commands leave the art of a graph design in a series out, a copy of the design given by id with an item /// included. #[test] diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 02875517f7..b4090cad29 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -538,7 +538,14 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) let item = item_target(s, p, cmd)?.of_kind(s, fill); let selected = item.targets(s, p)?; let ids = super::graph::paint_targets(&s.doc()?.doc, &selected); - let paint = if span { placed_across(&s.doc()?.doc, &ids, paint, p, fill) } else { paint }; + let of = super::appearance::reference_of(s, p, item, cmd)?; + let paint = if span { + // With a design's art, the gradient spans only the objects with a row equal to the art's, which take it. + let spanned: Vec = ids.iter().zip(item.per_target_of(s, of, &ids)).filter_map(|(id, it)| it.map(|_| *id)).collect(); + placed_across(&s.doc()?.doc, &spanned, paint, p, fill) + } else { + paint + }; // The gradient mesh points picked with the Mesh tool or Direct Selection: a solid fill // recolours just those points of their meshes. let mesh_points = if fill && matches!(paint, Paint::Solid { .. }) { picked_mesh_points(s, &ids) } else { vec![] }; @@ -555,10 +562,12 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) // The type objects whose characters took the paint (no row targeted): only those change what a graph's labels keep // for their characters; a row edit is kept with the label's rows. let characters = std::cell::RefCell::new(Vec::new()); - edit_items_then( + // The objects counted: with a design's art, those with a row equal to the art's. + let ids = edit_items_then( s, &ids, item, + of, cmd, label, fill, @@ -1004,26 +1013,34 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn( return Ok(json!({ "changed": changed as usize })); } let item = item_target(s, p, cmd)?.of_kind(s, !stroke); - let (r, shown_item) = match item { + let (r, shown_at) = match item { ItemTarget::Item { index, .. } => { - let ids = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?); + let targets = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?); + let of = super::appearance::reference_of(s, p, item, cmd)?; + // With design art first, the row shown is the one edited in the first object that has it. + let shown_at = match of { + Some(_) => super::appearance::first_edited(s, item, of, &targets).and_then(|(id, it)| match it { + ItemTarget::Item { index, .. } => Some((id, Some(index))), + ItemTarget::Top => None, + }), + None => ids.first().map(|id| (*id, Some(index))), + }; let mut changed = 0; - edit_items(s, &ids, item, cmd, label, !stroke, |n, i| { + edit_items(s, &targets, item, of, cmd, label, !stroke, |n, i| { let paint = if stroke { n.appearance.stroke_at_mut(i).map(|l| &mut l.paint) } else { n.appearance.fill_at_mut(i).map(|l| &mut l.paint) }; changed += paint.is_some_and(|paint| super::colorcmds::map_paint(paint, f)) as usize; Ok(()) })?; - (json!({ "changed": changed }), Some(index)) + (json!({ "changed": changed }), shown_at) } // The proxy recolors its paints, including a colorized image's fill, without changing image pixels or pattern tiles. ItemTarget::Top => { let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::>()}); - (super::colorcmds::recolor(s, &q, cmd, label, f)?, None) + (super::colorcmds::recolor(s, &q, cmd, label, f)?, leaf_targets(s, &ids)?.first().map(|id| (*id, None))) } }; - let shown_ids = if shown_item.is_some() { ids } else { leaf_targets(s, &ids)? }; - if let Some(shown) = shown_ids.first().and_then(|id| s.doc().ok()?.doc.node(*id).map(|n| proxy_paint(n, stroke, shown_item))) { + if let Some(shown) = shown_at.and_then(|(id, at)| s.doc().ok()?.doc.node(id).map(|n| proxy_paint(n, stroke, at))) { s.remember_paint(&shown); } Ok(r) diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 2a0159930d..5ee137b399 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -22,7 +22,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md); on a graph label's stroke row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {objects: how many objects took the stroke options}", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md); on a graph label's stroke row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {objects: how many objects took the stroke options}", has_doc, stroke_set ), @@ -303,7 +303,9 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { st.profile = pr.clone(); } }; - edit_items(s, &ids, item, C, "Stroke", false, |n, index| { + let of = super::appearance::reference_of(s, p, item, C)?; + // The objects counted: with a design's art, those with a row equal to the art's. + let ids = edit_items(s, &ids, item, of, C, "Stroke", false, |n, index| { if index.is_none() && let NodeKind::Text(t) = &mut n.kind { @@ -372,7 +374,7 @@ fn stroke_step(s: &mut Session, p: &Value, up: bool) -> Result { } let ids = super::appearance::drop_design_art(&s.doc()?.doc, ids); let label = if up { "Stroke Weight Up" } else { "Stroke Weight Down" }; - edit_items(s, &ids, item, c, label, false, |n, index| { + edit_items(s, &ids, item, None, c, label, false, |n, index| { if index.is_none() && let NodeKind::Text(t) = &mut n.kind { @@ -535,7 +537,8 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result { { arrows_and_profile(st); } - edit_items(s, &ids, item, C, "Stroke", false, |n, index| { + let of = super::appearance::reference_of(s, p, item, C)?; + edit_items(s, &ids, item, of, C, "Stroke", false, |n, index| { let Some(st) = n.appearance.stroke_at_mut(index) else { return Ok(()) }; arrows_and_profile(st); if let Some(b) = &brush { diff --git a/docs/mcp.md b/docs/mcp.md index 7b13e9eb68..435e1d9629 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -829,9 +829,13 @@ given), `threeD.mapGraphic`, do not use the Appearance panel's active row. When the first object given is design art, rows and effects given with `aligned: true` are rows of its stack in `appearance.setItem`, `appearance.removeItem`, `appearance.duplicateItem`, `transparency.set` with an `item`, `effect.apply`, `appearance.addEffect` and the other `effect.*` commands that edit, -move or remove an effect: each other object edits the row or effect equal to it, and an object without one is left as -it is. When no object has one, `appearance.setItem` and `appearance.removeItem` leave the document as it is, as they do -for a row no object has, and the other commands are an error. `appearance.moveItem`, `effect.move` and +move or remove an effect, `paint.setFill`, `paint.setStroke`, `paint.invert` and `paint.complement` with an `item`, +`stroke.set`, `stroke.setAdvanced`, `paint.editGradient`, `paint.setGradientGeom` and the `paint.freeform.*` commands +that edit points and lines: each other object edits the row or effect equal to it, and an object without one is left +as it is. When no object has one, `appearance.setItem` and `appearance.removeItem` leave the document as it is, as they +do for a row no object has, and the other commands are an error. The paint, stroke and gradient commands are also an +error when the art's row is not the kind of row they edit, and `paint.editGradient`, `paint.setGradientGeom` and the +`paint.freeform.*` commands without `stroke` edit the kind of the art's row. `appearance.moveItem`, `effect.move` and `appearance.duplicateItem` with `to` are an error when the art's stack differs from the other objects', and `appearance.duplicateItem` with `to` is also an error when two or more other objects are given, as it is without the art. To change the art, edit the design with `graph.design`.