diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 211905166..2af52fa00 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -41,6 +41,24 @@ pub fn specs() -> Vec { has_doc, swap ), + cmd!( + "paint.fillToStroke", + "Apply Fill to Stroke", + [], + None, + "{ids?|id?, skipNone?: bool} each target's top stroke takes its own top fill, as it is (a colour in its model, a global or spot colour linked at its tint, a gradient or pattern with its placement, None); a stroke given paint at width 0 gets 1 pt, as Swap Fill and Stroke does. Type: each run's stroke takes its own fill, and while the Type tool has characters selected (no ids), only those. skipNone: leave alone a target whose fill is None. The default stroke takes the default fill the same way → {objects}", + has_doc, + |s, p| copy_paint(s, p, true) + ), + cmd!( + "paint.strokeToFill", + "Apply Stroke to Fill", + [], + None, + "{ids?|id?, skipNone?: bool} each target's top fill takes its own top stroke's paint, as paint.fillToStroke does the other way; the stroke itself is unchanged. The default fill takes the default stroke → {objects}", + has_doc, + |s, p| copy_paint(s, p, false) + ), cmd!( "paint.default", "Default Fill and Stroke", @@ -793,6 +811,95 @@ fn swap(s: &mut Session, p: &Value) -> Result { ok() } +/// Apply Fill to Stroke (`to_stroke`) or Apply Stroke to Fill: each target copies one of its own +/// paints onto the other, so a mixed selection keeps each object's colour (#5530). Aimed and +/// undone like Swap Fill and Stroke; characters selected with the Type tool are the only ones that +/// change, as for `paint.setFill`. When nothing changes, the document and its undo history stay +/// as they are. +fn copy_paint(s: &mut Session, p: &Value, to_stroke: bool) -> Result { + let (cmd, label) = if to_stroke { ("paint.fillToStroke", "Apply Fill to Stroke") } else { ("paint.strokeToFill", "Apply Stroke to Fill") }; + let skip_none = switch_or(p, "skipNone", false, cmd)?; + // One text style: its stroke takes its fill, or the other way. + let copy_run = |st: &mut CharStyle| { + let from = if to_stroke { st.fill.clone() } else { st.stroke.clone() }; + if skip_none && from.is_none() { + return; + } + if to_stroke { + set_run_stroke(st, from); + } else { + st.fill = from; + } + }; + if p.get("ids").is_none() + && p.get("id").is_none() + && let Some((id, a, b)) = super::textedit::editing_range(s)? + && a != b + { + // After Select All in threaded text, every frame's text takes it. + let story = super::textedit::editing_story(s); + let ranges: Vec<(NodeId, usize, usize)> = + if story.is_empty() { vec![(id, a, b)] } else { story.iter().map(|f| (*f, 0, usize::MAX)).collect() }; + let ids: Vec = ranges.iter().map(|(f, _, _)| *f).collect(); + let before = s.doc()?.doc.clone(); + let n = super::overprint::edit_counted(s, label, |d| { + for &(id, a, b) in &ranges { + let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; + vectorcraft_text::edit::style_range(&mut t.runs, a, b, copy_run); + super::typecmd::refresh_bounds(t); + } + Ok(super::appearance::count_changed(d, &before, &ids)) + })?; + return Ok(json!({"objects": n})); + } + let ids = paint_targets(s, p)?; + let mut n = 0; + if !ids.is_empty() { + let before = s.doc()?.doc.clone(); + n = super::overprint::edit_counted(s, label, |d| { + for id in &ids { + let Some(node) = d.node_mut(*id) else { continue }; + if let NodeKind::Text(t) = &mut node.kind { + for r in &mut t.runs { + copy_run(&mut r.style); + } + super::typecmd::refresh_bounds(t); + continue; + } + let ap = &mut node.appearance; + if to_stroke { + let f = ap.fill_paint(); + if skip_none && f.is_none() { + continue; + } + restore_unpainted_stroke(ap, None, &f); + ap.set_stroke(f); + } else { + let st = ap.stroke_paint(); + if skip_none && st.is_none() { + continue; + } + ap.set_fill(st); + } + } + Ok(super::appearance::count_changed(d, &before, &ids)) + })?; + } + // The defaults for new art change the same way, as Swap Fill and Stroke changes them. + let from = if to_stroke { s.paint.fill.clone() } else { s.paint.stroke.clone() }; + if !(skip_none && from.is_none()) { + if to_stroke { + if s.paint.stroke.is_none() && !from.is_none() && s.paint.stroke_width == 0.0 { + s.paint.stroke_width = 1.0; + } + s.paint.stroke = from; + } else { + s.paint.fill = from; + } + } + Ok(json!({"objects": n})) +} + fn default_paint(s: &mut Session, p: &Value) -> Result { let ids = paint_targets(s, p)?; if !ids.is_empty() { diff --git a/crates/engine/src/tests/paintproxy.rs b/crates/engine/src/tests/paintproxy.rs index a9e832bb3..984c8f9f9 100644 --- a/crates/engine/src/tests/paintproxy.rs +++ b/crates/engine/src/tests/paintproxy.rs @@ -414,3 +414,149 @@ fn a_group_shows_its_contents_in_the_proxies() { s.execute("select.none", &json!({})).unwrap(); assert_eq!(s.proxy_paints().0, s.paint.fill); } + +/// The top fill and top stroke of object `id`. +fn paints(s: &Session, id: NodeId) -> (Paint, Paint, f64) { + let n = node(s, id); + (n.appearance.fill_paint(), n.appearance.stroke_paint(), n.appearance.stroke().map_or(0.0, |st| st.width)) +} + +/// Apply Fill to Stroke gives each selected object a stroke of its own fill, not the first +/// object's; Apply Stroke to Fill does the other way (#5530). A stroke that had no paint and no +/// width gets the 1 pt Swap Fill and Stroke gives it, and the paint copied from stays. +#[test] +fn each_object_copies_its_own_paint_onto_the_other() { + let mut s = session(); + let (a, b) = (rect(&mut s), rect(&mut s)); + for (id, hex) in [(a, "#ff0000"), (b, "#0080ff")] { + s.execute("paint.setFill", &json!({"ids": [id.0], "color": hex})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [id.0], "none": true})).unwrap(); + s.execute("object.setStrokeWidth", &json!({"ids": [id.0], "width": 0})).ok(); + } + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let r = s.execute("paint.fillToStroke", &json!({})).unwrap(); + assert_eq!(r["objects"], 2); + for (id, hex) in [(a, "#ff0000"), (b, "#0080ff")] { + let (fill, stroke, width) = paints(&s, id); + assert_eq!(fill.color().unwrap().to_hex(), hex, "the fill stays"); + assert_eq!(stroke, fill, "the stroke is the object's own fill"); + assert!(width > 0.0, "and shows: {width}"); + } + s.execute("paint.setStroke", &json!({"ids": [a.0], "color": "#00ff00"})).unwrap(); + s.execute("paint.strokeToFill", &json!({"ids": [a.0]})).unwrap(); + let (fill, stroke, _) = paints(&s, a); + assert_eq!((fill.color().unwrap().to_hex(), stroke.color().unwrap().to_hex()), ("#00ff00".into(), "#00ff00".into())); + assert_eq!(paints(&s, b).0.color().unwrap().to_hex(), "#0080ff", "an object not aimed at is left alone"); +} + +/// A global swatch at a tint stays linked at that tint, and a gradient keeps its stops and +/// placement: the paint is copied as it is. +#[test] +fn a_linked_swatch_and_a_gradient_copy_as_they_are() { + let mut s = session(); + let a = rect(&mut s); + s.execute("swatch.new", &json!({"name": "Ink", "color": "#c82828", "global": true})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [a.0], "swatch": "Ink", "tint": 40})).unwrap(); + s.execute("paint.fillToStroke", &json!({"ids": [a.0]})).unwrap(); + let (fill, stroke, _) = paints(&s, a); + assert!(matches!(&stroke, Paint::Solid { swatch: Some(name), .. } if name == "Ink"), "{stroke:?}"); + assert_eq!(stroke, fill); + let g = json!({"gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}], "angle": 30}}); + let mut q = g.clone(); + q["ids"] = json!([a.0]); + s.execute("paint.setFill", &q).unwrap(); + s.execute("paint.fillToStroke", &json!({"ids": [a.0]})).unwrap(); + let (fill, stroke, _) = paints(&s, a); + assert!(matches!(stroke, Paint::Gradient { .. }), "{stroke:?}"); + assert_eq!(stroke, fill, "the same stops and placement"); +} + +/// None copies as None, as Swap moves it; `skipNone` leaves an object whose source is None alone. +#[test] +fn none_copies_unless_skipped() { + let mut s = session(); + let (a, b) = (rect(&mut s), rect(&mut s)); + s.execute("paint.setFill", &json!({"ids": [a.0], "none": true})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [a.0, b.0], "color": "#123456"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [b.0], "color": "#ff8000"})).unwrap(); + let r = s.execute("paint.fillToStroke", &json!({"ids": [a.0, b.0], "skipNone": true})).unwrap(); + assert_eq!(r["objects"], 1, "only the object with a fill changed"); + assert_eq!(paints(&s, a).1.color().unwrap().to_hex(), "#123456", "skipped: its stroke stays"); + assert_eq!(paints(&s, b).1.color().unwrap().to_hex(), "#ff8000"); + s.execute("paint.fillToStroke", &json!({"ids": [a.0]})).unwrap(); + assert!(paints(&s, a).1.is_none(), "without skipNone, no fill gives no stroke"); +} + +/// The objects inside a group each copy their own paint, as for Swap Fill and Stroke. +#[test] +fn a_groups_members_each_copy_their_own() { + let mut s = session(); + let (a, b) = (rect(&mut s), rect(&mut s)); + s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#ff0000"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [b.0], "color": "#00ff00"})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + s.execute("paint.fillToStroke", &json!({})).unwrap(); + assert_eq!(paints(&s, a).1.color().unwrap().to_hex(), "#ff0000"); + assert_eq!(paints(&s, b).1.color().unwrap().to_hex(), "#00ff00"); +} + +/// Type: each run's stroke takes its own fill, so multi-colour text keeps its colours. +#[test] +fn each_text_run_copies_its_own_fill() { + let mut s = session(); + let id = NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [id.0], "color": "#aa0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [id.0], "none": true})).unwrap(); + s.execute("paint.fillToStroke", &json!({"ids": [id.0]})).unwrap(); + let NodeKind::Text(t) = &node(&s, id).kind else { panic!("not text") }; + for r in &t.runs { + assert_eq!(r.style.stroke, r.style.fill, "{:?}", r.text); + assert!(r.style.stroke_width > 0.0, "a painted stroke is visible"); + } +} + +/// One undo step each; when nothing changes, none, and the document stays unmodified. Bad +/// parameters change nothing, not even the defaults. +#[test] +fn one_undo_step_none_when_nothing_changes_and_bad_params_change_nothing() { + let mut s = session(); + let id = rect(&mut s); + s.execute("paint.setFill", &json!({"ids": [id.0], "color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [id.0], "color": "#ff0000"})).unwrap(); + select_with_a_step_to_redo(&mut s, &[id]); + let before = history(&s); + s.execute("paint.fillToStroke", &json!({})).unwrap(); + s.execute("paint.strokeToFill", &json!({})).unwrap(); + assert_eq!(history(&s), before, "the paints were already the same"); + s.execute("paint.setStroke", &json!({"ids": [id.0], "color": "#0000ff"})).unwrap(); + let before = history(&s); + s.execute("paint.fillToStroke", &json!({})).unwrap(); + assert_eq!(history(&s), (before.0 + 1, 0, true), "one step"); + assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Apply Fill to Stroke")); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(paints(&s, id).1.color().unwrap().to_hex(), "#0000ff"); + let defaults = (s.paint.fill.clone(), s.paint.stroke.clone()); + for (cmd, p) in [("paint.fillToStroke", json!({"ids": "not an array"})), ("paint.strokeToFill", json!({"skipNone": "yes"}))] { + assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}"); + } + assert_eq!((s.paint.fill.clone(), s.paint.stroke.clone()), defaults, "the defaults are as they were"); +} + +/// With nothing selected the defaults for new art change the same way: the default stroke takes +/// the default fill, with the weight Swap gives a stroke that had none. +#[test] +fn with_nothing_selected_the_defaults_take_it() { + let mut s = session(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#336699"})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + let r = s.execute("paint.fillToStroke", &json!({})).unwrap(); + assert_eq!(r["objects"], 0); + assert_eq!(s.paint.stroke, s.paint.fill); + assert!(s.paint.stroke_width > 0.0); + s.execute("paint.setStroke", &json!({"color": "#ff00ff"})).unwrap(); + s.execute("paint.strokeToFill", &json!({})).unwrap(); + assert_eq!(s.paint.fill.color().unwrap().to_hex(), "#ff00ff"); +} diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 3b90d0dc2..58e149373 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -3003,3 +3003,28 @@ fn the_fallback_glyph_of_a_consonant_after_its_vowel_sign_is_reported() { let query = s.execute("text.glyphAlternates", &json!({"id": boxed.0, "start": 0})).unwrap(); assert_eq!(query["chosen"], json!(ka), "an overflowing Ka reports the fallback face's glyph for Ka"); } + +/// Apply Fill to Stroke with characters selected with the Type tool: only those characters take +/// a stroke of their own fill, as one undo step (#5530). +#[test] +fn apply_fill_to_stroke_with_characters_selected_changes_only_them() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 50.0, 300.0); + let id = s.doc().unwrap().selection.objects[0]; + s.tool_text("Sample Text", v).unwrap(); + // All of it red, then the first word green: a stroke on the rest would show. + s.execute("paint.setFill", &json!({"ids": [id.0], "color": "#ff0000"})).unwrap(); + s.set_tool_option("select", &json!({"start": 0, "end": 6})); + s.execute("paint.setFill", &json!({"color": "#00a000"})).unwrap(); + let steps = undo_labels(&s).len(); + s.execute("paint.fillToStroke", &json!({})).unwrap(); + let t = obj(&s, id); + assert_eq!(t.runs[0].text, "Sample"); + assert_eq!(t.runs[0].style.stroke, t.runs[0].style.fill, "the selected characters take their fill as a stroke"); + assert!(t.runs[0].style.stroke_width > 0.0); + assert!(t.runs[1].style.stroke.is_none(), "the rest of the type keeps its stroke"); + let labels = undo_labels(&s); + assert_eq!((labels.len(), labels.last().map(String::as_str)), (steps + 1, Some("Apply Fill to Stroke"))); +} diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 643a7ff66..80c567759 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -782,6 +782,12 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { recolor(app, ui, &tgt, invert, false); } } + // Each selected object's stroke takes its own fill, or the other way (#5530). + for (label, cmd) in [(tl!("Apply Fill to Stroke"), "paint.fillToStroke"), (tl!("Apply Stroke to Fill"), "paint.strokeToFill")] { + if menu_item(ui, label, true, false) { + app.run(cmd, json!({})).ok(); + } + } ui.separator(); if menu_item(ui, tl!("Create New Swatch…"), color.is_some(), false) && let Some(c) = color