mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
These commands edited the document even when nothing changed, so the call recorded an undo step, emptied Redo and marked the document modified: - Fill, Stroke, None, Apply Last Color and Apply Last Gradient with the paint the objects have already, also on characters selected with the Type tool. - stroke.set and stroke.setAdvanced with the options the strokes have already. - paint.editGradient, paint.setGradientGeom and the freeform point commands when the gradient comes out as it was. - Invert and Complement on the Appearance panel's selected row when no color changes. - Swap Fill and Stroke and Default Fill and Stroke when every object comes out as it was. - appearance.setItem with the values the item has or an index no object has, and transparency.set with an item and the values that item has; appearance.removeItem with indices past every stack; appearance.moveItem, Reduce to Basic Appearance, Clear Appearance, the Eyedropper (appearance.copyFrom) and appearance.transfer when every object comes out as it was. These commands now return without editing the document. The replies have the same form as before. On a graph's series or labels, a fill or stroke that the art has already still records one undo step when the graph does not keep that paint for them (for example after the Eyedropper painted the art), and the graph then keeps it. The Eyedropper test whose second copy changed nothing gives its target a green fill first, which the copy turns red again. One line in the evidence list of docs/target-app-parity.md names these commands, apart from Invert and Complement.
327 lines
15 KiB
Rust
327 lines
15 KiB
Rust
//! Fill/Stroke proxy commands: invert, complement, last colour/gradient, swap/default on type and
|
|
//! the Session's recent colours.
|
|
|
|
use serde_json::json;
|
|
use vectorcraft_color::{Color, Paint};
|
|
use vectorcraft_doc::NodeKind;
|
|
|
|
use super::*;
|
|
|
|
fn session() -> Session {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
|
s
|
|
}
|
|
|
|
fn rect(s: &mut Session) -> NodeId {
|
|
let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
|
NodeId(r["id"].as_u64().unwrap())
|
|
}
|
|
|
|
fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
|
|
s.doc().unwrap().doc.node(id).unwrap().clone()
|
|
}
|
|
|
|
fn hexes(s: &Session) -> Vec<String> {
|
|
s.recent_colors.iter().map(Color::to_hex).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn complement_of_red_on_the_fill_only_gives_cyan() {
|
|
let mut s = session();
|
|
let id = rect(&mut s);
|
|
s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap();
|
|
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
|
let r = s.execute("paint.complement", &json!({})).unwrap();
|
|
assert_eq!(r["changed"], 1);
|
|
let n = node(&s, id);
|
|
assert_eq!(n.appearance.fill_paint().color().unwrap().to_hex(), "#00ffff");
|
|
assert_eq!(n.appearance.stroke_paint().color().unwrap().to_hex(), "#ff0000");
|
|
// One undo step, and the new colour is remembered.
|
|
assert_eq!(s.last_solid.to_hex(), "#00ffff");
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert_eq!(node(&s, id).appearance.fill_paint().color().unwrap().to_hex(), "#ff0000");
|
|
// `stroke: true` picks the stroke whatever proxy is in front.
|
|
s.execute("paint.complement", &json!({"stroke": true})).unwrap();
|
|
assert_eq!(node(&s, id).appearance.stroke_paint().color().unwrap().to_hex(), "#00ffff");
|
|
}
|
|
|
|
#[test]
|
|
fn invert_keeps_cmyk_and_acts_on_defaults_without_selection() {
|
|
let mut s = session();
|
|
let id = rect(&mut s);
|
|
s.execute("paint.setFill", &json!({"color": {"c": 0.0, "m": 1.0, "y": 1.0, "k": 0.0}})).unwrap();
|
|
s.execute("paint.invert", &json!({})).unwrap();
|
|
let c = node(&s, id).appearance.fill_paint().color().unwrap();
|
|
assert!(matches!(c, Color::Cmyk { .. }), "{c:?}");
|
|
assert_ne!(c, Color::cmyk(0.0, 1.0, 1.0, 0.0));
|
|
// A CMYK complement stays CMYK too (K kept).
|
|
s.execute("paint.setFill", &json!({"color": {"c": 0.0, "m": 1.0, "y": 1.0, "k": 0.25}})).unwrap();
|
|
s.execute("paint.complement", &json!({})).unwrap();
|
|
assert_eq!(node(&s, id).appearance.fill_paint().color(), Some(Color::cmyk(1.0, 0.0, 0.0, 0.25)));
|
|
// Nothing selected: the default fill changes and the document doesn't.
|
|
s.execute("select.none", &json!({})).unwrap();
|
|
let before = s.doc().unwrap().doc.clone();
|
|
s.paint.fill = Paint::solid(Color::rgb(1.0, 1.0, 0.0));
|
|
s.execute("paint.invert", &json!({})).unwrap();
|
|
assert_eq!(s.paint.fill.color().unwrap().to_hex(), "#0000ff");
|
|
assert_eq!(s.doc().unwrap().doc, before);
|
|
}
|
|
|
|
#[test]
|
|
fn last_color_and_gradient_are_reapplied() {
|
|
let mut s = session();
|
|
let id = rect(&mut s);
|
|
s.execute(
|
|
"paint.setFill",
|
|
&json!({"gradient": {"kind": "radial", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}}),
|
|
)
|
|
.unwrap();
|
|
s.execute("paint.setFill", &json!({"color": "#336699"})).unwrap();
|
|
assert_eq!(node(&s, id).appearance.fill_paint().color().unwrap().to_hex(), "#336699");
|
|
// `.` brings back the gradient after a solid colour, `,` the colour after the gradient.
|
|
s.execute("paint.lastGradient", &json!({})).unwrap();
|
|
let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!("not a gradient") };
|
|
assert_eq!(g.gradient.stops[0].color.to_hex(), "#ff0000");
|
|
assert!(g.geom.is_none(), "fitted to the object");
|
|
s.execute("paint.lastColor", &json!({})).unwrap();
|
|
assert_eq!(node(&s, id).appearance.fill_paint().color().unwrap().to_hex(), "#336699");
|
|
// On the stroke proxy they paint the stroke and focus it.
|
|
s.execute("paint.lastColor", &json!({"stroke": true})).unwrap();
|
|
assert_eq!(node(&s, id).appearance.stroke_paint().color().unwrap().to_hex(), "#336699");
|
|
assert!(!s.fill_active);
|
|
let r = s.execute("paint.recent", &json!({})).unwrap();
|
|
assert_eq!(r["lastColor"]["hex"], "#336699");
|
|
assert_eq!(r["lastGradient"]["gradient"]["kind"], "Radial");
|
|
}
|
|
|
|
#[test]
|
|
fn swap_and_default_work_on_type() {
|
|
let mut s = session();
|
|
let r = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap();
|
|
let id = NodeId(r["id"].as_u64().unwrap());
|
|
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
|
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
|
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
|
let style = |s: &Session| match &node(s, id).kind {
|
|
NodeKind::Text(t) => t.first_style(),
|
|
_ => panic!("not text"),
|
|
};
|
|
s.execute("paint.swap", &json!({})).unwrap();
|
|
let st = style(&s);
|
|
assert_eq!(st.fill, Paint::None);
|
|
assert_eq!(st.stroke.color().unwrap().to_hex(), "#ff0000");
|
|
assert!(st.stroke_width > 0.0, "a painted stroke is visible");
|
|
s.execute("paint.default", &json!({})).unwrap();
|
|
let st = style(&s);
|
|
assert_eq!(st.fill, Paint::solid(Color::BLACK));
|
|
assert_eq!(st.stroke, Paint::None);
|
|
}
|
|
|
|
/// 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();
|
|
}
|
|
|
|
/// Default Fill and Stroke on the default fill and stroke, None on a stroke of None, Invert and
|
|
/// Complement on that stroke's Appearance row, Swap Fill and Stroke with the same fill and stroke,
|
|
/// and Apply Last Color and Apply Last Gradient on paint that is the last color or gradient already
|
|
/// record no undo step: the step to redo stays and the document stays unmodified.
|
|
#[test]
|
|
fn proxy_commands_that_change_nothing_record_no_undo_step() {
|
|
let mut s = session();
|
|
let id = rect(&mut s);
|
|
let no_step = |s: &mut Session, calls: &[(&str, serde_json::Value)]| {
|
|
select_with_a_step_to_redo(s, &[id]);
|
|
let before = history(s);
|
|
for (cmd, p) in calls {
|
|
s.execute(cmd, p).unwrap();
|
|
assert_eq!(history(s), before, "{cmd} {p}");
|
|
}
|
|
};
|
|
no_step(&mut s, &[("paint.default", json!({}))]);
|
|
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
|
no_step(&mut s, &[("paint.none", json!({}))]);
|
|
// The stroke's Appearance row (item 1) has no color to invert or complement.
|
|
let row = json!({"item": 1, "stroke": true});
|
|
no_step(&mut s, &[("paint.invert", row.clone()), ("paint.complement", row)]);
|
|
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
|
s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap();
|
|
no_step(&mut s, &[("paint.swap", json!({})), ("paint.lastColor", json!({})), ("paint.lastColor", json!({"stroke": false}))]);
|
|
s.execute(
|
|
"paint.setFill",
|
|
&json!({"gradient": {"kind": "radial", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}}),
|
|
)
|
|
.unwrap();
|
|
no_step(&mut s, &[("paint.lastGradient", json!({"stroke": false}))]);
|
|
// Swapping a gradient fill and a red stroke is one step.
|
|
let before = history(&s);
|
|
s.execute("paint.swap", &json!({})).unwrap();
|
|
assert_eq!(history(&s), (before.0 + 1, 0, true));
|
|
}
|
|
|
|
#[test]
|
|
fn recent_colors_are_fed_by_every_paint_command() {
|
|
let mut s = session();
|
|
let id = rect(&mut s);
|
|
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
|
s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap();
|
|
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
|
assert_eq!(hexes(&s)[..2], ["#ff0000", "#00ff00"]);
|
|
// The eyedropper's colour sample goes through the same path.
|
|
s.execute("paint.sampleColor", &json!({"color": "#0000ff", "stroke": false})).unwrap();
|
|
assert_eq!(hexes(&s)[0], "#0000ff");
|
|
// None and gradients don't add colours.
|
|
s.execute("paint.none", &json!({})).unwrap();
|
|
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap();
|
|
assert_eq!(hexes(&s).len(), 3);
|
|
// The list holds the newest `RECENT_MAX` colours.
|
|
for i in 0..20u8 {
|
|
s.execute("paint.setFill", &json!({"color": Color::rgb8(i, 0, 0).to_hex()})).unwrap();
|
|
}
|
|
assert_eq!(s.recent_colors.len(), Session::RECENT_MAX);
|
|
assert_eq!(s.recent_colors[0].to_hex(), "#130000");
|
|
let r = s.execute("paint.recent", &json!({})).unwrap();
|
|
assert_eq!(r["colors"][0]["hex"], "#130000");
|
|
let _ = id;
|
|
}
|
|
|
|
#[test]
|
|
fn live_previews_remember_only_the_committed_colour() {
|
|
let mut s = session();
|
|
rect(&mut s);
|
|
s.begin_interaction("Color").unwrap();
|
|
for hex in ["#100000", "#200000", "#300000"] {
|
|
s.preview("paint.setFill", &json!({"color": hex})).unwrap();
|
|
}
|
|
assert!(s.recent_colors.is_empty(), "nothing is remembered while dragging");
|
|
s.commit_interaction().unwrap();
|
|
assert_eq!(hexes(&s), ["#300000"]);
|
|
s.begin_interaction("Color").unwrap();
|
|
s.preview("paint.setFill", &json!({"color": "#400000"})).unwrap();
|
|
s.cancel_interaction().unwrap();
|
|
assert_eq!(hexes(&s), ["#300000"], "a cancelled preview is forgotten");
|
|
}
|
|
|
|
#[test]
|
|
fn focus_false_keeps_the_active_proxy() {
|
|
let mut s = session();
|
|
let id = rect(&mut s);
|
|
assert!(s.fill_active);
|
|
s.execute("paint.setStroke", &json!({"color": "#ff0000", "focus": false})).unwrap();
|
|
assert!(s.fill_active);
|
|
assert_eq!(node(&s, id).appearance.stroke_paint().color().unwrap().to_hex(), "#ff0000");
|
|
}
|
|
|
|
#[test]
|
|
fn a_mixed_selection_shows_a_question_mark_proxy() {
|
|
let mut s = session();
|
|
let a = rect(&mut s);
|
|
let b = rect(&mut s);
|
|
let q = |s: &mut Session| s.execute("paint.proxies", &json!({})).unwrap();
|
|
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
|
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
|
let r = q(&mut s);
|
|
assert_eq!((r["fillMixed"].as_bool(), r["strokeMixed"].as_bool()), (Some(false), Some(false)));
|
|
assert_eq!(r["fillActive"], true);
|
|
s.execute("paint.setFill", &json!({"color": "#00ff00", "ids": [b.0]})).unwrap();
|
|
let r = q(&mut s);
|
|
assert_eq!((r["fillMixed"].as_bool(), r["strokeMixed"].as_bool()), (Some(true), Some(false)));
|
|
assert_eq!(r["fill"]["color"], json!(Color::rgb(1.0, 0.0, 0.0)), "the first selected object's fill");
|
|
// One selected object, or the same gradient fitted to different objects, is never mixed.
|
|
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
|
|
assert_eq!(q(&mut s)["fillMixed"], false);
|
|
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
|
s.execute("paint.setFill", &json!({"gradient": {"stops": [{"offset": 0, "color": "#000000"}, {"offset": 1, "color": "#ffffff"}]}})).unwrap();
|
|
assert_eq!(s.proxy_mixed(), (false, false));
|
|
s.execute("select.none", &json!({})).unwrap();
|
|
assert_eq!(q(&mut s)["fillMixed"], false);
|
|
}
|
|
|
|
#[test]
|
|
fn every_selection_change_bumps_the_revision() {
|
|
// The "?" proxy is cached per revision, so selecting by Magic Wand or font must bump it.
|
|
let mut s = session();
|
|
let a = rect(&mut s);
|
|
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
|
let b = rect(&mut s);
|
|
s.execute("paint.setFill", &json!({"color": "#fa0000"})).unwrap();
|
|
s.execute("select.none", &json!({})).unwrap();
|
|
let rev = s.doc().unwrap().revision;
|
|
s.execute("select.magicWand", &json!({"id": a.0})).unwrap();
|
|
assert_eq!(s.doc().unwrap().selection.len(), 2, "{a:?} and {b:?} are within the tolerance");
|
|
assert!(s.doc().unwrap().revision > rev);
|
|
assert_eq!(s.proxy_mixed(), (true, false));
|
|
let r = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "Hi"})).unwrap();
|
|
let family = match &node(&s, NodeId(r["id"].as_u64().unwrap())).kind {
|
|
NodeKind::Text(t) => t.first_style().font_family,
|
|
_ => panic!("not text"),
|
|
};
|
|
let rev = s.doc().unwrap().revision;
|
|
s.execute("select.font", &json!({"family": family})).unwrap();
|
|
assert!(s.doc().unwrap().revision > rev);
|
|
}
|
|
|
|
#[test]
|
|
fn proxies_show_the_first_selected_object_or_the_defaults() {
|
|
let mut s = session();
|
|
let id = rect(&mut s);
|
|
s.execute("paint.setFill", &json!({"color": "#123456"})).unwrap();
|
|
assert_eq!(s.proxy_paints().0.color().unwrap().to_hex(), "#123456");
|
|
s.execute("select.none", &json!({})).unwrap();
|
|
s.paint.fill = Paint::solid(Color::WHITE);
|
|
assert_eq!(s.proxy_paints().0, Paint::solid(Color::WHITE));
|
|
// Type shows its first run's paints.
|
|
let r = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap();
|
|
s.execute("select.set", &json!({"ids": [r["id"]]})).unwrap();
|
|
s.execute("paint.setFill", &json!({"color": "#654321"})).unwrap();
|
|
assert_eq!(s.proxy_paints().0.color().unwrap().to_hex(), "#654321");
|
|
assert_eq!(s.execute("paint.proxies", &json!({})).unwrap()["fill"], json!(s.proxy_paints().0));
|
|
let _ = id;
|
|
}
|
|
|
|
#[test]
|
|
fn live_paint_and_appearance_items_feed_recent_colours() {
|
|
let mut s = session();
|
|
let id = rect(&mut s);
|
|
s.execute("appearance.setItem", &json!({"index": 0, "color": "#0a0b0c"})).unwrap();
|
|
assert_eq!(hexes(&s)[0], "#0a0b0c");
|
|
// Opacity alone doesn't change the recent colours.
|
|
s.execute("appearance.setItem", &json!({"index": 0, "opacity": 50})).unwrap();
|
|
assert_eq!(hexes(&s).len(), 1);
|
|
s.execute("livePaint.make", &json!({"ids": [id.0]})).unwrap();
|
|
let g = s.doc().unwrap().selection.objects[0];
|
|
s.execute("livePaint.fill", &json!({"group": g.0, "point": [50, 30], "color": "#00ff00"})).unwrap();
|
|
assert_eq!(hexes(&s)[0], "#00ff00");
|
|
}
|
|
|
|
#[test]
|
|
fn a_group_shows_its_contents_in_the_proxies() {
|
|
let mut s = session();
|
|
let a = rect(&mut s);
|
|
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
|
let b = rect(&mut s);
|
|
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
|
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
|
s.execute("object.group", &json!({})).unwrap();
|
|
// The group shows what the paint commands change: its contents' red fill, not mixed.
|
|
assert_eq!(s.doc().unwrap().selection.len(), 1);
|
|
assert_eq!(s.proxy_paints().0.color().unwrap().to_hex(), "#ff0000");
|
|
assert_eq!(s.proxy_mixed(), (false, false));
|
|
// Contents that differ make even a single selected group a "?" proxy.
|
|
s.execute("paint.setFill", &json!({"color": "#00ff00", "ids": [b.0]})).unwrap();
|
|
assert_eq!(s.execute("paint.proxies", &json!({})).unwrap()["fillMixed"], true);
|
|
// An empty selection shows the defaults.
|
|
s.execute("select.none", &json!({})).unwrap();
|
|
assert_eq!(s.proxy_paints().0, s.paint.fill);
|
|
}
|