//! 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 crate::*; 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); } /// With a fill row active, Add New Fill puts a copy of that row right above it and makes the copy /// the active row; the rows above it stay where they are. #[test] fn add_fill_copies_the_active_fill_row_right_above_it() { let mut s = session(); let id = rect(&mut s, 100.0); run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); run(&mut s, "appearance.addFill", json!({})); run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#0000ff"})); // [red, Stroke, blue] run(&mut s, "appearance.setActiveItem", json!({"index": 0})); let steps = s.doc().unwrap().history.undo.len(); run(&mut s, "appearance.addFill", json!({})); let n = node(&s, id); assert_eq!(n.appearance.items.iter().map(AppearanceItem::kind_name).collect::>(), ["fill", "fill", "stroke", "fill"]); let hex = |i: usize| fill_paint(&n, i).color().unwrap().to_hex(); assert_eq!((hex(0), hex(1), hex(3)), ("#ff0000".to_string(), "#ff0000".to_string(), "#0000ff".to_string())); assert_eq!(s.appearance_item(), Some(1), "the new fill is the active row"); // The next edit goes to the new fill. run(&mut s, "paint.setFill", json!({"color": "#00ff00"})); let n = node(&s, id); assert_eq!((fill_paint(&n, 0).color().unwrap().to_hex(), fill_paint(&n, 1).color().unwrap().to_hex()), ("#ff0000".into(), "#00ff00".into())); assert_eq!(s.doc().unwrap().history.undo.len(), steps + 2); run(&mut s, "edit.undo", json!({})); run(&mut s, "edit.undo", json!({})); assert_eq!(node(&s, id).appearance.items.len(), 3); // An active stroke row, or ids, leave the new fill on top. run(&mut s, "appearance.setActiveItem", json!({"index": 1})); run(&mut s, "appearance.addFill", json!({})); assert_eq!(node(&s, id).appearance.items.iter().map(AppearanceItem::kind_name).collect::>(), ["fill", "stroke", "fill", "fill"]); run(&mut s, "appearance.setActiveItem", json!({"index": 0})); run(&mut s, "appearance.addFill", json!({"ids": [id.0]})); assert_eq!(fill_paint(&node(&s, id), 4).color().unwrap().to_hex(), "#0000ff"); assert_eq!(s.appearance_item(), Some(0)); } /// In a mixed selection the active fill row goes above itself in the first object; an object that /// doesn't share that row gets a copy of its top fill on top, as without an active row. #[test] fn add_fill_above_an_unshared_active_row_tops_the_other_objects() { let mut s = session(); let (a, b) = mixed_circles(&mut s, true); // a [yellow, Stroke], b [red, green, Stroke] run(&mut s, "appearance.setActiveItem", json!({"index": 0})); run(&mut s, "appearance.addFill", json!({})); let (na, nb) = (node(&s, a), node(&s, b)); assert_eq!(na.appearance.items.iter().map(AppearanceItem::kind_name).collect::>(), ["fill", "fill", "stroke"]); assert_eq!(fill_paint(&na, 1).color().unwrap().to_hex(), "#ffff00"); assert_eq!(nb.appearance.items.iter().map(AppearanceItem::kind_name).collect::>(), ["fill", "fill", "stroke", "fill"]); assert_eq!(fill_paint(&nb, 3).color().unwrap().to_hex(), "#00ff00"); assert_eq!(s.appearance_item(), Some(1)); } /// The active row is a row of the first selected object, also when that object paints above the /// others. #[test] fn add_fill_goes_above_the_active_row_of_the_first_selected_object() { let mut s = session(); let (a, b) = mixed_circles(&mut s, true); // a [yellow, Stroke], b [red, green, Stroke] run(&mut s, "select.set", json!({"ids": [b.0, a.0]})); run(&mut s, "appearance.setActiveItem", json!({"index": 1})); // b's green fill run(&mut s, "appearance.addFill", json!({})); let (na, nb) = (node(&s, a), node(&s, b)); assert_eq!(nb.appearance.items.iter().map(AppearanceItem::kind_name).collect::>(), ["fill", "fill", "fill", "stroke"]); assert_eq!(fill_paint(&nb, 2).color().unwrap().to_hex(), "#00ff00"); assert_eq!(na.appearance.items.iter().map(AppearanceItem::kind_name).collect::>(), ["fill", "stroke", "fill"]); assert_eq!(fill_paint(&na, 2).color().unwrap().to_hex(), "#ffff00"); assert_eq!(s.appearance_item(), Some(2)); } #[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); } /// Reduce to Basic Appearance keeps the Appearance panel's active fill or stroke row instead of the /// topmost one of its kind, in each selected object with the same stack. With `ids` it keeps the /// topmost ones. #[test] fn reduce_to_basic_keeps_the_active_row() { let mut s = session(); let a = rect(&mut s, 50.0); let b = rect(&mut s, 200.0); let c = rect(&mut s, 300.0); let stack = |s: &mut Session| { run(s, "appearance.addFill", json!({})); run(s, "appearance.addStroke", json!({})); // [Fill, Stroke, Fill, Stroke] run(s, "appearance.setItem", json!({"index": 0, "color": "#00ff00"})); run(s, "appearance.setItem", json!({"index": 2, "color": "#ff0000"})); run(s, "appearance.setItem", json!({"index": 1, "weight": 7})); }; run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); stack(&mut s); // The lower, green fill row is active: both objects keep it and their top stroke. run(&mut s, "appearance.setActiveItem", json!({"index": 0})); run(&mut s, "appearance.reduceToBasic", json!({})); for id in [a, b] { let n = node(&s, id); assert!(n.appearance.is_basic(), "{:?}", n.appearance); assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#00ff00"); assert_eq!(stroke(&n, 1).width, 1.0); } // The lower stroke row is active: it stays, with the top fill. run(&mut s, "edit.undo", json!({})); run(&mut s, "appearance.setActiveItem", json!({"index": 1})); run(&mut s, "appearance.reduceToBasic", json!({})); let n = node(&s, a); assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000"); assert_eq!(stroke(&n, 1).width, 7.0); // Given ids, the topmost fill and stroke stay. run(&mut s, "select.set", json!({"ids": [c.0]})); stack(&mut s); run(&mut s, "appearance.setActiveItem", json!({"index": 0})); run(&mut s, "appearance.reduceToBasic", json!({"ids": [c.0]})); let n = node(&s, c); assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000"); assert_eq!(stroke(&n, 1).width, 1.0); } /// The active row is a row of the first selected object even when that object paints above the /// others; another object without an equal row keeps its topmost fill. #[test] fn reduce_to_basic_keeps_the_active_row_of_the_first_selected_object() { let mut s = session(); let a = rect(&mut s, 50.0); run(&mut s, "appearance.addFill", json!({})); run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#0000ff"})); // [Fill, Stroke, Fill blue] let b = rect(&mut s, 200.0); run(&mut s, "appearance.addFill", json!({})); run(&mut s, "appearance.setItem", json!({"index": 0, "color": "#00ff00"})); run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#ff0000"})); // [Fill green, Stroke, Fill red] run(&mut s, "select.set", json!({"ids": [b.0, a.0]})); run(&mut s, "appearance.setActiveItem", json!({"index": 0})); run(&mut s, "appearance.reduceToBasic", json!({})); assert_eq!(fill_paint(&node(&s, b), 0).color().unwrap().to_hex(), "#00ff00"); assert_eq!(fill_paint(&node(&s, a), 0).color().unwrap().to_hex(), "#0000ff"); } /// b `[Fill red, Fill white, Stroke]` above a `[Fill white, Stroke]`, selected b first: b's rows 1 /// and 2 equal a's rows 0 and 1. fn reversed_pair(s: &mut Session) -> (NodeId, NodeId) { let a = rect(s, 50.0); let b = rect(s, 200.0); run(s, "appearance.addFill", json!({})); run(s, "appearance.moveItem", json!({"from": 2, "to": 0})); run(s, "appearance.setItem", json!({"index": 0, "color": "#ff0000"})); run(s, "select.set", json!({"ids": [b.0, a.0]})); (a, b) } /// The active row is a row of the first selected object also for paint, stroke and effect edits /// when that object paints above the others. #[test] fn edits_change_the_active_row_of_the_first_selected_object() { let mut s = session(); let (a, b) = reversed_pair(&mut s); run(&mut s, "appearance.setActiveItem", json!({"index": 0})); // b's red fill run(&mut s, "paint.setFill", json!({"color": "#00ff00"})); let (na, nb) = (node(&s, a), node(&s, b)); assert_eq!(fill_paint(&nb, 0).color().unwrap().to_hex(), "#00ff00"); assert_eq!(*fill_paint(&nb, 1), Paint::solid(Color::WHITE)); // a shares no red row, so its fill row 0 changes, as for an active row the objects don't share. assert_eq!(fill_paint(&na, 0).color().unwrap().to_hex(), "#00ff00"); run(&mut s, "effect.apply", json!({"effect": "distort.twist"})); let nb = node(&s, b); assert_eq!((nb.appearance.items[0].effects().len(), nb.appearance.items[1].effects().len()), (1, 0)); let mut s = session(); let (a, b) = reversed_pair(&mut s); run(&mut s, "appearance.setActiveItem", json!({"index": 1})); // b's white fill, a's row 0 run(&mut s, "paint.editGradient", json!({"kind": "radial"})); let (na, nb) = (node(&s, a), node(&s, b)); assert!(matches!(fill_paint(&nb, 1), Paint::Gradient(_)), "b's white fill takes the gradient"); assert!(matches!(fill_paint(&na, 0), Paint::Gradient(_)), "so does a's equal fill"); assert_eq!(fill_paint(&nb, 0).color().unwrap().to_hex(), "#ff0000"); assert!(!matches!(stroke(&nb, 2).paint, Paint::Gradient(_)) && !matches!(stroke(&na, 1).paint, Paint::Gradient(_))); // The effect goes on the equal rows, and the reply names the active row of b. run(&mut s, "appearance.setActiveItem", json!({"index": 2})); // b's stroke, a's row 1 let r = run(&mut s, "effect.apply", json!({"effect": "distort.twist"})); assert_eq!(r["item"], 2); let (na, nb) = (node(&s, a), node(&s, b)); assert_eq!((stroke(&nb, 2).effects.len(), stroke(&na, 1).effects.len()), (1, 1)); // With nothing selected an aligned item is an error, not a crash. run(&mut s, "select.set", json!({"ids": []})); let e = s.execute("effect.remove", &json!({"index": 0, "item": 0, "aligned": true})).unwrap_err().to_string(); assert!(!e.contains("internal error"), "{e}"); } /// `effect.apply`'s reply names the new effect by its index in the object whose row `item` is, /// also when the objects painted after it already have effects there. #[test] fn apply_reports_the_index_in_the_object_whose_row_it_names() { let mut s = session(); let (a, b) = reversed_pair(&mut s); let c = rect(&mut s, 350.0); run(&mut s, "effect.apply", json!({"effect": "distort.twist", "item": 0})); // b's red fill row is not shared, so each object's row 0 takes the effect: c's second one. run(&mut s, "select.set", json!({"ids": [b.0, a.0, c.0]})); run(&mut s, "appearance.setActiveItem", json!({"index": 0})); let r = run(&mut s, "effect.apply", json!({"effect": "distort.twist"})); assert_eq!(node(&s, c).appearance.items[0].effects().len(), 2); assert_eq!((r["item"].clone(), r["index"].clone()), (json!(0), json!(0)), "b's row 0 holds one effect"); // An explicit item is a row of the first target, a, which paints below c. run(&mut s, "select.set", json!({"ids": [c.0, a.0]})); let r = run(&mut s, "effect.apply", json!({"effect": "distort.twist", "item": 0})); assert_eq!(node(&s, c).appearance.items[0].effects().len(), 3); assert_eq!((r["item"].clone(), r["index"].clone()), (json!(0), json!(1)), "a's row 0 holds two effects"); } /// `effect.move`'s reply names where the effect landed in the object whose row the active item is /// (the first selected object), also when that object paints above the others. #[test] fn move_reports_the_index_in_the_object_whose_row_it_names() { let mut s = session(); let (a, b) = reversed_pair(&mut s); run(&mut s, "select.set", json!({"ids": [a.0]})); run(&mut s, "effect.apply", json!({"effect": "distort.twist", "item": 0})); run(&mut s, "select.set", json!({"ids": [b.0]})); run(&mut s, "effect.apply", json!({"effect": "distort.twist", "item": 0})); run(&mut s, "effect.apply", json!({"effect": "distort.twist", "item": 0})); run(&mut s, "select.set", json!({"ids": [b.0, a.0]})); run(&mut s, "appearance.setActiveItem", json!({"index": 0})); // Row 0's first effect goes to the end of the row: index 1 in b, 0 in a, which paints first. let r = run(&mut s, "effect.move", json!({"from": 0, "to": 9})); assert_eq!((node(&s, a).appearance.items[0].effects().len(), node(&s, b).appearance.items[0].effects().len()), (1, 2)); assert_eq!((r["item"].clone(), r["index"].clone()), (json!(0), json!(1)), "b's row 0 holds two effects"); // An explicit item is a row of the first target, a. let r = run(&mut s, "effect.move", json!({"from": 0, "to": 9, "fromItem": 0})); assert_eq!((r["item"].clone(), r["index"].clone()), (json!(0), json!(0)), "a's row 0 holds one effect"); // When the first selected object has no effect to move, the reply names the first object that moved. run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); run(&mut s, "appearance.setActiveItem", json!({"index": 0})); let r = run(&mut s, "effect.move", json!({"from": 1, "to": 0})); assert_eq!(r["ids"], json!([b.0]), "only b has a second effect in row 0"); assert_eq!((r["item"].clone(), r["index"].clone()), (json!(0), json!(0)), "b's effect moved to the start of its row 0"); // From the active row to an explicit destination (here the object's effects, where b already // has one), the reply names the first target, a. run(&mut s, "select.set", json!({"ids": [b.0]})); run(&mut s, "effect.apply", json!({"effect": "distort.twist", "item": null})); run(&mut s, "select.set", json!({"ids": [b.0, a.0]})); run(&mut s, "appearance.setActiveItem", json!({"index": 0})); let r = run(&mut s, "effect.move", json!({"from": 0, "to": 9, "toItem": null})); assert_eq!((node(&s, a).appearance.effects.len(), node(&s, b).appearance.effects.len()), (1, 2)); assert_eq!((r["item"].clone(), r["index"].clone()), (json!(null), json!(0)), "a's effects hold one effect"); } /// How many steps there are to undo and to redo, and whether the document has unsaved changes. fn history(s: &Session) -> (usize, usize, bool) { let st = s.doc().unwrap(); (st.history.undo.len(), st.history.redo.len(), st.is_dirty()) } /// Select `ids` with a move of them left to redo, and mark the document saved. fn select_with_a_step_to_redo(s: &mut Session, ids: &[NodeId]) { s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); s.execute("edit.undo", &json!({})).unwrap(); s.doc_mut().unwrap().mark_saved(); } /// Fill, stroke and gradient edits that leave every object as it was (the color, stroke options, /// gradient or item values the objects have already) record no undo step: the step to redo stays /// and the document stays unmodified. A fill that changes one of them is one step. #[test] fn fill_stroke_and_gradient_edits_that_change_nothing_record_no_undo_step() { let mut s = session(); let a = rect(&mut s, 50.0); let b = rect(&mut s, 200.0); run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); run(&mut s, "paint.setStroke", json!({"color": "#0000ff"})); run(&mut s, "stroke.set", json!({"weight": 3, "dash": [4, 2], "cap": "round"})); run(&mut s, "appearance.addFill", json!({})); run(&mut s, "appearance.addFill", json!({})); // [Fill, Stroke, Fill, Fill], every fill red run(&mut s, "paint.setGradientGeom", json!({"item": 0, "start": [60, 150], "end": [140, 150]})); run(&mut s, "paint.editGradient", json!({"item": 2, "kind": "freeform"})); run(&mut s, "paint.freeform.setPoint", json!({"item": 2, "index": 0, "color": "#00ff00"})); select_with_a_step_to_redo(&mut s, &[a, b]); let before = history(&s); assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved"); for (id, p) in [ ("paint.setFill", json!({"color": "#ff0000"})), ("paint.setStroke", json!({"color": "#0000ff"})), ("paint.setFill", json!({"color": "#ff0000", "ids": [a.0]})), ("paint.lastColor", json!({"stroke": false})), ("stroke.set", json!({"weight": 3, "dash": [4, 2], "cap": "round"})), ("stroke.setAdvanced", json!({"arrowScale": [100, 100]})), ("paint.setGradientGeom", json!({"item": 0, "start": [60, 150], "end": [140, 150]})), ("paint.editGradient", json!({"item": 0, "kind": "linear"})), ("paint.freeform.setPoint", json!({"item": 2, "index": 0, "color": "#00ff00"})), ("transparency.set", json!({"item": 1, "opacity": 100, "blend": "normal"})), ] { run(&mut s, id, p.clone()); assert_eq!(history(&s), before, "{id} {p}"); } run(&mut s, "paint.setFill", json!({"color": "#00ff00", "ids": [b.0]})); assert_eq!(history(&s), (before.0 + 1, 0, true)); assert_eq!(fill_paint(&node(&s, b), 3).color().unwrap().to_hex(), "#00ff00"); } /// Appearance panel commands that leave every object as it was record no undo step: Reduce to Basic /// Appearance on a basic appearance, an item set to the values it has or to an index no object has, /// Remove Item past every stack, Move Item to where the item is, the Eyedropper and Move Appearance /// between objects that look the same, and Clear Appearance on cleared objects. Clearing objects that /// have something to clear is one step. #[test] fn appearance_commands_that_change_nothing_record_no_undo_step() { let mut s = session(); let a = rect(&mut s, 50.0); let b = rect(&mut s, 200.0); select_with_a_step_to_redo(&mut s, &[a, b]); let before = history(&s); for (id, p) in [ ("appearance.reduceToBasic", json!({})), ("appearance.setItem", json!({"index": 1, "opacity": 100, "blend": "normal", "visible": true, "weight": 1})), ("appearance.setItem", json!({"index": 5, "opacity": 50})), ("appearance.removeItem", json!({"index": 5})), ("appearance.moveItem", json!({"from": 1, "to": 1})), ("appearance.copyFrom", json!({"source": b.0, "ids": [a.0]})), ("appearance.transfer", json!({"source": b.0, "target": a.0, "copy": true})), ] { run(&mut s, id, p.clone()); assert_eq!(history(&s), before, "{id} {p}"); } run(&mut s, "appearance.clear", json!({})); assert_eq!(history(&s), (before.0 + 1, 0, true), "the first Clear is one step"); s.doc_mut().unwrap().mark_saved(); let cleared = history(&s); for (id, p) in [("appearance.clear", json!({})), ("appearance.transfer", json!({"source": b.0, "target": a.0}))] { run(&mut s, id, p.clone()); assert_eq!(history(&s), cleared, "{id} {p}"); } } /// The Appearance panel's commands refuse a weight or opacity that is not a number and an on/off /// option that is not true or false, given as text or as another value of the wrong kind; the /// objects and the undo history stay as they were. Numbers, true, false and null still work. #[test] fn appearance_commands_refuse_a_number_or_boolean_given_as_text() { use crate::tests::{not_a_number, not_a_switch, not_refused}; let mut s = session(); let a = rect(&mut s, 50.0); let b = rect(&mut s, 200.0); run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); let calls = [ not_a_number("appearance.setItem", json!({"index": 1, "weight": "3"}), "weight"), not_a_number("appearance.setItem", json!({"index": 0, "opacity": "50"}), "opacity"), not_a_switch("appearance.setItem", json!({"index": 0, "visible": "false"}), "visible"), not_a_switch("appearance.transfer", json!({"source": b.0, "target": a.0, "copy": "true"}), "copy"), not_a_switch("appearance.copyFrom", json!({"source": b.0, "ids": [a.0], "reverse": "true"}), "reverse"), not_a_switch("appearance.copyFrom", json!({"source": b.0, "ids": [a.0], "append": 1}), "append"), ]; let wrong = not_refused(&mut s, &calls); assert!(wrong.is_empty(), "not refused: {wrong:#?}"); run(&mut s, "appearance.setItem", json!({"index": 1, "weight": 3, "visible": null})); run(&mut s, "appearance.transfer", json!({"source": b.0, "target": a.0, "copy": true})); assert_eq!((stroke(&node(&s, a), 1).width, fill_paint(&node(&s, a), 0)), (stroke(&node(&s, b), 1).width, fill_paint(&node(&s, b), 0))); } #[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); } /// #4174: two circles with different fills, the same 11 pt blue stroke and the same Feather, /// both selected. Their default stacks are `[Fill, Stroke]`; the second gets a second fill under /// its stroke when `extra_fill`, so the shared stroke sits at another index there. fn mixed_circles(s: &mut Session, extra_fill: bool) -> (NodeId, NodeId) { let mut circle = |x: f64, color: &str| { let id = NodeId(run(s, "shape.ellipse", json!({"x": x, "y": 100, "width": 100, "height": 100}))["id"].as_u64().unwrap()); run(s, "paint.setFill", json!({"ids": [id.0], "color": color})); run(s, "appearance.setItem", json!({"ids": [id.0], "index": 1, "color": "#0000ff", "weight": 11})); run(s, "effect.apply", json!({"ids": [id.0], "effect": "stylize.feather", "item": null})); id }; let (a, b) = (circle(0.0, "#ffff00"), circle(150.0, "#ff0000")); if extra_fill { // Another fill on top of b's fill and moved under its stroke: [Fill, Fill, Stroke]. run(s, "appearance.addFill", json!({"ids": [b.0]})); run(s, "appearance.moveItem", json!({"ids": [b.0], "from": 2, "to": 1})); run(s, "appearance.setItem", json!({"ids": [b.0], "index": 1, "color": "#00ff00"})); } run(s, "select.set", json!({"ids": [a.0, b.0]})); (a, b) } /// The shared stroke row edits the stroke of both circles, whatever its index in each, as one undo /// step, leaving their fills alone; undo restores both. #[test] fn a_shared_row_edits_every_selected_object() { for extra in [false, true] { let mut s = session(); let (a, b) = mixed_circles(&mut s, extra); let b_stroke = if extra { 2 } else { 1 }; let fills = (node(&s, a).appearance.items.clone(), node(&s, b).appearance.items.clone()); let steps = s.doc().unwrap().history.undo.len(); // The panel lists the first object's rows: the stroke is a's item 1. run(&mut s, "appearance.setItem", json!({"index": 1, "weight": 4, "aligned": true})); assert_eq!(stroke(&node(&s, a), 1).width, 4.0); assert_eq!(stroke(&node(&s, b), b_stroke).width, 4.0, "extra fill: {extra}"); assert_eq!(node(&s, a).appearance.items[0], fills.0[0], "a's fill untouched"); assert_eq!(node(&s, b).appearance.items.len(), fills.1.len()); assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step"); run(&mut s, "edit.undo", json!({})); assert_eq!((node(&s, a).appearance.items.clone(), node(&s, b).appearance.items.clone()), fills, "undo restores both"); // The active row (a's item 1) carries paint edits from other panels to both strokes too. run(&mut s, "appearance.setActiveItem", json!({"index": 1})); run(&mut s, "stroke.set", json!({"weight": 7})); assert_eq!(stroke(&node(&s, b), b_stroke).width, 7.0, "extra fill: {extra}"); } } /// Removing the shared effect removes it from both; adding a fill adds one to both. #[test] fn shared_effects_and_new_fills_reach_every_selected_object() { let mut s = session(); let (a, b) = mixed_circles(&mut s, true); run(&mut s, "effect.remove", json!({"index": 0, "item": null, "aligned": true})); assert!(node(&s, a).appearance.effects.is_empty() && node(&s, b).appearance.effects.is_empty()); let (na, nb) = (node(&s, a).appearance.items.len(), node(&s, b).appearance.items.len()); run(&mut s, "appearance.addFill", json!({})); assert_eq!((node(&s, a).appearance.items.len(), node(&s, b).appearance.items.len()), (na + 1, nb + 1)); // Rows of different stacks aren't reordered together. assert!(s.execute("appearance.moveItem", &json!({"from": 1, "to": 0, "aligned": true})).is_err()); } /// Rows deleted together, one shared (the stroke) and one not (a's fill): each object loses its /// own copy of the shared row, and the other object keeps the items at those indices of a's stack. #[test] fn removing_shared_and_unshared_rows_removes_only_the_shared_ones_elsewhere() { let mut s = session(); let (a, b) = mixed_circles(&mut s, true); // b is [Fill, Fill, Stroke]; a's rows 0 (its fill) and 1 (the shared stroke). let fills_b: Vec<_> = node(&s, b).appearance.items.iter().take(2).cloned().collect(); run(&mut s, "appearance.removeItem", json!({"indices": [0, 1], "aligned": true})); assert!(node(&s, a).appearance.items.is_empty(), "a loses both"); assert_eq!(node(&s, b).appearance.items, fills_b, "b loses only its stroke, keeping both fills"); }