mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-08 21:10:40 +00:00
Paint: Apply Fill to Stroke and Apply Stroke to Fill (#5530)
paint.fillToStroke and paint.strokeToFill copy each target's own top fill onto its top stroke, or the other way, so a mixed selection keeps each object's colour. They aim and undo like Swap Fill and Stroke: groups and compound paths by their members, each text run with its own paint, and only the characters the Type tool has selected. A stroke given paint at width 0 gets 1 pt, skipNone leaves objects whose source paint is None alone, and the defaults for new art change the same way. Both are in the Color panel menu after Invert and Complement.
This commit is contained in:
@@ -41,6 +41,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<NodeId> = 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<Value> {
|
||||
let ids = paint_targets(s, p)?;
|
||||
if !ids.is_empty() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user