Merge pull request #6793 from dotCooCoo/feat/gradients-organic-fades

Swatch libraries: Gradients › Organic Fades adds soft multi-stop fades that thin out to transparent
This commit is contained in:
BFlat
2026-11-12 13:17:19 +07:00
committed by GitHub
36 changed files with 427 additions and 10 deletions
+34
View File
@@ -109,6 +109,40 @@ fn unlinked(g: &Gradient) -> Gradient {
g
}
/// Give each saved preset in `saved` a name no built-in preset or earlier saved one has (any case):
/// a clash becomes the first free "<name> N", N from 2. Returns the renames as (old, new).
pub(crate) fn rename_clashing_presets(saved: &mut [GradientPreset]) -> Vec<(String, String)> {
if saved.is_empty() {
return vec![];
}
let builtin: std::collections::HashSet<String> = builtin_presets().map(|e| e.name.to_ascii_lowercase()).collect();
let mut taken: std::collections::HashSet<String> =
builtin.iter().cloned().chain(saved.iter().map(|p| p.name.trim().to_ascii_lowercase())).collect();
let mut kept = std::collections::HashSet::new();
// The next suffix to try for each name, so many presets with one name take linear time.
let mut next: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
let mut renamed = vec![];
for p in saved {
let key = p.name.trim().to_ascii_lowercase();
if !builtin.contains(&key) && kept.insert(key.clone()) {
continue;
}
let base = p.name.trim();
let n = next.entry(key).or_insert(2);
let name = loop {
let candidate = format!("{base} {n}");
*n += 1;
if !taken.contains(&candidate.to_ascii_lowercase()) {
break candidate;
}
};
taken.insert(name.to_ascii_lowercase());
kept.insert(name.to_ascii_lowercase());
renamed.push((std::mem::replace(&mut p.name, name.clone()), name));
}
renamed
}
impl Session {
/// The Gradient panel's presets: the built-in ones by library, then the saved ones.
pub fn gradient_presets(&self) -> Vec<GradientPresetEntry> {
+4
View File
@@ -687,6 +687,10 @@ impl Session {
for e in self.prefs.keep_folders(&mut p) {
log::warn!("{e}");
}
// Saved gradient presets get names no built-in preset or other saved one has.
for (old, new) in super::gradpresets::rename_clashing_presets(&mut p.gradient_presets) {
log::warn!("saved gradient preset {old} is listed as {new}: another preset has its name");
}
// The edit the active tool computes on another thread is applied first (the Preferences
// dialog applies its settings here, outside a command). A failure to apply it was the
// edit's own, and the preferences apply all the same.
+52 -1
View File
@@ -38,7 +38,10 @@ fn built_in_presets_are_the_gradient_libraries_by_theme_with_unique_names() {
// 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"]);
assert_eq!(
libraries,
["Spectrum Gradients", "Sky Gradients", "Neutral Gradients", "Organic Fades", "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();
@@ -65,6 +68,20 @@ fn applying_a_preset_edits_the_gradient_in_place_as_one_undo_step() {
assert_eq!(paint(&s, id, false), before);
}
#[test]
fn an_organic_fade_applies_with_its_method_midpoints_and_opacities() {
let (mut s, id) = session_with(json!({"color": "#336699"}));
let mist = s.gradient_preset("Mist").unwrap();
assert_eq!((mist.library, mist.gradient.method), (Some("Organic Fades"), GradientMethod::Perceptual));
s.execute("gradient.presets.apply", &json!({"name": "Mist"})).unwrap();
let g = paint(&s, id, false).gradient;
assert_eq!((g.kind, g.method, &g.stops), (GradientKind::Linear, GradientMethod::Perceptual, &mist.gradient.stops));
assert_eq!(s.gradient_preset_name(&g).as_deref(), Some("Mist"));
let list = s.execute("gradient.presets.list", &json!({})).unwrap();
let row = list["presets"].as_array().unwrap().iter().find(|r| r["name"] == "Mist").unwrap().clone();
assert_eq!(row["gradient"]["method"], "perceptual");
}
#[test]
fn a_preset_applies_to_the_stroke_and_to_a_solid_paint() {
let (mut s, id) = session_with(json!({"color": "#336699"}));
@@ -334,3 +351,37 @@ fn saved_presets_round_trip_with_the_preferences() {
fresh.apply_prefs(back);
assert!(fresh.gradient_preset("mine").is_some_and(|e| e.library.is_none()));
}
/// Preferences can hold saved presets named like a built-in preset (saved before it existed) or
/// like each other (edited by hand): applied, each gets a name of its own, any case.
#[test]
fn saved_presets_named_like_a_built_in_or_another_saved_one_get_a_free_name() {
let (mut s, id) = session_with(json!({"gradient": {"kind": "radial"}}));
let mist = s.gradient_preset("Mist").unwrap().gradient;
let mut own = paint(&s, id, false).gradient;
own.dither = true;
let preset = |name: &str| crate::cmd::GradientPreset { name: name.into(), gradient: own.clone() };
let mut prefs = s.prefs.clone();
prefs.gradient_presets =
vec![preset("mist"), preset("Mine"), preset("MINE"), preset("Mist 2"), preset("Plain"), preset("PLAIN"), preset("plain")];
s.apply_prefs(prefs);
let saved: Vec<&str> = s.prefs.gradient_presets.iter().map(|p| p.name.as_str()).collect();
assert_eq!(saved, ["mist 3", "Mine", "MINE 2", "Mist 2", "Plain", "PLAIN 2", "plain 3"]);
let list = s.execute("gradient.presets.list", &json!({})).unwrap();
let mut seen = std::collections::HashSet::new();
assert!(names(&list).iter().all(|n| seen.insert(n.to_lowercase())), "a name names one preset");
// The renamed one applies and deletes; the built-in Mist stays as it was.
s.execute("gradient.presets.apply", &json!({"name": "mist 3"})).unwrap();
assert_eq!(paint(&s, id, false).gradient, own);
assert_eq!(s.gradient_preset("Mist").unwrap().gradient, mist);
assert_eq!(s.execute("gradient.presets.delete", &json!({"name": "Mist 3"})).unwrap()["deleted"], "mist 3");
assert!(s.execute("gradient.presets.delete", &json!({"name": "Mist"})).is_err(), "built in");
assert_eq!(s.prefs.gradient_presets.len(), 6);
// Many presets with one name each get their own number.
let mut prefs = s.prefs.clone();
prefs.gradient_presets = (0..2000).map(|_| preset("Same")).collect();
s.apply_prefs(prefs);
let mut seen = std::collections::HashSet::new();
assert!(s.prefs.gradient_presets.iter().all(|p| seen.insert(p.name.to_lowercase())));
assert_eq!(s.prefs.gradient_presets.last().map(|p| p.name.as_str()), Some("Same 2000"));
}
+33
View File
@@ -638,6 +638,39 @@ fn gradient_libraries_are_listed_and_gradient_swatches_rename_and_take_new_gradi
assert_eq!(doc(&s).swatch("Red").unwrap().paint.color().map(|c| c.to_hex()), Some("#00ff00".into()));
}
#[test]
fn organic_fades_add_with_their_method_midpoints_and_opacities_in_rgb_and_cmyk() {
let lib = vectorcraft_color::libraries::builtin_library("organic-fades").unwrap();
for mode in ["rgb", "cmyk"] {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": mode}));
let list = run(&mut s, "swatch.library.list", json!({}));
let row = list["libraries"].as_array().unwrap().iter().find(|l| l["id"] == "organic-fades").unwrap().clone();
assert_eq!(
(row["name"].as_str(), row["category"].as_str(), row["count"].as_u64()),
(Some("Organic Fades"), Some("gradients"), Some(lib.len() as u64))
);
let id = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone();
let r = run(&mut s, "swatch.library.add", json!({"library": "organic-fades", "names": ["Petal", "Dew"], "apply": "fill"}));
assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["Petal", "Dew"]), json!("Petal")), "{mode}");
for name in ["Petal", "Dew"] {
// The swatch comes as the library has it: stops (colors as given, opacities, midpoints) and method.
let Paint::Gradient(added) = &doc(&s).swatch(name).unwrap().paint else { panic!("{name} is a gradient") };
let Paint::Gradient(want) = &lib.swatch(name).unwrap().paint else { panic!("{name} is a gradient") };
assert_eq!(added.gradient, want.gradient, "{mode} {name}");
assert_eq!(added.gradient.method, vectorcraft_color::GradientMethod::Perceptual);
assert!(added.gradient.stops.iter().all(|st| matches!(st.color, Color::Rgb { .. })), "{mode} {name}: the stops stay RGB");
assert!(added.gradient.stops.iter().any(|st| st.opacity < 1.0 && (st.midpoint - 0.5).abs() > 0.02), "{mode} {name}");
}
// The fill takes the fade's method, opacities and midpoints.
let fill = doc(&s).node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap().appearance.fill_paint();
let (Paint::Gradient(fill), Paint::Gradient(want)) = (fill, &lib.swatch("Petal").unwrap().paint) else { panic!("a gradient fill") };
assert_eq!(fill.gradient.method, want.gradient.method, "{mode}");
let shape = |g: &vectorcraft_color::Gradient| g.stops.iter().map(|st| (st.offset, st.opacity, st.midpoint)).collect::<Vec<_>>();
assert_eq!(shape(&fill.gradient), shape(&want.gradient), "{mode}");
}
}
#[test]
fn pattern_libraries_bring_their_patterns_and_add_each_once() {
let mut s = session();