diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 0475830e5..7ae42d58f 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -106,9 +106,12 @@ pub struct ProfilePreset { } impl WidthProfile { - /// Width factor at `t` (average of both sides), linear between points. + /// (left, right) width factors at `t`, linear between points. pub fn at(&self, t: f64) -> (f64, f64) { - let p = &self.points; + Self::at_points(&self.points, t) + } + /// [`Self::at`] of a profile's points (a preset's or a saved profile's). + pub fn at_points(p: &[(f64, f64, f64)], t: f64) -> (f64, f64) { if p.is_empty() { return (1.0, 1.0); } @@ -125,11 +128,17 @@ impl WidthProfile { (l.1, l.2) } /// The built-in profiles, in menu order. "uniform" is the plain stroke (no profile). - pub const PRESETS: [ProfilePreset; 4] = [ + pub const PRESETS: [ProfilePreset; 7] = [ ProfilePreset { id: "uniform", label: "Uniform", points: &[(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }, ProfilePreset { id: "lens", label: "Lens", points: &[(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)] }, ProfilePreset { id: "taperStart", label: "Taper Start", points: &[(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)] }, ProfilePreset { id: "taperEnd", label: "Taper End", points: &[(0.0, 1.0, 1.0), (1.0, 0.0, 0.0)] }, + // Full width at both ends, pinched to a quarter in the middle. + ProfilePreset { id: "pinch", label: "Pinch", points: &[(0.0, 1.0, 1.0), (0.5, 0.25, 0.25), (1.0, 1.0, 1.0)] }, + // A round head a fifth of the way along, then a long taper to the end. + ProfilePreset { id: "teardrop", label: "Teardrop", points: &[(0.0, 0.0, 0.0), (0.2, 1.0, 1.0), (1.0, 0.0, 0.0)] }, + // Two swells between narrow necks. + ProfilePreset { id: "wave", label: "Wave", points: &[(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)] }, ]; /// The built-in profile with this id. pub fn preset(id: &str) -> Option { @@ -155,6 +164,14 @@ impl WidthProfile { } } +/// A width profile saved to the Profile list under a name (Add to Profiles), kept with the +/// preferences. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SavedProfile { + pub name: String, + pub profile: WidthProfile, +} + /// A live effect in an appearance stack. Parameters are interpreted by `vectorcraft-effects`. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Effect { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 349a38c92..011907782 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -34,8 +34,8 @@ pub(crate) mod skip { } pub use appearance::{ - Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, - WidthProfile, + Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, SavedProfile, StrokeAlign, + StrokeLayer, WidthProfile, }; pub use graph::{GraphKind, GraphSpec}; pub use hit::{Hit, HitKind}; diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index b926fd4d0..5f734181f 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -1,9 +1,9 @@ //! The Stroke panel: weight, cap, join, alignment, dashes, arrowheads, width profiles and brushes. //! With type selected it edits the characters' stroke (weight, cap, join, miter limit, dashes). -use serde_json::Value; +use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, StrokeLayer, Unit, WidthProfile}; +use vectorcraft_doc::{ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, StrokeLayer, Unit, WidthProfile}; use super::appearance::{ItemTarget, edit_items, item_target}; use super::paint::painted; @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?} Without a stroke item, type takes weight, cap, join, miterLimit and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?} Without a stroke item, type takes weight, cap, join, miterLimit and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone", has_doc, stroke_set ), @@ -31,6 +31,42 @@ pub fn specs() -> Vec { has_doc, stroke_advanced ), + cmd!( + "stroke.widthProfile.add", + "Add to Profiles", + [], + None, + "{name?} save the selected stroke's variable width to the Profile list (kept with the preferences) under `name` (default \"Width Profile N\"; unique, not a built-in's) → {name}", + can_add_profile, + profile_add + ), + cmd!( + "stroke.widthProfile.delete", + "Delete Profile", + [], + None, + "{name?} remove a saved profile from the Profile list (default: the selected stroke's); built-in profiles can't be deleted, and strokes keep their widths → {deleted}", + has_saved_profiles, + profile_delete + ), + cmd!( + "stroke.widthProfile.reset", + "Reset Profiles", + [], + None, + "{} remove every saved profile, leaving the built-ins → {removed: count}", + has_saved_profiles, + profile_reset + ), + cmd!( + query "stroke.widthProfile.list", + "Width Profiles", + [], + None, + "{} → {profiles: [{id (stroke.set `profile`), label, builtIn, points: [[t, left, right]…]}], current: the selected stroke's profile id or name, \"custom\" when it isn't listed, null without a stroke}", + always, + profile_list + ), ] } @@ -154,8 +190,7 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { }; let profile = match str_param(p, "profile") { None => None, - Some("uniform") => Some(None), - Some(id) => Some(Some(WidthProfile::preset(id).ok_or_else(|| bad(C, format!("unknown profile {id}")))?)), + Some(id) => Some(s.resolve_profile(id).ok_or_else(|| bad(C, format!("unknown profile {id} (see stroke.widthProfile.list)")))?), }; edit_items(s, &ids, item, C, "Stroke", false, |n, index| { if index.is_none() @@ -314,3 +349,118 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result { })?; ok() } + +/// One row of the Profile list: a built-in profile or a saved one. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ProfileEntry<'a> { + /// What `stroke.set {profile}` takes: a built-in's id, a saved profile's name. + pub id: &'a str, + pub label: &'a str, + pub points: &'a [(f64, f64, f64)], + pub built_in: bool, +} + +impl ProfileEntry<'_> { + /// Does this row describe `p` (`None`: the plain stroke, the Uniform row)? + pub fn matches(&self, p: Option<&WidthProfile>) -> bool { + match p { + None => self.built_in && self.id == "uniform", + Some(p) => p.points == self.points, + } + } +} + +impl Session { + /// The Profile list: the built-in profiles, then the saved ones. + pub fn profile_entries(&self) -> impl Iterator> { + let built_in = WidthProfile::PRESETS.iter().map(|p| ProfileEntry { id: p.id, label: p.label, points: p.points, built_in: true }); + let saved = + self.prefs.width_profiles.iter().map(|p| ProfileEntry { id: &p.name, label: &p.name, points: &p.profile.points, built_in: false }); + built_in.chain(saved) + } + + /// The Profile list row showing `p` (`None`: the plain stroke), if it is listed. + pub fn profile_entry(&self, p: Option<&WidthProfile>) -> Option> { + self.profile_entries().find(|e| e.matches(p)) + } + + /// The profile `stroke.set {profile: key}` applies: `Some(None)` for "uniform" (the plain + /// stroke), else a built-in's id or a saved profile's name; `None` when nothing is called `key`. + pub fn resolve_profile(&self, key: &str) -> Option> { + let e = self.profile_entries().find(|e| e.id == key)?; + Some((!e.matches(None)).then(|| WidthProfile { points: e.points.to_vec() })) + } + + /// The name Add to Profiles suggests: the first free "Width Profile N". + pub fn next_profile_name(&self) -> String { + (1..).map(|n| format!("Width Profile {n}")).find(|n| !self.profile_name_taken(n)).unwrap_or_default() + } + + /// Does a listed profile have `name` as its id or label (ignoring case)? + fn profile_name_taken(&self, name: &str) -> bool { + self.profile_entries().any(|e| e.id.eq_ignore_ascii_case(name) || e.label.eq_ignore_ascii_case(name)) + } + + /// The variable width Add to Profiles saves: the shown stroke's profile, when it isn't listed. + fn profile_to_add(&self) -> std::result::Result { + let p = self.shown_stroke().and_then(|s| s.profile).ok_or("select a stroke with a variable width")?; + match self.profile_entry(Some(&p)) { + Some(e) => Err(format!("the selected stroke's profile is already listed as {}", e.label)), + None => Ok(p), + } + } +} + +fn can_add_profile(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + s.profile_to_add().map(|_| ()) +} + +fn has_saved_profiles(s: &Session) -> std::result::Result<(), String> { + if s.prefs.width_profiles.is_empty() { Err("no saved profiles".into()) } else { Ok(()) } +} + +fn profile_add(s: &mut Session, p: &Value) -> Result { + const C: &str = "stroke.widthProfile.add"; + let profile = s.profile_to_add().map_err(|e| bad(C, e))?; + let name = match str_param(p, "name").map(str::trim) { + None => s.next_profile_name(), + Some("") => return Err(bad(C, "the name is empty")), + Some(n) if s.profile_name_taken(n) => return Err(bad(C, format!("a profile is already called {n}"))), + Some(n) => n.to_string(), + }; + s.prefs.width_profiles.push(SavedProfile { name: name.clone(), profile }); + Ok(json!({ "name": name })) +} + +fn profile_delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "stroke.widthProfile.delete"; + let name = match str_param(p, "name") { + Some(n) => n.to_string(), + None => { + let shown = s.shown_stroke().and_then(|st| st.profile); + let entry = shown.as_ref().and_then(|p| s.profile_entry(Some(p))); + entry.map(|e| e.id.to_string()).ok_or_else(|| bad(C, "give a name, or select a stroke with a saved profile"))? + } + }; + if WidthProfile::PRESETS.iter().any(|b| b.id == name || b.label == name) { + return Err(bad(C, format!("{name} is built in and can't be deleted"))); + } + let i = s.prefs.width_profiles.iter().position(|sp| sp.name == name).ok_or_else(|| bad(C, format!("no saved profile called {name}")))?; + s.prefs.width_profiles.remove(i); + Ok(json!({ "deleted": name })) +} + +fn profile_reset(s: &mut Session, _: &Value) -> Result { + let removed = std::mem::take(&mut s.prefs.width_profiles).len(); + Ok(json!({ "removed": removed })) +} + +fn profile_list(s: &mut Session, _: &Value) -> Result { + let profiles: Vec = s + .profile_entries() + .map(|e| json!({"id": e.id, "label": e.label, "builtIn": e.built_in, "points": e.points.iter().map(|&(t, l, r)| [t, l, r]).collect::>()})) + .collect(); + let current = s.shown_stroke().map(|st| s.profile_entry(st.profile.as_ref()).map_or("custom", |e| e.id).to_string()); + Ok(json!({ "profiles": profiles, "current": current })) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 96fbd8c87..c23d2172a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -322,6 +322,10 @@ pub struct Prefs { /// edit it. #[serde(skip_serializing_if = "Vec::is_empty")] pub flattener_presets: Vec, + /// 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")] + pub width_profiles: Vec, } impl Default for Prefs { @@ -441,6 +445,7 @@ impl Default for Prefs { }, eyedropper: Default::default(), flattener_presets: vec![], + width_profiles: vec![], } } } @@ -861,4 +866,6 @@ mod tests_tileedge; #[cfg(test)] mod tests_tints; #[cfg(test)] +mod tests_widthprofiles; +#[cfg(test)] mod tests_xform; diff --git a/crates/engine/src/tests_widthprofiles.rs b/crates/engine/src/tests_widthprofiles.rs new file mode 100644 index 000000000..65af9c6fe --- /dev/null +++ b/crates/engine/src/tests_widthprofiles.rs @@ -0,0 +1,140 @@ +//! The width profile library (`stroke.widthProfile.*`): saved profiles kept with the preferences +//! next to the built-in ones, applied by name with `stroke.set {profile}`. + +use serde_json::{Value, json}; +use vectorcraft_doc::WidthProfile; + +use super::*; + +fn session_with_line() -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let r = s.execute("shape.line", &json!({"x1": 10, "y1": 100, "x2": 150, "y2": 100})).unwrap(); + (s, NodeId(r["id"].as_u64().unwrap())) +} + +fn profile(s: &Session, id: NodeId) -> Option { + s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().profile.clone() +} + +fn custom() -> WidthProfile { + WidthProfile { points: vec![(0.0, 0.2, 0.2), (0.3, 1.6, 0.8), (1.0, 0.5, 0.5)] } +} + +/// Give the line `p` as its variable width (as the Width tool would). +fn set_profile(s: &mut Session, id: NodeId, p: WidthProfile) { + s.edit("Width", |d, _| { + d.node_mut(id).unwrap().appearance.stroke_mut().unwrap().profile = Some(p); + Ok(()) + }) + .unwrap(); +} + +fn ids(list: &Value) -> Vec<&str> { + list["profiles"].as_array().unwrap().iter().map(|p| p["id"].as_str().unwrap()).collect() +} + +#[test] +fn add_list_apply_delete_reset() { + let (mut s, id) = session_with_line(); + let built_in: Vec<&str> = WidthProfile::PRESETS.iter().map(|p| p.id).collect(); + let list = s.execute("stroke.widthProfile.list", &json!({})).unwrap(); + assert_eq!(ids(&list), built_in); + assert_eq!(list["current"], "uniform"); + // Nothing to save from a plain stroke, or from a built-in profile. + assert!(s.execute("stroke.widthProfile.add", &json!({})).is_err()); + s.execute("stroke.set", &json!({"profile": "lens"})).unwrap(); + assert!(s.execute("stroke.widthProfile.add", &json!({})).is_err()); + + set_profile(&mut s, id, custom()); + assert_eq!(s.execute("stroke.widthProfile.list", &json!({})).unwrap()["current"], "custom"); + let undo_depth = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("stroke.widthProfile.add", &json!({})).unwrap()["name"], "Width Profile 1"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_depth, "the library is not part of the document"); + // Listed after the built-ins, and now the stroke's profile. + let list = s.execute("stroke.widthProfile.list", &json!({})).unwrap(); + assert_eq!(ids(&list).last(), Some(&"Width Profile 1")); + assert_eq!(list["current"], "Width Profile 1"); + let row = list["profiles"].as_array().unwrap().last().unwrap().clone(); + assert_eq!((row["builtIn"].clone(), row["points"][1].clone()), (json!(false), json!([0.3, 1.6, 0.8]))); + // The same profile can't be saved twice. + assert!(s.execute("stroke.widthProfile.add", &json!({"name": "Again"})).is_err()); + + // Apply it by name to another stroke. + let other = NodeId(s.execute("shape.line", &json!({"x1": 10, "y1": 150, "x2": 150, "y2": 150})).unwrap()["id"].as_u64().unwrap()); + assert!(profile(&s, other).is_none()); + s.execute("stroke.set", &json!({"profile": "Width Profile 1"})).unwrap(); + assert_eq!(profile(&s, other), Some(custom())); + assert!(s.execute("stroke.set", &json!({"profile": "Nope"})).is_err()); + + // Delete it (the selected stroke's): strokes keep their widths. + assert_eq!(s.execute("stroke.widthProfile.delete", &json!({})).unwrap()["deleted"], "Width Profile 1"); + assert_eq!(ids(&s.execute("stroke.widthProfile.list", &json!({})).unwrap()), built_in); + assert_eq!(profile(&s, other), Some(custom())); + assert!(s.execute("stroke.set", &json!({"profile": "Width Profile 1"})).is_err()); + + // Reset removes every saved profile. + s.execute("stroke.widthProfile.add", &json!({"name": " Ribbon "})).unwrap(); + set_profile(&mut s, other, WidthProfile { points: vec![(0.0, 1.0, 0.0), (1.0, 0.0, 1.0)] }); + s.execute("stroke.widthProfile.add", &json!({"name": "Twist"})).unwrap(); + assert_eq!(s.prefs.width_profiles.iter().map(|p| p.name.as_str()).collect::>(), ["Ribbon", "Twist"]); + assert_eq!(s.execute("stroke.widthProfile.reset", &json!({})).unwrap()["removed"], 2); + assert!(s.prefs.width_profiles.is_empty()); + assert!(s.execute("stroke.widthProfile.reset", &json!({})).is_err(), "nothing left to reset"); +} + +#[test] +fn built_ins_cant_be_deleted_and_names_stay_unique() { + let (mut s, id) = session_with_line(); + set_profile(&mut s, id, custom()); + for taken in ["lens", "Taper Start", "UNIFORM"] { + assert!(s.execute("stroke.widthProfile.add", &json!({"name": taken})).is_err(), "{taken}"); + } + assert!(s.execute("stroke.widthProfile.add", &json!({"name": " "})).is_err()); + s.execute("stroke.widthProfile.add", &json!({"name": "Mine"})).unwrap(); + for b in ["lens", "Lens", "uniform", "wave"] { + let e = s.execute("stroke.widthProfile.delete", &json!({"name": b})).unwrap_err().to_string(); + assert!(e.contains("built in"), "{b}: {e}"); + } + // With a built-in profile selected, Delete has nothing to delete. + s.execute("stroke.set", &json!({"profile": "pinch"})).unwrap(); + assert!(s.execute("stroke.widthProfile.delete", &json!({})).is_err()); + assert!(s.execute("stroke.widthProfile.delete", &json!({"name": "Other"})).is_err()); + assert_eq!(s.next_profile_name(), "Width Profile 1"); + assert_eq!(s.execute("stroke.widthProfile.delete", &json!({"name": "Mine"})).unwrap()["deleted"], "Mine"); +} + +#[test] +fn saved_profiles_round_trip_with_the_preferences() { + let (mut s, id) = session_with_line(); + set_profile(&mut s, id, custom()); + s.execute("stroke.widthProfile.add", &json!({"name": "Mine"})).unwrap(); + let json = s.prefs.to_json(); + assert_eq!(json["widthProfiles"][0]["name"], "Mine"); + let back: Prefs = serde_json::from_value(json).unwrap(); + assert_eq!(back, s.prefs); + // Without saved profiles nothing is written, and older preference files load with none. + assert!(Prefs::default().to_json().get("widthProfiles").is_none()); + let old: Prefs = serde_json::from_value(json!({"keyboardIncrement": 2})).unwrap(); + assert!(old.width_profiles.is_empty()); + // Setting and resetting other preferences keeps them. + s.execute("prefs.set", &json!({"key": "keyboardIncrement", "value": 3})).unwrap(); + s.execute("prefs.reset", &json!({})).unwrap(); + assert_eq!(s.prefs.width_profiles.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.resolve_profile("Mine").is_some_and(|p| p == Some(custom()))); +} + +#[test] +fn every_built_in_applies_and_the_params_doc_names_them() { + let (mut s, id) = session_with_line(); + let spec = command_specs().iter().find(|c| c.id == "stroke.set").unwrap(); + for p in WidthProfile::PRESETS { + assert!(spec.params.contains(&format!("\"{}\"", p.id)), "{}", p.id); + s.execute("stroke.set", &json!({"profile": p.id})).unwrap(); + assert_eq!(s.execute("stroke.widthProfile.list", &json!({})).unwrap()["current"], p.id); + assert_eq!(profile(&s, id).map(|w| w.points), (p.id != "uniform").then(|| p.points.to_vec())); + } +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index ef049c667..ddeb3e7d6 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -127,8 +127,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let link = ui.link(egui::RichText::new("Stroke:").size(12.0).color(t.text).underline()).on_hover_text("Stroke options"); stroke_panel::popover(app, &link); stroke_panel::weight_field(app, ui, "cb-stroke", weight, 100.0); - let profile = vectorcraft_doc::WidthProfile::id_of(shown_stroke.as_ref().and_then(|s| s.profile.as_ref())); - if let Some(id) = stroke_panel::profile_dropdown(ui, profile) { + if let Some(id) = stroke_panel::profile_dropdown(app, ui, shown_stroke.as_ref().and_then(|s| s.profile.as_ref())) { app.run("stroke.set", json!({"profile": id})).ok(); } ui.add_space(4.0); diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 32458375b..6e4716549 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -599,7 +599,7 @@ fn stroke_notes(ui: &Ui, r: Rect, st: &vectorcraft_doc::StrokeLayer) { if let Some(p) = st.profile.as_ref().filter(|p| p.preset_id() != Some("uniform")) { let pr = Rect::from_min_size(pos2(x, r.center().y - 5.0), vec2(28.0, 10.0)); if pr.right() <= r.right() { - paint_profile(ui, pr, Some(p), t.text); + paint_profile(ui, pr, Some(&p.points), t.text); } } } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 2e88821a6..d0ea14cbd 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -441,4 +441,6 @@ mod tests_freeform; #[cfg(test)] mod tests_stroke; #[cfg(test)] +mod tests_strokedepth; +#[cfg(test)] mod tests_strokeux; diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index c52019b0c..361cbea48 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -3,7 +3,7 @@ use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::{Value, json}; -use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, WidthProfile}; +use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile}; use vectorcraft_engine::inspect::StrokeMixed; use super::{character, current_stroke, pstate, set_pstate, stroke_mixed}; @@ -14,9 +14,9 @@ use crate::{VectorcraftApp, icons}; pub const WEIGHT_PRESETS: [f64; 22] = [0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0]; -/// The silhouette drawn for profile `id` (None draws the plain uniform bar). -fn profile_of(id: &str) -> Option { - WidthProfile::preset(id).filter(|_| id != "uniform") +/// A profile's points as its silhouette: none (the plain bar) for the uniform stroke. +fn silhouette(points: &[(f64, f64, f64)]) -> Option<&[(f64, f64, f64)]> { + (points != WidthProfile::PRESETS[0].points).then_some(points) } /// The Arrowheads Align buttons: (alignment, `stroke.set` value, icon, tooltip). @@ -269,10 +269,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); widgets::divider(ui); // Profile. - let pid = WidthProfile::id_of(st.as_ref().and_then(|s| s.profile.as_ref())); ui.horizontal(|ui| { row_label(ui, "Profile:"); - if let Some(id) = profile_dropdown(ui, pid) { + if let Some(id) = profile_dropdown(app, ui, st.as_ref().and_then(|s| s.profile.as_ref())) { set(app, json!({"profile": id})); } let can_flip = st.as_ref().is_some_and(|s| s.profile.is_some()); @@ -350,15 +349,15 @@ fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option, start: bool) -> out } -/// Draw a width profile's silhouette into `r`. -pub fn paint_profile(ui: &Ui, r: Rect, prof: Option<&WidthProfile>, color: Color32) { +/// Draw a width profile's silhouette (its points; None: the plain bar) into `r`. +pub fn paint_profile(ui: &Ui, r: Rect, points: Option<&[(f64, f64, f64)]>, color: Color32) { let n = 32; let half = r.height() / 2.0 - 1.0; let mut top = vec![]; let mut bot = vec![]; for i in 0..=n { let tt = i as f64 / n as f64; - let (l, rr) = prof.map(|p| p.at(tt)).unwrap_or((0.35, 0.35)); + let (l, rr) = points.map_or((0.35, 0.35), |p| WidthProfile::at_points(p, tt)); let x = r.left() + tt as f32 * r.width(); top.push(pos2(x, r.center().y - l as f32 * half)); bot.push(pos2(x, r.center().y + rr as f32 * half)); @@ -376,31 +375,41 @@ pub fn paint_profile(ui: &Ui, r: Rect, prof: Option<&WidthProfile>, color: Color ui.painter().add(egui::Shape::mesh(mesh)); } -pub(crate) fn profile_dropdown(ui: &mut Ui, cur: &str) -> Option<&'static str> { +/// The Profile dropdown (Stroke panel, Control bar): the stroke's own silhouette on the button +/// (a custom profile too), the built-in profiles and then the saved ones in the list. Returns the +/// id or name picked. +pub(crate) fn profile_dropdown(app: &VectorcraftApp, ui: &mut Ui, cur: Option<&WidthProfile>) -> Option { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(100.0, 24.0), Sense::click()); ui.painter().rect_filled(r, 2, t.input); ui.painter().rect_stroke(r, 2, Stroke::new(1.0, if resp.hovered() { t.text } else { t.input_border }), StrokeKind::Inside); let body = Rect::from_min_max(r.min + vec2(6.0, 5.0), pos2(r.right() - 20.0, r.bottom() - 5.0)); - paint_profile(ui, body, profile_of(cur).as_ref(), t.text_strong); + paint_profile(ui, body, cur.and_then(|p| silhouette(&p.points)), t.text_strong); icons::paint(ui, "chevron-down", Rect::from_center_size(pos2(r.right() - 9.0, r.center().y), vec2(10.0, 10.0)), t.icon); let resp = resp.on_hover_text("Variable Width Profile"); let mut out = None; egui::Popup::menu(&resp).show(|ui| { - for ProfilePreset { id, label, .. } in WidthProfile::PRESETS { - let (row, rr) = ui.allocate_exact_size(vec2(170.0, 26.0), Sense::click()); - if id == cur { - ui.painter().rect_filled(row, 0.0, t.row_selected); - } else if rr.hovered() { - ui.painter().rect_filled(row, 0.0, t.hover); + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + // A divider between the built-in profiles and the saved ones. + let mut divide = true; + for e in app.session.profile_entries() { + if !e.built_in && std::mem::take(&mut divide) { + ui.separator(); + } + let (row, rr) = ui.allocate_exact_size(vec2(170.0, 26.0), Sense::click()); + if e.matches(cur) { + ui.painter().rect_filled(row, 0.0, t.row_selected); + } else if rr.hovered() { + ui.painter().rect_filled(row, 0.0, t.hover); + } + paint_profile(ui, Rect::from_min_size(row.min + vec2(6.0, 6.0), vec2(70.0, 14.0)), silhouette(e.points), t.text_strong); + ui.painter().text(row.left_center() + vec2(84.0, 0.0), egui::Align2::LEFT_CENTER, e.label, egui::FontId::proportional(11.5), t.text); + if rr.clicked() { + out = Some(e.id.to_string()); + ui.close(); + } } - paint_profile(ui, Rect::from_min_size(row.min + vec2(6.0, 6.0), vec2(70.0, 14.0)), profile_of(id).as_ref(), t.text_strong); - ui.painter().text(row.left_center() + vec2(84.0, 0.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(11.5), t.text); - if rr.clicked() { - out = Some(id); - ui.close(); - } - } + }); }); out } @@ -410,10 +419,20 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, if hidden { "Show Options" } else { "Hide Options" }, true, false) { set_pstate(ui.ctx(), "stroke-hide-options", !hidden); } - menu_item(ui, "Add to Profiles", false, false); - menu_item(ui, "Delete Profile", false, false); - if menu_item(ui, "Reset Profile", current_stroke(app).is_some_and(|s| s.profile.is_some()), false) { - set(app, json!({"profile": "uniform"})); + ui.separator(); + if menu_item(ui, "Add to Profiles…", crate::menus::enabled(app, "stroke.widthProfile.add"), false) { + let name = app.session.next_profile_name(); + let p = json!({"command": "stroke.widthProfile.add", "label": "Variable Width Profile", "params": {"name": name}}); + app.run("ui.paramDialog", p).ok(); + } + // Deletes the selected stroke's saved profile. + let shown = current_stroke(app).and_then(|s| s.profile); + let saved = shown.as_ref().and_then(|p| app.session.profile_entry(Some(p))).is_some_and(|e| !e.built_in); + if menu_item(ui, "Delete Profile", saved, false) { + app.run("stroke.widthProfile.delete", json!({})).ok(); + } + if menu_item(ui, "Reset Profiles", crate::menus::enabled(app, "stroke.widthProfile.reset"), false) { + app.run("stroke.widthProfile.reset", json!({})).ok(); } } @@ -437,9 +456,9 @@ mod tests { #[test] fn profile_silhouettes_and_arrow_labels() { - assert!(profile_of("uniform").is_none()); - assert_eq!(profile_of("lens"), Some(WidthProfile::lens())); - assert!(profile_of("custom").is_none()); + assert!(silhouette(WidthProfile::PRESETS[0].points).is_none(), "uniform draws the plain bar"); + let lens = WidthProfile::lens(); + assert_eq!(silhouette(&lens.points), Some(lens.points.as_slice())); assert_eq!(arrow_label(Some(Arrowhead::CircleOpen)), "Circle (open)"); } diff --git a/crates/ui-egui/src/panels/tests_strokedepth.rs b/crates/ui-egui/src/panels/tests_strokedepth.rs new file mode 100644 index 000000000..8792c184c --- /dev/null +++ b/crates/ui-egui/src/panels/tests_strokedepth.rs @@ -0,0 +1,89 @@ +//! Headless frames of the Stroke panel's width profile library: the ≡ menu's Add to Profiles…, +//! Delete Profile and Reset Profiles, and the Profile dropdown listing saved profiles. + +use serde_json::json; +use vectorcraft_doc::WidthProfile; + +use super::tests_appearance::{click, frame_events, run, text_rect}; +use super::*; + +fn app_with_line() -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + run(&mut app, "shape.line", json!({"x1": 10, "y1": 100, "x2": 150, "y2": 100})); + app +} + +fn custom() -> WidthProfile { + WidthProfile { points: vec![(0.0, 0.3, 0.3), (0.6, 1.4, 1.4), (1.0, 0.0, 0.0)] } +} + +/// Give the selected line `p` as its variable width. +fn set_profile(app: &mut VectorcraftApp, p: WidthProfile) { + app.session + .edit("Width", |d, sel| { + let id = sel.objects[0]; + d.node_mut(id).unwrap().appearance.stroke_mut().unwrap().profile = Some(p); + Ok(()) + }) + .unwrap(); +} + +fn panel_and_menu(app: &mut VectorcraftApp, ui: &mut Ui) { + stroke::show(app, ui); + stroke::menu(app, ui); +} + +fn profile_points(app: &VectorcraftApp) -> Option> { + current_stroke(app).and_then(|s| s.profile).map(|p| p.points) +} + +#[test] +fn menu_saves_deletes_and_resets_profiles() { + let ctx = egui::Context::default(); + let mut app = app_with_line(); + set_profile(&mut app, custom()); + let t = frame_events(&ctx, &mut app, vec![], panel_and_menu); + click(&ctx, &mut app, text_rect(&t, " Add to Profiles…").center(), panel_and_menu); + // A name dialog (OK runs stroke.widthProfile.add). + let d = app.ui.dialog.clone().expect("name dialog"); + assert_eq!((d.str("__command"), d.str("name")), ("stroke.widthProfile.add".to_string(), "Width Profile 1".to_string())); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Mine")); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.session.prefs.width_profiles[0].name, "Mine"); + // Saved profiles don't trip the Preferences dialog, which only edits preferences. + crate::prefs_dialog::open(&mut app, None); + crate::prefs_dialog::confirm(&mut app).unwrap(); + assert_eq!(app.session.prefs.width_profiles.len(), 1); + + // Delete Profile removes the selected stroke's saved profile; the stroke keeps its widths. + let t = frame_events(&ctx, &mut app, vec![], panel_and_menu); + click(&ctx, &mut app, text_rect(&t, " Delete Profile").center(), panel_and_menu); + assert!(app.session.prefs.width_profiles.is_empty()); + assert_eq!(profile_points(&app), Some(custom().points)); + + run(&mut app, "stroke.widthProfile.add", json!({"name": "A"})); + let t = frame_events(&ctx, &mut app, vec![], panel_and_menu); + click(&ctx, &mut app, text_rect(&t, " Reset Profiles").center(), panel_and_menu); + assert!(app.session.prefs.width_profiles.is_empty()); +} + +#[test] +fn dropdown_lists_saved_profiles_and_applies_them() { + let ctx = egui::Context::default(); + let mut app = app_with_line(); + set_profile(&mut app, custom()); + run(&mut app, "stroke.widthProfile.add", json!({"name": "Mine"})); + run(&mut app, "stroke.set", json!({"profile": "uniform"})); + let t = frame_events(&ctx, &mut app, vec![], stroke::show); + // The dropdown sits right of the "Profile:" label. + let label = text_rect(&t, "Profile:"); + let button = egui::pos2(label.right() + ctx.global_style().spacing.item_spacing.x + 50.0, label.center().y); + click(&ctx, &mut app, button, stroke::show); + let t = frame_events(&ctx, &mut app, vec![], stroke::show); + for p in WidthProfile::PRESETS { + text_rect(&t, p.label); + } + click(&ctx, &mut app, text_rect(&t, "Mine").center(), stroke::show); + assert_eq!(profile_points(&app), Some(custom().points)); +} diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs index ee71d88ee..092299d4a 100644 --- a/crates/ui-egui/src/tests_labels.rs +++ b/crates/ui-egui/src/tests_labels.rs @@ -60,7 +60,7 @@ fn library_submenus_are_disabled_placeholders() { #[test] fn stroke_profiles_have_descriptive_names() { let labels: Vec<&str> = vectorcraft_doc::WidthProfile::PRESETS.iter().map(|p| p.label).collect(); - assert_eq!(labels, ["Uniform", "Lens", "Taper Start", "Taper End"]); + assert_eq!(labels, ["Uniform", "Lens", "Taper Start", "Taper End", "Pinch", "Teardrop", "Wave"]); } #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 718fa6a41..07d51b99f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -484,4 +484,18 @@ and flat-colour regions, plus `regions` (bounds, and `id` for objects) for the ` ```json {"name":"run_command","arguments":{"command":"flattener.preview","params":{"highlight":"allRasterized","preset":"low"}}} + +## Width profiles + +The Profile list holds the built-in variable-width profiles (`uniform`, `lens`, `taperStart`, `taperEnd`, `pinch`, +`teardrop`, `wave`) and profiles saved from strokes; saved ones are kept with the preferences, not in the document +(no undo step). `stroke.widthProfile.list` returns every row (`id`, `label`, `builtIn`, `points` as `[t, left, +right]`) and `current`, the selected stroke's profile (`"custom"` when it isn't listed). +`stroke.widthProfile.add {name?}` saves the selected stroke's variable width (default name "Width Profile N"), +`stroke.widthProfile.delete {name?}` removes a saved one (default: the selected stroke's; built-ins can't be deleted) +and `stroke.widthProfile.reset` removes every saved one. `stroke.set {profile}` takes a built-in id or a saved name. + +```json +{"name":"run_command","arguments":{"command":"stroke.widthProfile.add","params":{"name":"Ribbon"}}} +{"name":"run_command","arguments":{"command":"stroke.set","params":{"ids":[9],"profile":"Ribbon"}}} ```