mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 13:17:19 -06:00
Gradient panel: presets to apply, save and delete (G13)
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.
This commit is contained in:
@@ -128,6 +128,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-30: Gradient panel presets: a Presets menu applies one of the built-in gradient libraries' presets (grouped by theme) or a saved one to the gradient behind the active proxy, keeping its placement, as one undo step; the panel menu saves the current gradient as a preset, kept with the preferences, and deletes saved ones (`gradient.presets.list/apply/save/delete`).
|
||||
- 2026-10-23: Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add a thin overprinting strip of the lighter colour inside the darker one where two solid colours meet, so misregistered plates leave no gap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)), with Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps. Illustrator effects 49/50 (SVG Filters remains); Live vector effects hours 6–10 → 4–8; headline numbers unchanged.
|
||||
- 2026-10-23: OpenType SVG colour fonts draw as vector art: each glyph's SVG document is read by the SVG importer, in the text's colours where it asks for them, and draws, exports and outlines as COLR glyphs do; all colour font formats are now drawn.
|
||||
- 2026-10-23: COLR colour fonts (v0 and v1, as in Noto Color Emoji's vector build, Segoe UI Emoji, Nabla) draw as vector art: their layers' colours, gradients, clips and blend modes on the canvas and in SVG, EPS, legacy `.ai` and PDF, and as editable paths with Create Outlines; glyphs using sweep gradients or compositing modes are drawn as images of the glyph.
|
||||
|
||||
@@ -33,6 +33,7 @@ mod fonts;
|
||||
pub(crate) mod freeform;
|
||||
pub mod globaledit;
|
||||
pub(crate) mod gradient;
|
||||
pub mod gradpresets;
|
||||
pub(crate) mod graph;
|
||||
mod halftone;
|
||||
pub mod help;
|
||||
@@ -244,6 +245,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(style::specs());
|
||||
v.extend(swatch::specs());
|
||||
v.extend(gradient::specs());
|
||||
v.extend(gradpresets::specs());
|
||||
v.extend(opacitymask::specs());
|
||||
v.extend(layer::specs());
|
||||
v.extend(layerpanel::specs());
|
||||
@@ -613,6 +615,9 @@ pub use flatten::FlattenOptions;
|
||||
/// A built-in or saved flattener preset ([`crate::Prefs::flattener_presets`]).
|
||||
pub use flatten::FlattenerPreset;
|
||||
|
||||
/// A saved Gradient panel preset ([`crate::Prefs::gradient_presets`]) and a row of the Presets list.
|
||||
pub use gradpresets::{GradientPreset, GradientPresetEntry};
|
||||
|
||||
/// A saved Image Trace preset ([`crate::Prefs::trace_presets`]), and a change to the saved presets.
|
||||
pub use buildcmds::{TracePreset, TracePresetChange};
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! The Gradient panel's presets: ready-made gradients grouped by theme, applied to the gradient
|
||||
//! behind the active proxy in one click, and the user's own gradients saved beside them.
|
||||
//!
|
||||
//! The built-in presets are the gradient swatches of the built-in swatch libraries
|
||||
//! ([`vectorcraft_color::libraries`]: the app's own computed palettes), each listed under its
|
||||
//! library's name. Saved presets are kept with the preferences ([`crate::Prefs::gradient_presets`]),
|
||||
//! so every document has them. A preset is the colour ramp (type, stops, method, dither), not a
|
||||
//! placement: applying one edits the gradient in place, as `paint.editGradient` does.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::libraries::{GRADIENT_LIBRARIES, SWATCH_LIBRARIES, builtin_library};
|
||||
use vectorcraft_color::{Gradient, GradientPaint, Paint};
|
||||
use vectorcraft_tools::params::{gradient_params, stops_json};
|
||||
|
||||
use super::appearance::{edits_stroke, item_target};
|
||||
use super::gradient::active_gradient;
|
||||
use super::{CommandSpec, Result, Session, always, bad, cmd, has_doc, text_param};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "gradient.presets.list",
|
||||
"Gradient Presets",
|
||||
[],
|
||||
None,
|
||||
"{} → {presets: [{name, library: the built-in library (theme) it comes from, null for a saved one, builtIn, gradient: {kind, stops, method?, dither?} (as paint.setFill's `gradient`)}]} the Gradient panel's presets: the built-in ones by library, then the saved ones; names are unique",
|
||||
always,
|
||||
list
|
||||
),
|
||||
cmd!(
|
||||
"gradient.presets.apply",
|
||||
"Apply Gradient Preset",
|
||||
[],
|
||||
None,
|
||||
"{name: a preset's name (gradient.presets.list), stroke?, item?, ids?|id?, target? (as paint.editGradient)} give the gradient behind the active proxy (or the targeted fill or stroke) the preset's type, stops and method, and a fill its dither, keeping its placement; a solid or no paint becomes the gradient. One undo step, as paint.editGradient → {name, ids: the objects painted (none: only the default changed)}",
|
||||
has_doc,
|
||||
apply
|
||||
),
|
||||
cmd!(
|
||||
query "gradient.presets.save",
|
||||
"Save Gradient Preset",
|
||||
[],
|
||||
None,
|
||||
"{name?: (default: a new \"Gradient Preset N\"; unique, any case, and not a built-in preset's)} save the gradient behind the active proxy (paint.toggleActive picks Fill or Stroke) as a preset, kept with the preferences: its type, stops, method and dither (stops linked to a swatch keep their colour, unlinked) → {name}",
|
||||
can_save,
|
||||
save
|
||||
),
|
||||
cmd!(
|
||||
query "gradient.presets.delete",
|
||||
"Delete Gradient Preset",
|
||||
[],
|
||||
None,
|
||||
"{name?: a saved preset (default: the saved preset the active proxy's gradient is)} remove a saved preset; built-in presets can't be deleted, and painted art keeps its gradient → {deleted}",
|
||||
has_saved_presets,
|
||||
delete
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// A gradient saved to the Gradient panel's presets ([`crate::Prefs::gradient_presets`]).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct GradientPreset {
|
||||
pub name: String,
|
||||
pub gradient: Gradient,
|
||||
}
|
||||
|
||||
/// A row of the Presets list: a built-in preset (with its library) or a saved one.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GradientPresetEntry {
|
||||
pub name: String,
|
||||
/// The built-in library (theme) it comes from; `None` for a saved preset.
|
||||
pub library: Option<&'static str>,
|
||||
pub gradient: Gradient,
|
||||
}
|
||||
|
||||
/// The built-in presets: every built-in library's gradient swatches, the gradient libraries first.
|
||||
fn builtin_presets() -> impl Iterator<Item = GradientPresetEntry> {
|
||||
GRADIENT_LIBRARIES.iter().chain(SWATCH_LIBRARIES).flat_map(|b| {
|
||||
let lib = builtin_library(b.id);
|
||||
let gradients: Vec<GradientPresetEntry> = lib
|
||||
.iter()
|
||||
.flat_map(|lib| lib.iter())
|
||||
.filter_map(|w| match &w.paint {
|
||||
Paint::Gradient(g) => Some(GradientPresetEntry { name: w.name.clone(), library: Some(b.name), gradient: g.gradient.clone() }),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
gradients
|
||||
})
|
||||
}
|
||||
|
||||
/// Do `a` and `b` have the same colour ramp (type, method, dither and stops), swatch links aside?
|
||||
fn same_ramp(a: &Gradient, b: &Gradient) -> bool {
|
||||
let stop = |x: &vectorcraft_color::GradientStop, y: &vectorcraft_color::GradientStop| {
|
||||
(x.offset, x.color, x.opacity, x.midpoint) == (y.offset, y.color, y.opacity, y.midpoint)
|
||||
};
|
||||
(a.kind, a.method, a.dither) == (b.kind, b.method, b.dither)
|
||||
&& a.stops.len() == b.stops.len()
|
||||
&& a.stops.iter().zip(&b.stops).all(|(x, y)| stop(x, y))
|
||||
}
|
||||
|
||||
/// `g` without links to document swatches: each linked stop keeps the colour it shows.
|
||||
fn unlinked(g: &Gradient) -> Gradient {
|
||||
let mut g = g.clone();
|
||||
for s in &mut g.stops {
|
||||
s.set_color(s.color, None);
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The Gradient panel's presets: the built-in ones by library, then the saved ones.
|
||||
pub fn gradient_presets(&self) -> Vec<GradientPresetEntry> {
|
||||
let saved =
|
||||
self.prefs.gradient_presets.iter().map(|p| GradientPresetEntry { name: p.name.clone(), library: None, gradient: p.gradient.clone() });
|
||||
builtin_presets().chain(saved).collect()
|
||||
}
|
||||
|
||||
/// The preset called `name` (any case).
|
||||
pub fn gradient_preset(&self, name: &str) -> Option<GradientPresetEntry> {
|
||||
let name = name.trim();
|
||||
self.gradient_presets().into_iter().find(|e| e.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// The name Save Gradient Preset suggests: the first free "Gradient Preset N".
|
||||
pub fn next_gradient_preset_name(&self) -> String {
|
||||
let presets = self.gradient_presets();
|
||||
(1..).map(|n| format!("Gradient Preset {n}")).find(|n| !presets.iter().any(|e| e.name.eq_ignore_ascii_case(n))).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The name of the preset `g` is (its colour ramp, swatch links aside): a built-in one first,
|
||||
/// then a saved one. Cheap enough for every frame: nothing is copied but the name found.
|
||||
pub fn gradient_preset_name(&self, g: &Gradient) -> Option<String> {
|
||||
let builtin = GRADIENT_LIBRARIES.iter().chain(SWATCH_LIBRARIES).find_map(|b| {
|
||||
let lib = builtin_library(b.id)?;
|
||||
lib.iter().find(|w| matches!(&w.paint, Paint::Gradient(x) if same_ramp(&x.gradient, g))).map(|w| w.name.clone())
|
||||
});
|
||||
builtin.or_else(|| self.prefs.gradient_presets.iter().find(|p| same_ramp(&p.gradient, g)).map(|p| p.name.clone()))
|
||||
}
|
||||
|
||||
/// The saved preset the gradient behind the active proxy is (its colour ramp, links aside).
|
||||
pub fn active_saved_gradient_preset(&self) -> Option<&str> {
|
||||
let g = active_gradient(self)?;
|
||||
self.prefs.gradient_presets.iter().find(|p| same_ramp(&p.gradient, &g.gradient)).map(|p| p.name.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
fn can_save(s: &Session) -> std::result::Result<(), String> {
|
||||
active_gradient(s).map(|_| ()).ok_or_else(|| "the active paint is not a gradient".into())
|
||||
}
|
||||
|
||||
fn has_saved_presets(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.prefs.gradient_presets.is_empty() { Err("no saved gradient presets".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// `p`'s `name`: a string, required.
|
||||
fn name_param<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> {
|
||||
text_param(p, "name", cmd)?.ok_or_else(|| bad(cmd, "missing `name` (see gradient.presets.list)"))
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let rows: Vec<Value> = s
|
||||
.gradient_presets()
|
||||
.into_iter()
|
||||
.map(|e| json!({"name": e.name, "library": e.library, "builtIn": e.library.is_some(), "gradient": gradient_params(&GradientPaint::new(e.gradient))}))
|
||||
.collect();
|
||||
Ok(json!({ "presets": rows }))
|
||||
}
|
||||
|
||||
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "gradient.presets.apply";
|
||||
let name = name_param(p, C)?;
|
||||
let preset = s.gradient_preset(name).ok_or_else(|| bad(C, format!("no gradient preset `{name}` (see gradient.presets.list)")))?;
|
||||
let stroke = edits_stroke(s, p, item_target(s, p, C)?, !s.fill_active, C)?;
|
||||
let g = &preset.gradient;
|
||||
let mut edit = p.clone();
|
||||
let Some(o) = edit.as_object_mut() else { return Err(bad(C, "params must be an object")) };
|
||||
o.remove("name");
|
||||
o.insert("stroke".into(), json!(stroke));
|
||||
o.insert("kind".into(), json!(g.kind.label().to_lowercase()));
|
||||
o.insert("stops".into(), stops_json(&g.stops));
|
||||
o.insert("method".into(), json!(g.method.label().to_lowercase()));
|
||||
// Dither is a fill's (paint.editGradient refuses it on a stroke).
|
||||
if !stroke {
|
||||
o.insert("dither".into(), json!(g.dither));
|
||||
}
|
||||
let out = s.execute("paint.editGradient", &edit)?;
|
||||
Ok(json!({"name": preset.name, "ids": out.get("ids").cloned().unwrap_or_else(|| json!([]))}))
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "gradient.presets.save";
|
||||
let g = active_gradient(s).ok_or_else(|| bad(C, "the active paint is not a gradient"))?;
|
||||
let name = match text_param(p, "name", C)?.map(str::trim) {
|
||||
None => s.next_gradient_preset_name(),
|
||||
Some("") => return Err(bad(C, "the name is empty")),
|
||||
Some(n) if s.gradient_preset(n).is_some() => return Err(bad(C, format!("a gradient preset is already called {n}"))),
|
||||
Some(n) => n.to_string(),
|
||||
};
|
||||
s.prefs.gradient_presets.push(GradientPreset { name: name.clone(), gradient: unlinked(&g.gradient) });
|
||||
Ok(json!({ "name": name }))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "gradient.presets.delete";
|
||||
let name = match text_param(p, "name", C)? {
|
||||
Some(n) => n.trim().to_string(),
|
||||
None => s
|
||||
.active_saved_gradient_preset()
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| bad(C, "give a name, or paint the active proxy with a saved preset"))?,
|
||||
};
|
||||
if builtin_presets().any(|e| e.name.eq_ignore_ascii_case(&name)) {
|
||||
return Err(bad(C, format!("{name} is built in and can't be deleted")));
|
||||
}
|
||||
let i = s
|
||||
.prefs
|
||||
.gradient_presets
|
||||
.iter()
|
||||
.position(|g| g.name.eq_ignore_ascii_case(&name))
|
||||
.ok_or_else(|| bad(C, format!("no saved gradient preset called {name}")))?;
|
||||
Ok(json!({ "deleted": s.prefs.gradient_presets.remove(i).name }))
|
||||
}
|
||||
@@ -216,6 +216,11 @@ pub struct Prefs {
|
||||
/// `imageTrace.presets.*` edit it.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub trace_presets: Vec<cmd::TracePreset>,
|
||||
/// Gradient panel › Presets: the user's saved gradients (the built-in presets aren't stored).
|
||||
/// A local library, not a Preferences dialog field: resetting the preferences keeps it;
|
||||
/// `gradient.presets.*` edit it.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub gradient_presets: Vec<cmd::GradientPreset>,
|
||||
/// Width profiles saved to the Stroke panel's Profile list (`stroke.widthProfile.*`). Not a
|
||||
/// Preferences dialog field, so it has no [`cmd::prefscmds::PREF_SPECS`] row.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -448,6 +453,7 @@ impl Default for Prefs {
|
||||
glyph_snap: Default::default(),
|
||||
flattener_presets: vec![],
|
||||
trace_presets: vec![],
|
||||
gradient_presets: vec![],
|
||||
width_profiles: vec![],
|
||||
japanese_crop_marks: false,
|
||||
new_art_basic: true,
|
||||
|
||||
@@ -62,6 +62,7 @@ mod globaledit;
|
||||
mod glyphbounds;
|
||||
mod gradient;
|
||||
mod gradpanel;
|
||||
mod gradpresets;
|
||||
mod gradspan;
|
||||
mod halftone;
|
||||
mod history_mem;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! 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()));
|
||||
}
|
||||
@@ -19,6 +19,11 @@
|
||||
//! A freeform gradient shows its section instead of the angle, aspect and slider: the Draw
|
||||
//! toggle (Points or Lines: how the Gradient tool adds points) and the selected point's colour
|
||||
//! (edited in the Color panel), opacity and spread, with Delete Point.
|
||||
//!
|
||||
//! The Presets menu lists ready-made gradients by theme (the built-in gradient libraries), then
|
||||
//! the gradients saved with the panel menu's Save Gradient Preset…; choosing one applies it to the
|
||||
//! gradient behind the active proxy, keeping its placement (`gradient.presets.*`). Hide Presets in
|
||||
//! the panel menu leaves the row out.
|
||||
|
||||
use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
@@ -42,6 +47,8 @@ const ANGLE_PRESETS: [f64; 9] = [-180.0, -135.0, -90.0, -45.0, 0.0, 45.0, 90.0,
|
||||
const ASPECT_PRESETS: [f64; 9] = [10.0, 25.0, 50.0, 75.0, 100.0, 150.0, 200.0, 300.0, 400.0];
|
||||
/// Panel-state key of Show/Hide Options.
|
||||
const HIDE_OPTIONS: &str = "gradient-hide-options";
|
||||
/// Panel-state key of Show/Hide Presets.
|
||||
const HIDE_PRESETS: &str = "gradient-hide-presets";
|
||||
/// Panel-state key of the selected midpoint diamond.
|
||||
const MID_SELECTED: &str = "grad-mid";
|
||||
|
||||
@@ -277,6 +284,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !pstate::<bool>(ui.ctx(), HIDE_PRESETS) {
|
||||
ui.add_space(4.0);
|
||||
presets_row(app, ui, is_grad.then_some(&g));
|
||||
}
|
||||
// How the gradient lies on the stroke behind the Stroke proxy (a linear or radial one).
|
||||
let stroke_mode = (!app.session.fill_active && is_grad && !freeform).then(|| app.session.shown_stroke().map(|st| st.gradient_mode)).flatten();
|
||||
ui.add_space(4.0);
|
||||
@@ -380,6 +391,64 @@ fn method_row(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad:
|
||||
});
|
||||
}
|
||||
|
||||
/// The Presets row: a menu of the preset gradients by library (theme), then the
|
||||
/// saved ones; a choice applies to the gradient behind the active proxy, keeping its placement
|
||||
/// (`gradient.presets.apply`; a solid or no paint becomes the gradient). The menu shows the preset
|
||||
/// the gradient is, if any.
|
||||
fn presets_row(app: &mut VectorcraftApp, ui: &mut Ui, g: Option<&GradientPaint>) {
|
||||
let shown = g.and_then(|g| app.session.gradient_preset_name(&g.gradient)).unwrap_or_default();
|
||||
let enabled = crate::menus::enabled(app, "gradient.presets.apply");
|
||||
ui.horizontal(|ui| {
|
||||
widgets::dim_label(ui, tl!("Presets:"));
|
||||
let chosen = ui.add_enabled_ui(enabled, |ui| widgets::combo(ui, "grad-presets", &shown, 150.0, |ui| preset_list(app, ui, &shown))).inner;
|
||||
if let Some(name) = chosen {
|
||||
app.run("gradient.presets.apply", json!({ "name": name })).ok();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The Presets menu's rows: each library's name over its presets, then the saved presets. Returns
|
||||
/// the name of the one clicked.
|
||||
fn preset_list(app: &VectorcraftApp, ui: &mut Ui, shown: &str) -> Option<String> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.set_min_width(200.0);
|
||||
let mut chosen = None;
|
||||
// The library of the rows drawn last (`Some(None)`: the saved presets).
|
||||
let mut library: Option<Option<&str>> = None;
|
||||
for e in app.session.gradient_presets() {
|
||||
if library != Some(e.library) {
|
||||
if library.is_some() {
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
library = Some(e.library);
|
||||
widgets::dim_label(ui, e.library.map_or(tl!("Saved"), |l| tl!(l)));
|
||||
}
|
||||
if gradient_row(ui, &t, &GradientPaint::new(e.gradient), &e.name, e.name == shown).clicked() {
|
||||
chosen = Some(e.name);
|
||||
}
|
||||
}
|
||||
chosen
|
||||
}
|
||||
|
||||
/// A menu row: a chip of `g` and `name` (lit when `marked`).
|
||||
fn gradient_row(ui: &mut Ui, t: &Tokens, g: &GradientPaint, name: &str, marked: bool) -> egui::Response {
|
||||
let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
|
||||
// A long menu scrolls: rows out of view are laid out but not drawn, as labels aren't.
|
||||
if !ui.is_rect_visible(row) {
|
||||
return resp;
|
||||
}
|
||||
if marked {
|
||||
ui.painter().rect_filled(row, 0.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(row, 0.0, t.hover);
|
||||
}
|
||||
let chip = Rect::from_min_size(row.left_center() + vec2(4.0, -8.0), vec2(16.0, 16.0));
|
||||
widgets::gradient_chip(ui, chip, g);
|
||||
ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside);
|
||||
ui.painter().text(chip.right_center() + vec2(8.0, 0.0), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.5), t.text);
|
||||
resp
|
||||
}
|
||||
|
||||
/// The freeform points shown: the gradient's own, else (unplaced) as many as it places, coloured
|
||||
/// along the stops.
|
||||
pub(crate) fn shown_points(g: &GradientPaint) -> std::borrow::Cow<'_, Freeform> {
|
||||
@@ -478,18 +547,8 @@ fn thumbnail(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad:
|
||||
widgets::dim_label(ui, tl!("No gradient swatches"));
|
||||
}
|
||||
for (name, grad) in &swatches {
|
||||
let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
|
||||
// The gradient swatch the gradient was applied from.
|
||||
if g.swatch.as_ref() == Some(name) {
|
||||
ui.painter().rect_filled(row, 0.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(row, 0.0, t.hover);
|
||||
}
|
||||
let chip = Rect::from_min_size(row.left_center() + vec2(4.0, -8.0), vec2(16.0, 16.0));
|
||||
widgets::gradient_chip(ui, chip, grad);
|
||||
ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside);
|
||||
ui.painter().text(chip.right_center() + vec2(8.0, 0.0), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.5), t.text);
|
||||
if resp.clicked() {
|
||||
// The gradient swatch the gradient was applied from is lit.
|
||||
if gradient_row(ui, &t, grad, name, g.swatch.as_ref() == Some(name)).clicked() {
|
||||
chosen = Some(name.clone());
|
||||
}
|
||||
}
|
||||
@@ -832,6 +891,20 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if menu_item(ui, tl!("Reset to White, Black"), true, false) {
|
||||
set_stops(app, &Gradient::default().stops, None, Live::Released);
|
||||
}
|
||||
ui.separator();
|
||||
let presets_hidden: bool = pstate(ui.ctx(), HIDE_PRESETS);
|
||||
if menu_item(ui, if presets_hidden { tl!("Show Presets") } else { tl!("Hide Presets") }, true, false) {
|
||||
set_pstate(ui.ctx(), HIDE_PRESETS, !presets_hidden);
|
||||
}
|
||||
if menu_item(ui, tl!("Save Gradient Preset…"), crate::menus::enabled(app, "gradient.presets.save"), false) {
|
||||
let name = app.session.next_gradient_preset_name();
|
||||
let p = json!({"command": "gradient.presets.save", "label": "Gradient Preset", "params": {"name": name}});
|
||||
app.run("ui.paramDialog", p).ok();
|
||||
}
|
||||
// Deletes the saved preset the gradient behind the active proxy is.
|
||||
if menu_item(ui, tl!("Delete Gradient Preset"), app.session.active_saved_gradient_preset().is_some(), false) {
|
||||
app.run("gradient.presets.delete", json!({})).ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -98,6 +98,17 @@ impl Panel {
|
||||
|
||||
/// One frame with `events` (and `modifiers` held); returns the texts drawn and where.
|
||||
fn frame(&mut self, app: &mut VectorcraftApp, events: Vec<Event>, modifiers: Modifiers) -> Vec<(String, Pos2)> {
|
||||
self.frame_of(app, events, modifiers, show)
|
||||
}
|
||||
|
||||
/// [`Self::frame`] drawing `draw` (the panel, or its menu) instead of the panel.
|
||||
fn frame_of(
|
||||
&mut self,
|
||||
app: &mut VectorcraftApp,
|
||||
events: Vec<Event>,
|
||||
modifiers: Modifiers,
|
||||
draw: fn(&mut VectorcraftApp, &mut Ui),
|
||||
) -> Vec<(String, Pos2)> {
|
||||
fn texts(s: &egui::Shape, out: &mut Vec<(String, Pos2)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.rect.center().to_vec2())),
|
||||
@@ -114,7 +125,7 @@ impl Panel {
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(260.0, 600.0))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = self.ctx.run_ui(input, |ui| show(app, ui));
|
||||
let mut out = self.ctx.run_ui(input, |ui| draw(app, ui));
|
||||
out.textures_delta.clear();
|
||||
let mut v = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
|
||||
@@ -145,6 +156,29 @@ impl Panel {
|
||||
self.frame(app, vec![], Modifiers::NONE)
|
||||
}
|
||||
|
||||
/// A click at `at` on the panel menu (drawn alone); returns the menu's texts after it.
|
||||
fn menu_click(&mut self, app: &mut VectorcraftApp, at: Pos2) -> Vec<(String, Pos2)> {
|
||||
let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
self.frame_of(app, vec![Event::PointerMoved(at), b(true)], Modifiers::NONE, menu);
|
||||
self.frame_of(app, vec![b(false)], Modifiers::NONE, menu);
|
||||
self.frame_of(app, vec![], Modifiers::NONE, menu)
|
||||
}
|
||||
|
||||
/// The first widget right of `at` on its row (last frame).
|
||||
fn widget_after(&self, at: Pos2) -> Rect {
|
||||
let mut v: Vec<Rect> = self.ctx.viewport(|vp| {
|
||||
vp.prev_pass
|
||||
.widgets
|
||||
.layers()
|
||||
.flat_map(|(_, w)| w.iter())
|
||||
.map(|w| w.rect)
|
||||
.filter(|r| (r.center().y - at.y).abs() < 8.0 && r.left() > at.x)
|
||||
.collect()
|
||||
});
|
||||
v.sort_by(|a, b| a.left().total_cmp(&b.left()));
|
||||
*v.first().expect("a widget right of it")
|
||||
}
|
||||
|
||||
/// The `size`-square widgets right of `at` on its row (last frame), left to right.
|
||||
fn row_after(&self, at: Pos2, size: f32) -> Vec<Rect> {
|
||||
let mut v: Vec<Rect> = self.ctx.viewport(|vp| {
|
||||
@@ -579,3 +613,91 @@ fn the_stop_popover_has_an_eyedropper_for_a_stop() {
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label(TIP).is_none(), "no eyedropper for a freeform point");
|
||||
}
|
||||
|
||||
/// Where text `s` was drawn (menu items are drawn indented).
|
||||
fn text_at(texts: &[(String, Pos2)], s: &str) -> Option<Pos2> {
|
||||
texts.iter().find(|(t, _)| t.trim_start() == s).map(|(_, at)| *at)
|
||||
}
|
||||
|
||||
/// The Presets menu lists the presets by library (theme) and applies the one chosen to the
|
||||
/// gradient behind the active proxy, as one undo step; the menu then shows that preset.
|
||||
#[test]
|
||||
fn the_presets_menu_applies_a_preset_and_shows_it() {
|
||||
let mut app = app(json!({"gradient": {"angle": 30}}));
|
||||
let mut p = Panel::new();
|
||||
let texts = p.frame(&mut app, vec![], Modifiers::NONE);
|
||||
let label = text_at(&texts, "Presets:").unwrap_or_else(|| panic!("the Presets row: {texts:?}"));
|
||||
let combo = p.widget_after(label);
|
||||
let texts = p.click(&mut app, combo.center());
|
||||
assert!(text_at(&texts, "Spectrum Gradients").is_some(), "a library's name heads its presets: {texts:?}");
|
||||
let soft = text_at(&texts, "Soft Spectrum").expect("the library's presets");
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
p.click(&mut app, soft);
|
||||
let preset = app.session.gradient_preset("Soft Spectrum").unwrap().gradient;
|
||||
assert_eq!(fill(&app).gradient.stops, preset.stops);
|
||||
assert_eq!(fill(&app).angle, 30.0, "the placement stays");
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
let texts = p.frame(&mut app, vec![], Modifiers::NONE);
|
||||
assert!(text_at(&texts, "Soft Spectrum").is_some(), "the menu shows the preset: {texts:?}");
|
||||
// With the Stroke proxy in front a solid stroke takes the preset; the fill keeps its own.
|
||||
app.run("paint.setStroke", json!({"color": "#00ff00", "focus": false})).unwrap();
|
||||
let stroke = p.widget("stroke-proxy");
|
||||
p.click(&mut app, stroke.right_bottom() - vec2(2.0, 2.0));
|
||||
assert!(!app.session.fill_active);
|
||||
let texts = p.click(&mut app, combo.center());
|
||||
p.click(&mut app, text_at(&texts, "Spectrum").expect("the first preset"));
|
||||
let Paint::Gradient(st) = app.session.shown_stroke().unwrap().paint.clone() else { panic!("a gradient stroke") };
|
||||
assert_eq!(st.gradient.stops, app.session.gradient_preset("Spectrum").unwrap().gradient.stops);
|
||||
assert_eq!(fill(&app).gradient.stops, preset.stops);
|
||||
}
|
||||
|
||||
/// The panel menu saves the gradient as a preset (named in a dialog), which the Presets menu then
|
||||
/// lists under Saved; Delete Gradient Preset removes it, and Hide Presets leaves the row out.
|
||||
#[test]
|
||||
fn the_panel_menu_saves_deletes_and_hides_presets() {
|
||||
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
|
||||
let mut app = app(json!({"gradient": {"stops": stops}}));
|
||||
let mut p = Panel::new();
|
||||
let texts = p.frame_of(&mut app, vec![], Modifiers::NONE, menu);
|
||||
let save = text_at(&texts, "Save Gradient Preset…").unwrap_or_else(|| panic!("{texts:?}"));
|
||||
assert!(!app.session.active_saved_gradient_preset().is_some());
|
||||
p.menu_click(&mut app, save);
|
||||
let d = app.ui.dialog.take().expect("the name dialog");
|
||||
assert_eq!((d.fields["__command"].as_str(), d.fields["name"].as_str()), (Some("gradient.presets.save"), Some("Gradient Preset 1")));
|
||||
// The dialog's OK runs the command with the name typed.
|
||||
app.run("gradient.presets.save", json!({"name": "Red Blue"})).unwrap();
|
||||
assert_eq!(app.session.active_saved_gradient_preset(), Some("Red Blue"));
|
||||
let texts = p.frame(&mut app, vec![], Modifiers::NONE);
|
||||
let label = text_at(&texts, "Presets:").unwrap();
|
||||
assert!(text_at(&texts, "Red Blue").is_some(), "the menu shows the saved preset the gradient is: {texts:?}");
|
||||
let texts = p.click(&mut app, p.widget_after(label).center());
|
||||
// The saved presets come after the libraries: scroll the menu to them.
|
||||
let over = text_at(&texts, "Spectrum").expect("the menu is open");
|
||||
let wheel = Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: vec2(0.0, -5000.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: Modifiers::NONE,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
};
|
||||
p.frame(&mut app, vec![Event::PointerMoved(over), wheel], Modifiers::NONE);
|
||||
let mut texts = vec![];
|
||||
for _ in 0..30 {
|
||||
texts = p.frame(&mut app, vec![], Modifiers::NONE);
|
||||
}
|
||||
let saved = text_at(&texts, "Saved").unwrap_or_else(|| panic!("the saved presets have their heading: {texts:?}"));
|
||||
assert!(texts.iter().any(|(t, at)| t.trim_start() == "Red Blue" && at.y > saved.y), "the saved preset under it: {texts:?}");
|
||||
assert!(text_at(&texts, "Spectrum").is_none(), "rows scrolled out of view aren't drawn: {texts:?}");
|
||||
// Delete Gradient Preset removes the one the gradient is.
|
||||
let texts = p.frame_of(&mut app, vec![], Modifiers::NONE, menu);
|
||||
p.menu_click(&mut app, text_at(&texts, "Delete Gradient Preset").unwrap());
|
||||
assert!(app.session.prefs.gradient_presets.is_empty());
|
||||
// Hide Presets leaves the row out; Show Presets brings it back.
|
||||
let texts = p.frame_of(&mut app, vec![], Modifiers::NONE, menu);
|
||||
let texts = p.menu_click(&mut app, text_at(&texts, "Hide Presets").unwrap());
|
||||
assert!(text_at(&texts, "Show Presets").is_some());
|
||||
assert!(text_at(&p.frame(&mut app, vec![], Modifiers::NONE), "Presets:").is_none());
|
||||
let texts = p.frame_of(&mut app, vec![], Modifiers::NONE, menu);
|
||||
p.menu_click(&mut app, text_at(&texts, "Show Presets").unwrap());
|
||||
assert!(text_at(&p.frame(&mut app, vec![], Modifiers::NONE), "Presets:").is_some());
|
||||
}
|
||||
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (G1: Average & Join with a corner or smooth choice, Cmd+Alt+Shift+J, done; estimate unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (G13: gradient presets done; G1: Average & Join with a corner or smooth choice, Cmd+Alt+Shift+J, done; estimate unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -459,7 +459,11 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
segments for drawn lines. Controls are saved under Preferences › Smart Guides › Geometric Guides
|
||||
and available from Control bar › Snapping; both default to on. Existing-line endpoint editing and
|
||||
other tool cases remain unverified. [Public source](https://helpx.adobe.com/illustrator/desktop/measure-and-align/plot-and-measure/snap-to-perpendicular.html).
|
||||
- **Missing:** gradient presets (30.0); remaining tangent/perpendicular snapping cases and the consolidated
|
||||
- **Done (2026-10-30):** gradient presets (30.0): the Gradient panel's Presets menu applies one of the
|
||||
built-in gradient libraries' presets (by theme) or a saved one to the gradient behind the active proxy, keeping
|
||||
its placement, as one undo step; the panel menu saves the gradient as a preset (kept with the preferences) and
|
||||
deletes saved ones; `gradient.presets.*` commands.
|
||||
- **Missing:** remaining tangent/perpendicular snapping cases and the consolidated
|
||||
snapping options (30.0); artboard colors in exports and print, and managing artboards from the canvas other
|
||||
than locking and unlocking them from the Artboard tool's right-click menu (30.0); the Blend panel with ease
|
||||
in/out and separate colour acceleration (30.7);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Parity with Adobe Illustrator
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Presentation Mode fits the active artboard and hides selections, guides, grids and artboard edges; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Gradient panel presets done; Gradient stop popover: an eyedropper samples the stop's colour from the art (#4681); scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -112,14 +112,14 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
|
||||
| Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); open-path anchor Delete keeps gaps; Select › Start Global Edit finds the paths, compound paths and symbol instances of the selected object's shape (Appearance, Size and artboard options) and gives an edit of one to all of them in proportion, as one undo step (#3521); the Align panel menu's Align to Glyph Bounds › Point Text and Area Text measure type by its glyphs' ink for Align and Distribute (#4231); remaining: Free Transform/Scale/Reflect along a rotated box, Global Edit of similar groups and excluding a match with Shift-click, transform nuances (no-op anchor Align history regression-tested, #1180 follow-up) | 4–6 | estimated; Global Edit regression-tested (`crates/engine/src/tests/globaledit.rs`; Quick Action, menu label and Escape in `panels::properties::tests::start_global_edit_quick_action_and_escape`) |
|
||||
| Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged |
|
||||
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself), its gap detection and options; Live Paint Gap Options | 4–8 | estimated (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522) |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient dither, perceptual blending and gradient presets; freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251) |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient dither and perceptual blending (presets are done); freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251) |
|
||||
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder misses planar regions in real files (#937), its gap detection and options; Live Paint Gap Options | 4–8 | estimated (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522) |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient presets (gradient dither and perceptual blending are done); freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's perceptual blending and gradient presets (gradient dither is done); freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient dither, perceptual blending and gradient presets; freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient additions are done (dither, perceptual blending, presets); freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's perceptual blending (gradient dither and presets are done); freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient dither and perceptual blending (presets are done); freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged |
|
||||
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder misses planar regions in real files (#937); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522) |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient presets (gradient dither and perceptual blending are done); freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251) |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's perceptual blending and gradient presets (gradient dither is done); freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251) |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient additions are done (dither, perceptual blending, presets); freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251) |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's perceptual blending (gradient dither and presets are done); freeform and mixed spot/process gradients in SVG/PDF | 4–7 | estimated; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251) |
|
||||
| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (four generated libraries, User Defined, Other Library…, Save Brushes); remaining: Art brush Overlap, Pattern brush auto corners, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) |
|
||||
| Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated |
|
||||
| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) |
|
||||
@@ -1562,6 +1562,7 @@ spot colours, carry a colour adjustment, or are RGB, CMYK or grayscale images
|
||||
| 2026-10-22 | minor | Merge current main and complete the Turkish tab-drag labels and validation messages requested by the maintainer; all 24 catalogs covered; scores and estimates unchanged. |
|
||||
| 2026-10-22 | minor | Tab-drag follow-up: reconcile upstream progress updates and document drag lifecycle, insertion behavior and regression contracts; behavior, scores and estimates unchanged. |
|
||||
| 2026-10-22 | minor | Artwork dragged with Selection switches visible document tabs after a 400 ms hover, then copies editable artwork on canvas release; source and clipboard stay intact; scores and hours unchanged |
|
||||
| 2026-10-30 | minor | Gradient panel presets (30.0) done: the colour area no longer lists them; scores and estimates unchanged. |
|
||||
|
||||
2026-10-28 Flare interaction: the center drag turns only the rays by one degree per document point of increasing pointer y and back at the same rate for decreasing y. This is an application rule independent of event frequency. Shift keeps the current turn; Space during either drag moves the flare without changing its size or turn, including the first drag sample. Releasing a modifier resumes from the current pointer position without catching up skipped rotation. Scores and remaining estimates unchanged.
|
||||
## EPS Save evidence (2026-10-26)
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Smart Pen add-on: an Alt-drag away from paths selects points with a marquee; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Gradient panel presets; View › Hide Artboards hides their edges and shows the canvas as paper (#4038); scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -313,6 +313,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po
|
||||
| Blend panel (30.7) | partial | Window › Blend provides Type (Ease In, Ease Out, Ease In Out, Ease Out In), Ramp 0–100% and independent color Shift, alongside the existing blend controls. Easing is disabled without a spine. Original polynomial distributions approximate the publicly documented behavior; on-canvas easing handles remain missing. [Public source](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html) |
|
||||
| Gradient panel: Dither (30.0) | done | Dither box under the stops (`paint.editGradient` `dither`); dimmed behind the Stroke proxy, fills only; `panels::gradient` test |
|
||||
| Gradient panel: Method, Classic or Perceptual (30.0) | done | Method menu under the Stroke buttons for linear and radial gradients (`paint.editGradient` `method`); [public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html); `panels::gradient` test |
|
||||
| Gradient panel: Presets (30.0) | done | Presets menu by library (theme) plus Saved: applies a preset to the gradient behind the active proxy, keeping its placement, one undo step; panel menu Save Gradient Preset…, Delete Gradient Preset, Hide/Show Presets (`gradient.presets.*`); `panels::gradient` tests |
|
||||
| SVG Interactivity, Retype, Comments, Version History panels | missing | the last two are cloud features (out of scope) |
|
||||
| Workspaces (save, reset, switch); each remembers the layout changes made in it across switches and restarts until Reset <name> | done | `UiState::workspace_changes` |
|
||||
| Background Export: a progress dialog with the file name, the artboard count and a bar (busy for one artboard and for formats made in one go), Cancel (Esc) and Continue in Background; it opens after 0.4 s and never over another dialog. A cancelled export writes no file | done | #2564; on the shared `vectorcraft_doc::cancel` token (#2546), so a cancelled render keeps no half-drawn pattern tile or effect raster. The bar moves between artboards, not within one. Save As, Export for Screens and Export Selection don't show it yet |
|
||||
@@ -388,6 +389,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
|
||||
| 2026-10-30 | minor | Art moved whole with the Direct Selection or Group Selection tool and dragged off the canvas goes to the panels (Asset Export, Graphic Styles, Swatches, Symbols) as with the Selection tool; anchor points and segments dragged off stay a reshape (#4233). Tests in `crates/ui-egui/src/canvas/tests.rs`; scores and estimates unchanged |
|
||||
| 2026-10-30 | minor | Blob Brush and Eraser Tool Options: Size follows the pen pressure, Fixed or Pressure with a Variation (#4590, #3961); scores and estimates unchanged |
|
||||
| 2026-10-30 | minor | Blob Brush Tool Options: Size follows the pen pressure, Fixed or Pressure with a Variation (#4590); scores and estimates unchanged |
|
||||
| 2026-10-30 | minor | Gradient panel: Presets menu and Save/Delete Gradient Preset (30.0); scores and estimates unchanged |
|
||||
| 2026-10-29 | minor | Pathfinder: live compound shapes (#4522): the panel menu's Make, Release and Expand Compound Shape, Alt-clicking a Shape Mode, a selected member's Shape Mode and the Expand button act; scores and estimates unchanged |
|
||||
| 2026-10-29 | minor | Object › Path › Smooth… with its slider and Auto-Smooth (#4115); scores and estimates unchanged |
|
||||
| 2026-10-25 | minor | Selection tool: a double-click on a threaded port breaks the thread there (`text.thread.break`); scores and estimates unchanged |
|
||||
|
||||
Reference in New Issue
Block a user