//! Appearance stacks: the active item that paint, stroke, gradient and transparency edits target //! (`appearance.setActiveItem`, the `item` param). use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, GradientKind, Paint}; use vectorcraft_doc::{AppearanceItem, Node}; use super::*; fn session() -> Session { let mut s = Session::new(); s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); s } /// A selected 100×100 rectangle with the default appearance `[Fill white, Stroke black]`. fn rect(s: &mut Session, x: f64) -> NodeId { let r = s.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 100, "height": 100})).unwrap(); let id = NodeId(r["id"].as_u64().unwrap()); s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); id } fn node(s: &Session, id: NodeId) -> Node { s.doc().unwrap().doc.node(id).unwrap().clone() } fn run(s: &mut Session, id: &str, p: Value) -> Value { s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) } fn stroke(n: &Node, i: usize) -> &vectorcraft_doc::StrokeLayer { n.appearance.stroke_at(Some(i)).unwrap_or_else(|| panic!("item {i} is not a stroke")) } fn fill_paint(n: &Node, i: usize) -> &Paint { &n.appearance.fill_at(Some(i)).unwrap_or_else(|| panic!("item {i} is not a fill")).paint } #[test] fn stroke_set_with_item_changes_only_that_stroke() { let mut s = session(); let id = rect(&mut s, 100.0); run(&mut s, "appearance.addStroke", json!({})); // [Fill, Stroke, Stroke] run(&mut s, "stroke.set", json!({"item": 1, "dash": [4, 2], "weight": 6})); let n = node(&s, id); assert_eq!(stroke(&n, 1).dash.as_ref().unwrap().pattern, vec![4.0, 2.0]); assert_eq!(stroke(&n, 1).width, 6.0); assert!(stroke(&n, 2).dash.is_none()); assert_eq!(stroke(&n, 2).width, 1.0); run(&mut s, "stroke.setAdvanced", json!({"item": 1, "arrowScale": [50, 200]})); let n = node(&s, id); assert_eq!((stroke(&n, 1).arrow_scale, stroke(&n, 2).arrow_scale), ((50.0, 200.0), (100.0, 100.0))); // An explicit item must be a stroke; nothing changes when it is not. assert!(s.execute("stroke.set", &json!({"item": 0, "weight": 9})).is_err()); assert!(s.execute("stroke.set", &json!({"item": 7, "weight": 9})).is_err()); assert!(s.execute("stroke.set", &json!({"item": "top", "weight": 9})).is_err()); assert_eq!(stroke(&node(&s, id), 2).width, 1.0); // Without an item the top stroke changes. run(&mut s, "stroke.set", json!({"weight": 3})); let n = node(&s, id); assert_eq!((stroke(&n, 1).width, stroke(&n, 2).width), (6.0, 3.0)); } #[test] fn edit_gradient_on_the_lower_fill() { let mut s = session(); let id = rect(&mut s, 100.0); run(&mut s, "appearance.addFill", json!({})); // [Fill, Stroke, Fill] run(&mut s, "paint.editGradient", json!({"item": 0, "kind": "radial"})); let n = node(&s, id); let Paint::Gradient(g) = fill_paint(&n, 0) else { panic!("lower fill is not a gradient") }; assert_eq!(g.gradient.kind, GradientKind::Radial); assert_eq!(*fill_paint(&n, 2), Paint::solid(Color::WHITE)); // The gradient vector of the lower fill, through the active item (the gradient tool's path). run(&mut s, "appearance.setActiveItem", json!({"index": 0})); run(&mut s, "paint.setGradientGeom", json!({"start": [100, 150], "end": [200, 150]})); let n = node(&s, id); let Paint::Gradient(g) = fill_paint(&n, 0) else { panic!() }; assert_eq!(g.geom.unwrap().end.x, 200.0); assert_eq!(*fill_paint(&n, 2), Paint::solid(Color::WHITE)); // A stroke row makes the gradient vector a stroke gradient. run(&mut s, "appearance.setActiveItem", json!({"index": 1})); run(&mut s, "paint.setGradientGeom", json!({"start": [100, 150], "end": [100, 250]})); assert!(matches!(stroke(&node(&s, id), 1).paint, Paint::Gradient(_))); } #[test] fn active_item_targets_paint_and_proxies_and_clears_on_selection_change() { let mut s = session(); let other = rect(&mut s, 250.0); let id = rect(&mut s, 100.0); run(&mut s, "appearance.addFill", json!({})); // [Fill, Stroke, Fill] assert!(s.execute("appearance.setActiveItem", &json!({"index": 5})).is_err()); assert_eq!(run(&mut s, "appearance.setActiveItem", json!({"index": 0}))["index"], 0); assert_eq!(s.appearance_item(), Some(0)); assert_eq!(inspect::document(&s)["paint"]["appearanceItem"], 0); assert!(s.fill_active); run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); let n = node(&s, id); assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000"); assert_eq!(*fill_paint(&n, 2), Paint::solid(Color::WHITE)); // The stroke edits fall back to the top stroke while a fill row is active. run(&mut s, "paint.setStroke", json!({"color": "#00ff00"})); assert_eq!(stroke(&node(&s, id), 1).paint.color().unwrap().to_hex(), "#00ff00"); // The proxies show the active row for its kind and the top one for the other. let (f, st) = s.proxy_paints(); assert_eq!((f.color().unwrap().to_hex(), st.color().unwrap().to_hex()), ("#ff0000".to_string(), "#00ff00".to_string())); // A stroke row brings the Stroke proxy forward. run(&mut s, "appearance.setActiveItem", json!({"index": 1})); assert!(!s.fill_active); // Changing the selection clears it, also when the same object is selected again. run(&mut s, "select.set", json!({"ids": [other.0]})); assert_eq!(s.appearance_item(), None); run(&mut s, "select.set", json!({"ids": [id.0]})); assert_eq!(s.appearance_item(), None); run(&mut s, "paint.setFill", json!({"color": "#0000ff"})); let n = node(&s, id); assert_eq!(fill_paint(&n, 2).color().unwrap().to_hex(), "#0000ff"); assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000"); // Explicit ids never use the active item, and `null` forces the top one. run(&mut s, "appearance.setActiveItem", json!({"index": 0})); run(&mut s, "paint.setFill", json!({"color": "#222222", "ids": [id.0]})); assert_eq!(fill_paint(&node(&s, id), 2).color().unwrap().to_hex(), "#222222"); run(&mut s, "paint.setFill", json!({"color": "#111111", "item": null})); let n = node(&s, id); assert_eq!(fill_paint(&n, 2).color().unwrap().to_hex(), "#111111"); assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000"); run(&mut s, "appearance.setActiveItem", json!({"index": null})); assert_eq!(s.appearance_item(), None); } #[test] fn item_rows_follow_remove_and_move() { let mut s = session(); let id = rect(&mut s, 100.0); run(&mut s, "appearance.addFill", json!({})); // [Fill, Stroke, Fill] run(&mut s, "appearance.setActiveItem", json!({"index": 1})); run(&mut s, "appearance.moveItem", json!({"from": 1, "to": 2})); assert_eq!(s.appearance_item(), Some(2)); assert!(matches!(node(&s, id).appearance.items[2], AppearanceItem::Stroke(_))); run(&mut s, "appearance.removeItem", json!({"index": 0})); assert_eq!(s.appearance_item(), Some(1)); run(&mut s, "appearance.removeItem", json!({"index": 1})); assert_eq!(s.appearance_item(), None); } #[test] fn transparency_goes_to_the_targeted_item() { let mut s = session(); let id = rect(&mut s, 100.0); run(&mut s, "transparency.set", json!({"item": 1, "opacity": 40, "blend": "multiply"})); let n = node(&s, id); assert_eq!((stroke(&n, 1).opacity, stroke(&n, 1).blend), (0.4, BlendMode::Multiply)); assert_eq!((n.opacity, n.blend), (1.0, BlendMode::Normal)); run(&mut s, "appearance.setActiveItem", json!({"index": 0})); // Opacity and blend go to the active item, isolate stays on the object. run(&mut s, "transparency.set", json!({"opacity": 1, "isolate": true})); let n = node(&s, id); assert!((n.appearance.items[0].opacity() - 0.01).abs() < 1e-6); assert!(n.isolate); assert_eq!(n.opacity, 1.0); run(&mut s, "transparency.set", json!({"item": null, "opacity": 50})); assert_eq!(node(&s, id).opacity, 0.5); assert!(s.execute("transparency.set", &json!({"item": 9, "opacity": 50})).is_err()); } #[test] fn group_rows_are_the_groups_own_stack() { let mut s = session(); let a = rect(&mut s, 100.0); let b = rect(&mut s, 250.0); run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap()); run(&mut s, "appearance.addFill", json!({})); assert_eq!(node(&s, g).appearance.items.len(), 1); assert_eq!(node(&s, a).appearance.items.len(), 2); run(&mut s, "appearance.setActiveItem", json!({"index": 0})); run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); assert_eq!(fill_paint(&node(&s, g), 0).color().unwrap().to_hex(), "#ff0000"); assert_eq!(*fill_paint(&node(&s, a), 0), Paint::solid(Color::WHITE)); // Without a targeted item, a group's fill edit still recolours its contents. run(&mut s, "appearance.setActiveItem", json!({"index": null})); run(&mut s, "paint.setFill", json!({"color": "#00ff00"})); assert_eq!(fill_paint(&node(&s, b), 0).color().unwrap().to_hex(), "#00ff00"); } #[test] fn text_items_take_item_paint() { let mut s = session(); let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Hi"}))["id"].as_u64().unwrap()); run(&mut s, "select.set", json!({"ids": [t.0]})); run(&mut s, "appearance.addFill", json!({})); run(&mut s, "paint.setFill", json!({"item": 0, "color": "#ff0000"})); let n = node(&s, t); assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000"); let vectorcraft_doc::NodeKind::Text(tx) = &n.kind else { panic!() }; assert_ne!(tx.first_style().fill, Paint::solid(Color::from_hex("#ff0000").unwrap())); } #[test] fn effects_land_on_the_targeted_item() { let mut s = session(); let id = rect(&mut s, 100.0); let r = run(&mut s, "effect.apply", json!({"effect": "distort.roughen", "item": 1})); assert_eq!((r["index"].clone(), r["item"].clone()), (json!(0), json!(1))); let n = node(&s, id); assert_eq!(stroke(&n, 1).effects.len(), 1); assert!(n.appearance.effects.is_empty() && n.appearance.items[0].effects().is_empty()); run(&mut s, "edit.undo", json!({})); assert!(stroke(&node(&s, id), 1).effects.is_empty()); assert!(s.execute("effect.apply", &json!({"effect": "distort.roughen", "item": 9})).is_err()); assert!(s.execute("effect.apply", &json!({"effect": "distort.roughen", "item": -1})).is_err()); // The active item takes the Effect menu's effects (and its alias). run(&mut s, "appearance.setActiveItem", json!({"index": 0})); run(&mut s, "effect.apply", json!({"effect": "distort.twist"})); run(&mut s, "appearance.addEffect", json!({"effect": "stylize.roundCorners"})); let n = node(&s, id); assert_eq!(n.appearance.items[0].effects().iter().map(|e| e.id.as_str()).collect::>(), ["distort.twist", "stylize.roundCorners"]); let l = run(&mut s, "effect.list", json!({})); assert_eq!(l["activeItem"], 0); assert_eq!(l["applied"][0]["items"][0]["kind"], "fill"); assert_eq!(l["applied"][0]["items"][0]["effects"].as_array().unwrap().len(), 2); // Sub-row eye, duplicate and delete address the item's list. run(&mut s, "effect.setParams", json!({"index": 1, "visible": false})); run(&mut s, "effect.duplicate", json!({"index": 0})); run(&mut s, "effect.remove", json!({"index": 2})); let fx = node(&s, id).appearance.items[0].effects().clone(); assert_eq!(fx.iter().map(|e| (e.id.as_str(), e.visible)).collect::>(), [("distort.twist", true), ("distort.twist", true)]); assert!(s.execute("effect.remove", &json!({"index": 5})).is_err()); // `item: null` addresses the object's own effects. run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "item": null})); assert_eq!(node(&s, id).appearance.effects.len(), 1); } #[test] fn per_fill_offset_path_renders() { let mut s = session(); let id = rect(&mut s, 100.0); run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); run(&mut s, "paint.setStroke", json!({"none": true})); let render = |s: &Session| { vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default()) }; assert_eq!(render(&s).pixel(90, 150)[3], 0); run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 20}, "item": 0})); assert_eq!(node(&s, id).appearance.items[0].effects().len(), 1); let px = render(&s).pixel(90, 150); assert!(px[0] > 200 && px[3] > 200, "{px:?}"); } #[test] fn text_fill_items_composite_with_their_opacity() { let mut s = session(); let t = NodeId(run(&mut s, "text.create", json!({"x": 20, "y": 200, "text": "HIM", "size": 150}))["id"].as_u64().unwrap()); run(&mut s, "select.set", json!({"ids": [t.0]})); run(&mut s, "appearance.addFill", json!({})); run(&mut s, "appearance.setItem", json!({"index": 0, "color": "#ff0000"})); let render = |s: &Session| { vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 400, 400, vectorcraft_geom::Affine::IDENTITY, &Default::default()) }; let opaque = render(&s); // A pixel the red fill covers fully (over the black character fill). let (x, y) = (0..400u32).flat_map(|y| (0..400u32).map(move |x| (x, y))).find(|&(x, y)| opaque.pixel(x, y) == [255, 0, 0, 255]).expect("red text"); run(&mut s, "appearance.setItem", json!({"index": 0, "opacity": 50, "blend": "multiply"})); let px = render(&s).pixel(x, y); // Half of red multiplied over black: black. assert!(px[0] < 20 && px[3] == 255, "{px:?}"); run(&mut s, "appearance.setItem", json!({"index": 0, "blend": "normal"})); let px = render(&s).pixel(x, y); assert!((110..=145).contains(&px[0]) && px[1] < 10, "{px:?}"); } #[test] fn add_fill_and_stroke_copy_the_top_ones() { let mut s = session(); let id = rect(&mut s, 100.0); run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); run(&mut s, "stroke.set", json!({"weight": 4})); run(&mut s, "appearance.addFill", json!({})); run(&mut s, "appearance.addStroke", json!({})); // [Fill, Stroke, Fill, Stroke] let n = node(&s, id); assert_eq!(n.appearance.items.iter().map(AppearanceItem::kind_name).collect::>(), ["fill", "stroke", "fill", "stroke"]); assert_eq!(fill_paint(&n, 2).color().unwrap().to_hex(), "#ff0000"); assert_eq!(stroke(&n, 3).width, 4.0); assert!(!n.appearance.is_basic()); run(&mut s, "edit.undo", json!({})); assert_eq!(node(&s, id).appearance.items.len(), 3); } #[test] fn set_item_and_remove_item() { let mut s = session(); let id = rect(&mut s, 100.0); run(&mut s, "appearance.setItem", json!({"index": 1, "opacity": 50, "blend": "screen", "weight": 3, "color": "#0000ff"})); let n = node(&s, id); let st = stroke(&n, 1); assert_eq!((st.opacity, st.blend, st.width), (0.5, BlendMode::Screen, 3.0)); assert_eq!(st.paint.color().unwrap().to_hex(), "#0000ff"); run(&mut s, "appearance.setItem", json!({"index": 0, "visible": false, "opacity": 25})); let n = node(&s, id); assert!(!n.appearance.items[0].visible() && n.appearance.items[0].opacity() == 0.25); assert!(s.execute("appearance.setItem", &json!({})).is_err()); run(&mut s, "appearance.removeItem", json!({"index": 0})); let n = node(&s, id); assert_eq!(n.appearance.items.len(), 1); assert!(n.appearance.fill().is_none() && n.appearance.stroke().is_some()); } #[test] fn clear_and_reduce_to_basic() { let mut s = session(); let id = rect(&mut s, 100.0); run(&mut s, "appearance.addStroke", json!({})); run(&mut s, "appearance.setItem", json!({"index": 2, "visible": false, "color": "#00ff00"})); run(&mut s, "appearance.setItem", json!({"index": 1, "opacity": 50})); run(&mut s, "effect.apply", json!({"effect": "distort.roughen", "item": 1})); run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "item": null})); run(&mut s, "transparency.set", json!({"opacity": 40, "blend": "multiply"})); // Reduce keeps the topmost visible fill and stroke, plain, and the object's transparency. run(&mut s, "appearance.reduceToBasic", json!({})); let n = node(&s, id); assert!(n.appearance.is_basic(), "{:?}", n.appearance); assert_eq!(stroke(&n, 1).paint, Paint::solid(Color::BLACK)); assert_eq!((n.opacity, n.blend), (0.4, BlendMode::Multiply)); // Clear leaves one None fill and stroke, and resets the object's Opacity row. run(&mut s, "appearance.clear", json!({})); let n = node(&s, id); assert_eq!(n.appearance, vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 1.0)); assert_eq!((n.opacity, n.blend), (1.0, BlendMode::Normal)); run(&mut s, "edit.undo", json!({})); assert_eq!(node(&s, id).opacity, 0.4); } #[test] fn graphic_style_apply_replaces_the_stack() { let mut s = session(); let a = rect(&mut s, 100.0); run(&mut s, "appearance.addFill", json!({})); run(&mut s, "effect.apply", json!({"effect": "distort.roughen"})); let name = run(&mut s, "graphicStyle.new", json!({"name": "Rough"}))["name"].clone(); let b = rect(&mut s, 250.0); run(&mut s, "graphicStyle.apply", json!({ "name": name })); assert_eq!(node(&s, b).appearance, node(&s, a).appearance); assert!(s.execute("graphicStyle.apply", &json!({"name": "nope"})).is_err()); } #[test] fn an_active_row_of_the_other_kind_leaves_edits_on_the_painted_leaves() { let mut s = session(); let a = rect(&mut s, 100.0); let b = rect(&mut s, 250.0); run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap()); run(&mut s, "appearance.addFill", json!({})); // The group's own fill row is active: stroke edits still go to the members' strokes. run(&mut s, "appearance.setActiveItem", json!({"index": 0})); run(&mut s, "stroke.set", json!({"weight": 5})); run(&mut s, "paint.setStroke", json!({"color": "#00ff00"})); assert_eq!(node(&s, g).appearance.items.len(), 1, "no stroke row added to the group"); for id in [a, b] { assert_eq!(stroke(&node(&s, id), 1).width, 5.0); assert_eq!(stroke(&node(&s, id), 1).paint.color().unwrap().to_hex(), "#00ff00"); } } #[test] fn the_active_row_follows_only_edits_of_its_object() { let mut s = session(); let other = rect(&mut s, 250.0); rect(&mut s, 100.0); run(&mut s, "appearance.setActiveItem", json!({"index": 1})); run(&mut s, "appearance.removeItem", json!({"index": 0, "ids": [other.0]})); run(&mut s, "appearance.duplicateItem", json!({"index": 0, "ids": [other.0]})); assert_eq!(s.appearance_item(), Some(1)); } #[test] fn item_transparency_is_one_step_and_checks_the_blend_mode() { let mut s = session(); let id = rect(&mut s, 100.0); run(&mut s, "appearance.setActiveItem", json!({"index": 1})); let steps = |s: &Session| s.doc().unwrap().history.undo.len(); let before = steps(&s); run(&mut s, "transparency.set", json!({"knockout": true})); assert_eq!(steps(&s), before + 1, "isolate/knockout alone do not touch the item"); assert_eq!(node(&s, id).knockout, vectorcraft_doc::Knockout::On); assert!(s.execute("transparency.set", &json!({"blend": "nope"})).is_err()); assert_eq!(stroke(&node(&s, id), 1).blend, BlendMode::Normal); } #[test] fn clear_and_reduce_keep_a_groups_stack_empty() { let mut s = session(); let a = rect(&mut s, 100.0); let b = rect(&mut s, 250.0); let members = node(&s, a).appearance; run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap()); run(&mut s, "appearance.reduceToBasic", json!({})); assert!(node(&s, g).appearance.items.is_empty()); run(&mut s, "appearance.addStroke", json!({})); run(&mut s, "appearance.reduceToBasic", json!({})); assert_eq!(node(&s, g).appearance.items.iter().map(AppearanceItem::kind_name).collect::>(), ["stroke"]); run(&mut s, "transparency.set", json!({"opacity": 50})); run(&mut s, "appearance.clear", json!({})); let n = node(&s, g); assert!(n.appearance.items.is_empty() && n.opacity == 1.0); // The members keep their own appearance. assert_eq!(node(&s, a).appearance, members); }