mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 02:19:56 -06:00
840 lines
45 KiB
Rust
840 lines
45 KiB
Rust
//! 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::<Vec<_>>(), ["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::<Vec<_>>(), [("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::<Vec<_>>(), ["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::<Vec<_>>(), ["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::<Vec<_>>(), ["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::<Vec<_>>(), ["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::<Vec<_>>(), ["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::<Vec<_>>(), ["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::<Vec<_>>(), ["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::<Vec<_>>()})).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::<Vec<_>>(), ["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");
|
||
}
|