M3.61: width profile library: Add to Profiles, Delete Profile, Reset Profiles

The Stroke panel's Profile list now holds saved profiles next to the built-ins,
kept with the preferences (Prefs.width_profiles). Add to Profiles… names the
selected stroke's variable width, Delete Profile removes the selected stroke's
saved one (built-ins can't be deleted), Reset Profiles removes them all.
Three more generated built-ins (Pinch, Teardrop, Wave). The dropdown (Stroke
panel and Control bar) lists saved profiles and draws a custom profile's own
silhouette. Commands: stroke.widthProfile.add/delete/reset/list; stroke.set
{profile} takes a built-in id or a saved name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-05 02:55:21 +07:00
1 parent c73aa380e1
commit 3e73ad8581
12 files changed
+482 -45

No files matched your search

+20 -3
View File
@@ -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<Self> {
@@ -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 {
+2 -2
View File
@@ -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};
+155 -5
View File
@@ -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<CommandSpec> {
"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<CommandSpec> {
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<Value> {
};
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<Value> {
})?;
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<Item = ProfileEntry<'_>> {
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<ProfileEntry<'_>> {
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<Option<WidthProfile>> {
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<WidthProfile, String> {
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<Value> {
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<Value> {
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<Value> {
let removed = std::mem::take(&mut s.prefs.width_profiles).len();
Ok(json!({ "removed": removed }))
}
fn profile_list(s: &mut Session, _: &Value) -> Result<Value> {
let profiles: Vec<Value> = 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::<Vec<_>>()}))
.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 }))
}
+7
View File
@@ -322,6 +322,10 @@ pub struct Prefs {
/// edit it.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub flattener_presets: Vec<cmd::FlattenerPreset>,
/// 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<vectorcraft_doc::SavedProfile>,
}
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;
+140
View File
@@ -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<WidthProfile> {
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::<Vec<_>>(), ["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()));
}
}
+1 -2
View File
@@ -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);
+1 -1
View File
@@ -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);
}
}
}
+2
View File
@@ -441,4 +441,6 @@ mod tests_freeform;
#[cfg(test)]
mod tests_stroke;
#[cfg(test)]
mod tests_strokedepth;
#[cfg(test)]
mod tests_strokeux;
+50 -31
View File
@@ -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> {
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<Arrowhead>, 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<String> {
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)");
}
@@ -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<Vec<(f64, f64, f64)>> {
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));
}
+1 -1
View File
@@ -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]
+14
View File
@@ -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"}}}
```