mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 13:47:21 -06:00
People reuse the same gradients across documents and rebuilt them by hand. The Gradient panel now has a Presets menu: the presets of the app's built-in gradient libraries, grouped by library (theme), then the saved ones. Choosing one gives the gradient behind the active proxy (or a solid paint) the preset's type, stops, method and dither, keeping its placement, as one undo step, through paint.editGradient. The panel menu saves the current gradient as a preset (named in a dialog) and deletes saved ones; Hide/Show Presets toggles the row. Saved presets are kept with the preferences, next to the other user preset lists (width profiles, flattener, perspective). Stops linked to a document swatch keep their colour, unlinked, since a preset is used in other documents. Built-in presets can't be deleted; names are unique. Commands: gradient.presets.list, .apply, .save and .delete. The menu's rows are drawn only while in view, as its headings are. Tests: engine tests for list, apply (fill and stroke, placement kept, one undo step), save (unique names, unlinked swatch stops) and delete; panel tests for applying from the menu and saving, deleting and hiding presets from the panel menu, including scrolling the menu to the saved presets. Docs: gaps G13, target-app and UI parity, ROADMAP.
160 lines
9.8 KiB
Rust
160 lines
9.8 KiB
Rust
//! The Gradient panel's presets (`gradient.presets.*`): the built-in gradient libraries by theme,
|
|
//! applied in place to the gradient behind a proxy, and gradients saved beside them with the
|
|
//! preferences.
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_color::{Color, GradientKind, GradientMethod, Paint};
|
|
|
|
use crate::*;
|
|
|
|
/// A document with a selected rectangle filled with `fill` (`paint.setFill` params).
|
|
fn session_with(fill: Value) -> (Session, NodeId) {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
|
|
let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap();
|
|
s.execute("paint.setFill", &fill).unwrap();
|
|
(s, NodeId(r["id"].as_u64().unwrap()))
|
|
}
|
|
|
|
fn paint(s: &Session, id: NodeId, stroke: bool) -> vectorcraft_color::GradientPaint {
|
|
let n = s.doc().unwrap().doc.node(id).unwrap();
|
|
match n.appearance.paint_at(None, !stroke).unwrap().clone() {
|
|
Paint::Gradient(g) => *g,
|
|
p => panic!("expected a gradient, got {p:?}"),
|
|
}
|
|
}
|
|
|
|
fn names(list: &Value) -> Vec<String> {
|
|
list["presets"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap().to_string()).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn built_in_presets_are_the_gradient_libraries_by_theme_with_unique_names() {
|
|
let mut s = Session::new();
|
|
let list = s.execute("gradient.presets.list", &json!({})).unwrap();
|
|
let rows = list["presets"].as_array().unwrap();
|
|
assert!(rows.len() > 40, "{} presets", rows.len());
|
|
assert!(rows.iter().all(|r| r["builtIn"] == true && r["gradient"]["stops"].as_array().is_some_and(|a| a.len() >= 2)));
|
|
// Grouped by library, the gradient libraries first, then the swatch libraries holding gradients.
|
|
let mut libraries: Vec<&str> = rows.iter().map(|r| r["library"].as_str().unwrap()).collect();
|
|
libraries.dedup();
|
|
assert_eq!(libraries, ["Spectrum Gradients", "Sky Gradients", "Neutral Gradients", "Duotone Gradients", "Radial Glows", "Metallic Gradients"]);
|
|
let mut seen = std::collections::HashSet::new();
|
|
assert!(names(&list).iter().all(|n| seen.insert(n.to_lowercase())), "a name names one preset");
|
|
let vignette = rows.iter().find(|r| r["name"] == "Vignette").unwrap();
|
|
assert_eq!((vignette["library"].as_str(), vignette["gradient"]["kind"].as_str()), (Some("Neutral Gradients"), Some("radial")));
|
|
}
|
|
|
|
#[test]
|
|
fn applying_a_preset_edits_the_gradient_in_place_as_one_undo_step() {
|
|
let (mut s, id) = session_with(json!({"gradient": {"angle": 30}}));
|
|
s.execute("paint.editGradient", &json!({"dither": true})).unwrap();
|
|
let before = paint(&s, id, false);
|
|
let undo = s.doc().unwrap().history.undo.len();
|
|
let r = s.execute("gradient.presets.apply", &json!({"name": "vignette"})).unwrap();
|
|
assert_eq!((r["name"].as_str(), r["ids"].clone()), (Some("Vignette"), json!([id.0])));
|
|
let g = paint(&s, id, false);
|
|
let preset = s.gradient_preset("Vignette").unwrap().gradient;
|
|
assert_eq!((g.gradient.kind, &g.gradient.stops), (GradientKind::Radial, &preset.stops));
|
|
assert!(!g.gradient.dither, "the fill takes the preset's dither");
|
|
assert_eq!(g.angle, before.angle, "the placement stays");
|
|
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
|
assert_eq!(s.gradient_preset_name(&g.gradient).as_deref(), Some("Vignette"), "the gradient is that preset now");
|
|
assert_eq!(s.gradient_preset_name(&before.gradient), None);
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert_eq!(paint(&s, id, false), before);
|
|
}
|
|
|
|
#[test]
|
|
fn a_preset_applies_to_the_stroke_and_to_a_solid_paint() {
|
|
let (mut s, id) = session_with(json!({"color": "#336699"}));
|
|
s.execute("paint.setStroke", &json!({"color": "#000000", "focus": false})).unwrap();
|
|
s.execute("gradient.presets.apply", &json!({"name": "Gold"})).unwrap();
|
|
let gold = s.gradient_preset("Gold").unwrap();
|
|
assert_eq!((gold.library, paint(&s, id, false).gradient.stops), (Some("Metallic Gradients"), gold.gradient.stops.clone()));
|
|
// With the Stroke proxy in front (or `stroke: true`) the stroke takes it; dither isn't sent.
|
|
s.execute("paint.toggleActive", &json!({"fill": false})).unwrap();
|
|
s.execute("gradient.presets.apply", &json!({"name": "Dawn"})).unwrap();
|
|
assert_eq!(paint(&s, id, true).gradient.stops, s.gradient_preset("Dawn").unwrap().gradient.stops);
|
|
assert_eq!(paint(&s, id, false).gradient.stops, gold.gradient.stops, "the fill keeps its gradient");
|
|
for bad in [json!({}), json!({"name": 3}), json!({"name": "No Such Preset"})] {
|
|
assert!(s.execute("gradient.presets.apply", &bad).is_err(), "{bad}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn saving_listing_applying_and_deleting_a_preset() {
|
|
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 0.4, "color": "#00ff00", "opacity": 0.5, "midpoint": 0.3}, {"offset": 1, "color": "#0000ff"}]);
|
|
let (mut s, id) = session_with(json!({"gradient": {"kind": "radial", "stops": stops, "method": "perceptual"}}));
|
|
// A stop linked to a document swatch is saved with its colour, unlinked.
|
|
s.execute("swatch.new", &json!({"name": "Brand", "color": "#123456", "global": true})).unwrap();
|
|
s.execute("gradient.selectStop", &json!({"index": 1})).unwrap();
|
|
let mut linked = paint(&s, id, false).gradient.stops.clone();
|
|
linked[1].set_color(Color::rgb8(0x12, 0x34, 0x56), Some(("Brand".into(), 1.0)));
|
|
s.execute("paint.editGradient", &json!({"stops": vectorcraft_tools::params::stops_json(&linked)})).unwrap();
|
|
assert_eq!(paint(&s, id, false).gradient.stops[1].swatch.as_deref(), Some("Brand"));
|
|
assert_eq!(s.next_gradient_preset_name(), "Gradient Preset 1");
|
|
assert_eq!(s.execute("gradient.presets.save", &json!({})).unwrap()["name"], "Gradient Preset 1");
|
|
let saved = &s.prefs.gradient_presets[0].gradient;
|
|
assert_eq!((saved.kind, saved.method, saved.stops.len()), (GradientKind::Radial, GradientMethod::Perceptual, 3));
|
|
assert_eq!((saved.stops[1].swatch.as_ref(), saved.stops[1].color), (None, Color::rgb8(0x12, 0x34, 0x56)));
|
|
assert_eq!(s.active_saved_gradient_preset(), Some("Gradient Preset 1"), "the shown gradient is that preset");
|
|
// Listed after the built-in ones, without a library.
|
|
let list = s.execute("gradient.presets.list", &json!({})).unwrap();
|
|
let last = list["presets"].as_array().unwrap().last().unwrap().clone();
|
|
assert_eq!((last["name"].as_str(), last["builtIn"].as_bool(), last["library"].clone()), (Some("Gradient Preset 1"), Some(false), Value::Null));
|
|
// Names are unique, any case, and never a built-in preset's.
|
|
for (name, why) in [("gradient preset 1", "taken"), ("Sunset", "built in"), (" ", "empty")] {
|
|
assert!(s.execute("gradient.presets.save", &json!({"name": name})).is_err(), "{why}");
|
|
}
|
|
s.execute("paint.editGradient", &json!({"reverse": true})).unwrap();
|
|
assert_eq!(s.execute("gradient.presets.save", &json!({"name": " Mine "})).unwrap()["name"], "Mine");
|
|
// A saved preset applies to other art like a built-in one.
|
|
let r = s.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap();
|
|
let other = NodeId(r["id"].as_u64().unwrap());
|
|
s.execute("gradient.presets.apply", &json!({"name": "Mine"})).unwrap();
|
|
assert_eq!(paint(&s, other, false).gradient, s.prefs.gradient_presets[1].gradient);
|
|
// Delete: built-in ones stay; without a name, the saved preset the active proxy shows.
|
|
assert!(s.execute("gradient.presets.delete", &json!({"name": "Sunset"})).is_err());
|
|
assert!(s.execute("gradient.presets.delete", &json!({"name": "Nothing"})).is_err());
|
|
assert_eq!(s.execute("gradient.presets.delete", &json!({})).unwrap()["deleted"], "Mine");
|
|
assert!(s.execute("gradient.presets.delete", &json!({})).is_err(), "the ellipse's gradient is no saved preset now");
|
|
assert_eq!(s.execute("gradient.presets.delete", &json!({"name": "GRADIENT PRESET 1"})).unwrap()["deleted"], "Gradient Preset 1");
|
|
assert!((crate::find_command("gradient.presets.delete").unwrap().enabled)(&s).is_err(), "nothing left to delete");
|
|
}
|
|
|
|
#[test]
|
|
fn saving_needs_a_gradient_behind_the_proxy() {
|
|
let (mut s, _) = session_with(json!({"color": "#336699"}));
|
|
assert!((crate::find_command("gradient.presets.save").unwrap().enabled)(&s).is_err());
|
|
assert!(s.execute("gradient.presets.save", &json!({})).is_err());
|
|
// A gradient stroke is saved with the Stroke proxy in front.
|
|
s.execute("paint.setStroke", &json!({"gradient": {"kind": "radial"}, "focus": false})).unwrap();
|
|
assert!(s.execute("gradient.presets.save", &json!({})).is_err(), "the Fill is in front");
|
|
s.execute("paint.toggleActive", &json!({"fill": false})).unwrap();
|
|
assert_eq!(s.execute("gradient.presets.save", &json!({"name": "Ring"})).unwrap()["name"], "Ring");
|
|
assert_eq!(s.prefs.gradient_presets[0].gradient.kind, GradientKind::Radial);
|
|
assert!(s.execute("gradient.presets.save", &json!({"name": 7})).is_err(), "a name is text");
|
|
}
|
|
|
|
#[test]
|
|
fn saved_presets_round_trip_with_the_preferences() {
|
|
let (mut s, _) = session_with(json!({"gradient": {}}));
|
|
s.execute("gradient.presets.save", &json!({"name": "Mine"})).unwrap();
|
|
let json = s.prefs.to_json();
|
|
assert_eq!(json["gradientPresets"][0]["name"], "Mine");
|
|
let back: Prefs = serde_json::from_value(json).unwrap();
|
|
assert_eq!(back, s.prefs);
|
|
// Without saved presets nothing is written, and older preference files load with none.
|
|
assert!(Prefs::default().to_json().get("gradientPresets").is_none());
|
|
let old: Prefs = serde_json::from_value(json!({"keyboardIncrement": 2})).unwrap();
|
|
assert!(old.gradient_presets.is_empty());
|
|
// Resetting the preferences keeps them.
|
|
s.execute("prefs.reset", &json!({})).unwrap();
|
|
assert_eq!(s.prefs.gradient_presets.len(), 1);
|
|
// A restarted session (preferences applied from the saved file) lists them again.
|
|
let mut fresh = Session::new();
|
|
fresh.apply_prefs(back);
|
|
assert!(fresh.gradient_preset("mine").is_some_and(|e| e.library.is_none()));
|
|
}
|