Files
vectorcraft/crates/engine/src/tests/proxyitems.rs
T
dotCooCoo 771b4e54b5 Graphs: invert and complement remember the equal row they recolor; document aligned
With a mixed selection and the Appearance panel's active row, paint.invert
and paint.complement recolor each object's row equal to the active one,
but remembered the color at the active row's index in the first object in
paint order, which can be another row. They now remember the row they
recolor there, as the gradient and freeform edits do.

The params text of paint.setFill, paint.setStroke, paint.invert,
paint.complement, paint.lastColor, paint.lastGradient, stroke.setAdvanced,
paint.editGradient, paint.setGradientGeom and the paint.freeform edits now
lists `aligned`. docs/mcp.md names paint.lastColor and paint.lastGradient,
which take the same rows as paint.setFill, and docs/target-app-parity.md
gets a line for the rows of a design's art.
2026-11-14 11:14:22 -08:00

139 lines
7.1 KiB
Rust

//! The Fill/Stroke proxies and the Appearance panel's active item: Invert / Complement and the "?"
//! state follow the active fill or stroke, objects without bounds still show their paints, and the
//! proxy focus can be set directly (M3.51).
use serde_json::json;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Node};
use vectorcraft_geom::PathData;
use crate::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s
}
/// A selected rectangle with a red fill below its stroke and a blue fill on top: [Fill, Stroke, Fill].
fn two_fills(s: &mut Session, x: f64) -> NodeId {
let r = s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 50, "height": 50})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("appearance.addFill", &json!({})).unwrap();
s.execute("appearance.setItem", &json!({"index": 2, "color": "#0000ff"})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
fn item_hex(s: &Session, id: NodeId, index: usize) -> String {
s.doc().unwrap().doc.node(id).unwrap().appearance.items[index].paint().color().unwrap().to_hex()
}
#[test]
fn invert_and_complement_recolour_the_active_item() {
let mut s = session();
let id = two_fills(&mut s, 10.0);
// Without an active item the proxy's colours go (every fill, as Edit Colors does).
s.execute("paint.invert", &json!({})).unwrap();
assert_eq!((item_hex(&s, id, 0), item_hex(&s, id, 2)), ("#00ffff".into(), "#ffff00".into()));
s.execute("edit.undo", &json!({})).unwrap();
// The bottom fill is the Appearance panel's active row: only it changes, as one undo step.
s.execute("appearance.setActiveItem", &json!({"index": 0})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
assert_eq!(s.execute("paint.invert", &json!({})).unwrap(), json!({"changed": 1}));
assert_eq!((item_hex(&s, id, 0), item_hex(&s, id, 2)), ("#00ffff".into(), "#0000ff".into()));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
assert_eq!(s.proxy_paints().0.color().unwrap().to_hex(), "#00ffff", "the proxy shows the active row");
assert_eq!(s.recent_colors[0].to_hex(), "#00ffff");
s.execute("paint.complement", &json!({})).unwrap();
assert_eq!((item_hex(&s, id, 0), item_hex(&s, id, 2)), ("#ff0000".into(), "#0000ff".into()));
// The Stroke proxy isn't the active row's kind: the stroke is complemented as usual.
s.execute("paint.complement", &json!({"stroke": true})).unwrap();
assert_eq!(item_hex(&s, id, 1), "#000000", "black's complement is black");
assert_eq!((item_hex(&s, id, 0), item_hex(&s, id, 2)), ("#ff0000".into(), "#0000ff".into()));
}
/// With a mixed selection, Invert on the Appearance panel's active row remembers the color of the row it recolors in the
/// first object, the row equal to the active one, which need not have the active row's index.
#[test]
fn invert_of_the_active_row_remembers_the_equal_row_it_recolors() {
let mut s = session();
let rect = |s: &mut Session, x: f64| {
let r = s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 50, "height": 50})).unwrap();
NodeId(r["id"].as_u64().unwrap())
};
let (a, b) = (rect(&mut s, 10.0), rect(&mut s, 100.0));
// `a` paints first: [red Fill, Stroke, blue Fill]. `b`, selected first: [green Fill, red Fill, Stroke].
let look = Appearance::basic(Paint::solid(Color::from_hex("#ff0000").unwrap()), Paint::solid(Color::BLACK), 1.0);
let (mut a_look, mut b_look) = (look.clone(), look.clone());
let (mut green, mut blue) = (look.items[0].clone(), look.items[0].clone());
*green.paint_mut() = Paint::solid(Color::from_hex("#00ff00").unwrap());
*blue.paint_mut() = Paint::solid(Color::from_hex("#0000ff").unwrap());
b_look.items.insert(0, green);
a_look.items.push(blue);
s.edit("Looks", |d, _| {
d.node_mut(a).unwrap().appearance = vectorcraft_doc::Shared::new(a_look.clone());
d.node_mut(b).unwrap().appearance = vectorcraft_doc::Shared::new(b_look.clone());
Ok(())
})
.unwrap();
s.execute("select.set", &json!({"ids": [b.0, a.0]})).unwrap();
s.execute("appearance.setActiveItem", &json!({"index": 1})).unwrap();
s.last_solid = Color::from_hex("#808080").unwrap();
s.execute("paint.invert", &json!({})).unwrap();
let hexes = (item_hex(&s, a, 0), item_hex(&s, a, 2), item_hex(&s, b, 0), item_hex(&s, b, 1));
assert_eq!(hexes, ("#00ffff".into(), "#0000ff".into(), "#00ff00".into(), "#00ffff".into()));
assert_eq!(s.last_solid.to_hex(), "#00ffff", "the last color is the inverted red, not row 1 of the first object");
}
#[test]
fn the_mixed_proxy_compares_the_active_item() {
let mut s = session();
let a = two_fills(&mut s, 10.0);
let b = two_fills(&mut s, 100.0);
// Same top fills, different bottom fills.
s.execute("appearance.setItem", &json!({"index": 0, "color": "#00ff00", "ids": [b.0]})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
assert_eq!(s.proxy_mixed(), (false, false));
s.execute("appearance.setActiveItem", &json!({"index": 0})).unwrap();
assert_eq!(s.proxy_mixed(), (true, false), "the active bottom fills differ");
assert_eq!(s.execute("paint.proxies", &json!({})).unwrap()["fillMixed"], true);
// A stroke row leaves the fill proxy on the top fills.
s.execute("appearance.setActiveItem", &json!({"index": 1})).unwrap();
assert_eq!(s.proxy_mixed(), (false, false));
}
#[test]
fn an_object_without_bounds_shows_its_paints() {
let mut s = session();
let id = s
.edit("Add", |d, _| {
let l = d.layers[0].id;
let id = d.alloc_id();
let ap = Appearance::basic(Paint::solid(Color::from_hex("#123456").unwrap()), Paint::solid(Color::from_hex("#654321").unwrap()), 2.0);
d.insert(Some(l), 0, Node::path(id, PathData::default(), ap)).map_err(|e| crate::EngineError::Other(e.to_string()))
})
.unwrap();
assert!(s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().is_none());
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let (f, st) = s.proxy_paints();
assert_eq!((f.color().unwrap().to_hex(), st.color().unwrap().to_hex()), ("#123456".into(), "#654321".into()));
}
#[test]
fn toggle_active_can_name_the_proxy() {
let mut s = Session::new();
assert_eq!(s.execute("paint.toggleActive", &json!({"fill": false})).unwrap(), json!({"fillActive": false}));
assert_eq!(s.execute("paint.toggleActive", &json!({"fill": false})).unwrap(), json!({"fillActive": false}), "already in front");
assert_eq!(s.execute("paint.toggleActive", &json!({})).unwrap(), json!({"fillActive": true}));
}
#[test]
fn the_last_colour_keeps_its_model() {
let mut s = session();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
s.execute("paint.setFill", &json!({"color": {"c": 0.1, "m": 0.9, "y": 0.2, "k": 0.0}})).unwrap();
s.execute("paint.setFill", &json!({"none": true})).unwrap();
s.execute("paint.lastColor", &json!({})).unwrap();
assert_eq!(s.proxy_paints().0.color(), Some(Color::cmyk(0.1, 0.9, 0.2, 0.0)));
}